diff --git a/graphify/extractors/resolution.py b/graphify/extractors/resolution.py index 097c32b6a..8900a4395 100644 --- a/graphify/extractors/resolution.py +++ b/graphify/extractors/resolution.py @@ -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)) @@ -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 @@ -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: @@ -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 @@ -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] diff --git a/tests/test_jsconfig_baseurl.py b/tests/test_jsconfig_baseurl.py index c99d07950..c143bf2a4 100644 --- a/tests/test_jsconfig_baseurl.py +++ b/tests/test_jsconfig_baseurl.py @@ -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)