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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,15 @@

## [Unreleased]

### Fixed

- TypeScript and JavaScript imports resolve against `compilerOptions.baseUrl`
when no `paths` entry resolves them, as `tsc` does. A tsconfig that sets
`baseUrl` without `paths` resolved no non-relative import at all: on a
NestJS monorepo whose services import `src/...`, 1,849 import edges stayed
bare strings, `importers_of` for one service found 14 of its 35 importers,
and calls on its injected services fell back to name matching.

## [2.3.9] - 2026-09-18

### Added
Expand Down
20 changes: 14 additions & 6 deletions code_review_graph/tsconfig_resolver.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
"""TypeScript tsconfig.json / jsconfig.json path alias resolver.

Resolves TypeScript path aliases (e.g., ``@/ -> src/``) declared in
``compilerOptions.paths`` so that ``IMPORTS_FROM`` edges can point to
real file paths instead of raw alias strings. Plain-JS projects (Vue,
``compilerOptions.paths``, and non-relative specifiers against
``compilerOptions.baseUrl`` when no ``paths`` entry resolves them, so that
``IMPORTS_FROM`` edges can point to real file paths instead of raw alias
strings. Plain-JS projects (Vue,
Nuxt, Vite) declare the same aliases in ``jsconfig.json``, which shares
the ``compilerOptions`` schema, so it is handled by the same parser.
"""
Expand Down Expand Up @@ -52,15 +54,21 @@ def resolve_alias(self, import_str: str, file_path: str) -> Optional[str]:
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)
if paths:
resolved = self._match_and_probe(import_str, paths, base_dir)
if resolved:
return resolved

# Like tsc, resolve a specifier no paths entry resolved against baseUrl.
if base_url:
found = _probe_path((base_dir / import_str).resolve())
return str(found) if found else None
return None
except (OSError, ValueError, TypeError):
logger.debug(
"TsconfigResolver: unexpected error for %s", file_path, exc_info=True,
Expand Down
40 changes: 40 additions & 0 deletions tests/test_tsconfig_resolver.py
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,46 @@ def test_jsconfig_with_jsonc_comments_and_extends(self):
assert Path(result) == target.resolve()


class TestBaseUrlResolution:
"""Non-relative specifiers resolve against baseUrl, as tsc resolves them."""

def setup_method(self):
self.resolver = TsconfigResolver()

def _project(self, root: Path, compiler_options: dict) -> Path:
(root / "tsconfig.json").write_text(
json.dumps({"compilerOptions": compiler_options}), encoding="utf-8",
)
target = root / "src" / "lib" / "util.ts"
target.parent.mkdir(parents=True)
target.write_text("export const util = 1;\n", encoding="utf-8")
return target

def test_base_url_without_paths_resolves(self, tmp_path):
target = self._project(tmp_path, {"baseUrl": "./"})
importer = tmp_path / "src" / "app.ts"
result = self.resolver.resolve_alias("src/lib/util", str(importer))
assert result is not None
assert Path(result) == target.resolve()

def test_base_url_resolves_when_no_paths_pattern_matches(self, tmp_path):
target = self._project(tmp_path, {"baseUrl": ".", "paths": {"@/*": ["src/*"]}})
importer = tmp_path / "src" / "app.ts"
result = self.resolver.resolve_alias("src/lib/util", str(importer))
assert result is not None
assert Path(result) == target.resolve()

def test_base_url_leaves_packages_unresolved(self, tmp_path):
self._project(tmp_path, {"baseUrl": "./"})
importer = tmp_path / "src" / "app.ts"
assert self.resolver.resolve_alias("react", str(importer)) is None

def test_no_base_url_and_no_paths_returns_none(self, tmp_path):
self._project(tmp_path, {"strict": True})
importer = tmp_path / "src" / "app.ts"
assert self.resolver.resolve_alias("src/lib/util", str(importer)) is None


def test_resolve_alias_probes_mts_and_cts(tmp_path):
_write_config(tmp_path, "tsconfig.json", {"@lib/*": ["src/lib/*"]})
lib = tmp_path / "src" / "lib"
Expand Down