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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,16 @@
qualified name to pick another. A name that matches no symbol is
`not_found` instead of falling back to a symbol whose name merely
contains it.
- tsconfig and jsconfig path aliases inherited through a relative `extends`
resolve against the config that declares `baseUrl` (or `paths`, when no
config in the chain sets `baseUrl`), as tsc does, instead of the nearest
config's directory. In an Nx or Turborepo layout every app extending a root
`tsconfig.base.json` lost its alias imports, so `importers_of` and the
impact radius of a shared library missed those apps (#1110). Also as in
tsc, a base config reached through a symlink (to the file or to a
directory above it) anchors where the link sits, and a `..` in its
`baseUrl` or `paths` climbs from there; `"baseUrl": null` in an extending
config unsets the inherited `baseUrl`.

## [2.3.9] - 2026-09-18

Expand Down
51 changes: 39 additions & 12 deletions code_review_graph/tsconfig_resolver.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

import json
import logging
import os
import re
from pathlib import Path
from typing import Optional
Expand Down Expand Up @@ -48,19 +49,20 @@ def resolve_alias(self, import_str: str, file_path: str) -> Optional[str]:
if config is None:
return None

base_url: Optional[str] = config.get("baseUrl")
paths: dict[str, list[str]] = config.get("paths", {})
tsconfig_dir: str = config.get("_tsconfig_dir", "")

if not paths:
return None

if base_url:
base_dir = (Path(tsconfig_dir) / base_url).resolve()
else:
base_dir = Path(tsconfig_dir).resolve()

return self._match_and_probe(import_str, paths, base_dir)
# Like tsc: ``paths`` resolve against ``baseUrl`` when the extends
# chain sets one, otherwise against the config that declared
# ``paths``. Both anchors are fixed by _resolve_extends() where
# they were declared, not where the nearest config sits.
anchor: str = (
config.get("_base_url_dir")
or config.get("_paths_dir")
or config.get("_tsconfig_dir", "")
)
return self._match_and_probe(import_str, paths, Path(anchor))
except (OSError, ValueError, TypeError):
logger.debug(
"TsconfigResolver: unexpected error for %s", file_path, exc_info=True,
Expand Down Expand Up @@ -109,7 +111,17 @@ def _parse_tsconfig(self, tsconfig_path: Path) -> dict:
return self._resolve_extends(tsconfig_path, seen)

def _resolve_extends(self, tsconfig_path: Path, seen: set[str]) -> dict:
"""Recursively resolve the tsconfig extends chain."""
"""Recursively resolve the tsconfig extends chain.

``baseUrl`` and ``paths`` are relative to the config file that declares
them, so a root ``tsconfig.base.json`` extended from ``apps/api/`` still
maps ``libs/*`` to ``<root>/libs/*``. The absolute anchors are kept in
``_base_url_dir`` and ``_paths_dir`` and inherited unchanged until a
config down the chain declares its own; ``"baseUrl": null`` unsets the
inherited ``baseUrl``. As in tsc, a config is located by the path it was
opened through, normalized without following symlinks, so a base config
reached through a symlink anchors where the link sits.
"""
canonical = str(tsconfig_path.resolve())
if canonical in seen:
logger.debug("TsconfigResolver: cycle detected at %s", canonical)
Expand All @@ -133,16 +145,28 @@ def _resolve_extends(self, tsconfig_path: Path, seen: set[str]) -> dict:

extends: Optional[str] = data.get("extends")
if extends and isinstance(extends, str) and extends.startswith("."):
parent_path = (tsconfig_path.parent / extends).resolve()
parent_path = Path(os.path.abspath(tsconfig_path.parent / extends))
if not parent_path.suffix:
parent_path = parent_path.with_suffix(".json")
if parent_path.is_file():
parent_config = self._resolve_extends(parent_path, seen)
parent_opts = parent_config.get("compilerOptions", {})
result.setdefault("compilerOptions", {}).update(parent_opts)
for anchor in ("_base_url_dir", "_paths_dir"):
if anchor in parent_config:
result[anchor] = parent_config[anchor]

child_opts: dict = data.get("compilerOptions", {})
result.setdefault("compilerOptions", {}).update(child_opts)
if isinstance(child_opts, dict):
config_dir = os.path.abspath(tsconfig_path.parent)
base_url = child_opts.get("baseUrl")
if isinstance(base_url, str):
result["_base_url_dir"] = os.path.normpath(os.path.join(config_dir, base_url))
elif base_url is None and "baseUrl" in child_opts:
result.pop("_base_url_dir", None)
if "paths" in child_opts:
result["_paths_dir"] = config_dir

compiler_options = result.get("compilerOptions", {})
if "baseUrl" in compiler_options:
Expand Down Expand Up @@ -224,7 +248,10 @@ def _pattern_specificity(item: tuple[str, list[str]]) -> int:
else:
mapped = replacement

candidate_base = (base_dir / mapped).resolve()
# Normalized before it is resolved, as tsc does: a ``..`` climbs
# from the anchor as written, not from where a symlink in it
# leads.
candidate_base = Path(os.path.normpath(base_dir / mapped)).resolve()
found = _probe_path(candidate_base)
if found:
return str(found)
Expand Down
Loading