fix(extract): cross-file indirect_call survives id relativization (0.9.4 regression)

Local install-testing of 0.9.4 surfaced that `graphify extract .` dropped every
cross-file indirect_call edge — the headline feature, broken on the primary code
path — while the extract() API worked. Root cause: the cross-file callable-target
guard unioned per-file `callable_nids` (pre-remap ids), but extract() rewrites node
ids afterward (id_remap / prefix sym_remap / _disambiguate_colliding_node_ids). When
the scan root relativizes ids (cache_root == project root, which the CLI passes), the
guard set went stale and `tgt not in callable_nids` rejected every remapped target.
In-file indirect edges survived (emitted with consistently-remapped endpoints), which
masked it — only cross-file dropped.

Fix: mark callable defs with a `_callable` attribute on the node dict instead of
exporting an id list. A marker rides through every id remap; callable_nids is rebuilt
from the final (post-remap) nodes right before the pass that uses it, and the marker
is stripped before output (like origin_file). Regression test extracts with
cache_root == project root (the CLI shape) and asserts the cross-file edge survives
and _callable never ships to graph.json. Full suite 2769.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
safishamsi
2026-07-01 11:43:05 +01:00
co-authored by Claude Opus 4.8
parent b7f88afc30
commit e34e27c24c
3 changed files with 52 additions and 10 deletions
+4
View File
@@ -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.
+21 -10
View File
@@ -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
+27
View File
@@ -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",