fix(extract): don't guess an ambiguous barrel re-export (follow-up to #2034)

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) <noreply@anthropic.com>
This commit is contained in:
safishamsi
2026-07-20 11:41:44 +01:00
co-authored by Claude Opus 4.8
parent 868f75de38
commit e7ad693b5b
2 changed files with 60 additions and 6 deletions
+16 -6
View File
@@ -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:
+44
View File
@@ -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