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
109 changes: 101 additions & 8 deletions graphify/extract.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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),
})


Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand All @@ -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.
Expand All @@ -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)
Expand Down
85 changes: 85 additions & 0 deletions tests/test_js_import_resolution.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}"