diff --git a/graphify/extract.py b/graphify/extract.py index 868ce0a..d55f456 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -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 +

{projects.length}

+""", + ) + _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'; +--- +

x

+""", + ) + 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"