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
14 changes: 9 additions & 5 deletions graphify/extract.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@
_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,
Expand Down Expand Up @@ -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:
Expand All @@ -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))
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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):
Expand All @@ -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))
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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:
Expand All @@ -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))
Expand Down
103 changes: 90 additions & 13 deletions graphify/extractors/resolution.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@

_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")
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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():
Expand All @@ -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
Expand Down Expand Up @@ -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)

Expand Down
Loading
Loading