Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions graphify/extract.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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()

Expand Down
100 changes: 100 additions & 0 deletions graphify/extractors/resolution.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Health regression_resolve_js_module_path()

fans out to 7 callees (efferent coupling); 33 callers depend on it (afferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

"""Resolve a JS/TS module path or specifier to a local source file.

Expand All @@ -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":

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Health regression_resolve_js_import_target()

8 callers depend on it (afferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

Expand Down
125 changes: 125 additions & 0 deletions tests/test_package_json_subpath_imports.py
Original file line number Diff line number Diff line change
@@ -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