mirror of
https://github.com/safishamsi/graphify.git
synced 2026-07-20 14:21:33 +00:00
#1529 (regression from the 0.9.0 full-repo-relative node-ID migration): relative JS/TS imports resolve to repo-relative paths and ride the extract() id-remap to canonical node IDs, but tsconfig path-alias and workspace-package imports resolve to ABSOLUTE paths (their bases are .resolve()'d), so the import-target ID baked in the on-disk prefix and never matched the repo-relative definition node — the edge was dropped at build (common on Next.js/SvelteKit `@/`-alias codebases). The id-remap post-pass now also registers the absolute-resolved form of each input path (file-level edges) and both the input-form and absolute-form symbol prefixes (named-import edges), so alias/workspace import targets remap to the canonical ID. Verified the built graph has no orphan nodes or dangling edges. #1531: tsconfig `paths` values are ordered fallback lists (tsc tries each target until one resolves), but only targets[0] was kept. The alias map now stores all targets in order, and a single _resolve_tsconfig_alias helper (replacing six duplicated inline loops) returns the first target whose candidate exists on disk, falling back to the first candidate when none exist (no false edge). Wildcards, baseUrl, and array `extends` are preserved. 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
36b5e5cb29
commit
2133539930
+74
-48
@@ -76,7 +76,7 @@ def _file_node_id(rel_path: Path) -> str:
|
||||
return _make_id(_file_stem(rel_path))
|
||||
|
||||
|
||||
_TSCONFIG_ALIAS_CACHE: dict[str, dict[str, str]] = {}
|
||||
_TSCONFIG_ALIAS_CACHE: dict[str, dict[str, list[str]]] = {}
|
||||
_WORKSPACE_PACKAGE_CACHE: dict[str, dict[str, Path]] = {}
|
||||
_WORKSPACE_MANIFEST_NAMES = ("pnpm-workspace.yaml", "package.json")
|
||||
_JS_CACHE_BYPASS_SUFFIXES = {".js", ".jsx", ".mjs", ".ts", ".tsx", ".vue", ".svelte"}
|
||||
@@ -183,7 +183,7 @@ def _strip_jsonc(text: str) -> str:
|
||||
return stripped
|
||||
|
||||
|
||||
def _read_tsconfig_aliases(tsconfig: Path, base_dir: Path, seen: set) -> dict[str, str]:
|
||||
def _read_tsconfig_aliases(tsconfig: Path, base_dir: Path, seen: set) -> dict[str, list[str]]:
|
||||
"""Recursively read path aliases from a tsconfig, following extends chains.
|
||||
|
||||
Child config paths override parent. Circular extends are detected via seen set.
|
||||
@@ -211,7 +211,7 @@ def _read_tsconfig_aliases(tsconfig: Path, base_dir: Path, seen: set) -> dict[st
|
||||
print(f" warning: failed to parse {tsconfig} ({type(e).__name__}: {e})", file=sys.stderr, flush=True)
|
||||
return {}
|
||||
|
||||
aliases: dict[str, str] = {}
|
||||
aliases: dict[str, list[str]] = {}
|
||||
# `extends` may be a string or, since TypeScript 5.0, an array of paths.
|
||||
# For an array, parents are processed in order with later entries
|
||||
# overriding earlier ones; the extending config (paths below) overrides
|
||||
@@ -250,17 +250,26 @@ def _read_tsconfig_aliases(tsconfig: Path, base_dir: Path, seen: set) -> dict[st
|
||||
if not targets:
|
||||
continue
|
||||
alias_prefix = alias.rstrip("/*")
|
||||
target_base = targets[0].rstrip("/*")
|
||||
aliases[alias_prefix] = str(os.path.normpath(paths_base / target_base))
|
||||
# Keep ALL targets in declared order — tsc tries each until one resolves
|
||||
# on disk. Discarding the fallbacks (#1531) misresolved/dropped imports
|
||||
# whose file lived at a non-first target. Empty/non-string entries skipped.
|
||||
target_bases = [
|
||||
str(os.path.normpath(paths_base / t.rstrip("/*")))
|
||||
for t in targets
|
||||
if isinstance(t, str) and t
|
||||
]
|
||||
if target_bases:
|
||||
aliases[alias_prefix] = target_bases
|
||||
|
||||
return aliases
|
||||
|
||||
|
||||
def _load_tsconfig_aliases(start_dir: Path) -> dict[str, str]:
|
||||
def _load_tsconfig_aliases(start_dir: Path) -> dict[str, list[str]]:
|
||||
"""Walk up from start_dir to find tsconfig.json and return compilerOptions.paths aliases.
|
||||
|
||||
Follows extends chains so SvelteKit/Nuxt/NestJS inherited aliases are included.
|
||||
Returns a dict mapping alias prefix (e.g. "@/") to resolved base dir (e.g. "src/").
|
||||
Returns a dict mapping alias prefix (e.g. "@") to an ordered list of resolved
|
||||
base dirs (e.g. ["src"]) — tsc tries each in declared order (#1531).
|
||||
Result is cached by tsconfig path string.
|
||||
"""
|
||||
current = start_dir.resolve()
|
||||
@@ -274,6 +283,26 @@ def _load_tsconfig_aliases(start_dir: Path) -> dict[str, str]:
|
||||
return {}
|
||||
|
||||
|
||||
def _resolve_tsconfig_alias(raw: str, aliases: dict[str, list[str]]) -> "Path | None":
|
||||
"""Resolve `raw` against tsconfig path aliases. Try each target in declared
|
||||
order; return the first whose candidate resolves to a real file (tsc parity).
|
||||
If none exist, return the first candidate (no false edge fabricated, prior
|
||||
single-target behavior preserved). Returns a Path or None if no alias matches."""
|
||||
for alias_prefix, alias_bases in aliases.items():
|
||||
if raw == alias_prefix or raw.startswith(alias_prefix + "/"):
|
||||
rest = raw[len(alias_prefix):].lstrip("/")
|
||||
first = None
|
||||
for base in alias_bases:
|
||||
cand = Path(os.path.normpath(Path(base) / rest))
|
||||
resolved = _resolve_js_import_path(cand)
|
||||
if resolved.is_file():
|
||||
return resolved
|
||||
if first is None:
|
||||
first = cand
|
||||
return first
|
||||
return None
|
||||
|
||||
|
||||
def _find_workspace_root(start_dir: Path) -> Path | None:
|
||||
current = start_dir.resolve()
|
||||
for candidate in [current, *current.parents]:
|
||||
@@ -428,10 +457,9 @@ def _resolve_js_module_path(raw: str | Path, start_dir: Path | None = None) -> P
|
||||
return _resolve_js_import_path(start_dir / raw)
|
||||
|
||||
aliases = _load_tsconfig_aliases(start_dir)
|
||||
for alias_prefix, alias_base in aliases.items():
|
||||
if raw == alias_prefix or raw.startswith(alias_prefix + "/"):
|
||||
rest = raw[len(alias_prefix):].lstrip("/")
|
||||
return _resolve_js_import_path(Path(os.path.normpath(Path(alias_base) / rest)))
|
||||
hit = _resolve_tsconfig_alias(raw, aliases)
|
||||
if hit is not None:
|
||||
return _resolve_js_import_path(hit)
|
||||
|
||||
return _resolve_workspace_import(raw, start_dir)
|
||||
|
||||
@@ -4213,12 +4241,7 @@ def extract_svelte(path: Path) -> dict:
|
||||
# Check tsconfig.json path aliases (e.g. "$lib/" -> "src/lib/", "@/" -> "src/")
|
||||
# before treating as external. Mirrors _import_js logic so SvelteKit alias
|
||||
# imports resolve to the same file node IDs the extractor creates (#701).
|
||||
resolved_alias = None
|
||||
for alias_prefix, alias_base in aliases.items():
|
||||
if raw == alias_prefix or raw.startswith(alias_prefix + "/"):
|
||||
rest = raw[len(alias_prefix):].lstrip("/")
|
||||
resolved_alias = Path(os.path.normpath(Path(alias_base) / rest))
|
||||
break
|
||||
resolved_alias = _resolve_tsconfig_alias(raw, aliases)
|
||||
if resolved_alias is not None:
|
||||
resolved_alias = _resolve_js_module_path(resolved_alias)
|
||||
node_id = _make_id(str(resolved_alias))
|
||||
@@ -4276,12 +4299,7 @@ def extract_svelte(path: Path) -> dict:
|
||||
node_id = _make_id(str(resolved))
|
||||
stub_source_file = str(resolved)
|
||||
else:
|
||||
resolved_alias = None
|
||||
for alias_prefix, alias_base in aliases.items():
|
||||
if raw == alias_prefix or raw.startswith(alias_prefix + "/"):
|
||||
rest = raw[len(alias_prefix):].lstrip("/")
|
||||
resolved_alias = Path(os.path.normpath(Path(alias_base) / rest))
|
||||
break
|
||||
resolved_alias = _resolve_tsconfig_alias(raw, aliases)
|
||||
if resolved_alias is not None:
|
||||
node_id = _make_id(str(resolved_alias))
|
||||
stub_source_file = str(resolved_alias)
|
||||
@@ -4346,12 +4364,7 @@ def extract_astro(path: Path) -> dict:
|
||||
node_id = _make_id(str(resolved))
|
||||
stub_source_file = str(resolved)
|
||||
else:
|
||||
resolved_alias = None
|
||||
for alias_prefix, alias_base in aliases.items():
|
||||
if raw == alias_prefix or raw.startswith(alias_prefix + "/"):
|
||||
rest = raw[len(alias_prefix):].lstrip("/")
|
||||
resolved_alias = Path(os.path.normpath(Path(alias_base) / rest))
|
||||
break
|
||||
resolved_alias = _resolve_tsconfig_alias(raw, aliases)
|
||||
if resolved_alias is not None:
|
||||
resolved_alias = _resolve_js_module_path(resolved_alias)
|
||||
node_id = _make_id(str(resolved_alias))
|
||||
@@ -4412,12 +4425,7 @@ def extract_astro(path: Path) -> dict:
|
||||
node_id = _make_id(str(resolved))
|
||||
stub_source_file = str(resolved)
|
||||
else:
|
||||
resolved_alias = None
|
||||
for alias_prefix, alias_base in aliases.items():
|
||||
if raw == alias_prefix or raw.startswith(alias_prefix + "/"):
|
||||
rest = raw[len(alias_prefix):].lstrip("/")
|
||||
resolved_alias = Path(os.path.normpath(Path(alias_base) / rest))
|
||||
break
|
||||
resolved_alias = _resolve_tsconfig_alias(raw, aliases)
|
||||
if resolved_alias is not None:
|
||||
node_id = _make_id(str(resolved_alias))
|
||||
stub_source_file = str(resolved_alias)
|
||||
@@ -4528,12 +4536,7 @@ def extract_vue(path: Path) -> dict:
|
||||
node_id = _make_id(str(resolved))
|
||||
stub_source_file = str(resolved)
|
||||
else:
|
||||
resolved_alias = None
|
||||
for alias_prefix, alias_base in aliases.items():
|
||||
if raw == alias_prefix or raw.startswith(alias_prefix + "/"):
|
||||
rest = raw[len(alias_prefix):].lstrip("/")
|
||||
resolved_alias = Path(os.path.normpath(Path(alias_base) / rest))
|
||||
break
|
||||
resolved_alias = _resolve_tsconfig_alias(raw, aliases)
|
||||
if resolved_alias is not None:
|
||||
resolved_alias = _resolve_js_module_path(resolved_alias)
|
||||
node_id = _make_id(str(resolved_alias))
|
||||
@@ -13402,7 +13405,14 @@ def extract(
|
||||
# main_run -- splitting the symbol into AST/semantic ghosts (#1096). Relativize
|
||||
# the symbol prefix the same way, gated by source_file so two files sharing a
|
||||
# prefix can't cross-contaminate. Keyed by resolved path -> (old_pref, new_pref).
|
||||
prefix_remap: dict[Path, tuple[str, str]] = {}
|
||||
# Each file maps from up to TWO old prefixes — the input-form prefix
|
||||
# _file_node_id(path) and the absolute-resolved-form prefix
|
||||
# _file_node_id(path.resolve()). Alias/workspace imports resolve specifiers
|
||||
# through .resolve(), so their edge targets are keyed off the ABSOLUTE form;
|
||||
# when inputs are relative the two forms differ and absolute-derived targets
|
||||
# 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]]] = {}
|
||||
for path in paths:
|
||||
old_id = _make_id(str(path))
|
||||
try:
|
||||
@@ -13415,9 +13425,21 @@ def extract(
|
||||
new_id = _file_node_id(rel)
|
||||
if old_id != new_id:
|
||||
id_remap[old_id] = new_id
|
||||
# Also register the absolute-resolved form of the file-level id so
|
||||
# alias/workspace import targets (resolved via .resolve()) remap to
|
||||
# canonical instead of orphaning (#1529).
|
||||
old_id_abs = _make_id(str(path.resolve()))
|
||||
if old_id_abs != new_id:
|
||||
id_remap[old_id_abs] = new_id
|
||||
old_prefs: list[tuple[str, str]] = []
|
||||
old_pref = _file_node_id(path)
|
||||
if old_pref != new_id:
|
||||
prefix_remap[path.resolve()] = (old_pref, new_id)
|
||||
old_prefs.append((old_pref, new_id))
|
||||
old_pref_abs = _file_node_id(path.resolve())
|
||||
if old_pref_abs != new_id and old_pref_abs != old_pref:
|
||||
old_prefs.append((old_pref_abs, new_id))
|
||||
if old_prefs:
|
||||
prefix_remap[path.resolve()] = old_prefs
|
||||
if id_remap:
|
||||
for n in all_nodes:
|
||||
if n.get("id") in id_remap:
|
||||
@@ -13445,12 +13467,16 @@ def extract(
|
||||
continue
|
||||
if entry is None:
|
||||
continue
|
||||
old_pref, new_pref = entry
|
||||
nid = n.get("id", "")
|
||||
if nid.startswith(old_pref + "_"):
|
||||
new_nid = new_pref + nid[len(old_pref):]
|
||||
if new_nid != nid:
|
||||
sym_remap[nid] = new_nid
|
||||
# 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.
|
||||
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
|
||||
break
|
||||
if sym_remap:
|
||||
for n in all_nodes:
|
||||
if n.get("id") in sym_remap:
|
||||
|
||||
@@ -636,3 +636,108 @@ def test_ts_type_relationships_and_contexts(tmp_path: Path):
|
||||
assert ("run", "Payload", "parameter_type") in reference_contexts
|
||||
assert ("run", "Result", "return_type") in reference_contexts
|
||||
assert ("run", "Payload", "generic_arg") in reference_contexts
|
||||
|
||||
|
||||
# ── #1531: tsconfig path-alias fallback targets ──────────────────────────────
|
||||
|
||||
|
||||
def test_tsconfig_alias_resolves_second_target_when_first_missing(tmp_path: Path):
|
||||
# tsc tries each `paths` target in declared order until one resolves on disk.
|
||||
# The file lives only at the SECOND target, so keeping only the first entry
|
||||
# (#1531) dropped the edge.
|
||||
_write(
|
||||
tmp_path / "tsconfig.json",
|
||||
json.dumps({"compilerOptions": {"baseUrl": ".", "paths": {"$lib/*": ["generated/*", "src/lib/*"]}}}),
|
||||
)
|
||||
target = _write(tmp_path / "src/lib/utils.ts", "export const helper = 1\n")
|
||||
importer = _write(
|
||||
tmp_path / "src/routes/page.ts",
|
||||
"import { helper } from '$lib/utils'\nconsole.log(helper)\n",
|
||||
)
|
||||
|
||||
result = _extract_for([target, importer], tmp_path)
|
||||
|
||||
assert _has_edge(result, "src/routes/page.ts", "src/lib/utils.ts")
|
||||
|
||||
|
||||
def test_tsconfig_alias_first_target_wins_when_both_exist(tmp_path: Path):
|
||||
# When the file exists at BOTH targets, tsc resolves to the FIRST. The edge
|
||||
# must target the generated/ copy, not src/lib.
|
||||
_write(
|
||||
tmp_path / "tsconfig.json",
|
||||
json.dumps({"compilerOptions": {"baseUrl": ".", "paths": {"$lib/*": ["generated/*", "src/lib/*"]}}}),
|
||||
)
|
||||
first = _write(tmp_path / "generated/utils.ts", "export const helper = 1\n")
|
||||
second = _write(tmp_path / "src/lib/utils.ts", "export const helper = 2\n")
|
||||
importer = _write(
|
||||
tmp_path / "src/routes/page.ts",
|
||||
"import { helper } from '$lib/utils'\nconsole.log(helper)\n",
|
||||
)
|
||||
|
||||
result = _extract_for([first, second, importer], tmp_path)
|
||||
|
||||
assert _has_edge(result, "src/routes/page.ts", "generated/utils.ts")
|
||||
assert not _has_edge(result, "src/routes/page.ts", "src/lib/utils.ts")
|
||||
|
||||
|
||||
def test_tsconfig_alias_none_exist_creates_no_false_edge(tmp_path: Path):
|
||||
# The file exists at neither target; no concrete imports_from edge to either
|
||||
# candidate may be fabricated (it stays an external/phantom target).
|
||||
_write(
|
||||
tmp_path / "tsconfig.json",
|
||||
json.dumps({"compilerOptions": {"baseUrl": ".", "paths": {"$lib/*": ["generated/*", "src/lib/*"]}}}),
|
||||
)
|
||||
other = _write(tmp_path / "src/routes/other.ts", "export const x = 1\n")
|
||||
importer = _write(
|
||||
tmp_path / "src/routes/page.ts",
|
||||
"import { helper } from '$lib/utils'\nconsole.log(helper)\n",
|
||||
)
|
||||
|
||||
result = _extract_for([other, importer], tmp_path)
|
||||
|
||||
assert not _has_edge(result, "src/routes/page.ts", "generated/utils.ts")
|
||||
assert not _has_edge(result, "src/routes/page.ts", "src/lib/utils.ts")
|
||||
|
||||
|
||||
# ── #1529: alias/workspace import targets orphaned by the full-path migration ──
|
||||
|
||||
|
||||
def test_alias_import_edge_resolves_with_relative_input_paths(tmp_path, monkeypatch):
|
||||
# CRUCIAL: pass RELATIVE input paths (chdir into the project). Alias imports
|
||||
# resolve specifiers through .resolve(), so the import-target id is keyed off
|
||||
# the ABSOLUTE path; with relative inputs the id_remap (keyed on the input
|
||||
# form) never rewrote it -> orphan -> dropped edge (#1529). Absolute/tmp_path
|
||||
# inputs hide the bug because the two forms coincide.
|
||||
_write(
|
||||
tmp_path / "tsconfig.json",
|
||||
json.dumps({"compilerOptions": {"baseUrl": ".", "paths": {"@/*": ["src/*"]}}}),
|
||||
)
|
||||
_write(tmp_path / "src/lib/utils.ts", "export function formatDate(d) { return d }\n")
|
||||
_write(
|
||||
tmp_path / "src/components/Button.tsx",
|
||||
"import { formatDate } from '@/lib/utils'\nexport function Button() { return formatDate(1) }\n",
|
||||
)
|
||||
|
||||
monkeypatch.chdir(tmp_path)
|
||||
rel_paths = [Path("src/lib/utils.ts"), Path("src/components/Button.tsx")]
|
||||
result = extract(rel_paths, cache_root=Path("."))
|
||||
|
||||
node_ids = {n["id"] for n in result["nodes"]}
|
||||
target_id = _file_node_id(Path("src/lib/utils.ts"))
|
||||
|
||||
# The file-level imports_from edge must target the REAL utils file node (a node
|
||||
# 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 = {
|
||||
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
|
||||
# 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")
|
||||
|
||||
Reference in New Issue
Block a user