fix(resolution): honor jsconfig.json and compilerOptions.baseUrl (#2153)
Non-relative imports produced no edge in a Rails/webpacker project, so every module under `baseUrl` was orphaned and `affected` answered nothing. Two defects. First, only `tsconfig.json` was probed, never `jsconfig.json` — the plain-JS spelling of the same file, which json_config.py already indexes, so the config's nodes appeared in the graph while resolution ignored it entirely. Second, `baseUrl` was consumed only as the base that `paths` targets resolve against, so a config declaring `baseUrl` and NO `paths` produced an empty alias map and every bare specifier died. `_find_js_config` now probes both names, tsconfig winning within a directory as tsc and editors do. `baseUrl` is exposed separately and used as a resolution root of LAST RESORT, tried only when no declared alias matches, so `paths` precedence (#1269, #927, #1531) is untouched. It is deliberately not modelled as a synthesized `*` alias: that would score (1, 0) in _match_tsconfig_alias and beat a declared non-wildcard directory-prefix alias at (2, -len), silently shadowing it. The fallback also returns a candidate only when it is a real file, so an external package import is not fabricated into a <baseUrl>/<pkg> edge. Threaded through the three regex-rescue dynamic-import paths (Svelte, Astro, TS/TSX) as well as static resolution, since the issue reports both.
This commit is contained in:
+9
-5
@@ -91,6 +91,7 @@ from graphify.extractors.resolution import ( # noqa: E402,F401
|
||||
_js_source_path,
|
||||
_js_top_level_function_bodies,
|
||||
_load_tsconfig_aliases,
|
||||
_load_tsconfig_base_url,
|
||||
_load_workspace_packages,
|
||||
_match_tsconfig_alias,
|
||||
_merge_decl_def_classes,
|
||||
@@ -1285,6 +1286,7 @@ def extract_svelte(path: Path) -> dict:
|
||||
# endpoint is a phantom node and build_from_json drops the edge (#701).
|
||||
file_node_id = _make_id(str(path))
|
||||
aliases = _load_tsconfig_aliases(path.parent)
|
||||
base_url = _load_tsconfig_base_url(path.parent)
|
||||
for m in _re.finditer(r"""import\(\s*['"]([^'"]+)['"]\s*\)""", src):
|
||||
raw = m.group(1)
|
||||
if not raw:
|
||||
@@ -1302,7 +1304,7 @@ def extract_svelte(path: Path) -> dict:
|
||||
# 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)
|
||||
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))
|
||||
@@ -1360,7 +1362,7 @@ def extract_svelte(path: Path) -> dict:
|
||||
node_id = _make_id(str(resolved))
|
||||
stub_source_file = str(resolved)
|
||||
else:
|
||||
resolved_alias = _resolve_tsconfig_alias(raw, aliases)
|
||||
resolved_alias = _resolve_tsconfig_alias(raw, aliases, base_url=base_url)
|
||||
if resolved_alias is not None:
|
||||
node_id = _make_id(str(resolved_alias))
|
||||
stub_source_file = str(resolved_alias)
|
||||
@@ -1413,6 +1415,7 @@ def extract_astro(path: Path) -> dict:
|
||||
existing_ids = {n["id"] for n in result.get("nodes", [])}
|
||||
file_node_id = _make_id(str(path))
|
||||
aliases = _load_tsconfig_aliases(path.parent)
|
||||
base_url = _load_tsconfig_base_url(path.parent)
|
||||
# Dynamic imports anywhere in the file: `import('./X.astro')` is legal in
|
||||
# frontmatter setup code and inside expression slots.
|
||||
for m in _re.finditer(r"""import\(\s*['"]([^'"]+)['"]\s*\)""", src):
|
||||
@@ -1425,7 +1428,7 @@ def extract_astro(path: Path) -> dict:
|
||||
node_id = _make_id(str(resolved))
|
||||
stub_source_file = str(resolved)
|
||||
else:
|
||||
resolved_alias = _resolve_tsconfig_alias(raw, aliases)
|
||||
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))
|
||||
@@ -1486,7 +1489,7 @@ def extract_astro(path: Path) -> dict:
|
||||
node_id = _make_id(str(resolved))
|
||||
stub_source_file = str(resolved)
|
||||
else:
|
||||
resolved_alias = _resolve_tsconfig_alias(raw, aliases)
|
||||
resolved_alias = _resolve_tsconfig_alias(raw, aliases, base_url=base_url)
|
||||
if resolved_alias is not None:
|
||||
node_id = _make_id(str(resolved_alias))
|
||||
stub_source_file = str(resolved_alias)
|
||||
@@ -1553,6 +1556,7 @@ def extract_vue(path: Path) -> dict:
|
||||
existing_ids = {n["id"] for n in result.get("nodes", [])}
|
||||
file_node_id = _make_id(str(path))
|
||||
aliases = _load_tsconfig_aliases(path.parent)
|
||||
base_url = _load_tsconfig_base_url(path.parent)
|
||||
for m in re.finditer(r"""import\(\s*['"]([^'"]+)['"]\s*\)""", src):
|
||||
raw = m.group(1)
|
||||
if not raw:
|
||||
@@ -1563,7 +1567,7 @@ def extract_vue(path: Path) -> dict:
|
||||
node_id = _make_id(str(resolved))
|
||||
stub_source_file = str(resolved)
|
||||
else:
|
||||
resolved_alias = _resolve_tsconfig_alias(raw, aliases)
|
||||
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))
|
||||
|
||||
@@ -19,6 +19,9 @@ import sys
|
||||
|
||||
_TSCONFIG_ALIAS_CACHE: dict[str, dict[str, list[str]]] = {}
|
||||
|
||||
# compilerOptions.baseUrl per config path, as an absolute dir (#2153).
|
||||
_TSCONFIG_BASEURL_CACHE: "dict[str, Path | None]" = {}
|
||||
|
||||
_WORKSPACE_MANIFEST_NAMES = ("pnpm-workspace.yaml", "package.json")
|
||||
|
||||
_JS_RESOLVE_EXTS = (".ts", ".tsx", ".mts", ".cts", ".svelte", ".js", ".jsx", ".mjs", ".cjs")
|
||||
@@ -164,23 +167,82 @@ def _read_tsconfig_aliases(tsconfig: Path, base_dir: Path, seen: set) -> dict[st
|
||||
|
||||
return aliases
|
||||
|
||||
def _read_json_config(path: Path) -> "dict | None":
|
||||
"""Parse a tsconfig/jsconfig as JSON, falling back to JSONC (#2153).
|
||||
|
||||
Mirrors the read/parse handling in `_read_tsconfig_aliases`; returns None on
|
||||
any unreadable or unparseable file so a malformed config degrades to "no
|
||||
baseUrl" instead of raising.
|
||||
"""
|
||||
try:
|
||||
raw = path.read_text(encoding="utf-8", errors="replace")
|
||||
except OSError:
|
||||
return None
|
||||
for candidate in (raw, _strip_jsonc(raw)):
|
||||
try:
|
||||
data = json.loads(candidate)
|
||||
except Exception:
|
||||
continue
|
||||
return data if isinstance(data, dict) else None
|
||||
return None
|
||||
|
||||
def _find_js_config(start_dir: Path) -> "tuple[Path, Path] | None":
|
||||
"""Nearest tsconfig.json/jsconfig.json walking up from start_dir.
|
||||
|
||||
`jsconfig.json` is the plain-JS spelling of the same file (already indexed by
|
||||
json_config.py) and was never probed here, so a Rails/webpacker project that
|
||||
configures resolution in jsconfig.json got no aliases at all (#2153).
|
||||
tsconfig.json wins when both sit in one directory, matching tsc and editors,
|
||||
which consult jsconfig.json only when there is no tsconfig.json.
|
||||
"""
|
||||
current = start_dir.resolve()
|
||||
for candidate in [current, *current.parents]:
|
||||
for name in ("tsconfig.json", "jsconfig.json"):
|
||||
config = candidate / name
|
||||
if config.exists():
|
||||
return config, candidate
|
||||
return None
|
||||
|
||||
def _load_tsconfig_aliases(start_dir: Path) -> dict[str, list[str]]:
|
||||
"""Walk up from start_dir to find tsconfig.json and return compilerOptions.paths aliases.
|
||||
"""Walk up from start_dir to find tsconfig/jsconfig.json and return compilerOptions.paths aliases.
|
||||
|
||||
Follows extends chains so SvelteKit/Nuxt/NestJS inherited aliases are included.
|
||||
Returns a dict mapping alias patterns to ordered resolved target patterns;
|
||||
wildcard tokens remain intact for substitution during resolution (#927).
|
||||
Result is cached by tsconfig path string.
|
||||
Result is cached by config path string.
|
||||
"""
|
||||
current = start_dir.resolve()
|
||||
for candidate in [current, *current.parents]:
|
||||
tsconfig = candidate / "tsconfig.json"
|
||||
if tsconfig.exists():
|
||||
key = str(tsconfig)
|
||||
if key not in _TSCONFIG_ALIAS_CACHE:
|
||||
_TSCONFIG_ALIAS_CACHE[key] = _read_tsconfig_aliases(tsconfig, candidate, seen=set())
|
||||
return _TSCONFIG_ALIAS_CACHE[key]
|
||||
return {}
|
||||
found = _find_js_config(start_dir)
|
||||
if found is None:
|
||||
return {}
|
||||
config, candidate = found
|
||||
key = str(config)
|
||||
if key not in _TSCONFIG_ALIAS_CACHE:
|
||||
_TSCONFIG_ALIAS_CACHE[key] = _read_tsconfig_aliases(config, candidate, seen=set())
|
||||
return _TSCONFIG_ALIAS_CACHE[key]
|
||||
|
||||
def _load_tsconfig_base_url(start_dir: Path) -> "Path | None":
|
||||
"""`compilerOptions.baseUrl` of the nearest config, as an absolute directory.
|
||||
|
||||
baseUrl was only ever used as the base that `paths` targets resolve against,
|
||||
so a config declaring baseUrl and NO paths yielded an empty alias map and
|
||||
every non-relative import went unresolved (#2153). Exposed separately so it
|
||||
can act as a resolution root of last resort, after all declared aliases miss.
|
||||
Returns None when no config declares baseUrl.
|
||||
"""
|
||||
found = _find_js_config(start_dir)
|
||||
if found is None:
|
||||
return None
|
||||
config, candidate = found
|
||||
key = str(config)
|
||||
if key not in _TSCONFIG_BASEURL_CACHE:
|
||||
base_url = None
|
||||
data = _read_json_config(config)
|
||||
if data is not None:
|
||||
raw_base = data.get("compilerOptions", {}).get("baseUrl")
|
||||
if isinstance(raw_base, str) and raw_base:
|
||||
base_url = Path(os.path.normpath(candidate / raw_base))
|
||||
_TSCONFIG_BASEURL_CACHE[key] = base_url
|
||||
return _TSCONFIG_BASEURL_CACHE[key]
|
||||
|
||||
def _match_tsconfig_alias(raw: str, pattern: str) -> "tuple[tuple[int, int], str, bool] | None":
|
||||
"""Return (specificity, captured text, is_wildcard) when pattern matches raw.
|
||||
@@ -208,12 +270,21 @@ def _match_tsconfig_alias(raw: str, pattern: str) -> "tuple[tuple[int, int], str
|
||||
return (2, -len(prefix)), raw[len(prefix):].lstrip("/"), False
|
||||
return None
|
||||
|
||||
def _resolve_tsconfig_alias(raw: str, aliases: dict[str, list[str]]) -> "Path | None":
|
||||
def _resolve_tsconfig_alias(raw: str, aliases: dict[str, list[str]],
|
||||
base_url: "Path | None" = None) -> "Path | None":
|
||||
"""Resolve `raw` against the most specific matching tsconfig alias pattern.
|
||||
|
||||
Within that pattern, try targets in declared order and return the first whose
|
||||
candidate resolves to a real file. If none exist, return the first candidate
|
||||
so existing phantom/external-edge behavior stays unchanged.
|
||||
|
||||
`base_url` is a resolution root of last resort, tried only when NO declared
|
||||
alias matches (#2153). It must not participate in the specificity contest:
|
||||
a bare `*` alias would score (1, 0) and so beat a declared non-wildcard
|
||||
directory-prefix alias at (2, -len), silently shadowing it and regressing
|
||||
#1269. Unlike the alias path this returns a candidate only when it is a real
|
||||
file on disk, so a genuine external package (`import React from 'react'`)
|
||||
still resolves to nothing instead of a fabricated <baseUrl>/react edge.
|
||||
"""
|
||||
best: "tuple[tuple[int, int], str, bool, list[str]] | None" = None
|
||||
for pattern, targets in aliases.items():
|
||||
@@ -225,6 +296,11 @@ def _resolve_tsconfig_alias(raw: str, aliases: dict[str, list[str]]) -> "Path |
|
||||
best = specificity, captured, is_wildcard, targets
|
||||
|
||||
if best is None:
|
||||
if base_url is not None:
|
||||
candidate = Path(os.path.normpath(base_url / raw))
|
||||
resolved = _resolve_js_import_path(candidate)
|
||||
if resolved.is_file():
|
||||
return resolved
|
||||
return None
|
||||
|
||||
_, captured, is_wildcard, targets = best
|
||||
@@ -442,7 +518,8 @@ def _resolve_js_module_path(raw: str | Path, start_dir: Path | None = None) -> P
|
||||
return _resolve_js_import_path(start_dir / raw)
|
||||
|
||||
aliases = _load_tsconfig_aliases(start_dir)
|
||||
hit = _resolve_tsconfig_alias(raw, aliases)
|
||||
hit = _resolve_tsconfig_alias(raw, aliases,
|
||||
base_url=_load_tsconfig_base_url(start_dir))
|
||||
if hit is not None:
|
||||
return _resolve_js_import_path(hit)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user