Skip to content

fix(tsconfig): anchor inherited baseUrl and paths at the config that declares them - #1111

Open
rnoap wants to merge 6 commits into
tirth8205:stagingfrom
rnoap:fix/tsconfig-extends-anchor
Open

rnoap wants to merge 6 commits into
tirth8205:stagingfrom
rnoap:fix/tsconfig-extends-anchor

Conversation

@rnoap

@rnoap rnoap commented Oct 10, 2026 •

Copy link
Copy Markdown

Pull Request

Linked issue

Closes #1110

What & why

TsconfigResolver merged baseUrl and paths from an extended config but resolved them against the directory of the nearest config (_tsconfig_dir). tsc resolves both relative to the config file that declares them. In an Nx or Turborepo layout (root tsconfig.base.json, apps/<app>/tsconfig.json extending it) every alias was probed under apps/<app>/, resolve_alias() returned None, and the IMPORTS_FROM edge kept the raw alias string. importers_of and the impact radius of a shared library then missed every app that inherits its paths.

All changes are in code_review_graph/tsconfig_resolver.py:

  • _resolve_extends() records the absolute anchor where baseUrl is declared (_base_url_dir) and where paths is declared (_paths_dir), and passes both down the chain unchanged until a config redeclares them. "baseUrl": null in an extending config drops the inherited baseUrl anchor, since tsc treats null as unset.
  • As in tsc, a config is anchored at the path it was opened through. The extends path, the config's directory and baseUrl are normalized with os.path.abspath / os.path.normpath, which do not follow symlinks, so a base config reached through a symlink, to the file or to a directory above it, anchors where the link sits.
  • resolve_alias() resolves paths against _base_url_dir, then _paths_dir, then the nearest config's directory as before. _match_and_probe() normalizes the joined path before resolving it, so a .. in paths climbs from the anchor as written. The joined path is still resolved before probing, so a file it finds is returned in the same form as before.

A config that declares its own baseUrl and paths, the only shape that worked before, resolves as it did. The one exception is a .. that climbs out of a symlinked directory, which now climbs from the link, as in tsc. The scope is unchanged: only a relative string extends is followed; array-form and package extends are still not.

On the monorepo where this showed up (5.3k files), 0 of 430 imports of the shared types library were linked from the apps and libraries that inherit paths. With this change the resolver maps the aliases from each of those apps and libraries (checked on eight representative imports, one per app or library).

Review follow-up

The review found two shapes where the first version disagreed with tsc: a symlinked base config was anchored at the link's target, and a child "baseUrl": null kept the inherited anchor. Both are fixed in f638eb6. The tests from review/pr-1111 are on this branch with their original commits (1893de6, eeca314); f638eb6 removes their two xfail marks. Checking the symlink fix against tsc found the same problem for a base config inside a symlinked directory, now covered by test_base_config_in_a_symlinked_directory_climbs_from_the_link.

Each shape, checked with ts.resolveModuleName from TypeScript 5.9.3 and 6.0.3 on Linux (both versions agree):

Shape tsc staging before f638eb6 this branch
symlinked base config, "baseUrl": "." apps/api/src/util.ts apps/api/src/util.ts tools/src/util.ts apps/api/src/util.ts
symlinked base config, paths only apps/api/src/util.ts apps/api/src/util.ts tools/src/util.ts apps/api/src/util.ts
child "baseUrl": null foo.ts None base/foo.ts foo.ts
base config in a symlinked directory, "baseUrl": ".." apps/api/src/util.ts None tools/src/util.ts apps/api/src/util.ts
same, paths only, "../src/*" apps/api/src/util.ts None tools/src/util.ts apps/api/src/util.ts

How it was tested

Branch merged with staging d72e8fb. Two environments:

  • Windows 11, Python 3.11.14, environment from uv sync --extra dev.
  • Linux (WSL Ubuntu 22.04), Python 3.10.12, with the locked dependencies from uv.lock installed for cp310 and the tests run with python3 -m pytest. The symlink tests need it: on Windows they skip, because creating a symlink needs a privilege.
