GitNexus/gitnexus/test/unit/tsconfig-index.test.ts

237 lines
9.3 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* `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<Record<string, string>>): 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 configs `baseUrl` to that configs 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 configs 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('returns null for a repo with no config at all', async () => {
expect(await loadTsconfigIndex(repo({ 'src/a.ts': '' }))).toBeNull();
});
});