-
-
Notifications
You must be signed in to change notification settings - Fork 11.3k
fix: resolve unmapped @/ imports in JS projects #3362
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Closed
hopstreax
wants to merge
1
commit into
Graphify-Labs:v8
from
hopstreax:investigate/3357-js-alias-calls-regression
+345
−1
Closed
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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": | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
9 callers depend on it (afferent coupling). Grounded coupling-delta finding (deterministic), not an LLM guess. |
||
| """Resolve a JS/TS import path string to (target_nid, resolved_path). | ||
|
|
||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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( | ||
| '<script>\nimport { helper } from "@/lib.js";\n</script>\n<h1>Hello</h1>\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 |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
_resolve_js_module_path()fans out to 8 callees (efferent coupling); 28 callers depend on it (afferent coupling).
Grounded coupling-delta finding (deterministic), not an LLM guess.