mirror of
https://github.com/safishamsi/graphify.git
synced 2026-07-13 02:47:00 +00:00
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:
@@ -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
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user