Skip to content
Open
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
53 changes: 48 additions & 5 deletions graphify/extractors/resolution.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,14 +86,44 @@ def _replace(match: re.Match) -> str:
stripped = re.sub(r",(\s*[}\]])", r"\1", stripped)
return stripped

def _read_tsconfig_aliases(tsconfig: Path, base_dir: Path, seen: set) -> dict[str, list[str]]:
_CONFIG_DIR_TOKEN = "${configDir}"

def _expand_config_dir_token(value: str, config_dir: Path) -> str:
"""Substitute TS 5.5's literal `${configDir}` template (#2340).

Expands ONLY that exact token (no general `${...}`/env-var expansion) to the
absolute directory of the originating config. TS gives the token meaning only
at the START of the value, so any leading "./" (including "././" and ".//") is
dropped first: the substitution yields an absolute path, and a "./" prefix
would turn it back into a bogus relative string. A token anywhere else is left
literal rather than spliced mid-path — tsc does not accept it there, and
substituting would fabricate a nonsense path instead of just failing to
resolve, which is the pre-#2340 behavior for such a config.
"""
if _CONFIG_DIR_TOKEN not in value:
return value
head = value
while head.startswith("./"):
head = head[2:].lstrip("/")
if not head.startswith(_CONFIG_DIR_TOKEN):
return value
return head.replace(_CONFIG_DIR_TOKEN, str(config_dir), 1)

