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:
co-authored by
Claude Opus 4.8
parent
b7f88afc30
commit
e34e27c24c
@@ -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
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user