diff --git a/graphify/extract.py b/graphify/extract.py index a18a9b1c3..11053185f 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -359,6 +359,12 @@ def _import_js(node, source: bytes, file_nid: str, stem: str, edges: list, str_p "source_file": str_path, "source_location": f"L{line}", "weight": 1.0, + # Which file this symbol target was synthesized + # from, so the id-remap post-pass can repoint a + # target the candidates rewrite never learns — + # a barrel defines no symbols (#1983). Transient, + # stripped at build like the #1814 stamp. + "target_file": str(resolved_path), }) else: # Handle: import { Foo, type Bar } from './bar' @@ -380,6 +386,8 @@ def _import_js(node, source: bytes, file_nid: str, stem: str, edges: list, str_p "source_file": str_path, "source_location": f"L{line}", "weight": 1.0, + # See the re_exports stamp above (#1983). + "target_file": str(resolved_path), }) @@ -4520,6 +4528,11 @@ def extract( # would otherwise orphan (#1529). Stored as a list so the symbol-prefix remap # below can try both (identical forms collapse to one — a no-op). prefix_remap: dict[Path, list[tuple[str, str]]] = {} + # Canonical stem plus every prefix form a file's symbol ids may appear + # under, keyed by resolved path — consumed by the target_file-guided + # barrel repoint below (#1983). Unlike prefix_remap this records ALL + # in-root files, not just those whose prefix changed. + stem_forms: dict[Path, tuple[str, list[str]]] = {} for path in paths: old_id = _make_id(str(path)) try: @@ -4547,6 +4560,11 @@ def extract( old_prefs.append((old_pref_abs, new_id)) if old_prefs: prefix_remap[path.resolve()] = old_prefs + # Absolute form first: it is the longest, so prefix decomposition can + # try forms in order without a shorter form shadowing it. + stem_forms[path.resolve()] = ( + new_id, [old_pref_abs, old_pref, new_id] + ) if id_remap: for n in all_nodes: if n.get("id") in id_remap: @@ -4618,10 +4636,15 @@ def extract( if cn in sym_remap: rc["caller_nid"] = sym_remap[cn] if edge_alias_candidates: - edge_key_counts = Counter( - json.dumps(edge, sort_keys=True, separators=(",", ":"), default=str) - for edge in all_edges - ) + def _edge_key(edge: dict) -> str: + # target_file is a transient stamp (#1814/#1983); exclude it + # from twin identity or an alias edge (stamped) never matches + # the canonical twin the shared resolver emits (unstamped). + return json.dumps( + {k: v for k, v in edge.items() if k != "target_file"}, + sort_keys=True, separators=(",", ":"), default=str, + ) + edge_key_counts = Counter(_edge_key(edge) for edge in all_edges) owned_node_ids = {node.get("id") for node in all_nodes} deduped_edges: list[dict] = [] for edge in all_edges: @@ -4638,10 +4661,7 @@ def extract( ) if len(candidates) == 1: candidate = next(iter(candidates)) - twin = {**edge, "target": candidate} - twin_key = json.dumps( - twin, sort_keys=True, separators=(",", ":"), default=str - ) + twin_key = _edge_key({**edge, "target": candidate}) # Drop only when the shared resolver emitted the exact # canonical twin. Otherwise the target may be a legitimate # owned node id. @@ -4652,6 +4672,79 @@ def extract( deduped_edges.append(edge) all_edges[:] = deduped_edges + # Repoint symbol-level alias edges that resolve THROUGH a barrel (#1983 + # follow-up). The candidates rewrite above learns old→canonical forms only + # from symbols a file DEFINES; a barrel defines nothing, so a re-export or + # named import that resolves to one keeps an absolute-prefixed, dangling + # target no rewrite ever learns. Use the target_file stamp to decompose + # such a target into (canonical file stem, symbol), follow the barrel's own + # already-canonical re_exports edge to the defining symbol — iterating so + # multi-hop barrel chains resolve one hop per pass — and, when no chain + # leads to a real node, canonicalize the prefix anyway so a checkout path + # never survives in an edge target. + if stem_forms: + owned_ids = {n.get("id") for n in all_nodes} + + def _decompose(target: str, tf: str) -> "tuple[str, str] | None": + try: + forms = stem_forms.get(Path(tf).resolve()) + except (OSError, RuntimeError): + return None + if not forms: + return None + canonical, prefixes = forms + for pref in prefixes: + if pref and target.startswith(pref + "_"): + return canonical, target[len(pref) + 1:] + return None + + # (canonical file id, symbol) → owned target, learned from symbol-level + # re_exports edges that already point at a real node. + chain: dict[tuple[str, str], str] = {} + + def _learn(e: dict) -> None: + tf = e.get("target_file") + if not tf or e.get("target") not in owned_ids: + return + dec = _decompose(e.get("target", ""), tf) + if dec is not None: + chain[(e.get("source"), dec[1])] = e["target"] + + for e in all_edges: + if e.get("relation") == "re_exports": + _learn(e) + + pending = [ + e for e in all_edges + if e.get("relation") in ("re_exports", "imports") + and e.get("target_file") + and e.get("target") not in owned_ids + ] + for _ in range(8): # bounded: each pass resolves one barrel hop + progressed = False + still: list[dict] = [] + for e in pending: + dec = _decompose(e.get("target", ""), e["target_file"]) + resolved_target = chain.get((dec[0], dec[1])) if dec else None + if resolved_target is None: + still.append(e) + continue + e["target"] = resolved_target + if e.get("relation") == "re_exports": + # This barrel's edge now feeds the next hop. Learn it + # directly — decomposing the repointed target against this + # edge's own target_file would fail, since the target now + # carries the DEFINING file's stem, not the barrel's. + chain[(e.get("source"), dec[1])] = resolved_target + progressed = True + pending = still + if not progressed: + break + for e in pending: + dec = _decompose(e.get("target", ""), e["target_file"]) + if dec is not None: + e["target"] = f"{dec[0]}_{dec[1]}" + _merge_swift_extensions(per_file, all_nodes, all_edges) _disambiguate_colliding_node_ids(all_nodes, all_edges, all_raw_calls, root) _canonicalize_csharp_namespace_nodes(all_nodes, all_edges) diff --git a/tests/test_js_import_resolution.py b/tests/test_js_import_resolution.py index 06b8c7665..694fd5897 100644 --- a/tests/test_js_import_resolution.py +++ b/tests/test_js_import_resolution.py @@ -1458,3 +1458,88 @@ def test_alias_import_preserves_owned_same_line_symbol_edge(tmp_path, monkeypatc assert sorted(edge["target"] for edge in imports) == sorted([target_symbol, mirror_symbol]) assert all(edge["source"] in node_ids and edge["target"] in node_ids for edge in imports) + + +# --- #1983 (follow-up): alias re-exports THROUGH a barrel --------------------- +# The candidates rewrite learns old->canonical symbol forms only from symbols a +# file DEFINES. A barrel defines nothing, so a re-export/import that resolves to +# the barrel synthesizes an absolute-prefixed target no rewrite ever learns: +# the checkout path leaks into the id and the edge dangles. + +def _barrel_fixture(tmp_path): + _write( + tmp_path / "tsconfig.json", + json.dumps({"compilerOptions": {"baseUrl": ".", "paths": {"@/*": ["src/*"]}}}), + ) + _write(tmp_path / "src/lib/utils.ts", "export function formatDate() { return 'ok' }\n") + _write(tmp_path / "src/lib/index.ts", "export { formatDate } from '@/lib/utils'\n") + + +def test_alias_reexport_through_barrel_resolves_to_defining_symbol(tmp_path, monkeypatch): + _barrel_fixture(tmp_path) + _write(tmp_path / "src/barrel2.ts", "export { formatDate } from '@/lib'\n") + + monkeypatch.chdir(tmp_path) + files = sorted(Path("src").rglob("*.ts")) + result = extract(files, cache_root=Path(".")) + + node_ids = {node["id"] for node in result["nodes"]} + defining_symbol = _make_id(_file_stem(Path("src/lib/utils.ts")), "formatDate") + barrel_reexports = [ + edge + for edge in result["edges"] + if edge["source"] == _file_node_id(Path("src/barrel2.ts")) + and edge["relation"] == "re_exports" + and edge["target"] != _file_node_id(Path("src/lib/index.ts")) + ] + + assert [edge["target"] for edge in barrel_reexports] == [defining_symbol] + assert all(edge["target"] in node_ids for edge in barrel_reexports) + + +def test_alias_reexport_two_hop_barrel_chain_resolves(tmp_path, monkeypatch): + _barrel_fixture(tmp_path) + _write(tmp_path / "src/barrel2.ts", "export { formatDate } from '@/lib'\n") + _write(tmp_path / "src/barrel3.ts", "export { formatDate } from '@/barrel2'\n") + + monkeypatch.chdir(tmp_path) + files = sorted(Path("src").rglob("*.ts")) + result = extract(files, cache_root=Path(".")) + + node_ids = {node["id"] for node in result["nodes"]} + defining_symbol = _make_id(_file_stem(Path("src/lib/utils.ts")), "formatDate") + barrel3_reexports = [ + edge + for edge in result["edges"] + if edge["source"] == _file_node_id(Path("src/barrel3.ts")) + and edge["relation"] == "re_exports" + and edge["target"] != _file_node_id(Path("src/barrel2.ts")) + ] + + assert [edge["target"] for edge in barrel3_reexports] == [defining_symbol] + assert all(edge["target"] in node_ids for edge in barrel3_reexports) + + +def test_no_symbol_edge_target_contains_checkout_prefix(tmp_path, monkeypatch): + """No re_exports/imports target may embed the absolute checkout path — + the core complaint of #1983, which barrels still triggered after the + single-hop fix.""" + _barrel_fixture(tmp_path) + _write(tmp_path / "src/barrel2.ts", "export { formatDate } from '@/lib'\n") + _write( + tmp_path / "src/consumer.ts", + "import { formatDate } from '@/lib'\nexport function useIt() { return formatDate() }\n", + ) + + monkeypatch.chdir(tmp_path) + files = sorted(Path("src").rglob("*.ts")) + result = extract(files, cache_root=Path(".")) + + abs_prefix = _make_id(str(Path("src").resolve().parent)) + offenders = [ + (edge["relation"], edge["target"]) + for edge in result["edges"] + if edge.get("relation") in ("re_exports", "imports") + and str(edge.get("target", "")).startswith(abs_prefix) + ] + assert offenders == [], f"checkout path leaked into edge targets: {offenders}"