def _read_tsconfig_aliases(tsconfig: Path, base_dir: Path, seen: set, config_dir: "Path | None" = None) -> dict[str, list[str]]:
"""Recursively read path aliases from a tsconfig, following extends chains.

Child config paths override parent. Circular extends are detected via seen set.
npm package configs (e.g. @tsconfig/svelte) are skipped since they're not on disk.
Handles JSONC (comments + trailing commas) which is the default tsconfig format
for SvelteKit, NestJS, Vite, T3, Astro, etc. (#700).

`config_dir` is the directory of the originating (top-level) config file, set once
at the top of the extends chain and passed through unchanged to extended configs.
TS 5.5's `${configDir}` template always expands to that directory, not the
directory of whichever file in the chain declares the option (#2340).
"""
if config_dir is None:
config_dir = base_dir
if str(tsconfig) in seen:
return {}
seen.add(str(tsconfig))
Expand Down Expand Up @@ -136,7 +166,7 @@ def _read_tsconfig_aliases(tsconfig: Path, base_dir: Path, seen: set) -> dict[st
if not extended_path.suffix:
extended_path = extended_path.with_suffix(".json")
if extended_path.exists():
aliases.update(_read_tsconfig_aliases(extended_path, extended_path.parent, seen))
aliases.update(_read_tsconfig_aliases(extended_path, extended_path.parent, seen, config_dir))

# tsconfig `paths` are resolved relative to `baseUrl` (itself relative to
# the tsconfig's directory), not the tsconfig directory directly. Honoring
Expand All @@ -147,7 +177,8 @@ def _read_tsconfig_aliases(tsconfig: Path, base_dir: Path, seen: set) -> dict[st
# relative to the tsconfig dir, the TS 4.1+ behavior) keep working.
compiler_options = data.get("compilerOptions", {})
base_url = compiler_options.get("baseUrl") or "."
paths_base = base_dir / base_url
base_url = _expand_config_dir_token(base_url, config_dir)
paths_base = Path(base_url) if os.path.isabs(base_url) else base_dir / base_url
paths = compiler_options.get("paths", {})
for alias, targets in paths.items():
if not targets:
Expand All @@ -157,10 +188,15 @@ def _read_tsconfig_aliases(tsconfig: Path, base_dir: Path, seen: set) -> dict[st
# whose file lived at a non-first target. Preserve wildcard tokens in
# both sides until the resolver substitutes the captured segment, then
# normalizes the concrete path (#927). Empty/non-string entries are skipped.
# A target containing `${configDir}` (#2340) expands to an absolute path
# already anchored at the originating config's dir, so it's used as-is
# instead of being joined under paths_base (a `Path` join with an absolute
# right-hand side already discards the left, which is the behavior we want).
target_patterns = [
str(paths_base / t)
str(Path(expanded) if os.path.isabs(expanded) else paths_base / expanded)
for t in targets
if isinstance(t, str) and t
for expanded in [_expand_config_dir_token(t, config_dir)]
]
if target_patterns:
aliases[alias] = target_patterns
Expand Down Expand Up @@ -240,7 +276,14 @@ def _load_tsconfig_base_url(start_dir: Path) -> "Path | None":
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))
# `${configDir}` (#2340) expands to this config's own dir here, since
# this function only ever reads a single file (no `extends` chain,
# see #2200), so `candidate` and "the originating config's dir" coincide.
raw_base = _expand_config_dir_token(raw_base, candidate)
if os.path.isabs(raw_base):
base_url = Path(os.path.normpath(raw_base))
else:
base_url = Path(os.path.normpath(candidate / raw_base))
_TSCONFIG_BASEURL_CACHE[key] = base_url
return _TSCONFIG_BASEURL_CACHE[key]

Expand Down
142 changes: 142 additions & 0 deletions tests/test_jsconfig_baseurl.py
Original file line number Diff line number Diff line change
Expand Up @@ -191,3 +191,145 @@ def test_tsconfig_wins_when_both_configs_present(tmp_path):
targets = _targets(r)
assert _cid(tmp_path, ts_hit) in targets
assert _cid(tmp_path, tmp_path / "js_root" / "mods" / "W.js") not in targets


# --- TS 5.5 `${configDir}` template (#2340) ---------------------------------
#
# TS 5.5 allows the literal template `${configDir}` in `baseUrl` and `paths`
# values; it expands to the directory of the ORIGINATING config in the
# `extends` chain (the file passed to tsc), not the file that declares the
# option. Un-substituted, the literal token landed in the joined path and the
# result could never exist on disk, so aliased imports silently dropped.

def test_config_dir_token_in_baseurl_and_paths_resolves(tmp_path):
_write(tmp_path / "tsconfig.json",
'{\n "compilerOptions": {\n'
' "baseUrl": "${configDir}",\n'
' "paths": { "@/*": ["${configDir}/src/*"] }\n'
' }\n}\n')
helper = _write(tmp_path / "src" / "utils" / "helper.ts",
"export const x = 1;\n")
f = _write(tmp_path / "main.ts",
"import { x } from '@/utils/helper';\nexport default x;\n")
r = extract([f], cache_root=tmp_path)
assert _cid(tmp_path, helper) in _targets(r)


def test_config_dir_token_in_paths_without_baseurl(tmp_path):
_write(tmp_path / "tsconfig.json",
'{\n "compilerOptions": {\n'
' "paths": { "@/*": ["${configDir}/src/*"] }\n'
' }\n}\n')
helper = _write(tmp_path / "src" / "utils" / "helper.ts",
"export const x = 1;\n")
f = _write(tmp_path / "main.ts",
"import { x } from '@/utils/helper';\nexport default x;\n")
r = extract([f], cache_root=tmp_path)
assert _cid(tmp_path, helper) in _targets(r)


def test_config_dir_token_in_baseurl_only(tmp_path):
# baseUrl alone (no paths) uses the fallback-root path (#2153); the token
# must still be substituted there.
_write(tmp_path / "tsconfig.json",
'{\n "compilerOptions": {\n'
' "baseUrl": "${configDir}/app/javascript"\n'
' }\n}\n')
widget = _write(tmp_path / "app/javascript" / "mods" / "Widget.js",
"export default function Widget() {}\n")
f = _write(tmp_path / "app/javascript" / "packs" / "dashboard.js",
"import Widget from 'mods/Widget.js';\nexport default Widget;\n")
r = extract([f], cache_root=tmp_path)
assert _cid(tmp_path, widget) in _targets(r)


def test_config_dir_token_expands_to_originating_config_not_base(tmp_path):
# The spec-critical case: a BASE config (extended by a child) declares
# `paths` with `${configDir}`. It must expand to the CHILD's (originating)
# directory, not the base config's own directory.
base_dir = tmp_path / "base"
child_dir = tmp_path / "child"
_write(base_dir / "tsconfig.base.json",
'{\n "compilerOptions": {\n'
' "paths": { "@/*": ["${configDir}/src/*"] }\n'
' }\n}\n')
_write(child_dir / "tsconfig.json",
'{\n "extends": "../base/tsconfig.base.json",\n'
' "compilerOptions": {}\n'
'}\n')
helper = _write(child_dir / "src" / "utils" / "helper.ts",
"export const x = 1;\n")
# A file placed at the base dir's equivalent path must NOT be the target.
_write(base_dir / "src" / "utils" / "helper.ts", "export const x = 2;\n")
f = _write(child_dir / "main.ts",
"import { x } from '@/utils/helper';\nexport default x;\n")
r = extract([f], cache_root=tmp_path)
targets = _targets(r)
assert _cid(tmp_path, helper) in targets
assert _cid(tmp_path, base_dir / "src" / "utils" / "helper.ts") not in targets

# Also assert the directory explicitly at the alias-resolution level.
from graphify.extractors import resolution as r2
aliases = r2._load_tsconfig_aliases(child_dir)
assert aliases["@/*"] == [str(child_dir.resolve() / "src" / "*")]


def test_no_config_dir_token_unchanged(tmp_path):
# Regression guard: configs without the token resolve exactly as before.
f = _rails_tree(tmp_path, "tsconfig.json",
"import Widget from 'mods/Widget.js';\n"
"export default Widget;\n")
r = extract([f], cache_root=tmp_path)
assert _widget(tmp_path) in _targets(r)


def test_config_dir_token_jsconfig_variant(tmp_path):
_write(tmp_path / "jsconfig.json",
'{\n "compilerOptions": {\n'
' "baseUrl": "${configDir}",\n'
' "paths": { "@/*": ["${configDir}/src/*"] }\n'
' }\n}\n')
helper = _write(tmp_path / "src" / "utils" / "helper.js",
"export const x = 1;\n")
f = _write(tmp_path / "main.js",
"import { x } from '@/utils/helper';\nexport default x;\n")
r = extract([f], cache_root=tmp_path)
assert _cid(tmp_path, helper) in _targets(r)


def test_config_dir_token_with_dot_slash_prefix_variants(tmp_path):
# TS only gives the token meaning at the start of the value, and a "./" prefix
# in front of it must not survive into the substituted absolute path. Covers
# the doubled-slash form too, which an exact "./" check would miss (#2340).
_write(tmp_path / "tsconfig.json",
'{\n "compilerOptions": {\n'
' "baseUrl": "./${configDir}",\n'
' "paths": { "@/*": [".//${configDir}/src/*"] }\n'
' }\n}\n')
helper = _write(tmp_path / "src" / "utils" / "helper.js",
"export const x = 1;\n")
f = _write(tmp_path / "main.js",
"import { x } from '@/utils/helper';\nexport default x;\n")
r = extract([f], cache_root=tmp_path)
assert _cid(tmp_path, helper) in _targets(r)


def test_config_dir_token_mid_value_is_left_literal(tmp_path):
# tsc does not accept the token mid-path. Splicing it in would fabricate a
# nonsense path; leaving it literal keeps the pre-#2340 behavior (the alias
# simply does not resolve) rather than inventing a wrong edge. The sibling
# declared-relative alias in the same config must still resolve.
_write(tmp_path / "tsconfig.json",
'{\n "compilerOptions": {\n'
' "paths": {\n'
' "@bad/*": ["packages/${configDir}/src/*"],\n'
' "@ok/*": ["src/*"]\n'
' }\n }\n}\n')
helper = _write(tmp_path / "src" / "utils" / "helper.js",
"export const x = 1;\n")
f = _write(tmp_path / "main.js",
"import { x } from '@bad/utils/helper';\n"
"import { y } from '@ok/utils/helper';\n"
"export default x;\n")
r = extract([f], cache_root=tmp_path)
assert _cid(tmp_path, helper) in _targets(r)
Loading