diff --git a/graphify/extract.py b/graphify/extract.py index e015c9d715..f5fbd82cba 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -71,6 +71,7 @@ _JS_INDEX_FILES, _JS_PRIMITIVE_TYPES, _JS_RESOLVE_EXTS, + _PACKAGE_IMPORTS_CACHE, _TSCONFIG_ALIAS_CACHE, _TSCONFIG_BASEURL_CACHE, _VUE_SCRIPT_LANG_RE, @@ -5936,6 +5937,7 @@ def extract( # Clearing per run, not per file, leaves within-run caching intact. _TSCONFIG_ALIAS_CACHE.clear() _TSCONFIG_BASEURL_CACHE.clear() + _PACKAGE_IMPORTS_CACHE.clear() _XAML_CSHARP_CLASS_CACHE.clear() _MD_LINK_INDEX_CACHE.clear() diff --git a/graphify/extractors/resolution.py b/graphify/extractors/resolution.py index 44b027c696..0e1d6aa967 100644 --- a/graphify/extractors/resolution.py +++ b/graphify/extractors/resolution.py @@ -24,6 +24,11 @@ _WORKSPACE_MANIFEST_NAMES = ("pnpm-workspace.yaml", "package.json") +# Nearest package.json `imports` map (Node subpath imports), keyed by the +# resolved start directory. Same lifetime rule as _TSCONFIG_ALIAS_CACHE: no +# mtime component, so extract() clears it per run. +_PACKAGE_IMPORTS_CACHE: "dict[str, tuple[Path, dict] | None]" = {} + _JS_RESOLVE_EXTS = (".ts", ".tsx", ".mts", ".cts", ".svelte", ".js", ".jsx", ".mjs", ".cjs") _JS_INDEX_FILES = ("index.ts", "index.tsx", "index.svelte", "index.js", "index.jsx", "index.mjs") @@ -505,6 +510,94 @@ def _resolve_workspace_import(raw: str, start_dir: Path) -> Path | None: return resolved return None +def _load_package_imports(start_dir: Path) -> "tuple[Path, dict] | None": + """Nearest package.json `imports` map walking up from start_dir. + + Node subpath imports (https://nodejs.org/api/packages.html#subpath-imports): + keys start with `#`, values are relative targets or condition objects, and + the map applies to every file inside that package. AdonisJS 6 scaffolds + `"#services/*": "./app/services/*.js"` and imports through it everywhere; + without this map ~90% of an Adonis app's imports resolved to nothing and + `affected` saw only the handful of files using relative paths. Unlike + tsconfig `paths`, a nested tsconfig (e.g. `inertia/tsconfig.json`) cannot + shadow it, which matches how Node and tsc actually resolve these. + + Returns (package_dir, imports) or None. The nearest package.json wins even + when it has no `imports` (Node never walks past the enclosing package). + """ + current = start_dir.resolve() + key = str(current) + if key in _PACKAGE_IMPORTS_CACHE: + return _PACKAGE_IMPORTS_CACHE[key] + result: "tuple[Path, dict] | None" = None + for candidate in [current, *current.parents]: + manifest = candidate / "package.json" + if manifest.is_file(): + data = _read_json_config(manifest) + imports = data.get("imports") if isinstance(data, dict) else None + if isinstance(imports, dict) and imports: + result = (candidate, imports) + break + _PACKAGE_IMPORTS_CACHE[key] = result + return result + +def _match_subpath_import(raw: str, pattern: str) -> "tuple[int, str, bool] | None": + """Node semantics for an `imports` key: exact match, or a single `*` wildcard. + + Stricter than _match_tsconfig_alias on purpose: `#utils` must NOT match + `#utils/x` (Node treats a non-wildcard key as an exact specifier). Returns + (specificity, captured, is_wildcard); lower specificity wins, longest + literal prefix first among wildcards, mirroring Node's pattern ranking. + """ + if "*" in pattern: + if pattern.count("*") != 1: + return None + prefix, suffix = pattern.split("*", 1) + if not raw.startswith(prefix) or not raw.endswith(suffix): + return None + end = len(raw) - len(suffix) if suffix else len(raw) + if end < len(prefix): + return None + return -len(prefix), raw[len(prefix):end], True + if raw == pattern: + return -(len(pattern) + 1_000_000), "", False + return None + +def _resolve_package_import(raw: str, start_dir: Path) -> "Path | None": + """Resolve a `#subpath` specifier through package.json `imports` to a local file. + + Only local targets (`./…`) are followed: a value naming an external package + (`"#dep": "dep-node-native"`) is a third-party dependency and must keep the + existing external-reference behavior in _resolve_js_import_target. + """ + if not raw.startswith("#"): + return None + loaded = _load_package_imports(start_dir) + if loaded is None: + return None + package_dir, imports = loaded + best: "tuple[int, str, bool, object] | None" = None + for pattern, value in imports.items(): + if not isinstance(pattern, str) or not pattern.startswith("#"): + continue + match = _match_subpath_import(raw, pattern) + if match is None: + continue + specificity, captured, is_wildcard = match + if best is None or specificity < best[0]: + best = (specificity, captured, is_wildcard, value) + if best is None: + return None + _, captured, is_wildcard, value = best + target = _resolve_export_target(value) + if not isinstance(target, str) or not target.startswith("./"): + return None + if is_wildcard: + target = target.replace("*", captured, 1) if captured else target + candidate = Path(os.path.normpath(package_dir / target)) + resolved = _resolve_js_import_path(candidate) + return resolved if resolved.is_file() else None + 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,6 +619,13 @@ 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) + # Node subpath imports (`#services/foo` via package.json `imports`). Tried + # after tsconfig `paths` so an explicit alias keeps precedence (#1269). + if raw.startswith("#"): + hit = _resolve_package_import(raw, start_dir) + if hit is not None: + return hit + return _resolve_workspace_import(raw, start_dir) def _resolve_js_import_target(raw: str, str_path: str) -> "tuple[str, Path | None] | None": diff --git a/tests/test_package_json_subpath_imports.py b/tests/test_package_json_subpath_imports.py new file mode 100644 index 0000000000..f26c596810 --- /dev/null +++ b/tests/test_package_json_subpath_imports.py @@ -0,0 +1,125 @@ +"""Regression tests: Node subpath imports via package.json `imports`. + +`import ZitadelService from '#services/zitadel_service'` is how every AdonisJS 6 +app (and any package using Node's `imports` field) reaches its own modules. The +resolver only knew tsconfig `paths`, so in a real Adonis app 438 of 680 alias +imports produced no edge and `affected "ZitadelService"` listed 2 nodes from a +stray root script instead of the 7 controllers and the auth middleware that +actually depend on it. Declaring the same aliases in tsconfig `paths` fixed it, +which is the workaround this test makes unnecessary. + +Semantics follow https://nodejs.org/api/packages.html#subpath-imports: +keys start with `#`, a single `*` wildcard, condition objects allowed, targets +are package-relative, and the nearest package.json applies to every file in the +package regardless of nested tsconfigs. +""" +from pathlib import Path + +from graphify.extract import _make_id, extract +from graphify.extractors.resolution import _resolve_js_module_path + + +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 _cid(root: Path, abs_path: Path) -> str: + """Canonical root-relative file-node id of a cross-file import target (#2169).""" + return _make_id(str(Path(abs_path).relative_to(root).with_suffix(""))) + + +def _targets(result: dict) -> set[str]: + return {e["target"] for e in result["edges"]} + + +def _adonis_tree(tmp_path: Path, imports_json: str, importer_body: str, + importer: str = "app/controllers/users_controller.ts") -> Path: + """An AdonisJS-shaped project: `imports` in package.json, `.js` targets, `.ts` sources.""" + _write(tmp_path / "package.json", + '{\n "name": "app",\n "type": "module",\n' + f' "imports": {imports_json}\n}}\n') + _write(tmp_path / "app" / "services" / "zitadel_service.ts", + "export default class ZitadelService {}\n") + return _write(tmp_path / importer, importer_body) + + +ADONIS_IMPORTS = '{ "#services/*": "./app/services/*.js", "#models/*": "./app/models/*.js" }' + + +def test_wildcard_subpath_import_resolves(tmp_path): + f = _adonis_tree(tmp_path, ADONIS_IMPORTS, + "import ZitadelService from '#services/zitadel_service'\n" + "export default class UsersController {}\n") + r = extract([f], cache_root=tmp_path) + assert _cid(tmp_path, tmp_path / "app/services/zitadel_service.ts") in _targets(r) + + +def test_subpath_import_resolves_from_nested_dir_with_own_tsconfig(tmp_path): + # inertia/tsconfig.json declares its own `paths` without the `#` aliases. + # tsconfig lookup stops there and finds nothing; package.json `imports` + # still applies because Node resolves it from the enclosing package. + _write(tmp_path / "inertia" / "tsconfig.json", + '{ "compilerOptions": { "paths": { "~/*": ["./inertia/*"] } } }\n') + f = _adonis_tree(tmp_path, ADONIS_IMPORTS, + "import type ZitadelService from '#services/zitadel_service'\n" + "export const x = 1\n", + importer="inertia/pages/login.tsx") + r = extract([f], cache_root=tmp_path) + assert _cid(tmp_path, tmp_path / "app/services/zitadel_service.ts") in _targets(r) + + +def test_condition_object_target(tmp_path): + imports = ('{ "#services/*": { "types": "./app/services/*.ts", ' + '"import": "./app/services/*.js" } }') + f = _adonis_tree(tmp_path, imports, + "import ZitadelService from '#services/zitadel_service'\n") + r = extract([f], cache_root=tmp_path) + assert _cid(tmp_path, tmp_path / "app/services/zitadel_service.ts") in _targets(r) + + +def test_exact_key_and_no_prefix_match(tmp_path): + _write(tmp_path / "package.json", + '{ "imports": { "#config": "./config/index.js" } }\n') + target = _write(tmp_path / "config" / "index.ts", "export const cfg = 1\n") + start = tmp_path / "app" + start.mkdir() + assert _resolve_js_module_path("#config", start) == target + # Node treats a non-wildcard key as an exact specifier: no directory-prefix + # fallback, unlike the tsconfig alias matcher. + assert _resolve_js_module_path("#config/other", start) is None + + +def test_longest_literal_prefix_wins(tmp_path): + _write(tmp_path / "package.json", + '{ "imports": { "#lib/*": "./lib/*.js", "#lib/deep/*": "./deep/*.js" } }\n') + _write(tmp_path / "lib" / "deep" / "x.ts", "export const a = 1\n") + deep = _write(tmp_path / "deep" / "x.ts", "export const b = 1\n") + assert _resolve_js_module_path("#lib/deep/x", tmp_path) == deep + + +def test_external_target_is_left_to_external_handling(tmp_path): + # `"#dep": "dep-node-native"` maps to a third-party package, not a local + # file. It must resolve to nothing here so the caller keeps its ref-namespaced + # external id (#1638) instead of fabricating a local edge. + _write(tmp_path / "package.json", + '{ "imports": { "#dep": "dep-node-native" } }\n') + assert _resolve_js_module_path("#dep", tmp_path) is None + + +def test_tsconfig_paths_keep_precedence(tmp_path): + # An explicit tsconfig alias for the same specifier wins (#1269): the two + # mechanisms disagree here on purpose, and the resolver must pick tsconfig. + _write(tmp_path / "tsconfig.json", + '{ "compilerOptions": { "paths": { "#services/*": ["./ts_only/*"] } } }\n') + _write(tmp_path / "package.json", + '{ "imports": { "#services/*": "./app/services/*.js" } }\n') + ts_target = _write(tmp_path / "ts_only" / "svc.ts", "export const a = 1\n") + _write(tmp_path / "app" / "services" / "svc.ts", "export const b = 1\n") + assert _resolve_js_module_path("#services/svc", tmp_path) == ts_target + + +def test_no_imports_field_is_a_noop(tmp_path): + _write(tmp_path / "package.json", '{ "name": "app" }\n') + assert _resolve_js_module_path("#services/svc", tmp_path) is None