uv run pytest tests/test_tsconfig_resolver.py -q   # Windows: 40 passed, 4 skipped (symlinks). Linux (python3 -m pytest): 44 passed
uv run pytest tests/ --tb=no -q                    # full suite, see below
uv run ruff check code_review_graph/ tests/test_tsconfig_resolver.py   # All checks passed!
uv run mypy code_review_graph/ --ignore-missing-imports --no-strict-optional
bandit -r code_review_graph/ -c pyproject.toml     # No issues identified (staging: same)
  • This branch's tests/test_tsconfig_resolver.py run against the resolver from 6d148c5 (the merge, before f638eb6) has 5 failures on Linux: both cases of the symlinked base config test, both cases of the symlinked directory test and the null case. With only the _resolve_extends() half of the fix, the paths-only "../src/*" case still fails, which is why _match_and_probe() changed.
  • Full suite, staging d72e8fb against this branch:
    • Linux: staging 5005 passed and 0 failed; this branch 5038 passed and 0 failed. Both have 920 skipped, 3 xfailed and 2 xpassed. The 33 extra passes are the new tests in tests/test_tsconfig_resolver.py.
    • Windows: 103 failed on both, the identical set of tests (path separators in expected paths, file modes, the symlink privilege; none in test_tsconfig_resolver.py). Passed goes from 4923 to 4952 and skipped from 899 to 903, the 4 extra skips being the symlink cases.
  • mypy: no issues on Linux. On Windows it reports the same 3 errors as on staging, all in daemon.py (fcntl.flock, LOCK_EX and LOCK_NB are POSIX-only).

Checklist

  • Tests added for new functionality
  • All tests pass: uv run pytest tests/ --tb=short -q (Linux, run with python3 -m pytest as described above; on Windows the same 103 tests fail on staging)
  • Linting passes: uv run ruff check code_review_graph/
  • Type checking passes: uv run mypy code_review_graph/ --ignore-missing-imports --no-strict-optional (Linux; on Windows the same 3 POSIX-only errors in daemon.py as staging)
  • Lines are at most 100 characters
  • Docs updated where behavior changed (README, docs/, docstrings): _resolve_extends() docstring and a CHANGELOG.md entry under [Unreleased]

🤖 Generated with Claude Code

rnoap and others added 4 commits October 10, 2026 18:57
…declares them

TsconfigResolver merged baseUrl and paths from an extended config but resolved
them against the directory of the nearest config. tsc resolves both relative to
the file that declares them, so in an Nx/Turborepo layout (root
tsconfig.base.json, apps/<app>/tsconfig.json extending it) every alias was
probed under apps/<app>/ and came back None. The IMPORTS_FROM edge kept the raw
alias string, and importers_of / impact radius on a shared library missed every
app that inherits its paths.

_resolve_extends() now records the absolute anchor where baseUrl (and paths,
for configs without baseUrl) is declared and passes it down the chain unchanged
until a config redeclares it. resolve_alias() uses baseUrl's anchor first, then
the paths anchor, then the nearest config's directory as before.

The existing extends test kept base and child in one directory, where both
anchors coincide. Four new tests cover a base two levels up, paths without
baseUrl, a child that redeclares paths, and a child baseUrl that rebases
inherited paths.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Inherited baseUrl/paths anchoring through tsconfig extends chains:
- middle config declaring neither option, suffix-less extends
- paths declared by a middle config without baseUrl
- baseUrl pointing out of a sibling config directory
- inherited baseUrl anchoring paths the child declares
- child paths without any baseUrl, child empty paths
- inherited wildcards, extension and directory-index probing
- unicode and spaces in directories, jsconfig children
- several apps sharing one base, repeated lookups (cache)
- missing, array-form and package extends keep the child anchor
- extends cycles, invalid base JSON, malformed child options
- internal anchor key names in user configs are ignored
- 40-level extends chain
- full build, incremental update and rebuild store resolved targets

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@tirth8205

Copy link
Copy Markdown
Owner

