From e7ad693b5b14be58e2cf176b76a9dc8b1347a428 Mon Sep 17 00:00:00 2001 From: safishamsi Date: Mon, 20 Jul 2026 11:41:44 +0100 Subject: [PATCH] fix(extract): don't guess an ambiguous barrel re-export (follow-up to #2034) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The barrel-chain resolver keyed (source, symbol) -> target as last-write-wins, so a barrel re-exporting the same local name from two modules collapsed an importer's edge onto whichever was learned last — a fabricated wrong edge. Learn into a set per key and refuse to resolve when a name maps to more than one target; the edge falls to the dangling-canonical fallback (dropped at build) instead. Adds a regression test. Co-Authored-By: Claude Opus 4.8 (1M context) --- graphify/extract.py | 22 +++++++++++---- tests/test_js_import_resolution.py | 44 ++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 6 deletions(-) diff --git a/graphify/extract.py b/graphify/extract.py index 11053185..49aa4fcf 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -4698,9 +4698,19 @@ def extract( 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] = {} + # (canonical file id, symbol) → set of owned targets, learned from + # symbol-level re_exports edges that already point at a real node. A set + # (not last-write-wins): when a barrel re-exports the SAME local name + # from two different modules (`export {x} from './a'; export {x as y} + # from './b'` — both key on local name `x`), the key becomes ambiguous + # and must NOT be guessed, or we fabricate a wrong edge. Ambiguous keys + # resolve to None so the edge falls to the dangling-canonical fallback + # (dropped at build), while the shared resolver's correct edge survives. + chain: dict[tuple[str, str], set] = {} + + def _resolve1(key) -> "str | None": + targets = chain.get(key) + return next(iter(targets)) if targets and len(targets) == 1 else None def _learn(e: dict) -> None: tf = e.get("target_file") @@ -4708,7 +4718,7 @@ def extract( return dec = _decompose(e.get("target", ""), tf) if dec is not None: - chain[(e.get("source"), dec[1])] = e["target"] + chain.setdefault((e.get("source"), dec[1]), set()).add(e["target"]) for e in all_edges: if e.get("relation") == "re_exports": @@ -4725,7 +4735,7 @@ def extract( 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 + resolved_target = _resolve1((dec[0], dec[1])) if dec else None if resolved_target is None: still.append(e) continue @@ -4735,7 +4745,7 @@ def extract( # 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 + chain.setdefault((e.get("source"), dec[1]), set()).add(resolved_target) progressed = True pending = still if not progressed: diff --git a/tests/test_js_import_resolution.py b/tests/test_js_import_resolution.py index 694fd589..49452a98 100644 --- a/tests/test_js_import_resolution.py +++ b/tests/test_js_import_resolution.py @@ -1543,3 +1543,47 @@ def test_no_symbol_edge_target_contains_checkout_prefix(tmp_path, monkeypatch): and str(edge.get("target", "")).startswith(abs_prefix) ] assert offenders == [], f"checkout path leaked into edge targets: {offenders}" + + +def test_ambiguous_barrel_reexport_chain_does_not_guess(tmp_path, monkeypatch): + """When a barrel re-exports the SAME local name from two different modules, + the barrel-chain resolver must NOT collapse an importer's edge onto one of + them by last-write-wins (#2034 follow-up). With the ambiguity guard the chain + leaves the import unresolved at the barrel symbol (dangling, dropped at build) + rather than fabricating a specific target; without it the chain repoints to + whichever module was learned last.""" + _write( + tmp_path / "tsconfig.json", + json.dumps({"compilerOptions": {"baseUrl": ".", "paths": {"@/*": ["src/*"]}}}), + ) + _write(tmp_path / "src/lib/a.ts", "export function dup() { return 'a' }\n") + _write(tmp_path / "src/lib/b.ts", "export function dup() { return 'b' }\n") + _write(tmp_path / "src/lib/index.ts", + "export { dup } from '@/lib/a'\nexport { dup } from '@/lib/b'\n") + _write(tmp_path / "src/consumer.ts", + "import { dup } from '@/lib'\nexport function useIt() { return dup() }\n") + + monkeypatch.chdir(tmp_path) + result = extract(sorted(Path("src").rglob("*.ts")), cache_root=Path(".")) + + barrel_sym = _make_id(_file_stem(Path("src/lib/index.ts")), "dup") + consumer = _file_node_id(Path("src/consumer.ts")) + consumer_imports = [ + e for e in result["edges"] + if e.get("source") == consumer and e.get("relation") == "imports" + ] + # The chain-produced import edge stays at the barrel symbol (unresolved) — + # proof the chain refused to guess. Without the fix it would be repointed to + # src_lib_a_dup / src_lib_b_dup (last-write-wins), so this edge would vanish. + assert any(e.get("target") == barrel_sym for e in consumer_imports), ( + f"ambiguous barrel import was chain-resolved instead of left unresolved: " + f"{[e.get('target') for e in consumer_imports]}" + ) + # Both legitimate barrel re-exports still resolve to their own module. + barrel = _file_node_id(Path("src/lib/index.ts")) + reexport_targets = { + e.get("target") for e in result["edges"] + if e.get("source") == barrel and e.get("relation") == "re_exports" + } + assert _make_id(_file_stem(Path("src/lib/a.ts")), "dup") in reexport_targets + assert _make_id(_file_stem(Path("src/lib/b.ts")), "dup") in reexport_targets