fix(resolution): expand tsconfig ${configDir} in baseUrl and paths (#2340) - #2355
fix(resolution): expand tsconfig ${configDir} in baseUrl and paths (#2340)#2355Rishet11 wants to merge 1 commit into
Conversation
…raphify-Labs#2340) TypeScript 5.5 allows the literal ${configDir} template in compilerOptions.baseUrl and in paths values, expanding to the directory of the originating config (the root of the extends chain, not the file that declares the option). _read_tsconfig_aliases and _load_tsconfig_base_url joined it as a plain path segment, so with baseUrl and a paths value both using the token it appeared twice in the result and every aliased import resolved to a directory that cannot exist. Thread the originating config's directory through _read_tsconfig_aliases, unchanged across the extends recursion as the spec requires, and substitute the token before the join in both functions. Only that exact token is expanded, only at the start of a value, and any leading ./ is dropped first since the substitution yields an absolute path. A token elsewhere in a value is left literal: tsc does not accept it there, so failing to resolve is better than fabricating a path. baseUrl inherited through extends is still not followed by _load_tsconfig_base_url; that is Graphify-Labs#2200 and is left alone.
There was a problem hiding this comment.
Looks safe to merge — no coupling regressions and no blocking issues, checked against the code graph (not a self-assessment).
Graphify reviewed this change.
Looks safe to merge — no coupling regressions and no blocking issues, checked against the code graph (not a self-assessment).
Graphify review — findings
This PR adds support for TypeScript 5.5's ${configDir} template token in baseUrl and paths values within tsconfig/jsconfig alias resolution. A new _expand_config_dir_token helper substitutes the literal token with the originating config's directory, and _read_tsconfig_aliases now threads a config_dir parameter through the extends chain so the token always expands to the top-level config's location rather than the file that declares the option. The _load_tsconfig_base_url path and paths-joining logic were also updated to handle the now-possibly-absolute expanded values. The test file adds a suite of cases covering the token in baseUrl, paths, jsconfig variants, extends chains, ./-prefix handling, mid-value literals, and a no-token regression guard.
No blocking issues surfaced. 2 lower-confidence candidates did not survive cross-model review.
Analysis details — impact, health, verification
Impact & health
Graphify review
Impact — 879 functions depend on the 143 functions this change touches.
Health — this change adds coupling hotspots:
- worse:
extract()— 359 callers, 28 callees - worse:
_load_tsconfig_base_url()— 5 callers, 3 callees - worse:
_load_tsconfig_aliases()— 6 callers, 2 callees
Verification — 879 functions in the blast radius were not formally verified this run (proofs are advisory here).
Gate & verification
graphify gate
PASS — objectively clean (no health regressions, tests not run — proofs not run this pass (advisory)). Grounded, not self-assessed.
Advisory (not blocking):
- verification_scope: 556 function(s) in the blast radius were not formally verified this run
· 3 more finding(s) on lines outside this diff (see the check run).
Closes #2340
The bug
TypeScript 5.5 allows the literal
${configDir}template incompilerOptions.baseUrland inpathsvalues._read_tsconfig_aliases(graphify/extractors/resolution.py:150-161) and_load_tsconfig_base_url(:241-243) joined it as a plain path segment. With{"baseUrl": "${configDir}", "paths": {"@/*": ["${configDir}/src/*"]}}the token landed in the result twice — once frombaseUrl, once from thepathsvalue:Every aliased import resolved to a directory that cannot exist, so the edge was dropped. End to end through the real CLI on a two-file fixture:
The fix
config_diris threaded through_read_tsconfig_aliases, set once at the top-level entry to the directory of the originating config and passed unchanged through theextendsrecursion. Per spec the token expands relative to the originating config, not to whichever file in the chain declares the option — a test asserts exactly that._expand_config_dir_tokensubstitutes only that exact token (no general${...}or env-var expansion), only at the start of a value, and strips any leading./first since the substituted result is absolute._load_tsconfig_base_url, using the directory of the config it actually found.Scope, stated rather than hidden
_load_tsconfig_base_urlreads only a single config file and does not followextends, so abaseUrlinherited from a base config is invisible to it with or without this token. That is #2200 and I deliberately left it there rather than widening this PR.I also checked the other places the TS spec permits the template:
baseUrlandpathsvalues are the two this code reads and both are fixed; theextendsspecifier itself does not support the token per spec, andinclude/outDirare never read here. So there is no third site.Verification
tests/test_jsconfig_baseurl.py: token inbaseUrl+pathstogether; token in apathsvalue with nobaseUrl; token inbaseUrlonly; the spec-criticalextendscase asserting expansion to the originating dir and not the base config dir; ajsconfig.jsonvariant; a no-token regression guard; plus./and.//prefix variants and the mid-value-left-literal case.resolution.py, 5 of the first 6 fail (the no-token guard passes either way by design).v8tree here (1 intest_llm_backends.pyfrom a missingopenaimodule, 3 env-dependent ollama backend-detection tests). Failure set unchanged, verified by stashing and re-running.ruffclean.python -m tools.skillgen --check→check OK: 134 artifact(s) match committed output and expected/.A fresh-eyes review pass confirmed the
extendsthreading passes the originalconfig_dir(notextended_path.parent) at every level, that_load_tsconfig_base_urluses the found config's directory rather than the walk start dir, that the alias/baseUrl caches cannot return a value expanded against a different directory, and that the target-patterns comprehension drops no target. It flagged two edge cases in the original./-stripping —.//prefixes and a mid-value token both produced wrong paths — which is why the substitution is now start-anchored with full./stripping, with tests for both.