diff --git a/CHANGELOG.md b/CHANGELOG.md index 2310a1e..86c1d5c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ Full release notes with details on each version: [GitHub Releases](https://github.com/safishamsi/graphify/releases) +## Unreleased + +- Fix: cross-file `indirect_call` edges were dropped by `graphify extract` on the CLI (a 0.9.4 regression). The callable-target guard for cross-file indirect dispatch was keyed on node ids collected before the id-relativization/disambiguation passes; when the scan root relativizes ids (the CLI's default, `cache_root == project root`), those ids went stale and every cross-file indirect edge was silently dropped — only same-file ones survived. Callable-ness is now read from a node marker that rides through the remaps, so `submit(imported_fn)`, imported dispatch tables, assignment/getattr aliases across files resolve on the CLI as they already did via the `extract()` API. + ## 0.9.4 (2026-07-01) - Fix: Ruby class inheritance now emits an `inherits` edge (#1535, thanks @Synvoya). `class Dog < Animal` produced `contains`/method/call edges but no `inherits` edge — the inheritance handler had branches for Java/Kotlin/C#/Scala/C++/PHP/Swift/Python but none for Ruby, so the `superclass` field was never read. Handles both bare (`< Animal`) and qualified (`< M::Base`) superclasses. diff --git a/graphify/extract.py b/graphify/extract.py index f0a4927..30388c1 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -5051,11 +5051,15 @@ def _extract_generic( result = {"nodes": nodes, "edges": clean_edges, "raw_calls": raw_calls} if callable_def_nids: - # Function / method / class def ids in this file. The cross-file - # indirect_call resolvers use the union of these to ensure a callback - # passed by name resolves only to a real callable, never a same-named - # data symbol (mirrors the in-file `callable_def_nids` guard). - result["callable_nids"] = sorted(callable_def_nids) + # Mark function / method / class defs with a `_callable` attribute so the + # cross-file indirect_call pass can resolve a by-name callback only to a real + # callable (never a same-named data symbol). A marker rides on the node dict + # and survives the id-remap / disambiguation passes in extract(); a pre-remap + # id set would go stale and silently drop every cross-file indirect edge when + # ids are relativized (#1566 regression). Stripped before output, like origin_file. + for n in nodes: + if n["id"] in callable_def_nids: + n["_callable"] = True if swift_extensions: result["swift_extensions"] = swift_extensions if type_table: @@ -15182,15 +15186,15 @@ def extract( all_nodes: list[dict] = [] all_edges: list[dict] = [] all_raw_calls: list[dict] = [] - # Union of every file's function / method / class def ids. The cross-file - # indirect_call pass resolves a callback passed by name only to one of these, - # so a same-named data symbol can never become an indirect-dispatch target. - callable_nids: set[str] = set() for result in per_file: all_nodes.extend(result.get("nodes", [])) all_edges.extend(result.get("edges", [])) all_raw_calls.extend(result.get("raw_calls", [])) - callable_nids.update(result.get("callable_nids", ())) + # Function / method / class def ids for the cross-file indirect_call callable + # guard. Built from the `_callable` node marker AFTER the id-remap / disambiguation + # passes below (which rewrite node ids), so it can never go stale — see the + # marker set in the per-file extractor. Populated just before the pass that uses it. + callable_nids: set[str] = set() _augment_symbol_resolution_edges(paths, all_nodes, all_edges, root) @@ -15373,6 +15377,12 @@ def extract( key = normalised.lower() global_label_to_nids.setdefault(key, []).append(n["id"]) + # Callable-def ids for the indirect_call callable guard, read from the `_callable` + # marker on the FINAL (post-remap) nodes — so a callback resolves only to a real + # function/method/class, never a same-named data symbol, and the guard never goes + # stale when node ids were relativized/disambiguated above (#1566). + callable_nids = {n["id"] for n in all_nodes if n.get("_callable")} + # Build evidence index from import edges so cross-file calls backed by an # explicit import statement can be promoted from INFERRED to EXTRACTED. # Direct symbol imports (`import { foo }` / `const { foo } = require()`) are @@ -15557,6 +15567,7 @@ def extract( # cache keeps its own copy, which is what the colliding-id pass reads on a cache hit. for n in all_nodes: n.pop("origin_file", None) + n.pop("_callable", None) # internal indirect_call marker — never ships to graph.json # Tag AST provenance so the incremental watch rebuild can distinguish # AST-extracted nodes from semantic/LLM nodes. On a full re-extraction diff --git a/tests/test_indirect_dispatch.py b/tests/test_indirect_dispatch.py index ae2fd63..ee3d36b 100644 --- a/tests/test_indirect_dispatch.py +++ b/tests/test_indirect_dispatch.py @@ -201,6 +201,33 @@ def _extract_dir(tmp_path, files: dict[str, str]): return r, nid +def test_cross_file_indirect_survives_id_relativization(tmp_path): + """Regression: when the scan root relativizes node ids (cache_root == project + root, as the `graphify extract` CLI passes), the id-remap rewrites node ids + AFTER per-file extraction. The cross-file indirect callable guard must read + callable-ness from a node marker that survives the remap, not a stale pre-remap + id set — otherwise every cross-file indirect_call is silently dropped (only + in-file ones survive). This is the exact shape the CLI hit.""" + base = tmp_path / "proj" + (base / "handlers").mkdir(parents=True) + (base / "handlers" / "__init__.py").write_text("def on_event(x):\n return x\n") + (base / "scheduler.py").write_text( + "from handlers import on_event\n\n\ndef schedule(pool):\n pool.submit(on_event)\n" + ) + old = os.getcwd() + try: + os.chdir(base) + # cache_root == project root triggers source_file relativization + id-remap + r = extract([Path("handlers/__init__.py"), Path("scheduler.py")], + cache_root=base, parallel=False) + finally: + os.chdir(old) + nid = {n["label"].rstrip("()"): n["id"] for n in r["nodes"]} + assert (nid["schedule"], nid["on_event"]) in _rels(r, "indirect_call") + # the internal callable marker must never ship to graph.json + assert not any("_callable" in n for n in r["nodes"]) + + def test_cross_file_imported_callback_emits_indirect_call(tmp_path): r, nid = _extract_dir(tmp_path, { "handlers.py": "def on_event(x):\n return x\n",