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:
co-authored by
Claude Opus 4.8
parent
9eb76ace30
commit
d16510ed4a
+120
-191
@@ -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)):
|
||||
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user