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>
364 lines
14 KiB
TypeScript
364 lines
14 KiB
TypeScript
/**
|
||
* TypeScript/JavaScript module resolution — the declared-input rules (#2953).
|
||
*
|
||
* The property under test is one sentence: a specifier resolves when something
|
||
* in the repo DECLARES it, and not otherwise. Each arm names the declaration
|
||
* doing the work — a real path, a tsconfig mapping, a package manifest — and
|
||
* the negative arms are the ones the old suffix matcher got wrong, so they are
|
||
* paired with a positive arm resolving the same specifier to the same file once
|
||
* a declaration exists.
|
||
*/
|
||
import { describe, it, expect } from 'vitest';
|
||
import {
|
||
nodePackageNameOf,
|
||
substituteStar,
|
||
type NodeWorkspacePackages,
|
||
} from '../../src/core/ingestion/import-resolvers/node-workspace-packages.js';
|
||
import { resolveTsModule } from '../../src/core/ingestion/languages/typescript/module-resolution.js';
|
||
import type { TsconfigIndex } from '../../src/core/ingestion/languages/typescript/tsconfig.js';
|
||
|
||
const FILES = new Set([
|
||
'apps/web/src/main.ts',
|
||
'apps/web/src/utils/foo.ts',
|
||
'packages/inner/src/nest/index.ts',
|
||
'packages/inner/src/secret.ts',
|
||
'packages/utils/src/index.ts',
|
||
'packages/utils/src/deep/thing.ts',
|
||
]);
|
||
|
||
const PACKAGES: NodeWorkspacePackages = {
|
||
byName: new Map([
|
||
[
|
||
'@repo/utils',
|
||
{
|
||
dir: 'packages/utils',
|
||
entries: ['packages/utils/src/index'],
|
||
subpathExports: new Map(),
|
||
subpathImports: new Map(),
|
||
},
|
||
],
|
||
[
|
||
'@repo/inner',
|
||
{
|
||
dir: 'packages/inner',
|
||
entries: ['packages/inner/src/index'],
|
||
// An `exports` map is a restriction: `nest` is public, `secret` is not.
|
||
subpathExports: new Map([['nest', ['packages/inner/src/nest']]]),
|
||
subpathImports: new Map([['#hidden', ['packages/inner/src/secret']]]),
|
||
},
|
||
],
|
||
]),
|
||
};
|
||
|
||
function resolve(
|
||
specifier: string,
|
||
opts: {
|
||
from?: string;
|
||
tsconfigs?: TsconfigIndex | null;
|
||
packages?: NodeWorkspacePackages | null;
|
||
} = {},
|
||
): string | null {
|
||
return resolveTsModule(specifier, {
|
||
fromFile: opts.from ?? 'apps/web/src/main.ts',
|
||
allFilePaths: FILES,
|
||
tsconfigs: opts.tsconfigs ?? null,
|
||
workspacePackages: opts.packages === undefined ? PACKAGES : opts.packages,
|
||
});
|
||
}
|
||
|
||
const BASE_URL_SRC: TsconfigIndex = {
|
||
scopes: [{ dir: 'apps/web', baseUrl: 'apps/web/src', paths: [] }],
|
||
};
|
||
|
||
describe('nodePackageNameOf', () => {
|
||
it('takes two segments for a scoped specifier and one otherwise', () => {
|
||
expect(nodePackageNameOf('@acme/telemetry/nest')).toBe('@acme/telemetry');
|
||
expect(nodePackageNameOf('@repo/utils')).toBe('@repo/utils');
|
||
expect(nodePackageNameOf('lodash/fp')).toBe('lodash');
|
||
expect(nodePackageNameOf('utils/foo')).toBe('utils');
|
||
});
|
||
|
||
it('returns null for specifiers that name a path or a package-internal import', () => {
|
||
expect(nodePackageNameOf('./sibling')).toBeNull();
|
||
expect(nodePackageNameOf('../up')).toBeNull();
|
||
expect(nodePackageNameOf('/abs')).toBeNull();
|
||
expect(nodePackageNameOf('#hidden')).toBeNull();
|
||
expect(nodePackageNameOf('@scope-only')).toBeNull();
|
||
});
|
||
});
|
||
|
||
describe('relative specifiers', () => {
|
||
it('resolves by exact path, extension and directory index', () => {
|
||
expect(resolve('./utils/foo')).toBe('apps/web/src/utils/foo.ts');
|
||
expect(resolve('../../../packages/utils/src')).toBe('packages/utils/src/index.ts');
|
||
});
|
||
|
||
it('resolves the ESM `.js` spelling of a `.ts` source', () => {
|
||
expect(resolve('./utils/foo.js')).toBe('apps/web/src/utils/foo.ts');
|
||
});
|
||
|
||
it('resolves to nothing when the path does not exist', () => {
|
||
expect(resolve('./nowhere')).toBeNull();
|
||
});
|
||
});
|
||
|
||
describe('external packages (#2953 direction 1)', () => {
|
||
it('does not resolve a registry package into the repo', () => {
|
||
// The reported defect: `@acme/telemetry` is a registry dependency, and the
|
||
// repo's only path ending in `nest/index.ts` belongs to an unrelated
|
||
// package. Dropping leading segments found it; declared resolution does not.
|
||
expect(resolve('@acme/telemetry/nest')).toBeNull();
|
||
});
|
||
|
||
it('does not resolve a bare specifier that merely matches a path suffix', () => {
|
||
expect(resolve('utils/foo')).toBeNull();
|
||
expect(resolve('src/utils/foo')).toBeNull();
|
||
});
|
||
|
||
it('resolves that same specifier once a tsconfig baseUrl declares it', () => {
|
||
expect(resolve('utils/foo', { tsconfigs: BASE_URL_SRC })).toBe('apps/web/src/utils/foo.ts');
|
||
});
|
||
});
|
||
|
||
describe('relative traversal out of the repository', () => {
|
||
it('resolves nothing when a specifier climbs past the root', () => {
|
||
// Popping an empty segment list silently CLAMPS at the root, so
|
||
// `../../../secret` from `apps/web/src/main.ts` became `secret` and could
|
||
// resolve a repo-root file the specifier never named. Outside the repo
|
||
// there is nothing indexed, so the answer is nothing.
|
||
expect(resolve('../../../../../../etc/passwd')).toBeNull();
|
||
});
|
||
|
||
it('still resolves a traversal that lands exactly on the root', () => {
|
||
// The paired positive: climbing to the root is legal, only climbing PAST
|
||
// it is not, and the guard must not take the legal case with it.
|
||
expect(resolve('../../../packages/utils/src')).toBe('packages/utils/src/index.ts');
|
||
});
|
||
});
|
||
|
||
describe('tsconfig paths', () => {
|
||
const withPaths: TsconfigIndex = {
|
||
scopes: [
|
||
{
|
||
dir: '',
|
||
baseUrl: null,
|
||
paths: [
|
||
{ pattern: '@/*', targets: ['apps/web/src/*'] },
|
||
// A longer literal prefix must win over `@/*` even though it is
|
||
// declared second — tsc ranks by prefix length, not declaration order.
|
||
{ pattern: '@/utils/*', targets: ['packages/utils/src/*'] },
|
||
{ pattern: 'exact', targets: ['packages/utils/src/index'] },
|
||
],
|
||
},
|
||
],
|
||
};
|
||
|
||
it('substitutes the wildcard', () => {
|
||
expect(resolve('@/main', { tsconfigs: withPaths })).toBe('apps/web/src/main.ts');
|
||
});
|
||
|
||
it('prefers the longest matching pattern, not the first declared', () => {
|
||
expect(resolve('@/utils/deep/thing', { tsconfigs: withPaths })).toBe(
|
||
'packages/utils/src/deep/thing.ts',
|
||
);
|
||
});
|
||
|
||
it('supports a starless exact pattern', () => {
|
||
expect(resolve('exact', { tsconfigs: withPaths })).toBe('packages/utils/src/index.ts');
|
||
});
|
||
|
||
it('tries every target in order, not just the first', () => {
|
||
const twoTargets: TsconfigIndex = {
|
||
scopes: [
|
||
{
|
||
dir: '',
|
||
baseUrl: null,
|
||
// The first target names nothing; the old loader kept only this one.
|
||
paths: [{ pattern: '~/*', targets: ['generated/*', 'packages/utils/src/*'] }],
|
||
},
|
||
],
|
||
};
|
||
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: [
|
||
{ dir: 'apps/web', baseUrl: 'apps/web/src', paths: [] },
|
||
{ dir: '', baseUrl: 'packages/utils/src', paths: [] },
|
||
],
|
||
};
|
||
// `apps/web/src/main.ts` is governed by `apps/web`, whose baseUrl resolves
|
||
// `utils/foo`. The root config would have looked in `packages/utils/src`.
|
||
expect(resolve('utils/foo', { tsconfigs: nested })).toBe('apps/web/src/utils/foo.ts');
|
||
});
|
||
});
|
||
|
||
describe('workspace packages (#2953 direction 2)', () => {
|
||
it('resolves a package name to its manifest entry point', () => {
|
||
// The name appears in no file path, so nothing but the manifest can find it.
|
||
expect(resolve('@repo/utils')).toBe('packages/utils/src/index.ts');
|
||
});
|
||
|
||
it('resolves a subpath a package exports', () => {
|
||
expect(resolve('@repo/inner/nest')).toBe('packages/inner/src/nest/index.ts');
|
||
});
|
||
|
||
it('honours the restriction an `exports` map imposes', () => {
|
||
// `secret.ts` exists and its path would satisfy any suffix match, but the
|
||
// package does not export it. Node would refuse, and so does this.
|
||
expect(resolve('@repo/inner/secret')).toBeNull();
|
||
});
|
||
|
||
it('resolves a package-internal `#` import against the importing package', () => {
|
||
expect(resolve('#hidden', { from: 'packages/inner/src/nest/index.ts' })).toBe(
|
||
'packages/inner/src/secret.ts',
|
||
);
|
||
});
|
||
|
||
it('does not honour another package’s `#` imports', () => {
|
||
expect(resolve('#hidden', { from: 'apps/web/src/main.ts' })).toBeNull();
|
||
});
|
||
|
||
it('does not fall back into `src/` for an unexported subpath', () => {
|
||
// `@repo/utils` declares no `exports`, so Node resolves a subpath against
|
||
// the package DIRECTORY and only against it. An earlier draft also tried
|
||
// `<dir>/src/<subpath>` on the theory that a workspace package is consumed
|
||
// from source — but nothing declares that mapping, so it is the same kind
|
||
// of guess this module exists to remove, and the import it "resolves" is
|
||
// broken in the real project too.
|
||
expect(resolve('@repo/utils/deep/thing')).toBeNull();
|
||
});
|
||
|
||
it('resolves a `#imports` PATTERN key, not just an exact one', () => {
|
||
const patterned: NodeWorkspacePackages = {
|
||
byName: new Map([
|
||
[
|
||
'@repo/inner',
|
||
{
|
||
dir: 'packages/inner',
|
||
entries: ['packages/inner/src/index'],
|
||
subpathExports: new Map(),
|
||
subpathImports: new Map([['#internal/*', ['packages/inner/src/*']]]),
|
||
},
|
||
],
|
||
]),
|
||
};
|
||
|
||
expect(
|
||
resolveTsModule('#internal/secret', {
|
||
fromFile: 'packages/inner/src/nest/index.ts',
|
||
allFilePaths: FILES,
|
||
tsconfigs: null,
|
||
workspacePackages: patterned,
|
||
}),
|
||
).toBe('packages/inner/src/secret.ts');
|
||
});
|
||
|
||
it('honours an `exports` ARRAY as an ordered fallback list', () => {
|
||
// `["./dist/x.js", "./src/x.ts"]` is what a workspace package publishes to
|
||
// say "built output, or source". For a static analyser the source arm is
|
||
// the one that matters, because `dist/` is build output and is not indexed
|
||
// — and a build need not have run at all for the repo to be analysable.
|
||
const withArray: NodeWorkspacePackages = {
|
||
byName: new Map([
|
||
[
|
||
'@repo/inner',
|
||
{
|
||
dir: 'packages/inner',
|
||
entries: ['packages/inner/src/index'],
|
||
subpathExports: new Map([
|
||
['nest', ['packages/inner/dist/nest', 'packages/inner/src/nest']],
|
||
]),
|
||
subpathImports: new Map(),
|
||
},
|
||
],
|
||
]),
|
||
};
|
||
|
||
expect(
|
||
resolveTsModule('@repo/inner/nest', {
|
||
fromFile: 'apps/web/src/main.ts',
|
||
allFilePaths: FILES,
|
||
tsconfigs: null,
|
||
workspacePackages: withArray,
|
||
}),
|
||
).toBe('packages/inner/src/nest/index.ts');
|
||
});
|
||
|
||
it('refuses the package root when `exports` omits it', () => {
|
||
// `exports` is the package's ENTIRE public interface when present: Node
|
||
// ignores `main` and refuses anything the map does not list. The root is
|
||
// the same rule as a subpath, so a manifest exporting only `./nest` must
|
||
// not answer a bare `@repo/inner` — that import does not resolve in the
|
||
// real project, and an edge for it is a fabricated one.
|
||
const subpathOnly: NodeWorkspacePackages = {
|
||
byName: new Map([
|
||
[
|
||
'@repo/inner',
|
||
{
|
||
dir: 'packages/inner',
|
||
// What `readManifest` produces for `{"exports": {"./nest": …}}`:
|
||
// no root entry, and no legacy/conventional fallbacks.
|
||
entries: [],
|
||
subpathExports: new Map([['nest', ['packages/inner/src/nest']]]),
|
||
subpathImports: new Map(),
|
||
},
|
||
],
|
||
]),
|
||
};
|
||
const ctx = {
|
||
fromFile: 'apps/web/src/main.ts',
|
||
allFilePaths: FILES,
|
||
tsconfigs: null,
|
||
workspacePackages: subpathOnly,
|
||
};
|
||
|
||
expect(resolveTsModule('@repo/inner', ctx)).toBeNull();
|
||
// The paired positive: what the map DOES list still resolves.
|
||
expect(resolveTsModule('@repo/inner/nest', ctx)).toBe('packages/inner/src/nest/index.ts');
|
||
});
|
||
|
||
it('resolves nothing when the repo declares no packages at all', () => {
|
||
expect(resolve('@repo/utils', { packages: null })).toBeNull();
|
||
});
|
||
});
|
||
|
||
describe('subpath pattern substitution', () => {
|
||
it('substitutes the single `*` a pattern is allowed to contain', () => {
|
||
// Node subpath patterns and tsconfig `paths` both allow AT MOST one `*`, so
|
||
// substituting the first occurrence is the specified behaviour. Pinned
|
||
// because the obvious spelling — `String.replace` with a string needle —
|
||
// states that only by accident and reads as the replace-all footgun.
|
||
expect(substituteStar('packages/utils/src/*.ts', 'deep/thing')).toBe(
|
||
'packages/utils/src/deep/thing.ts',
|
||
);
|
||
});
|
||
|
||
it('leaves a starless target alone', () => {
|
||
expect(substituteStar('packages/utils/src/index', 'ignored')).toBe('packages/utils/src/index');
|
||
});
|
||
});
|