Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 7 additions & 5 deletions graphify/extractors/resolution.py
Original file line number Diff line number Diff line change
Expand Up @@ -1388,6 +1388,12 @@ def ensure_symbol_node(path: Path, name: str, line: int) -> str:
continue
declared.setdefault((node_path, normalize_id(bare)), node["id"])
node_by_id = {node.get("id"): node for node in nodes}
references_by_file: dict[Path | None, list[dict]] = {}
for edge in edges:
if edge.get("relation") == "references":
references_by_file.setdefault(
_js_source_path(str(edge.get("source_file") or ""), root), []
).append(edge)
for fact in facts.imports:
if effective_suffix(fact.file_path).lower() not in (
".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs"
Expand All @@ -1403,11 +1409,7 @@ def ensure_symbol_node(path: Path, name: str, line: int) -> str:
)
if not target_nid:
continue
for edge in edges:
if _js_source_path(str(edge.get("source_file") or ""), root) != source_path:
continue
if edge.get("relation") != "references":
continue
for edge in references_by_file.get(source_path, ()):
if edge.get("target") == target_nid:
continue
node = node_by_id.get(edge.get("target"))
Expand Down
55 changes: 55 additions & 0 deletions tests/test_js_import_facts_linear.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
"""Rebinding JS/TS import stubs must not walk every edge per import (#4905)."""
from pathlib import Path

from graphify.extract import extract
from graphify.extractors import resolution


def _project(root: Path, n: int) -> list[Path]:
src = root / "src"
src.mkdir()
for i in range(n):
lines = [f'import {{ f{(i + k) % n}_{k} }} from "./m{(i + k) % n}";' for k in range(1, 6)]
lines += [f"export function f{i}_{k}() {{ return 1; }}" for k in range(1, 6)]
lines.append("export const all = () => [" + ", ".join(f"f{(i + k) % n}_{k}()" for k in range(1, 6)) + "];")
(src / f"m{i}.ts").write_text("\n".join(lines) + "\n", encoding="utf-8")
return sorted(src.glob("*.ts"))


def _source_path_calls(tmp_path: Path, monkeypatch, n: int) -> int:
root = (tmp_path / str(n)).resolve()
root.mkdir()
paths = _project(root, n)
real = resolution._js_source_path
calls = 0

def counting(source_file, base):
nonlocal calls
calls += 1
return real(source_file, base)

monkeypatch.setattr(resolution, "_js_source_path", counting)
extract(paths, cache_root=root, root=root)
monkeypatch.setattr(resolution, "_js_source_path", real)
return calls


def test_source_path_lookups_grow_linearly_with_the_project(tmp_path, monkeypatch):
small = _source_path_calls(tmp_path, monkeypatch, 20)
large = _source_path_calls(tmp_path, monkeypatch, 40)

assert large < small * 3


def test_import_stub_is_still_rebound_to_the_declaration(tmp_path):
root = tmp_path.resolve()
(root / "a.ts").write_text("export interface Shape { x: number }\n", encoding="utf-8")
(root / "b.ts").write_text(
'import { Shape } from "./a";\nexport function area(s: Shape) { return s.x; }\n', encoding="utf-8"
)

result = extract([root / "a.ts", root / "b.ts"], cache_root=root, root=root)

declared = next(n["id"] for n in result["nodes"] if n["label"] == "Shape" and n.get("source_file"))
targets = {e["target"] for e in result["edges"] if e["source_file"].endswith("b.ts")}
assert declared in targets
Loading