From b2725960c1df6c424e16c8f6a47ab93035cc4395 Mon Sep 17 00:00:00 2001 From: himanshupatro-334 Date: Sat, 5 Sep 2026 23:34:29 +0530 Subject: [PATCH] fix: resolve unmapped @/ imports in JS projects --- graphify/extract.py | 5 + graphify/extractors/resolution.py | 47 +++- tests/test_unmapped_at_alias_resolution.py | 294 +++++++++++++++++++++ 3 files changed, 345 insertions(+), 1 deletion(-) create mode 100644 tests/test_unmapped_at_alias_resolution.py diff --git a/graphify/extract.py b/graphify/extract.py index 42fccce96a..70db2c2c86 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -1617,6 +1617,11 @@ def _resolve_rescued_specifier( resolved_file = (resolved_alias if resolved_alias is not None and resolved_alias.is_file() else None) return _make_id(str(resolved_alias)), str(resolved_alias), resolved_file + # Unmapped `@/` project-root convention fallback (#3357). + if raw.startswith("@/"): + resolved_unmapped = _resolve_js_module_path(raw, path.parent) + if resolved_unmapped is not None and resolved_unmapped.is_file(): + return _make_id(str(resolved_unmapped)), str(resolved_unmapped), resolved_unmapped # Bare/scoped import (node_modules) - use last segment; # build_from_json drops as external if no matching node exists. module_name = raw.split("/")[-1] diff --git a/graphify/extractors/resolution.py b/graphify/extractors/resolution.py index 6c5158ac9e..886787a67d 100644 --- a/graphify/extractors/resolution.py +++ b/graphify/extractors/resolution.py @@ -505,6 +505,33 @@ def _resolve_workspace_import(raw: str, start_dir: Path) -> Path | None: return resolved return None +def _find_js_project_anchor(start_dir: Path) -> Path: + """Discover the project root anchor for unmapped JS/TS convention aliases (#3357). + + Walks upward from start_dir looking for: + 1. Nearest directory containing package.json or pnpm-workspace.yaml + 2. Nearest directory containing a VCS marker (_find_vcs_root) + 3. Fallback: start_dir itself + """ + from graphify.detect import _find_vcs_root + + current = start_dir.resolve() + home = Path.home() + + for candidate in [current, *current.parents]: + if candidate == home: + break + if (candidate / "package.json").is_file() or (candidate / "pnpm-workspace.yaml").is_file(): + return candidate + + vcs_root = _find_vcs_root(start_dir) + if vcs_root is not None: + return vcs_root + + return current + + + def _resolve_js_module_path(raw: str | Path, start_dir: Path | None = None) -> Path | None: """Resolve a JS/TS module path or specifier to a local source file. @@ -526,7 +553,25 @@ def _resolve_js_module_path(raw: str | Path, start_dir: Path | None = None) -> P if hit is not None: return _resolve_js_import_path(hit) - return _resolve_workspace_import(raw, start_dir) + workspace_hit = _resolve_workspace_import(raw, start_dir) + if workspace_hit is not None: + return workspace_hit + + # Unmapped `@/` project-root convention fallback (#3357). + # Active ONLY when NO tsconfig.json or jsconfig.json exists anywhere in the upward tree. + if raw.startswith("@/") and _find_js_config(start_dir) is None: + subpath = raw[2:] + if subpath: + anchor = _find_js_project_anchor(start_dir) + if (anchor / "src").is_dir(): + cand = _resolve_js_import_path(anchor / "src" / subpath) + if cand.is_file(): + return cand + cand = _resolve_js_import_path(anchor / subpath) + if cand.is_file(): + return cand + + return None def _resolve_js_import_target(raw: str, str_path: str) -> "tuple[str, Path | None] | None": """Resolve a JS/TS import path string to (target_nid, resolved_path). diff --git a/tests/test_unmapped_at_alias_resolution.py b/tests/test_unmapped_at_alias_resolution.py new file mode 100644 index 0000000000..7ad230afc2 --- /dev/null +++ b/tests/test_unmapped_at_alias_resolution.py @@ -0,0 +1,294 @@ +"""Regression tests for #3357: unmapped `@/` path alias resolution. + +When no tsconfig.json or jsconfig.json exists, `@/...` represents an internal +project-root convention alias. It resolves against project_anchor / "src" / subpath +(if src/ exists) or project_anchor / subpath. +""" +from __future__ import annotations + +import json +from pathlib import Path + +from graphify.extract import _make_id, extract +from graphify.extractors.resolution import _resolve_js_module_path, _resolve_js_import_target +from graphify.extract import _resolve_rescued_specifier + + +def _write(path: Path, text: str) -> Path: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + return path + + +def test_3357_minimal_reproduction_no_config_emits_calls_edge(tmp_path: Path, monkeypatch): + """Minimal reproduction for #3357: + + - adapter.js exports fn + - caller.js imports fn from "@/adapter.js" + - no package.json, no tsconfig.json, no jsconfig.json + - emits imports_from, imports, and calls edges to the actual adapter/function nodes. + """ + # Prevent host machine VCS roots (e.g. C:\Users\HP\.git) from anchoring temp test paths + monkeypatch.setattr("graphify.detect._find_vcs_root", lambda start: None) + adapter = _write( + tmp_path / "adapter.js", + "export function enableBackgroundBle() { return 1; }\n", + ) + caller = _write( + tmp_path / "caller.js", + 'import { enableBackgroundBle } from "@/adapter.js";\n' + "function run() { enableBackgroundBle(); }\n", + ) + res = extract([adapter, caller], cache_root=tmp_path, parallel=False) + nodes = {n["id"]: n for n in res["nodes"]} + edges = res["edges"] + + + adapter_nid = _make_id(str(adapter.relative_to(tmp_path).with_suffix(""))) + fn_nid = _make_id("adapter", "enableBackgroundBle") + + # 1. imports_from edge points to adapter file node + imports_from = [ + e for e in edges + if e.get("relation") == "imports_from" and e.get("source") == "caller" + ] + assert len(imports_from) == 1 + assert imports_from[0]["target"] == adapter_nid + + # 2. imports edge points to actual function symbol node + imports = [ + e for e in edges + if e.get("relation") == "imports" and e.get("source") == "caller" + ] + assert len(imports) == 1 + assert imports[0]["target"] == fn_nid + + # 3. calls edge emitted with EXTRACTED confidence + calls = [ + e for e in edges + if e.get("relation") == "calls" + and nodes.get(e["source"], {}).get("label") == "run()" + ] + assert len(calls) == 1 + assert calls[0]["target"] == fn_nid + assert calls[0]["confidence"] == "EXTRACTED" + + +def test_nested_importer_src_layout_resolves_to_src(tmp_path: Path): + """In a standard src/ layout with a nested importer, @/ resolves to src/.""" + _write(tmp_path / "package.json", '{"name": "test-pkg"}\n') + adapter = _write( + tmp_path / "src" / "adapter.js", + "export function doThing() { return 2; }\n", + ) + caller = _write( + tmp_path / "src" / "features" / "caller.js", + 'import { doThing } from "@/adapter.js";\n' + "export function run() { doThing(); }\n", + ) + res = extract([adapter, caller], cache_root=tmp_path, parallel=False) + nodes = {n["id"]: n for n in res["nodes"]} + + adapter_nid = _make_id(str(adapter.relative_to(tmp_path).with_suffix(""))) + fn_nid = _make_id("src_adapter", "doThing") + + calls = [ + e for e in res["edges"] + if e.get("relation") == "calls" + and nodes.get(e["source"], {}).get("label") == "run()" + ] + assert len(calls) == 1 + assert calls[0]["target"] == fn_nid + assert calls[0]["confidence"] == "EXTRACTED" + + +def test_nested_importer_flat_layout_resolves_to_root(tmp_path: Path): + """In a flat layout (no src/ dir) with a nested importer, @/ resolves to root.""" + _write(tmp_path / "package.json", '{"name": "flat-pkg"}\n') + adapter = _write( + tmp_path / "adapter.js", + "export function rootHelper() { return 3; }\n", + ) + caller = _write( + tmp_path / "features" / "deep" / "caller.js", + 'import { rootHelper } from "@/adapter.js";\n' + "export function run() { rootHelper(); }\n", + ) + res = extract([adapter, caller], cache_root=tmp_path, parallel=False) + nodes = {n["id"]: n for n in res["nodes"]} + + adapter_nid = _make_id(str(adapter.relative_to(tmp_path).with_suffix(""))) + fn_nid = _make_id("adapter", "rootHelper") + + calls = [ + e for e in res["edges"] + if e.get("relation") == "calls" + and nodes.get(e["source"], {}).get("label") == "run()" + ] + assert len(calls) == 1 + assert calls[0]["target"] == fn_nid + assert calls[0]["confidence"] == "EXTRACTED" + + +def test_tsconfig_without_paths_leaves_at_alias_unresolved(tmp_path: Path): + """#3125 invariant: when tsconfig.json exists without paths, @/ must NOT resolve.""" + _write(tmp_path / "tsconfig.json", json.dumps({"compilerOptions": {"target": "es2020"}})) + adapter = _write( + tmp_path / "adapter.js", + "export function enableBackgroundBle() { return 1; }\n", + ) + caller = _write( + tmp_path / "caller.js", + 'import { enableBackgroundBle } from "@/adapter.js";\n' + "function run() { enableBackgroundBle(); }\n", + ) + res = extract([adapter, caller], cache_root=tmp_path, parallel=False) + edges = res["edges"] + + # Must fall back to ref target + ref_target = _make_id("ref", "@/adapter.js") + imports_from = [ + e for e in edges + if e.get("relation") == "imports_from" and e.get("source") == "caller" + ] + assert len(imports_from) == 1 + assert imports_from[0]["target"] == ref_target + + # No symbol import edge + assert not any(e.get("relation") == "imports" and e.get("source") == "caller" for e in edges) + + # No calls edge (gated by #1659) + assert not any(e.get("relation") == "calls" for e in edges) + + +def test_explicit_tsconfig_alias_takes_precedence_over_convention(tmp_path: Path): + """When tsconfig defines @/* paths, configured mapping wins over convention.""" + _write(tmp_path / "tsconfig.json", json.dumps({ + "compilerOptions": { + "baseUrl": ".", + "paths": {"@/*": ["custom/*"]} + } + })) + custom_adapter = _write( + tmp_path / "custom" / "adapter.js", + "export function targetFunc() { return 'custom'; }\n", + ) + # Root adapter that convention would have targeted + root_adapter = _write( + tmp_path / "adapter.js", + "export function targetFunc() { return 'root'; }\n", + ) + caller = _write( + tmp_path / "caller.js", + 'import { targetFunc } from "@/adapter.js";\n' + "function run() { targetFunc(); }\n", + ) + res = extract([custom_adapter, root_adapter, caller], cache_root=tmp_path, parallel=False) + nodes = {n["id"]: n for n in res["nodes"]} + + custom_fn_nid = _make_id("custom_adapter", "targetFunc") + calls = [ + e for e in res["edges"] + if e.get("relation") == "calls" + and nodes.get(e["source"], {}).get("label") == "run()" + ] + assert len(calls) == 1 + assert calls[0]["target"] == custom_fn_nid + + +def test_missing_at_target_uses_ref_fallback(tmp_path: Path): + """@/pointing to non-existent file falls back to stable ref target.""" + caller = _write( + tmp_path / "caller.js", + 'import { missingFn } from "@/does-not-exist.js";\n' + "function run() { missingFn(); }\n", + ) + res = extract([caller], cache_root=tmp_path, parallel=False) + edges = res["edges"] + + ref_target = _make_id("ref", "@/does-not-exist.js") + imports_from = [ + e for e in edges + if e.get("relation") == "imports_from" and e.get("source") == "caller" + ] + assert len(imports_from) == 1 + assert imports_from[0]["target"] == ref_target + + # No imports or calls edges + assert not any(e.get("relation") == "imports" for e in edges) + assert not any(e.get("relation") == "calls" for e in edges) + + +def test_scoped_package_import_remains_external(tmp_path: Path): + """@scope/pkg is not an @/ alias and remains an external reference.""" + caller = _write( + tmp_path / "caller.js", + 'import { something } from "@scope/pkg";\n' + "function run() { something(); }\n", + ) + res = extract([caller], cache_root=tmp_path, parallel=False) + edges = res["edges"] + + ref_target = _make_id("ref", "@scope/pkg") + imports_from = [ + e for e in edges + if e.get("relation") == "imports_from" and e.get("source") == "caller" + ] + assert len(imports_from) == 1 + assert imports_from[0]["target"] == ref_target + + +def test_regex_rescue_unmapped_at_alias_resolves(tmp_path: Path): + """The regex-rescue path in extract.py (#701) also resolves unmapped @/.""" + _write(tmp_path / "package.json", '{"name": "test-app"}\n') + lib = _write( + tmp_path / "lib.js", + "export const helper = 42;\n", + ) + svelte_file = tmp_path / "App.svelte" + # Svelte static import rescued by regex + svelte_file.write_text( + '\n

Hello

\n', + encoding="utf-8", + ) + resolution = _resolve_rescued_specifier(svelte_file, "@/lib.js", aliases={}, base_url=None) + assert resolution is not None + node_id, stub_sf, resolved_file = resolution + assert resolved_file == lib + + +def test_unresolved_at_alias_does_not_infer_call_to_unrelated_definition(tmp_path: Path): + """#1659 protection: an unresolved @/ alias must not infer calls to unrelated definitions. + + If caller.js imports { missingFn } from "@/does-not-exist.js" and unrelated.js + happens to export a lone matching function missingFn(), Graphify must NOT emit + a phantom INFERRED or EXTRACTED calls edge from caller.js to unrelated.js. + """ + caller = _write( + tmp_path / "caller.js", + 'import { missingFn } from "@/does-not-exist.js";\n' + "function run() { missingFn(); }\n", + ) + unrelated = _write( + tmp_path / "unrelated.js", + "export function missingFn() { return 'unrelated'; }\n", + ) + res = extract([caller, unrelated], cache_root=tmp_path, parallel=False) + edges = res["edges"] + + # The @/ import must resolve to ref_ target + ref_target = _make_id("ref", "@/does-not-exist.js") + imports_from = [ + e for e in edges + if e.get("relation") == "imports_from" and e.get("source") == "caller" + ] + assert len(imports_from) == 1 + assert imports_from[0]["target"] == ref_target + + # Must NOT produce any calls edge from caller.js (run) to unrelated.js (missingFn) + unrelated_fn_nid = _make_id("unrelated", "missingFn") + calls_to_unrelated = [ + e for e in edges + if e.get("relation") == "calls" and e.get("target") == unrelated_fn_nid + ] + assert not calls_to_unrelated