fix: promote cross-file call edges to EXTRACTED when import evidence exists

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
This commit is contained in:
Mani Saint-Victor, MD
2026-05-06 13:13:30 -04:00
parent c902ae952b
commit 2dd6ee6a9c
3 changed files with 98 additions and 2 deletions
+1
View File
@@ -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)
+48 -2
View File
@@ -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,
+49
View File
@@ -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"