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:
Rishet Mehra
2026-07-25 15:44:50 +01:00
committed by safishamsi
parent 8d8005b835
commit 9be345951c
2 changed files with 99 additions and 18 deletions
+9 -5
View File
@@ -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))
+90 -13
View File
@@ -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)