Thank you for this PR.
I tested extends chains in monorepo layouts against tsc 5.9.
Before I can merge it, please fix these:

  1. A symlinked base config gets the wrong folder. The code follows the link to the real file. tsc uses the folder where the link sits. See code_review_graph/tsconfig_resolver.py:144 and :158. Example: apps/api/tsconfig.shared.json links to tools/tsconfig.shared.json with "baseUrl": ".". Then @app/util maps to tools/src/util.ts, not apps/api/src/util.ts. Staging got this right. Test: test_symlinked_base_config_anchors_at_the_link_not_its_target.
  2. "baseUrl": null in a child keeps the parent's baseUrl. tsc treats null as unset. Then paths resolve from the config that declares them. See code_review_graph/tsconfig_resolver.py:159. Test: test_child_null_base_url_unsets_the_inherited_base_url.

The failing tests are on the branch review/pr-1111 in this repo. You can copy them from there. If a test there is marked xfail, please remove that mark with your fix.

After you push the fix, I will test it again.

rnoap and others added 2 commits October 20, 2026 11:15
# Conflicts:
#	CHANGELOG.md

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The review on tirth8205#1111 found two extends shapes where the anchors disagreed
with tsc:

- A base config reached through a symlink was anchored at the directory of
  the file the link points to, because the extends path was resolved before
  its directory was taken. tsc locates a config by the path it opened it
  through, normalized without following symlinks. The extends path, the
  config's directory and baseUrl are now normalized lexically, and an alias
  target is normalized before it is resolved, so a `..` in baseUrl or paths
  climbs from the link, not from its target.
- "baseUrl": null in an extending config kept the inherited baseUrl anchor.
  tsc treats null as unset, so paths fall back to the config that declares
  them.

Drops the xfail marks from the two tests on review/pr-1111 that pin these,
and adds one for a base config inside a symlinked directory, with
"baseUrl": ".." and with a paths-only "../src/*", both checked against tsc
5.9.3 and 6.0.3.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@rnoap

rnoap commented Oct 20, 2026

Copy link
Copy Markdown
Author

Thanks for the review and for the tests. Both points are fixed in f638eb6, and the branch is up to date with staging again (merged d72e8fb; the only conflict was CHANGELOG.md). Your two commits from review/pr-1111 are on the branch with their original SHAs (1893de6, eeca314); f638eb6 removes the two xfail marks.

  1. Symlinked base config. _resolve_extends() now normalizes the extends path, the config's directory and baseUrl with os.path.abspath / os.path.normpath instead of Path.resolve(), so a config is anchored at the path it was opened through. Checking that against tsc turned up the same problem one level up, a base config inside a symlinked directory: "baseUrl": ".." climbed out of the link's target, and so did a paths-only "../src/*". The second one also needed the probe changed, because resolve_alias() resolved the anchor before joining paths to it. _match_and_probe() now normalizes the joined path before resolving it, and still resolves it before probing, so a file it finds is returned in the same form as before. test_base_config_in_a_symlinked_directory_climbs_from_the_link covers both shapes.
  2. "baseUrl": null. A config that sets it drops the inherited _base_url_dir, so paths resolve from the config that declares them.

Each shape checked with ts.resolveModuleName from TypeScript 5.9.3 and 6.0.3 on Linux (both versions agree):

Shape tsc staging before f638eb6 now
symlinked base config, "baseUrl": "." apps/api/src/util.ts apps/api/src/util.ts tools/src/util.ts apps/api/src/util.ts
symlinked base config, paths only apps/api/src/util.ts apps/api/src/util.ts tools/src/util.ts apps/api/src/util.ts
child "baseUrl": null foo.ts None base/foo.ts foo.ts
base config in a symlinked directory, "baseUrl": ".." apps/api/src/util.ts None tools/src/util.ts apps/api/src/util.ts
same, paths only, "../src/*" apps/api/src/util.ts None tools/src/util.ts apps/api/src/util.ts

tests/test_tsconfig_resolver.py: 44 passed on Linux (Python 3.10). On Windows it is 40 passed and 4 skipped, because creating a symlink there needs a privilege. The same test file run against the resolver from 6d148c5 (the merge, before f638eb6) has 5 failures on Linux. The full-suite comparison against staging is in the description.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: tsconfig baseUrl/paths inherited through "extends" are resolved against the child config's directory

2 participants