mirror of
https://github.com/safishamsi/graphify.git
synced 2026-07-12 18:37:12 +00:00
62b8eb1416
When a callee had exactly one same-named definition repo-wide, the cross-file resolver emitted a `calls` edge at INFERRED/0.8 even with no import path between caller and callee. On a monorepo this fabricated dependencies: a 14-package repo showed `platform`/`sidecar` depending on `registry-protocol` purely because it exported generically-named symbols that unresolved calls collapsed onto. JS/TS modules have no implicit cross-module scope, so a cross-file call is real only if the caller imported it. Direct JS/TS cross-file `calls` attribution is now gated on import evidence and left unresolved otherwise. Scoped to direct calls: other languages keep the #1553 single-candidate resolution (C/C++ headers, Ruby autoload, same-package implicit scope), and the indirect_call path (already INFERRED + callable-gated) is untouched. Also hardens caller/candidate -> file mapping to resolve via the node's `source_file` string (identifying the file node by its basename label) instead of `relative_to(root.resolve())`, which threw on a path-resolution/symlink mismatch and fell back to a non-matching absolute id — spuriously failing import evidence. This both makes the new gate safe and fixes legitimate cross-file calls being mislabeled INFERRED instead of EXTRACTED. Full suite: 2898 passed, 3 skipped. Verified via CLI on the reporter's repro (phantom dropped) and a control (imported call resolves EXTRACTED). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
88 lines
4.0 KiB
Python
88 lines
4.0 KiB
Python
"""#1659 — a JS/TS call with no local definition and no import must not bind to
|
|
a same-named export in an unrelated package that was never imported.
|
|
|
|
JS/TS modules have no implicit cross-module scope: a call into another file is
|
|
real only if the caller imported it. The cross-file resolver used to fall back
|
|
to any lone same-named export repo-wide and emit a `calls` edge at INFERRED/0.8,
|
|
so on a monorepo a package that exports generically-named symbols (`*Schema`,
|
|
`validate`, ...) appeared depended-on by packages that import nothing from it.
|
|
|
|
The fix gates JS/TS cross-file call attribution on import evidence; other
|
|
languages keep the #1553 single-candidate resolution (headers, autoload,
|
|
same-package implicit scope legitimately call across files with no import).
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
|
|
from graphify.extract import extract
|
|
|
|
|
|
def _write(path: Path, text: str) -> Path:
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_text(text, encoding="utf-8")
|
|
return path
|
|
|
|
|
|
def _calls(files: list[Path], base: Path) -> set[tuple[str, str, str]]:
|
|
r = extract(files, cache_root=base, parallel=False)
|
|
lbl = {n["id"]: n["label"] for n in r["nodes"]}
|
|
return {
|
|
(lbl.get(e["source"], ""), lbl.get(e["target"], ""), e.get("confidence"))
|
|
for e in r["edges"] if e["relation"] == "calls"
|
|
}
|
|
|
|
|
|
def test_unimported_cross_package_call_emits_no_edge(tmp_path: Path) -> None:
|
|
_write(tmp_path / "pkg-a/src/index.ts",
|
|
"declare function validate(x: number): boolean;\n"
|
|
"export function run(x: number): boolean { return validate(x); }\n")
|
|
_write(tmp_path / "pkg-b/src/index.ts",
|
|
"export function validate(name: string): boolean { return name.length > 0; }\n")
|
|
calls = _calls(sorted(tmp_path.rglob("*.ts")), tmp_path)
|
|
assert not any("run" in s and "validate" in t for s, t, _ in calls), calls
|
|
|
|
|
|
def test_many_files_do_not_collapse_onto_one_export(tmp_path: Path) -> None:
|
|
# The real-world symptom: N packages importing nothing all showed edges to a
|
|
# single package that exported a generically-named symbol.
|
|
_write(tmp_path / "proto/index.ts",
|
|
"export function encode(x: string): string { return x; }\n")
|
|
for i in range(4):
|
|
_write(tmp_path / f"svc{i}/index.ts",
|
|
"declare function encode(x: string): string;\n"
|
|
f"export function use{i}(x: string) {{ return encode(x); }}\n")
|
|
calls = _calls(sorted(tmp_path.rglob("*.ts")), tmp_path)
|
|
assert not any("encode" in t for _s, t, _ in calls), calls
|
|
|
|
|
|
def test_imported_cross_file_call_still_resolves(tmp_path: Path) -> None:
|
|
# A real import must still resolve (and be promoted to EXTRACTED).
|
|
_write(tmp_path / "a.ts",
|
|
'import { validate } from "./b";\n'
|
|
"export function run(x: number) { return validate(x); }\n")
|
|
_write(tmp_path / "b.ts",
|
|
"export function validate(name: string): boolean { return name.length > 0; }\n")
|
|
calls = _calls([tmp_path / "a.ts", tmp_path / "b.ts"], tmp_path)
|
|
resolved = [c for c in calls if "run" in c[0] and "validate" in c[1]]
|
|
assert resolved, calls
|
|
assert resolved[0][2] == "EXTRACTED"
|
|
|
|
|
|
def test_same_file_call_unaffected(tmp_path: Path) -> None:
|
|
_write(tmp_path / "s.ts",
|
|
"function helper() { return 1; }\n"
|
|
"export function main() { return helper(); }\n")
|
|
calls = _calls([tmp_path / "s.ts"], tmp_path)
|
|
assert any("main" in s and "helper" in t for s, t, _ in calls), calls
|
|
|
|
|
|
def test_non_js_single_candidate_cross_file_still_resolves(tmp_path: Path) -> None:
|
|
# The gate is JS/TS-only. Ruby (autoload, no require) legitimately calls a
|
|
# lone same-named function across files without an import — keep the #1553
|
|
# single-candidate resolution for it.
|
|
_write(tmp_path / "helper.rb", "def transform(data)\n data.upcase\nend\n")
|
|
_write(tmp_path / "main.rb", "def handle(v)\n transform(v)\nend\n")
|
|
calls = _calls([tmp_path / "main.rb", tmp_path / "helper.rb"], tmp_path)
|
|
assert any("handle" in s and "transform" in t for s, t, _ in calls), calls
|