mirror of
https://github.com/safishamsi/graphify.git
synced 2026-08-23 14:56:39 +00:00
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:
co-authored by
Claude Opus 4.8
parent
868f75de38
commit
e7ad693b5b
+16
-6
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user