From dea6ec0c24c34bf780763794135a75373e6b326d Mon Sep 17 00:00:00 2001 From: oleksii-tumanov Date: Fri, 17 Jul 2026 03:50:05 -0500 Subject: [PATCH] fix(extract): remove dangling alias import edges --- graphify/extract.py | 52 ++++++++++- tests/test_js_import_resolution.py | 140 ++++++++++++++++++++++++++++- 2 files changed, 186 insertions(+), 6 deletions(-) diff --git a/graphify/extract.py b/graphify/extract.py index 2ff5411..fba6aa7 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -7,6 +7,7 @@ import json import os import re import sys +from collections import Counter from dataclasses import dataclass, field from pathlib import Path from typing import Any, Callable @@ -4536,6 +4537,7 @@ def extract( e["target"] = id_remap[e["target"]] if prefix_remap: sym_remap: dict[str, str] = {} + edge_alias_candidates: dict[str, set[str]] = {} for n in all_nodes: sf = n.get("source_file") if not sf: @@ -4556,12 +4558,28 @@ def extract( # Try both the input-form and absolute-form prefixes for this file # (#1529). source_file gating above already prevents cross-file # contamination, so the first matching prefix wins. + canonical_nid: str | None = None for old_pref, new_pref in entry: if nid.startswith(old_pref + "_"): - new_nid = new_pref + nid[len(old_pref):] - if new_nid != nid: - sym_remap[nid] = new_nid + canonical_nid = new_pref + nid[len(old_pref):] + if canonical_nid != nid: + sym_remap[nid] = canonical_nid break + if nid.startswith(new_pref + "_"): + canonical_nid = nid + break + if canonical_nid is None: + continue + # Named alias imports can retain an absolute-prefixed target even + # when the symbol node is already canonical. Record every old form + # so a redundant edge can be recognized without globally + # reinterpreting an id that another real node may own. + for old_pref, new_pref in entry: + if not canonical_nid.startswith(new_pref + "_"): + continue + old_nid = old_pref + canonical_nid[len(new_pref):] + if old_nid != canonical_nid: + edge_alias_candidates.setdefault(old_nid, set()).add(canonical_nid) if sym_remap: for n in all_nodes: if n.get("id") in sym_remap: @@ -4578,6 +4596,34 @@ def extract( cn = rc.get("caller_nid") 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 + ) + owned_node_ids = {node.get("id") for node in all_nodes} + deduped_edges: list[dict] = [] + for edge in all_edges: + candidates = ( + edge_alias_candidates.get(edge.get("target", ""), set()) + if edge.get("relation") == "imports" + else set() + ) + if len(candidates) == 1: + candidate = next(iter(candidates)) + twin = {**edge, "target": candidate} + twin_key = json.dumps( + twin, sort_keys=True, separators=(",", ":"), default=str + ) + # Drop only when the shared resolver emitted the exact + # canonical twin. Otherwise the target may be a legitimate + # owned node id. + if edge_key_counts[twin_key]: + if edge.get("target") in owned_node_ids: + edge_key_counts[twin_key] -= 1 + continue + deduped_edges.append(edge) + all_edges[:] = deduped_edges _merge_swift_extensions(per_file, all_nodes, all_edges) _disambiguate_colliding_node_ids(all_nodes, all_edges, all_raw_calls, root) diff --git a/tests/test_js_import_resolution.py b/tests/test_js_import_resolution.py index b8314a9..39c49d6 100644 --- a/tests/test_js_import_resolution.py +++ b/tests/test_js_import_resolution.py @@ -1212,15 +1212,149 @@ def test_alias_import_edge_resolves_with_relative_input_paths(tmp_path, monkeypa # that exists in the graph), not an orphan keyed by an absolute prefix. assert _has_edge(result, "src/components/Button.tsx", "src/lib/utils.ts") assert target_id in node_ids - import_targets = { + import_targets = [ e["target"] for e in result["edges"] if e["relation"] == "imports_from" and e["source"] == _file_node_id(Path("src/components/Button.tsx")) - } - assert target_id in import_targets + ] + assert import_targets == [target_id] # No surviving edge target may carry an absolute-path prefix from tmp_path. abs_prefix = _file_node_id(Path("src/lib/utils.ts").resolve()) assert all(not t.startswith(abs_prefix + "_") and t != abs_prefix for t in import_targets) # The named-symbol edge to formatDate must resolve to the real symbol node too. assert _has_symbol_edge(result, "src/components/Button.tsx", "src/lib/utils.ts", "formatDate") + symbol_target = _make_id(_file_stem(Path("src/lib/utils.ts")), "formatDate") + named_imports = [ + edge + for edge in result["edges"] + if edge["source"] == _file_node_id(Path("src/components/Button.tsx")) + and edge["relation"] == "imports" + and edge["source_location"] == "L1" + ] + assert [edge["target"] for edge in named_imports] == [symbol_target] + assert all( + edge["source"] in node_ids and edge["target"] in node_ids + for edge in result["edges"] + if edge["relation"] in ("imports", "imports_from") + ) + + +def test_alias_import_symbol_resolves_from_parent_working_directory(tmp_path, monkeypatch): + project = tmp_path / "project" + _write( + project / "tsconfig.json", + json.dumps({"compilerOptions": {"baseUrl": ".", "paths": {"@/*": ["src/*"]}}}), + ) + _write(project / "src/lib/utils.ts", "export function formatDate(d) { return d }\n") + _write( + project / "src/components/Button.tsx", + "import { formatDate } from '@/lib/utils'\n", + ) + + monkeypatch.chdir(tmp_path) + result = extract( + [Path("project/src/lib/utils.ts"), Path("project/src/components/Button.tsx")], + cache_root=Path("project"), + ) + + node_ids = {node["id"] for node in result["nodes"]} + source_id = _file_node_id(Path("src/components/Button.tsx")) + symbol_target = _make_id(_file_stem(Path("src/lib/utils.ts")), "formatDate") + named_imports = [ + edge + for edge in result["edges"] + if edge["source"] == source_id and edge["relation"] == "imports" + ] + + assert [edge["target"] for edge in named_imports] == [symbol_target] + assert all(edge["source"] in node_ids and edge["target"] in node_ids for edge in named_imports) + + +def test_alias_import_does_not_remap_an_owned_symbol_id(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + target = Path("src/lib/utils.ts") + absolute_prefix = _file_node_id(target.resolve()) + mirror = Path(f"{absolute_prefix}.ts") + button = Path("src/components/Button.tsx") + mirror_user = Path("src/components/Mirror.tsx") + + _write( + Path("tsconfig.json"), + json.dumps({"compilerOptions": {"baseUrl": ".", "paths": {"@/*": ["src/*"]}}}), + ) + _write(target, "export function formatDate(d) { return d }\n") + _write(mirror, "export function formatDate(d) { return 999 }\n") + _write( + button, + "import { formatDate } from '@/lib/utils'\nexport const a = formatDate(1)\n", + ) + _write( + mirror_user, + f"import {{ formatDate }} from '../../{mirror.stem}'\nexport const b = formatDate(2)\n", + ) + + result = extract( + [target, mirror, button, mirror_user], + cache_root=Path("."), + ) + + node_ids = {node["id"] for node in result["nodes"]} + symbols = { + node["source_file"]: node["id"] + for node in result["nodes"] + if node.get("label") == "formatDate()" + } + target_symbol = _make_id(_file_stem(target), "formatDate") + mirror_symbol = _make_id(_file_stem(mirror), "formatDate") + assert symbols[str(target)] == target_symbol + assert symbols[str(mirror)] == mirror_symbol + + imports = [ + edge + for edge in result["edges"] + if edge["relation"] == "imports" and edge["source_location"] == "L1" + ] + by_source: dict[str, list[str]] = {} + for edge in imports: + by_source.setdefault(edge["source_file"], []).append(edge["target"]) + assert by_source[str(button)] == [target_symbol] + assert by_source[str(mirror_user)] == [mirror_symbol] + assert all(edge["source"] in node_ids and edge["target"] in node_ids for edge in imports) + + +def test_alias_import_preserves_owned_same_line_symbol_edge(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + target = Path("src/lib/utils.ts") + absolute_prefix = _file_node_id(target.resolve()) + mirror = Path(f"{absolute_prefix}.ts") + importer = Path("src/components/Both.tsx") + + _write( + Path("tsconfig.json"), + json.dumps({"compilerOptions": {"baseUrl": ".", "paths": {"@/*": ["src/*"]}}}), + ) + _write(target, "export function formatDate(d) { return d }\n") + _write(mirror, "export function formatDate(d) { return 999 }\n") + _write( + importer, + f"import {{ formatDate as a }} from '@/lib/utils'; " + f"import {{ formatDate as b }} from '../../{mirror.stem}';\n" + "export const value = a(1) + b(2)\n", + ) + + result = extract([target, mirror, importer], cache_root=Path(".")) + + node_ids = {node["id"] for node in result["nodes"]} + target_symbol = _make_id(_file_stem(target), "formatDate") + mirror_symbol = _make_id(_file_stem(mirror), "formatDate") + imports = [ + edge + for edge in result["edges"] + if edge["source"] == _file_node_id(importer) + and edge["relation"] == "imports" + and edge["source_location"] == "L1" + ] + + 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)