Skip to content
Merged
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,14 @@

Full release notes with details on each version: [GitHub Releases](https://github.com/safishamsi/graphify/releases)

## Unreleased

- Feat: extend `indirect_call` to assignment and return references (#1569, #1566 slice 2, thanks @sheik-hiiobd). A function bound to a name (`cb = handler`), returned from a factory (`def make(): return handler`), or aliased at module level (`CALLBACK = handler`) now emits an `indirect_call` edge, so `affected` reaches it. Captures the value side only (a bare name or a bare unpack `a, b = f, g`); a collection literal on the RHS stays with the dispatch-table scan. Reuses the shared guard, so the inverted-shadow trap is handled by construction — a param/local named on the RHS still hits the shadow guard and emits nothing (no return of #1565's false edges). Function and module scope; Python only for now.
- Fix: the skill-version mismatch warning is now direction-aware (#1568, thanks @TPAteeq). It used to advise `Run 'graphify install' to update` on ANY version difference, but `install` writes the package's own bundled skill and re-stamps the version — so when the skill on disk was NEWER than the package (a stale `uv tool` CLI, or a contributor's dev checkout), following that advice silently DOWNGRADED the skill to make the warning go away. Now when the skill is newer, the warning recommends upgrading the package (`uv tool upgrade graphifyy` / `pip install -U graphifyy`) instead; the older-skill case still recommends `install`. Versions compare numerically (so `0.10` > `0.9`).
- Feat: extend `indirect_call` capture to JS/TS (#1566). The same model now applies to JavaScript and TypeScript: a callback passed by name (`arr.map(fn)`, `setTimeout(fn)`, Express-style `app.get("/", handler)`, event wiring `emitter.on("e", handler)`) and functions listed in object/array dispatch tables (`const ROUTES = { create: handler }`, `const HOOKS = [onStart, onStop]`). Arrow-const functions (`const cb = () => {}`) count as callable targets; object shorthand (`{ handler }`) is a reference; inline arrows/function expressions are direct definitions and are not captured; object KEYS and non-callable values are excluded. Same guards as Python: callable-target-only, not shadowed by a param/local/module reassignment, single-definition god-node guard cross-file. Cross-file resolution is import-aware — a `import { onEvent }` edge to the symbol no longer suppresses the `indirect_call` to it. Module-level call-argument registration (idiomatic in JS) is captured in addition to the function-scoped capture Python has.
- Feat: extend `indirect_call` to dispatch tables (#1566). A function listed as a VALUE in a dict/list/set/tuple literal — a route/handler registry like `ROUTES = {"create": create_user, "delete": delete_user}` or `HOOKS = [on_start, on_stop]` — now emits an `indirect_call` edge so `affected` reaches those handlers too. Works at module level (attributed to the file) and inside a function (attributed to the function), same-file and cross-file. Same guards as the call-argument case: callable-target-only, not shadowed by a param/local/module-level reassignment, dict KEYS excluded (only values are references).
- Feat: capture indirect dispatch as `indirect_call` edges so `graphify affected` (blast radius) catches callers that pass a function by name as a call argument — `executor.submit(fn)`, `Thread(target=fn)`, `map(fn, xs)`, callbacks (#1565, thanks @sheik-hiiobd). Kept as a distinct INFERRED relation separate from `calls` (strict call-graph queries stay precise) and added to the affected relation set. Hardened against false edges: the argument name must resolve to a callable definition and must NOT be shadowed by a parameter or local binding in the enclosing function — so the idiomatic `def via(pool, handler): pool.submit(handler)` (handler is the param) and a data variable sharing a function's name produce no edge. Now also resolves cross-file: a callback imported from another module (`from .handlers import on_event; pool.submit(on_event)`) routes through the same cross-file resolver as direct calls — single-definition god-node guard, callable-target-only, staying INFERRED — closing the gap where #1565 saw only same-file callbacks (the common real-world shape is cross-module). Python only for now.

## 0.9.3 (2026-06-30)

- Feat: cross-file member-call resolution for C++ and Objective-C (#1547, #1556). A class declared in a header and defined in its `.cpp`/`.m` no longer fragments into two nodes (a decl/def merge pass collapses the sibling header/impl pair, gated to same-directory same-name so unrelated classes never merge), and a member call now resolves across files by the receiver's inferred type: C++ `Foo f; f.bar()` / `Foo::bar()` / `this->bar()` and ObjC `Foo *f = [[Foo alloc] init]; [f doThing]` / `[self render]` link to the owning class's method. Resolution is by receiver type, never bare name, with the single-definition god-node guard — an uninferable or ambiguous receiver produces no edge (high precision over recall, grounded in how compiler-free indexers like ctags/Doxygen mis-resolve by name). Also routes C++ headers to the C++ extractor and ObjC `#import` bridging headers to the ObjC extractor. Reported by @c0dezer019 and @JabberYQ. (Residual cross-file `#include` edge resolution under symlinked roots and ObjC dynamic-dispatch receivers remain follow-ups.)
Expand Down
36 changes: 35 additions & 1 deletion graphify/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,41 @@ def _check_skill_version(skill_dst: Path) -> None:
except OSError:
return
if installed != __version__:
print(f" warning: skill is from graphify {installed}, package is {__version__}. Run 'graphify install' to update.", file=sys.stderr)
if _version_tuple(installed) > _version_tuple(__version__):
# The skill on disk is NEWER than the running package. `graphify install`
# writes the package's OWN (older) bundled skill and re-stamps the version,
# so following the old "run install" advice would silently DOWNGRADE the
# skill. The real fix is to upgrade the package (#1568). Common for a stale
# `uv tool` CLI, or a contributor whose dev checkout stamped a newer skill.
print(
f" warning: skill is from graphify {installed}, but the package is "
f"{__version__} (older). Upgrade the package "
f"(e.g. 'uv tool upgrade graphifyy' or 'pip install -U graphifyy'); "
f"running 'graphify install' would downgrade the skill.",
file=sys.stderr,
)
else:
print(f" warning: skill is from graphify {installed}, package is {__version__}. Run 'graphify install' to update.", file=sys.stderr)


def _version_tuple(version: str) -> tuple[int, ...]:
"""Parse a version string into a comparable integer tuple (``0.9.2`` -> ``(0, 9, 2)``).

Reads the leading digits of each dot-segment, so pre/post-release suffixes
(``1.0.0rc1``) compare by their numeric core. A non-numeric or empty segment
becomes 0, so a malformed stamp degrades to a conservative comparison rather
than raising.
"""
parts: list[int] = []
for segment in str(version).split("."):
digits = ""
for ch in segment:
if ch.isdigit():
digits += ch
else:
break
parts.append(int(digits) if digits else 0)
return tuple(parts)


def _refresh_all_version_stamps() -> None:
Expand Down
1 change: 1 addition & 0 deletions graphify/affected.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

DEFAULT_AFFECTED_RELATIONS = (
"calls",
"indirect_call",
"references",
"imports",
"imports_from",
Expand Down
Loading
Loading