From 2dd6ee6a9c2e24ec5d83ccbf72a859db3bc656fb Mon Sep 17 00:00:00 2001 From: "Mani Saint-Victor, MD" Date: Wed, 6 May 2026 13:13:30 -0400 Subject: [PATCH] fix: promote cross-file call edges to EXTRACTED when import evidence exists MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cross-file call resolver in `extract()` unconditionally marked every resolved call edge as INFERRED with confidence_score 0.8 — even when the caller's file had an explicit `imports` (symbol) or `imports_from` (module) edge to the callee. The new CJS require handler made this gap visible: imports were correctly EXTRACTED but the call edges that those imports backed remained INFERRED, so downstream consumers couldn't tell high-evidence calls apart from name-match guesses. This pass runs after the file-id remap (line 4736), so we relativize node `source_file` paths before computing file_nids — otherwise the caller's computed file_nid (absolute-path-derived) wouldn't match the imports_from edge source (already remapped to relative form). Promotion rule: - Symbol-level `imports` edge from caller's file -> callee node id => EXTRACTED, confidence_score 1.0 - Module-level `imports_from` edge from caller's file -> callee's file => EXTRACTED, confidence_score 1.0 - Otherwise => INFERRED, confidence_score 0.8 (existing behavior) Validated on a 92-file CJS orchestrator: 5 previously-INFERRED edges from runExecute() now resolve to EXTRACTED, and 88% of cross-file calls in the corpus (104 of 118) promote, leaving INFERRED only for genuine heuristic guesses with no import backing. Adds two tests: - test_cross_file_call_promoted_to_extracted_with_import_evidence - test_cross_file_call_remains_inferred_without_import_evidence --- CHANGELOG.md | 1 + graphify/extract.py | 50 +++++++++++++++++++++++++++++++++++++++++-- tests/test_extract.py | 49 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 98 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d122c51..d406af9b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ Full release notes with details on each version: [GitHub Releases](https://githu ## 0.7.8 (2026-05-06) - Fix: CommonJS `require()` imports now extracted from JS/TS -- `const { foo } = require('./mod')`, `const m = require('./mod')`, and `const x = require('./mod').y` all emit EXTRACTED `imports_from` (and per-symbol `imports`) edges. Previously CJS-only Node.js codebases produced AST graphs missing every import edge, which downgraded all cross-file calls to INFERRED. +- Fix: cross-file `calls` edges are now promoted from INFERRED to EXTRACTED when the caller's file has an explicit `imports` or `imports_from` edge to the callee. Previously every cross-file call was unconditionally INFERRED, even when a top-of-file `import` / `require` proved the binding. On a 92-file CJS Node.js corpus this promoted 88% of cross-file calls (104 of 118) to EXTRACTED. - Feat: Gemini and OpenAI backends -- `graphify extract ./docs --backend gemini` (GEMINI_API_KEY / GOOGLE_API_KEY) or `--backend openai` (OPENAI_API_KEY); `[gemini]` and `[openai]` extras added (#735) - Feat: Groovy and Spock support -- `.groovy` and `.gradle` extracted via tree-sitter-groovy; Spock spec files (`def "feature"()` syntax) handled via regex fallback (#732) - Feat: Luau support -- `.luau` (Roblox Luau) added to code extraction using the Lua tree-sitter parser (#745) diff --git a/graphify/extract.py b/graphify/extract.py index 49505daa..cdd2c503 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -4794,6 +4794,35 @@ def extract( key = normalised.lower() global_label_to_nids.setdefault(key, []).append(n["id"]) + # 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 + # the strongest evidence — caller's file_id has an `imports` edge directly to + # the callee's symbol id. Module imports (`imports_from`) are weaker but still + # confirm the caller pulled in the callee's source file. + file_to_symbol_imports: dict[str, set[str]] = {} + file_to_module_imports: dict[str, set[str]] = {} + for e in all_edges: + if e.get("relation") == "imports": + file_to_symbol_imports.setdefault(e["source"], set()).add(e["target"]) + elif e.get("relation") == "imports_from": + file_to_module_imports.setdefault(e["source"], set()).add(e["target"]) + + # Map each node back to its containing file_id so we can ask + # "did the caller's file import the callee's file?" + # Use relativized paths to match how file node IDs were remapped above (#502). + nid_to_file_nid: dict[str, str] = {} + for n in all_nodes: + sf = n.get("source_file") + if not sf: + continue + sf_path = Path(sf) + try: + sf_rel = sf_path.relative_to(root) if sf_path.is_absolute() else sf_path + except ValueError: + sf_rel = sf_path + nid_to_file_nid[n["id"]] = _make_id(str(sf_rel)) + existing_pairs = {(e["source"], e["target"]) for e in all_edges} for result in per_file: for rc in result.get("raw_calls", []): @@ -4814,13 +4843,30 @@ def extract( caller = rc["caller_nid"] if tgt != caller and (caller, tgt) not in existing_pairs: existing_pairs.add((caller, tgt)) + # Promote to EXTRACTED when there's a direct import edge from the + # caller's file pointing at either the callee symbol itself or the + # file the callee lives in. + caller_file_nid = nid_to_file_nid.get(caller) + callee_file_nid = nid_to_file_nid.get(tgt) + imported_symbols = file_to_symbol_imports.get(caller_file_nid, set()) + imported_modules = file_to_module_imports.get(caller_file_nid, set()) + has_import_evidence = ( + tgt in imported_symbols + or (callee_file_nid is not None and callee_file_nid in imported_modules) + ) + if has_import_evidence: + confidence = "EXTRACTED" + confidence_score = 1.0 + else: + confidence = "INFERRED" + confidence_score = 0.8 all_edges.append({ "source": caller, "target": tgt, "relation": "calls", "context": "call", - "confidence": "INFERRED", - "confidence_score": 0.8, + "confidence": confidence, + "confidence_score": confidence_score, "source_file": rc.get("source_file", ""), "source_location": rc.get("source_location"), "weight": 1.0, diff --git a/tests/test_extract.py b/tests/test_extract.py index 880b4a10..560ac84b 100644 --- a/tests/test_extract.py +++ b/tests/test_extract.py @@ -240,3 +240,52 @@ def test_extract_js_arrow_function_still_extracted(): assert "greet()" in labels finally: arrow_fixture.unlink() + + +def test_cross_file_call_promoted_to_extracted_with_import_evidence(tmp_path): + """A cross-file `calls` edge must be EXTRACTED when the caller's file has + an `imports` or `imports_from` edge linking it to the callee.""" + caller = tmp_path / "caller.js" + callee = tmp_path / "lib.js" + caller.write_text( + "const { doWork } = require('./lib');\n" + "function run() { doWork(); }\n" + ) + callee.write_text( + "function doWork() { return 1; }\n" + "module.exports = { doWork };\n" + ) + result = extract([caller, callee], cache_root=tmp_path) + nodes = {n["id"]: n for n in result["nodes"]} + call_edges = [ + e for e in result["edges"] + if e["relation"] == "calls" + and nodes[e["source"]]["label"] == "run()" + and nodes[e["target"]]["label"] == "doWork()" + ] + assert len(call_edges) == 1 + assert call_edges[0]["confidence"] == "EXTRACTED" + assert call_edges[0]["confidence_score"] == 1.0 + + +def test_cross_file_call_remains_inferred_without_import_evidence(tmp_path): + """A cross-file `calls` edge must stay INFERRED when there is no import + edge — name collision alone is insufficient evidence.""" + caller = tmp_path / "caller.js" + callee = tmp_path / "lib.js" + # Caller does NOT require lib — same-name function happens to exist elsewhere + caller.write_text("function run() { doUnique(); }\n") + callee.write_text( + "function doUnique() { return 1; }\n" + "module.exports = { doUnique };\n" + ) + result = extract([caller, callee], cache_root=tmp_path) + nodes = {n["id"]: n for n in result["nodes"]} + call_edges = [ + e for e in result["edges"] + if e["relation"] == "calls" + and nodes[e["source"]]["label"] == "run()" + and nodes[e["target"]]["label"] == "doUnique()" + ] + assert len(call_edges) == 1 + assert call_edges[0]["confidence"] == "INFERRED"