diff --git a/gitnexus/scripts/cross-platform-tests.ts b/gitnexus/scripts/cross-platform-tests.ts index 79169214b..abf7de8c3 100644 --- a/gitnexus/scripts/cross-platform-tests.ts +++ b/gitnexus/scripts/cross-platform-tests.ts @@ -36,6 +36,18 @@ const PLATFORM_LOGIC = [ // must exercise the Windows backslash branch, so run it on the OS matrix (#2394). 'test/unit/cli-entry.test.ts', 'test/unit/platform-capabilities.test.ts', + // The tsconfig loader rebases `paths` targets through `path.resolve`, so the + // wildcard suffix it must recognise is `/*` on POSIX and `\*` on Windows. It + // only looked for `/*`, and every alias target came back as `src*` on + // Windows while the Ubuntu run stayed green — so this file has to run where + // the separator differs. + 'test/unit/tsconfig-index.test.ts', + // The unit half of the same rebasing rule. Fixture-free and pathApi-injectable + // (every separator assertion passes an explicit `path.win32` / `path.posix`), + // so unlike the fixture suite above it fails on EVERY runner when the + // normalisation is removed rather than only on windows-latest. Registered + // beside its fixture sibling so the two halves stay discoverable as one group. + 'test/unit/tsconfig-rebase-target.test.ts', // The gitnexus-plan safe writer resolves every name through a per-platform // backend: Linux anchors through /proc/self/fd, macOS resolves lexically and // verifies each step against descriptors it holds open. Publication is link(2) diff --git a/gitnexus/src/core/ingestion/languages/typescript/tsconfig.ts b/gitnexus/src/core/ingestion/languages/typescript/tsconfig.ts index d47374473..30da1a677 100644 --- a/gitnexus/src/core/ingestion/languages/typescript/tsconfig.ts +++ b/gitnexus/src/core/ingestion/languages/typescript/tsconfig.ts @@ -318,8 +318,8 @@ function parseJsonc(raw: string): Record { return JSON.parse(withoutComments) as Record; } -function repoRelative(repoRoot: string, absDir: string): string { - const rel = path.relative(repoRoot, absDir).split(path.sep).join('/'); +function repoRelative(repoRoot: string, absDir: string, pathApi: typeof path = path): string { + const rel = pathApi.relative(repoRoot, absDir).split(pathApi.sep).join('/'); return rel === '.' || rel === '' ? '' : rel; } @@ -329,11 +329,34 @@ function repoRelative(repoRoot: string, absDir: string): string { * `path.resolve` swallows the wildcard into a path segment, so it is stripped * before resolving and re-appended after — the `*` is a substitution marker, * not a directory named `*`. + * + * `pathApi` is injectable so the win32 separator branch is unit-testable from a + * POSIX runner; production callers always use the platform-bound `path`. */ -function rebaseTarget(repoRoot: string, absTarget: string): string { +function rebaseTarget(repoRoot: string, absTarget: string, pathApi: typeof path = path): string { // `/repo/src/*` must come back as `src/*`, not `src*`: stripping only the // star leaves a trailing slash that `path.relative` then eats. - const suffix = absTarget.endsWith('/*') ? '/*' : absTarget.endsWith('*') ? '*' : ''; - const base = suffix === '' ? absTarget : absTarget.slice(0, -suffix.length); - return `${repoRelative(repoRoot, base)}${suffix}`; + // + // The target arrives from `path.resolve`, so on Windows it is `C:\repo\src\*` + // and an `endsWith('/*')` check never matches. It fell through to the bare + // `*` branch, `path.relative` ate the trailing backslash, and every alias + // target came back as `src*` — which `substituteStar` turns into `srclib/x`, + // so nothing an alias reached was ever resolved on Windows. Normalise the + // separator before looking at the suffix. + const normalized = absTarget.split(pathApi.sep).join('/'); + const suffix = normalized.endsWith('/*') ? '/*' : normalized.endsWith('*') ? '*' : ''; + const base = suffix === '' ? normalized : normalized.slice(0, -suffix.length); + const prefix = repoRelative(repoRoot, base, pathApi); + // A target naming the repo ROOT (`"*": ["./*"]` under `baseUrl: "."`) leaves + // an empty prefix, and `${''}${'/*'}` is `/*`. `substituteStar` turns that + // into `/lib/date`, but `resolveFile` matches repo-relative keys and never + // strips a leading slash, so `lib/date.ts` misses and the alias goes + // external. The bare `*` is the encoding that substitutes correctly — and it + // is what the pre-#3203 Windows path emitted by accident, so this keeps the + // separator fix from narrowing what already resolved there. + if (prefix === '' && suffix === '/*') return '*'; + return `${prefix}${suffix}`; } + +/** Test seam for {@link rebaseTarget} (see `test/unit/tsconfig-rebase-target.test.ts`). */ +export const _rebaseTargetForTests = rebaseTarget; diff --git a/gitnexus/test/unit/node-workspace-packages.test.ts b/gitnexus/test/unit/node-workspace-packages.test.ts index c8e5131ae..ecfd8ef03 100644 --- a/gitnexus/test/unit/node-workspace-packages.test.ts +++ b/gitnexus/test/unit/node-workspace-packages.test.ts @@ -181,6 +181,29 @@ describe('tsconfig paths', () => { expect(resolve('~/index', { tsconfigs: twoTargets })).toBe('packages/utils/src/index.ts'); }); + it('substitutes a repo-root target written as the bare `*`', () => { + // A `"*": ["./*"]` target resolves to the repo root, so `rebaseTarget` + // leaves an EMPTY repo-relative prefix and the suffix is the whole + // encoding. This is the pair that says why it must be `*` and not `/*`. + // + // `baseUrl` is `null` — no baseUrl declared — rather than the `''` the + // loader emits for a real `"baseUrl": "."`, and deliberately so: `''` + // resolves `packages/utils/src/index` through the baseUrl arm on its own, + // which would answer the negative case for a reason that has nothing to do + // with the target encoding. `paths` is tried first either way, so cutting + // the fallback is what leaves this asserting only what it names. + const rootWildcard = (target: string): TsconfigIndex => ({ + scopes: [{ dir: '', baseUrl: null, paths: [{ pattern: '*', targets: [target] }] }], + }); + + expect(resolve('packages/utils/src/index', { tsconfigs: rootWildcard('*') })).toBe( + 'packages/utils/src/index.ts', + ); + // `substituteStar('/*', …)` yields `/packages/utils/src/index`, and + // `resolveFile` matches repo-relative keys without a leading slash. + expect(resolve('packages/utils/src/index', { tsconfigs: rootWildcard('/*') })).toBeNull(); + }); + it('applies the nearest config, not the root one', () => { const nested: TsconfigIndex = { scopes: [ diff --git a/gitnexus/test/unit/tsconfig-index.test.ts b/gitnexus/test/unit/tsconfig-index.test.ts index f711ae9cc..18cab3057 100644 --- a/gitnexus/test/unit/tsconfig-index.test.ts +++ b/gitnexus/test/unit/tsconfig-index.test.ts @@ -231,6 +231,24 @@ describe('parsing', () => { 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(); }); diff --git a/gitnexus/test/unit/tsconfig-rebase-target.test.ts b/gitnexus/test/unit/tsconfig-rebase-target.test.ts new file mode 100644 index 000000000..140fc76ea --- /dev/null +++ b/gitnexus/test/unit/tsconfig-rebase-target.test.ts @@ -0,0 +1,83 @@ +/** + * `rebaseTarget` — `paths` target rebasing, across both path flavours. + * + * The fixture suite (`tsconfig-index.test.ts`) builds real directories, so it + * only ever sees the HOST separator: its `…/*` assertions are green on Ubuntu + * whatever `rebaseTarget` does with a backslash, and only the windows-latest + * lane can fail them. That is how the alias bug survived a green CI in the + * first place. Injecting `pathApi` — the seam `isInside` and the `\\?\` prefix + * guard already use — makes both platform branches assertable from any runner, + * so deleting the separator normalisation fails here on Ubuntu too. + * + * Two behaviours are pinned: + * + * - `path.resolve` emits `C:\repo\src\*` on Windows, so the `endsWith('/*')` + * check never matched, the bare-`*` branch ate the trailing separator, and + * every alias target came back as `src*` — `substituteStar` then produced + * `srclib/date`, which matches no file, so the common Vite/shadcn + * `"@/*": ["./src/*"]` resolved to nothing on Windows. + * - a repo-ROOT target (`"*": ["./*"]` under `baseUrl: "."`) rebases to an + * EMPTY prefix, and `${''}${'/*'}` is `/*`. `substituteStar('/*', 'lib/date')` + * yields `/lib/date`, and `resolveFile` matches repo-relative keys without + * a leading slash, so the alias went external. The bare `*` is the encoding + * that substitutes correctly, on both platforms. + */ +import { describe, it, expect } from 'vitest'; +import path from 'node:path'; +import { _rebaseTargetForTests as rebaseTarget } from '../../src/core/ingestion/languages/typescript/tsconfig.js'; + +describe('rebaseTarget — Windows separator normalisation', () => { + it('rebases the Vite/shadcn `@/*` target to `src/*`, not `src*`', () => { + // What `path.win32.resolve('C:\\repo', './src/*')` hands the rebaser. + expect(rebaseTarget('C:\\repo', 'C:\\repo\\src\\*', path.win32)).toBe('src/*'); + }); + + it('rebases a nested monorepo target', () => { + expect(rebaseTarget('C:\\repo', 'C:\\repo\\packages\\ui\\src\\*', path.win32)).toBe( + 'packages/ui/src/*', + ); + }); + + it('leaves a starless target alone', () => { + expect(rebaseTarget('C:\\repo', 'C:\\repo\\src\\exact.ts', path.win32)).toBe('src/exact.ts'); + }); + + it('produces the identical result on POSIX', () => { + // The normalisation is a no-op here — `split('/').join('/')` is identity — + // so these are the OLD values as much as the new ones. Pinning them keeps + // the Windows fix from being paid for with a POSIX regression. + expect(rebaseTarget('/repo', '/repo/src/*', path.posix)).toBe('src/*'); + expect(rebaseTarget('/repo', '/repo/packages/ui/src/*', path.posix)).toBe('packages/ui/src/*'); + expect(rebaseTarget('/repo', '/repo/src/exact.ts', path.posix)).toBe('src/exact.ts'); + }); + + it('defaults to the platform-bound path module', () => { + const root = path.resolve('repo'); + expect(rebaseTarget(root, path.resolve(root, './src/*'))).toBe('src/*'); + }); +}); + +describe('rebaseTarget — repo-root wildcard', () => { + it('emits a bare `*` rather than `/*` for a root target', () => { + // `"baseUrl": "."` with `"*": ["./*"]` — the target resolves to the repo + // root itself, so the repo-relative prefix is empty and only the suffix is + // left. `/*` substitutes to `/lib/date`, which no indexed key matches. + expect(rebaseTarget('/repo', '/repo/*', path.posix)).toBe('*'); + expect(rebaseTarget('C:\\repo', 'C:\\repo\\*', path.win32)).toBe('*'); + }); + + it('still emits `/*` once the target is one directory in', () => { + // The bare `*` is the empty-prefix case ONLY; a real prefix keeps its + // separator or `src*` comes back, which is the bug above wearing a + // different hat. + expect(rebaseTarget('/repo', '/repo/src/*', path.posix)).toBe('src/*'); + expect(rebaseTarget('C:\\repo', 'C:\\repo\\src\\*', path.win32)).toBe('src/*'); + }); + + it('leaves a starless root target as the empty prefix', () => { + // `resolveFile('')` is `null`, which is the honest answer for a target + // naming the repo root and no file. Unchanged by the wildcard rule. + expect(rebaseTarget('/repo', '/repo', path.posix)).toBe(''); + expect(rebaseTarget('C:\\repo', 'C:\\repo', path.win32)).toBe(''); + }); +});