/** * `tsconfig` loading for module resolution (#2953). * * The previous loader read three filenames at the repo root, required `paths` * to exist, and kept only `targets[0]`. Each arm here is one of the things that * made it unusable for resolution — and the `extends` arms are where the * subtlety is, because `baseUrl` and `paths` routinely live in different files. */ import { describe, it, expect, afterAll } from 'vitest'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { loadTsconfigIndex, tsconfigFor, } from '../../src/core/ingestion/languages/typescript/tsconfig.js'; const roots: string[] = []; function repo(files: Readonly>): string { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-tsc-')); roots.push(root); for (const [rel, contents] of Object.entries(files)) { const full = path.join(root, rel); fs.mkdirSync(path.dirname(full), { recursive: true }); fs.writeFileSync(full, contents); } return root; } afterAll(() => { for (const root of roots) fs.rmSync(root, { recursive: true, force: true }); }); describe('extends chains', () => { it('inherits `baseUrl` from the config it extends', async () => { const root = repo({ 'tsconfig.base.json': JSON.stringify({ compilerOptions: { baseUrl: 'src' } }), 'tsconfig.json': JSON.stringify({ extends: './tsconfig.base.json' }), }); const scope = tsconfigFor(await loadTsconfigIndex(root), 'src/main.ts'); expect(scope?.baseUrl).toBe('src'); }); it('resolves own `paths` targets against the INHERITED baseUrl', async () => { // The subtle one. This config declares `paths` but no `baseUrl`, so the // effective base is the inherited `src`. Resolving the targets against this // config's own directory instead loads the right alias pattern and points // every target at the wrong place — an alias that silently resolves to // nothing, or worse to a same-named file one directory up. const root = repo({ 'tsconfig.base.json': JSON.stringify({ compilerOptions: { baseUrl: 'src' } }), 'tsconfig.json': JSON.stringify({ extends: './tsconfig.base.json', compilerOptions: { paths: { '@/*': ['./features/*'] } }, }), }); const scope = tsconfigFor(await loadTsconfigIndex(root), 'src/main.ts'); expect(scope?.paths[0]?.targets).toEqual(['src/features/*']); }); it('lets an own `baseUrl` win over the inherited one', async () => { const root = repo({ 'tsconfig.base.json': JSON.stringify({ compilerOptions: { baseUrl: 'src' } }), 'tsconfig.json': JSON.stringify({ extends: './tsconfig.base.json', compilerOptions: { baseUrl: 'app', paths: { '@/*': ['./features/*'] } }, }), }); const scope = tsconfigFor(await loadTsconfigIndex(root), 'app/main.ts'); expect(scope?.baseUrl).toBe('app'); expect(scope?.paths[0]?.targets).toEqual(['app/features/*']); }); it('rebases a base config’s `baseUrl` to that config’s own directory', async () => { // `extends` does not rebase: `"baseUrl": "."` inside `configs/` means // `configs/`, even when extended from the repo root. const root = repo({ 'configs/tsconfig.base.json': JSON.stringify({ compilerOptions: { baseUrl: '.' } }), 'tsconfig.json': JSON.stringify({ extends: './configs/tsconfig.base.json' }), }); const scope = tsconfigFor(await loadTsconfigIndex(root), 'src/main.ts'); expect(scope?.baseUrl).toBe('configs'); }); it('reads a package-form `extends` out of node_modules when it is installed', async () => { // `node_modules` is not INDEXED — it is dependency code, not the // repository's own — but that is different from not reading it. A shared // internal base is exactly where a monorepo puts the `paths` its packages // import through, so refusing to open it loses aliases the repository does // declare. const root = repo({ 'node_modules/@acme/tsconfig/tsconfig.json': JSON.stringify({ compilerOptions: { baseUrl: 'src', paths: { '~/*': ['./shared/*'] } }, }), 'tsconfig.json': JSON.stringify({ extends: '@acme/tsconfig' }), }); const scope = tsconfigFor(await loadTsconfigIndex(root), 'src/main.ts'); // Resolved relative to the config that DECLARED them, which is tsc's rule // and not an artefact of reading from `node_modules`: `extends` never // rebases `baseUrl`. So a package base points at its own directory, and // that is worth pinning rather than wishing away — it is the reason a // published base config rarely contributes aliases a repo's own files // resolve through, and why the `@tsconfig/*` family (which sets `target` // and `lib`, never `paths`) is a no-op here either way. expect(scope?.baseUrl).toBe('node_modules/@acme/tsconfig/src'); expect(scope?.paths[0]?.targets).toEqual(['node_modules/@acme/tsconfig/src/shared/*']); }); it('keeps the extending config’s own options when the package is not installed', async () => { // An un-installed checkout is a shape a static analyser must expect and a // compiler may refuse. Degrading to fewer resolutions is correct; inventing // a base is not. const root = repo({ 'tsconfig.json': JSON.stringify({ extends: '@tsconfig/node20/tsconfig.json', compilerOptions: { baseUrl: 'src' }, }), }); const scope = tsconfigFor(await loadTsconfigIndex(root), 'src/main.ts'); expect(scope?.baseUrl).toBe('src'); }); it('prefers an exact `paths` pattern over a wildcard that also matches', async () => { // `a` and `a*` both match `a` with the same literal prefix length, so // sorting on length alone left tsc's exact-wins rule to declaration order. const root = repo({ 'tsconfig.json': JSON.stringify({ compilerOptions: { baseUrl: '.', paths: { 'a*': ['./wild/*'], a: ['./exact'] }, }, }), }); const scope = tsconfigFor(await loadTsconfigIndex(root), 'src/main.ts'); const exact = scope?.paths.find((mapping) => mapping.pattern === 'a'); expect(exact?.targets).toEqual(['exact']); }); }); describe('which config governs a file', () => { it('prunes root artifact configs while keeping nested source directories', async () => { const root = repo({ 'generated/tsconfig.json': JSON.stringify({ compilerOptions: { baseUrl: 'root-artifact' } }), 'packages/api/generated/tsconfig.json': JSON.stringify({ compilerOptions: { baseUrl: 'src' }, }), }); const index = await loadTsconfigIndex(root); expect(tsconfigFor(index, 'generated/main.ts')).toBeNull(); expect(tsconfigFor(index, 'packages/api/generated/main.ts')?.baseUrl).toBe( 'packages/api/generated/src', ); }); it('lets a child config with no baseUrl shadow the root, rather than inheriting it', async () => { // The child project declares no `baseUrl`, which in TypeScript means its // non-relative specifiers are PACKAGE lookups. Dropping the empty child let // `tsconfigFor` fall through to the root and apply the root's aliases to // the child's files — inventing a resolution the child never declared. const root = repo({ 'tsconfig.json': JSON.stringify({ compilerOptions: { baseUrl: '.' } }), 'apps/web/tsconfig.json': JSON.stringify({ compilerOptions: { strict: true } }), }); const index = await loadTsconfigIndex(root); const child = tsconfigFor(index, 'apps/web/src/main.ts'); expect(child?.dir).toBe('apps/web'); expect(child?.baseUrl).toBeNull(); expect(child?.paths).toEqual([]); // The root still governs everything outside that package. expect(tsconfigFor(index, 'tools/script.ts')?.baseUrl).toBe(''); }); it('takes the nearest config, not the root one', async () => { const root = repo({ 'tsconfig.json': JSON.stringify({ compilerOptions: { baseUrl: '.' } }), 'apps/web/tsconfig.json': JSON.stringify({ compilerOptions: { baseUrl: 'src' } }), }); const index = await loadTsconfigIndex(root); // A monorepo's per-package config is what governs that package's files; // the root config governing them was the old loader's whole model. expect(tsconfigFor(index, 'apps/web/src/main.ts')?.baseUrl).toBe('apps/web/src'); expect(tsconfigFor(index, 'tools/script.ts')?.baseUrl).toBe(''); }); }); describe('parsing', () => { it('reads a config written as JSONC', async () => { const root = repo({ 'tsconfig.json': `{ // the base for absolute imports "compilerOptions": { /* block */ "baseUrl": "src", }, }`, }); expect(tsconfigFor(await loadTsconfigIndex(root), 'src/a.ts')?.baseUrl).toBe('src'); }); it('keeps every `paths` target, in order', async () => { // The old loader kept `targets[0]`, which silently mis-resolves the common // `["./src/*", "./generated/*"]` shape. const root = repo({ 'tsconfig.json': JSON.stringify({ compilerOptions: { baseUrl: '.', paths: { '@/*': ['./src/*', './generated/*'] } }, }), }); const scope = tsconfigFor(await loadTsconfigIndex(root), 'src/a.ts'); expect(scope?.paths[0]?.targets).toEqual(['src/*', 'generated/*']); }); it('encodes a repo-root target as the bare `*`', async () => { // `"*": ["./*"]` resolves to the repo root itself, so the repo-relative // prefix is empty and the suffix is the whole target. `/*` substitutes to // `/lib/date`, which `resolveFile` never matches — see the unit assertions // in `tsconfig-rebase-target.test.ts` for both path flavours. const root = repo({ 'tsconfig.json': JSON.stringify({ compilerOptions: { baseUrl: '.', paths: { '*': ['./*'], '@/*': ['./src/*'] } }, }), }); const scope = tsconfigFor(await loadTsconfigIndex(root), 'src/a.ts'); expect(scope?.paths.find((mapping) => mapping.pattern === '*')?.targets).toEqual(['*']); // The common alias is unaffected — only the empty-prefix case changes. expect(scope?.paths.find((mapping) => mapping.pattern === '@/*')?.targets).toEqual(['src/*']); }); it('returns null for a repo with no config at all', async () => { expect(await loadTsconfigIndex(repo({ 'src/a.ts': '' }))).toBeNull(); }); });