fix(extract): canonicalize regex-rescue import target ids (#2195)

The Svelte/Astro/Vue regex-rescue import passes minted stub target nodes
with absolute-path ids (ghost nodes alongside the real file node, plus
dangling imports_from edges). They now resolve via _resolve_js_module_path
and stamp edge target_file so the #2169 canonicalization repoints them;
when the target is an in-root real code file, only the edge is emitted (no
duplicate stub). The final relativization pass also remaps ids in its
in-root branch, closing the residual class.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
safishamsi
2026-07-26 11:52:20 +01:00
co-authored by Claude Opus 4.8
parent 9eb76ace30
commit d16510ed4a
2 changed files with 325 additions and 191 deletions
+120 -191
View File
@@ -1269,6 +1269,86 @@ def _extract_js_rationale(path: Path, result: dict) -> None:
_add_doc_ref(m.group(1), lineno)
def _emit_rescued_import(
result: dict,
existing_ids: set,
file_node_id: str,
path: Path,
raw: str,
relation: str,
aliases,
base_url,
) -> None:
"""Shared edge/stub emit for the Svelte/Astro/Vue regex-rescue import passes.
Resolves the specifier the same way ``_import_js`` does — relative paths and
tsconfig aliases both go through :func:`_resolve_js_module_path` so
extensionless specifiers probe real on-disk extensions (``../lib/content``
-> ``content.ts``) instead of a naive ``.js``->``.ts`` suffix swap.
When the resolved target is a real file on disk, mirror ``_import_js``:
emit ONLY the edge, stamped with ``target_file``, and mint no stub node.
The #2169 canonicalization loop in :func:`extract` reads the stamp and
repoints the edge at the real file node's canonical id. Minting a stub
here would carry an absolute-path-derived id when the input path is
absolute — a ghost node (e.g. ``private_tmp_..._src_lib_content``)
duplicating the real ``src_lib_content`` node and clobbering its label on
dedupe (#2195). Stub nodes are still minted for unresolved specifiers
(externals, not-yet-created files) so prior behavior is preserved.
"""
resolved_file: "Path | None" = None
if raw.startswith("."):
resolved = _resolve_js_module_path(
Path(os.path.normpath(path.parent / raw))
)
node_id = _make_id(str(resolved))
stub_source_file = str(resolved)
if resolved is not None and resolved.is_file():
resolved_file = resolved
else:
# Check tsconfig.json path aliases (e.g. "$lib/" -> "src/lib/",
# "@/" -> "src/") before treating as external. Mirrors _import_js
# logic so alias imports resolve to the same file node IDs the
# extractor creates (#701).
resolved_alias = _resolve_tsconfig_alias(raw, aliases, base_url=base_url)
if resolved_alias is not None:
resolved_alias = _resolve_js_module_path(resolved_alias)
node_id = _make_id(str(resolved_alias))
stub_source_file = str(resolved_alias)
if resolved_alias is not None and resolved_alias.is_file():
resolved_file = resolved_alias
else:
# Bare/scoped import (node_modules) - use last segment;
# build_from_json drops as external if no matching node exists.
module_name = raw.split("/")[-1]
if not module_name:
return
node_id = _make_id(module_name)
stub_source_file = raw
edge = {
"source": file_node_id, "target": node_id,
"relation": relation, "confidence": "EXTRACTED",
"source_file": str(path),
}
if resolved_file is not None:
# Real file on disk: edge only (no stub node), stamped so the #2169
# canonicalization pass repoints it at the real node (#2195).
edge["target_file"] = str(resolved_file)
result.setdefault("edges", []).append(edge)
return
if node_id in existing_ids:
# Edge target already a real node - just add the edge, don't add a node.
result.setdefault("edges", []).append(edge)
return
result.setdefault("nodes", []).append({
"id": node_id, "label": raw,
"file_type": "code", "source_file": stub_source_file,
"confidence": "EXTRACTED",
})
result.setdefault("edges", []).append(edge)
existing_ids.add(node_id)
def extract_svelte(path: Path) -> dict:
"""Extract imports from .svelte files: script-block via JS AST + template regex fallback.
@@ -1291,51 +1371,14 @@ def extract_svelte(path: Path) -> dict:
raw = m.group(1)
if not raw:
continue
if raw.startswith("."):
# Relative import - resolve to full path so IDs match file node IDs.
resolved = Path(os.path.normpath(path.parent / raw))
# Apply same TS/Svelte resolver fixups as static imports so dynamic
# imports of bare paths and .svelte.ts rune files land on real
# file nodes instead of phantom ids (#716).
resolved = _resolve_js_module_path(resolved)
node_id = _make_id(str(resolved))
stub_source_file = str(resolved)
else:
# 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 = _resolve_tsconfig_alias(raw, aliases, base_url=base_url)
if resolved_alias is not None:
resolved_alias = _resolve_js_module_path(resolved_alias)
node_id = _make_id(str(resolved_alias))
stub_source_file = str(resolved_alias)
else:
# Bare/scoped import (node_modules) - use last segment;
# build_from_json drops as external if no matching node exists.
module_name = raw.split("/")[-1]
if not module_name:
continue
node_id = _make_id(module_name)
stub_source_file = raw
if node_id in existing_ids:
# Edge target already a real node - just add the edge, don't add a node.
result.setdefault("edges", []).append({
"source": file_node_id, "target": node_id,
"relation": "dynamic_import", "confidence": "EXTRACTED",
"source_file": str(path),
})
continue
result.setdefault("nodes", []).append({
"id": node_id, "label": raw,
"file_type": "code", "source_file": stub_source_file,
"confidence": "EXTRACTED",
})
result.setdefault("edges", []).append({
"source": file_node_id, "target": node_id,
"relation": "dynamic_import", "confidence": "EXTRACTED",
"source_file": str(path),
})
existing_ids.add(node_id)
# Resolution + emit shared with the static pass below: relative
# paths and tsconfig aliases probe real on-disk extensions (#716,
# #701), and a target that IS a real file emits an edge stamped
# with target_file instead of an absolute-id ghost stub (#2195).
_emit_rescued_import(
result, existing_ids, file_node_id, path, raw,
"dynamic_import", aliases, base_url,
)
# Static imports inside <script> blocks. The JS tree-sitter parser fed
# the full .svelte file produces a top-level ERROR node (HTML markup
# is not valid JS), so import_statement nodes are never reached and
@@ -1353,43 +1396,10 @@ def extract_svelte(path: Path) -> dict:
raw = m.group(1)
if not raw:
continue
if raw.startswith("."):
resolved = Path(os.path.normpath(path.parent / raw))
if resolved.suffix == ".js":
resolved = resolved.with_suffix(".ts")
elif resolved.suffix == ".jsx":
resolved = resolved.with_suffix(".tsx")
node_id = _make_id(str(resolved))
stub_source_file = str(resolved)
else:
resolved_alias = _resolve_tsconfig_alias(raw, aliases, base_url=base_url)
if resolved_alias is not None:
node_id = _make_id(str(resolved_alias))
stub_source_file = str(resolved_alias)
else:
module_name = raw.split("/")[-1]
if not module_name:
continue
node_id = _make_id(module_name)
stub_source_file = raw
if node_id in existing_ids:
result.setdefault("edges", []).append({
"source": file_node_id, "target": node_id,
"relation": "imports_from", "confidence": "EXTRACTED",
"source_file": str(path),
})
continue
result.setdefault("nodes", []).append({
"id": node_id, "label": raw,
"file_type": "code", "source_file": stub_source_file,
"confidence": "EXTRACTED",
})
result.setdefault("edges", []).append({
"source": file_node_id, "target": node_id,
"relation": "imports_from", "confidence": "EXTRACTED",
"source_file": str(path),
})
existing_ids.add(node_id)
_emit_rescued_import(
result, existing_ids, file_node_id, path, raw,
"imports_from", aliases, base_url,
)
except Exception:
pass
return result
@@ -1422,41 +1432,10 @@ def extract_astro(path: Path) -> dict:
raw = m.group(1)
if not raw:
continue
if raw.startswith("."):
resolved = Path(os.path.normpath(path.parent / raw))
resolved = _resolve_js_module_path(resolved)
node_id = _make_id(str(resolved))
stub_source_file = str(resolved)
else:
resolved_alias = _resolve_tsconfig_alias(raw, aliases, base_url=base_url)
if resolved_alias is not None:
resolved_alias = _resolve_js_module_path(resolved_alias)
node_id = _make_id(str(resolved_alias))
stub_source_file = str(resolved_alias)
else:
module_name = raw.split("/")[-1]
if not module_name:
continue
node_id = _make_id(module_name)
stub_source_file = raw
if node_id in existing_ids:
result.setdefault("edges", []).append({
"source": file_node_id, "target": node_id,
"relation": "dynamic_import", "confidence": "EXTRACTED",
"source_file": str(path),
})
continue
result.setdefault("nodes", []).append({
"id": node_id, "label": raw,
"file_type": "code", "source_file": stub_source_file,
"confidence": "EXTRACTED",
})
result.setdefault("edges", []).append({
"source": file_node_id, "target": node_id,
"relation": "dynamic_import", "confidence": "EXTRACTED",
"source_file": str(path),
})
existing_ids.add(node_id)
_emit_rescued_import(
result, existing_ids, file_node_id, path, raw,
"dynamic_import", aliases, base_url,
)
# Static imports: scan the `---...---` frontmatter at the file head plus any
# client-side <script> blocks. Both are TS/JS regions but live inside a file
# the JS tree-sitter parser cannot validate as a whole.
@@ -1480,43 +1459,10 @@ def extract_astro(path: Path) -> dict:
raw = m.group(1)
if not raw:
continue
if raw.startswith("."):
resolved = Path(os.path.normpath(path.parent / raw))
if resolved.suffix == ".js":
resolved = resolved.with_suffix(".ts")
elif resolved.suffix == ".jsx":
resolved = resolved.with_suffix(".tsx")
node_id = _make_id(str(resolved))
stub_source_file = str(resolved)
else:
resolved_alias = _resolve_tsconfig_alias(raw, aliases, base_url=base_url)
if resolved_alias is not None:
node_id = _make_id(str(resolved_alias))
stub_source_file = str(resolved_alias)
else:
module_name = raw.split("/")[-1]
if not module_name:
continue
node_id = _make_id(module_name)
stub_source_file = raw
if node_id in existing_ids:
result.setdefault("edges", []).append({
"source": file_node_id, "target": node_id,
"relation": "imports_from", "confidence": "EXTRACTED",
"source_file": str(path),
})
continue
result.setdefault("nodes", []).append({
"id": node_id, "label": raw,
"file_type": "code", "source_file": stub_source_file,
"confidence": "EXTRACTED",
})
result.setdefault("edges", []).append({
"source": file_node_id, "target": node_id,
"relation": "imports_from", "confidence": "EXTRACTED",
"source_file": str(path),
})
existing_ids.add(node_id)
_emit_rescued_import(
result, existing_ids, file_node_id, path, raw,
"imports_from", aliases, base_url,
)
except Exception:
pass
return result
@@ -1561,41 +1507,10 @@ def extract_vue(path: Path) -> dict:
raw = m.group(1)
if not raw:
continue
if raw.startswith("."):
resolved = Path(os.path.normpath(path.parent / raw))
resolved = _resolve_js_module_path(resolved)
node_id = _make_id(str(resolved))
stub_source_file = str(resolved)
else:
resolved_alias = _resolve_tsconfig_alias(raw, aliases, base_url=base_url)
if resolved_alias is not None:
resolved_alias = _resolve_js_module_path(resolved_alias)
node_id = _make_id(str(resolved_alias))
stub_source_file = str(resolved_alias)
else:
module_name = raw.split("/")[-1]
if not module_name:
continue
node_id = _make_id(module_name)
stub_source_file = raw
if node_id in existing_ids:
result.setdefault("edges", []).append({
"source": file_node_id, "target": node_id,
"relation": "dynamic_import", "confidence": "EXTRACTED",
"source_file": str(path),
})
continue
result.setdefault("nodes", []).append({
"id": node_id, "label": raw,
"file_type": "code", "source_file": stub_source_file,
"confidence": "EXTRACTED",
})
result.setdefault("edges", []).append({
"source": file_node_id, "target": node_id,
"relation": "dynamic_import", "confidence": "EXTRACTED",
"source_file": str(path),
})
existing_ids.add(node_id)
_emit_rescued_import(
result, existing_ids, file_node_id, path, raw,
"dynamic_import", aliases, base_url,
)
except Exception:
pass
return result
@@ -5352,10 +5267,24 @@ def extract(
if not sf_path.is_absolute():
continue
try:
item["source_file"] = sf_path.relative_to(root).as_posix()
continue
rel = sf_path.relative_to(root)
except ValueError:
pass
else:
# Belt-and-braces for #2195: a stub node minted by the Svelte/
# Astro/Vue regex rescue from an ABSOLUTE input path keeps an
# absolute-path-derived id when no earlier pass learned it (the
# target never resolved to a real file, so the edge carried no
# target_file stamp for the #2169 remap). Mirror the out-of-root
# check below: remap it to the same canonical repo-relative form
# the real file node would use (_file_node_id) so the scan root
# can never leak into a persisted id. Real file nodes were
# already remapped by the #2169 pass, so only leftover stubs
# match here.
if item.get("id") == _make_id(str(sf_path)):
ext_id_remap[item["id"]] = _file_node_id(rel)
item["source_file"] = rel.as_posix()
continue
portable = _portable_out_of_root_sf(sf_path)
# A node whose id was minted from this absolute path also leaks it.
if "id" in item and item.get("id") == _make_id(str(sf_path)):
+205
View File
@@ -0,0 +1,205 @@
"""Regression tests for #2195: Astro/Svelte regex-rescued imports must not mint
ghost nodes keyed by the absolute scan path.
The Svelte/Astro/Vue regex-rescue passes used to mint stub TARGET nodes whose id
was ``_make_id(str(resolved_path))`` — ABSOLUTE when the input paths were
absolute — producing ghost nodes (e.g. ``private_tmp_..._src_lib_content``)
alongside the real file node (``src_lib_content``), plus dangling
``imports_from`` edges. The static branches also used a naive ``.js``->``.ts``
suffix swap instead of extension probing, so extensionless specifiers
(``../../lib/content``) never matched the real ``content.ts`` file and the
edges carried no ``target_file`` stamp for the #2169 canonicalization pass to
learn from.
Fixed by routing all five rescue sites through the shared helper, which mirrors
``_import_js``: resolve via ``_resolve_js_module_path`` (extension probing) and,
when the target is a real file on disk, emit ONLY the ``target_file``-stamped
edge — no stub node.
"""
from __future__ import annotations
import os
from pathlib import Path
from graphify.extract import _file_node_id, _make_id, extract
def _write(path: Path, body: str) -> Path:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(body, encoding="utf-8")
return path
def _astro_project(tmp_path: Path) -> Path:
# realpath: on macOS pytest's tmp dir lives under /private/var but is
# handed out as /var — the extractor resolves paths, so anchor the test
# on the resolved form to keep id/slug comparisons meaningful.
root = Path(os.path.realpath(tmp_path))
_write(
root / "src/pages/work/index.astro",
"""---
import { projects } from '../../lib/content';
import { SITE } from '../../config';
import '../styles/global.css';
---
<h1>{SITE}</h1>
""",
)
_write(root / "src/lib/content.ts", "export const projects = [1];\n")
_write(root / "src/config.ts", "export const SITE = 'x';\n")
_write(root / "src/pages/styles/global.css", "body { margin: 0 }\n")
return root
def _assert_no_root_slug(result: dict, root: Path) -> None:
"""No node id and no edge endpoint may embed the scan-root path slug."""
root_slug = _make_id(str(root))
for n in result["nodes"]:
assert root_slug not in n["id"], f"ghost node id leaks scan root: {n['id']}"
for e in result["edges"]:
assert root_slug not in str(e.get("source") or ""), (
f"edge source leaks scan root: {e['source']}"
)
assert root_slug not in str(e.get("target") or ""), (
f"edge target leaks scan root: {e['target']}"
)
def _astro_paths(root: Path) -> list[Path]:
return [
root / "src/pages/work/index.astro",
root / "src/lib/content.ts",
root / "src/config.ts",
]
def test_astro_absolute_inputs_no_ghost_import_nodes(tmp_path):
"""Absolute input paths must not mint absolute-id ghost stubs (#2195)."""
root = _astro_project(tmp_path)
result = extract(_astro_paths(root), cache_root=root)
_assert_no_root_slug(result, root)
# Exactly one node represents src/lib/content.ts, under its canonical id.
content_file_nodes = [
n for n in result["nodes"]
if n.get("source_file") == "src/lib/content.ts"
and n["id"] == _file_node_id(Path("src/lib/content.ts"))
]
assert len(content_file_nodes) == 1
assert content_file_nodes[0]["id"] == "src_lib_content"
# And no OTHER node claims to BE that file (a ghost stub would carry the
# same source_file but an absolute-derived id).
file_level = [
n for n in result["nodes"]
if n.get("source_file") == "src/lib/content.ts"
and n["id"].endswith("content")
]
assert file_level == content_file_nodes
# The rescued import edge lands on the canonical real node.
index_id = _file_node_id(Path("src/pages/work/index.astro"))
import_edges = {
(e["source"], e["target"])
for e in result["edges"]
if e.get("relation") == "imports_from"
}
assert (index_id, "src_lib_content") in import_edges
assert (index_id, "src_config") in import_edges
# The CSS side-effect import edge must not leak the root either; its
# canonical target is derived from the repo-relative path.
css_targets = [
e["target"] for e in result["edges"]
if e.get("relation") == "imports_from" and "global" in str(e.get("target"))
]
assert css_targets, "css import edge missing"
assert all(t == "src_pages_styles_global" for t in css_targets)
def test_astro_relative_inputs_keep_canonical_ids(tmp_path, monkeypatch):
"""Relative inputs: the real file node keeps its canonical id — the #1462
colliding-id disambiguator must not rename it away because a same-id ghost
stub was minted (regression guard)."""
root = _astro_project(tmp_path)
monkeypatch.chdir(root)
rel_paths = [
Path("src/pages/work/index.astro"),
Path("src/lib/content.ts"),
Path("src/config.ts"),
]
result = extract(rel_paths, cache_root=Path("."))
_assert_no_root_slug(result, root)
content_file_nodes = [
n for n in result["nodes"] if n["id"] == "src_lib_content"
]
assert len(content_file_nodes) == 1
assert content_file_nodes[0]["source_file"] == "src/lib/content.ts"
index_id = _file_node_id(Path("src/pages/work/index.astro"))
import_edges = {
(e["source"], e["target"])
for e in result["edges"]
if e.get("relation") == "imports_from"
}
assert (index_id, "src_lib_content") in import_edges
assert (index_id, "src_config") in import_edges
def test_svelte_absolute_inputs_no_ghost_import_nodes(tmp_path):
"""Svelte <script> static imports go through the same rescue path (#2195)."""
root = Path(os.path.realpath(tmp_path))
_write(
root / "src/routes/page.svelte",
"""<script>
import { projects } from '../lib/content';
const lazy = () => import('../lib/content');
</script>
<h1>{projects.length}</h1>
""",
)
_write(root / "src/lib/content.ts", "export const projects = [1];\n")
result = extract(
[root / "src/routes/page.svelte", root / "src/lib/content.ts"],
cache_root=root,
)
_assert_no_root_slug(result, root)
content_file_nodes = [
n for n in result["nodes"] if n["id"] == "src_lib_content"
]
assert len(content_file_nodes) == 1
assert content_file_nodes[0]["source_file"] == "src/lib/content.ts"
page_id = _file_node_id(Path("src/routes/page.svelte"))
edge_pairs = {
(e["source"], e["target"], e["relation"]) for e in result["edges"]
}
assert (page_id, "src_lib_content", "imports_from") in edge_pairs
assert (page_id, "src_lib_content", "dynamic_import") in edge_pairs
def test_astro_unresolved_relative_import_id_still_portable(tmp_path):
"""A rescued import whose target does NOT exist still mints a stub (so the
edge survives as a hint), but the stub id must be the repo-relative form,
not the absolute-path slug — the belt-and-braces remap in the
relativization pass (#2195)."""
root = Path(os.path.realpath(tmp_path))
_write(
root / "src/pages/index.astro",
"""---
import { gone } from '../missing/nowhere';
---
<p>x</p>
""",
)
result = extract([root / "src/pages/index.astro"], cache_root=root)
_assert_no_root_slug(result, root)
stub = [n for n in result["nodes"] if n.get("label") == "../missing/nowhere"]
assert len(stub) == 1
assert stub[0]["id"] == "src_missing_nowhere"