mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-01 02:01:24 +00:00
* fix(typescript): resolve tsconfig `paths` aliases on Windows `rebaseTarget` turns a `paths` target that `path.resolve` produced back into a repo-relative one, and it recognised the wildcard suffix only as `/*`. On Windows the resolved target is `C:\repo\src\*`, so the check never matched, the bare-`*` branch stripped the star, `path.relative` ate the trailing backslash, and every alias target came back as `src*`. `substituteStar` then built `srclib/date` from `@/lib/date`, nothing resolved, and every alias import was dropped as external — no IMPORTS/CALLS edge for anything reached through `@/`, so `impact` answered UNKNOWN for a repo's whole shared layer. Normalise the separator before looking at the suffix. `tsconfig-index.test.ts` already asserts the `src/*` shape and fails 4 of 13 cases on Windows without this; it only ever ran on Ubuntu, so add it to the cross-platform lane. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * test(typescript): make `paths` rebasing assertable from any runner `rebaseTarget` reads the platform separator, but the only suite covering it builds real directories — so on Ubuntu it sees `/` whatever the code does with `\`, and only the windows-latest lane can fail it. That is how an alias bug affecting every Windows install reached a green CI in the first place. Thread an injectable `pathApi` through `rebaseTarget` and `repoRelative` — the seam `isInside` and the `\\?\` prefix guard already use — and add a fixture-free suite that pins the win32 and POSIX branches explicitly. Deleting the separator normalisation now fails on every runner rather than only on windows-latest. Registered beside its fixture sibling in the cross-platform lane so the two halves of the same rule stay discoverable as one group. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(typescript): emit a bare `*` for a repo-root `paths` target A target naming the repo ROOT — `"*": ["./*"]` under `"baseUrl": "."` — rebases to an EMPTY repo-relative prefix, so only the suffix survived and the encoding came out as `/*`. `substituteStar` turns that into `/lib/date`, and `resolveFile` matches repo-relative keys without stripping a leading slash, so the indexed `lib/date.ts` misses and the alias is dropped as external. Emit the bare `*` for that case instead, which substitutes to `lib/date` and resolves. This is the shipped POSIX encoding as much as the Windows one, and it is what the Windows path emitted by accident before the separator was normalised — so the normalisation does not narrow what already resolved there. The common `"@/*": ["./src/*"]` is untouched: it has a real prefix and stays `src/*`. Pinned at all three levels the encoding passes through: the rebaser, on both path flavours; the loader, through a real fixture; and resolution, where `*` finds the file and `/*` does not. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs(test): say why the root-wildcard fixture declares no `baseUrl` The comment introduced the fixture as `"baseUrl": "."`, which the loader encodes as `''`, while the fixture passes `null` — a different scope, since `null` means the config declares no baseUrl at all and takes the paths arm alone. `null` is the right fixture and the comment was the wrong half: with `''` the baseUrl arm resolves `packages/utils/src/index` by itself, so the negative case would return null for a reason unrelated to the target encoding. Verified by substitution — `''` makes that assertion report the file instead. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
255 lines
10 KiB
TypeScript
255 lines
10 KiB
TypeScript
/**
|
||
* `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 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();
|
||
});
|
||
});
|