mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-08-28 05:25:25 +00:00
* fix: apply ESM .js extension fallback to tsconfig path alias resolution Path alias imports (e.g. `@/utils.js` via tsconfig paths) now correctly strip JS-family extensions and retry with TS equivalents when the literal .js file does not exist. This applies the same stripJsExtension fallback already used for relative imports to the alias resolution branch. Fixes #1528 * chore(autofix): apply prettier + eslint fixes via /autofix command * test(esm): cover .mjs/.cjs path-alias extension resolution Co-authored-by: Cursor <cursoragent@cursor.com> * test(esm): use Map for path aliases in resolveWithAlias helper Matches TsconfigPaths.aliases from language-config. CI cannot run tsc -p tsconfig.test.json yet: the project has hundreds of pre-existing errors under test/ (fixtures + unit/integration); enable that step after backlog cleanup. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Gergő Magyar <gergomagyar@icloud.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Cursor <cursoragent@cursor.com>
226 lines
9 KiB
TypeScript
226 lines
9 KiB
TypeScript
/**
|
|
* Unit tests for TypeScript ESM .js extension resolution.
|
|
*
|
|
* TypeScript ESM requires imports to use .js extensions even when source
|
|
* files are .ts. The resolver must map .js → .ts (and .jsx → .tsx,
|
|
* .mjs → .mts, .cjs → .cts) when the literal .js file does not exist.
|
|
*/
|
|
|
|
import { describe, it, expect } from 'vitest';
|
|
import { resolveImportPath } from '../../src/core/ingestion/import-resolvers/standard.js';
|
|
import { stripJsExtension } from '../../src/core/ingestion/import-resolvers/standard.js';
|
|
import { buildSuffixIndex } from '../../src/core/ingestion/import-resolvers/utils.js';
|
|
import { SupportedLanguages } from 'gitnexus-shared';
|
|
|
|
function makeCtx(files: string[]) {
|
|
// Match production normalization: only replace backslashes with forward slashes
|
|
const normalized = files.map((f) => f.replace(/\\/g, '/'));
|
|
const allFilesSet = new Set(files);
|
|
const index = buildSuffixIndex(normalized, files);
|
|
const cache = new Map<string, string | null>();
|
|
return { files, normalized, allFilesSet, index, cache };
|
|
}
|
|
|
|
function resolve(
|
|
currentFile: string,
|
|
importPath: string,
|
|
language: SupportedLanguages,
|
|
ctx: ReturnType<typeof makeCtx>,
|
|
): string | null {
|
|
return resolveImportPath(
|
|
currentFile,
|
|
importPath,
|
|
ctx.allFilesSet,
|
|
ctx.files,
|
|
ctx.normalized,
|
|
ctx.cache,
|
|
language,
|
|
null,
|
|
ctx.index,
|
|
);
|
|
}
|
|
|
|
describe('TypeScript ESM .js extension resolution', () => {
|
|
it('resolves ./utils.js to ./utils.ts when .js does not exist', () => {
|
|
const ctx = makeCtx(['src/index.ts', 'src/utils.ts']);
|
|
const result = resolve('src/index.ts', './utils.js', SupportedLanguages.TypeScript, ctx);
|
|
expect(result).toBe('src/utils.ts');
|
|
});
|
|
|
|
it('resolves ./component.jsx to ./component.tsx', () => {
|
|
const ctx = makeCtx(['src/app.ts', 'src/component.tsx']);
|
|
const result = resolve('src/app.ts', './component.jsx', SupportedLanguages.TypeScript, ctx);
|
|
expect(result).toBe('src/component.tsx');
|
|
});
|
|
|
|
it('resolves ./config.mjs to ./config.mts', () => {
|
|
const ctx = makeCtx(['src/index.ts', 'src/config.mts']);
|
|
const result = resolve('src/index.ts', './config.mjs', SupportedLanguages.TypeScript, ctx);
|
|
expect(result).toBe('src/config.mts');
|
|
});
|
|
|
|
it('resolves ./legacy.cjs to ./legacy.cts', () => {
|
|
const ctx = makeCtx(['src/index.ts', 'src/legacy.cts']);
|
|
const result = resolve('src/index.ts', './legacy.cjs', SupportedLanguages.TypeScript, ctx);
|
|
expect(result).toBe('src/legacy.cts');
|
|
});
|
|
|
|
it('prefers actual .js file when it exists', () => {
|
|
const ctx = makeCtx(['src/index.ts', 'src/utils.js', 'src/utils.ts']);
|
|
const result = resolve('src/index.ts', './utils.js', SupportedLanguages.TypeScript, ctx);
|
|
expect(result).toBe('src/utils.js');
|
|
});
|
|
|
|
it('resolves relative path with ../ and .js extension', () => {
|
|
const ctx = makeCtx(['src/helpers/token.ts', 'src/core/engine.ts']);
|
|
const result = resolve(
|
|
'src/core/engine.ts',
|
|
'../helpers/token.js',
|
|
SupportedLanguages.TypeScript,
|
|
ctx,
|
|
);
|
|
expect(result).toBe('src/helpers/token.ts');
|
|
});
|
|
|
|
it('works for JavaScript language too', () => {
|
|
const ctx = makeCtx(['src/index.js', 'src/utils.ts']);
|
|
const result = resolve('src/index.js', './utils.js', SupportedLanguages.JavaScript, ctx);
|
|
expect(result).toBe('src/utils.ts');
|
|
});
|
|
|
|
it('does NOT apply ESM fallback for non-TS/JS languages', () => {
|
|
const ctx = makeCtx(['src/main.py', 'src/utils.ts']);
|
|
const result = resolve('src/main.py', './utils.js', SupportedLanguages.Python, ctx);
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it('returns null when neither .js nor .ts exists', () => {
|
|
const ctx = makeCtx(['src/index.ts']);
|
|
const result = resolve('src/index.ts', './missing.js', SupportedLanguages.TypeScript, ctx);
|
|
expect(result).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('ESM extension resolution — .mjs/.cjs with competing siblings', () => {
|
|
it('resolves ./config.mjs to .ts when only .ts exists (no .mts)', () => {
|
|
const ctx = makeCtx(['src/index.ts', 'src/config.ts']);
|
|
const result = resolve('src/index.ts', './config.mjs', SupportedLanguages.TypeScript, ctx);
|
|
// .ts wins because EXTENSIONS order tries .ts before .mts
|
|
expect(result).toBe('src/config.ts');
|
|
});
|
|
|
|
it('resolves ./config.mjs to .mts when both .ts and .mts exist', () => {
|
|
// Note: EXTENSIONS order is .tsx, .ts, .mts, .cts — so .ts wins over .mts.
|
|
// This is intentional for a source-analysis tool: we resolve to the first
|
|
// matching source file. In practice, having both config.ts and config.mts
|
|
// in the same directory is extremely rare.
|
|
const ctx = makeCtx(['src/index.ts', 'src/config.ts', 'src/config.mts']);
|
|
const result = resolve('src/index.ts', './config.mjs', SupportedLanguages.TypeScript, ctx);
|
|
expect(result).toBe('src/config.ts');
|
|
});
|
|
|
|
it('resolves ./config.cjs to .cts when only .cts exists', () => {
|
|
const ctx = makeCtx(['src/index.ts', 'src/config.cts']);
|
|
const result = resolve('src/index.ts', './config.cjs', SupportedLanguages.TypeScript, ctx);
|
|
expect(result).toBe('src/config.cts');
|
|
});
|
|
});
|
|
|
|
describe('ESM extension resolution — directory index boundary', () => {
|
|
it('resolves ./dir.js to dir/index.ts when dir/ exists (bundler-mode)', () => {
|
|
// After stripping .js from "dir.js" → "dir", tryResolveWithExtensions probes
|
|
// "/index.ts" suffix. This matches bundler-mode behavior where bare directory
|
|
// imports resolve to index files. Intentional for source-analysis compatibility.
|
|
const ctx = makeCtx(['src/index.ts', 'src/dir/index.ts']);
|
|
const result = resolve('src/index.ts', './dir.js', SupportedLanguages.TypeScript, ctx);
|
|
expect(result).toBe('src/dir/index.ts');
|
|
});
|
|
|
|
it('resolves ./dir/index.js to dir/index.ts', () => {
|
|
const ctx = makeCtx(['src/index.ts', 'src/dir/index.ts']);
|
|
const result = resolve('src/index.ts', './dir/index.js', SupportedLanguages.TypeScript, ctx);
|
|
expect(result).toBe('src/dir/index.ts');
|
|
});
|
|
});
|
|
|
|
describe('stripJsExtension', () => {
|
|
it('strips .js', () => expect(stripJsExtension('foo/bar.js')).toBe('foo/bar'));
|
|
it('strips .jsx', () => expect(stripJsExtension('foo/bar.jsx')).toBe('foo/bar'));
|
|
it('strips .mjs', () => expect(stripJsExtension('foo/bar.mjs')).toBe('foo/bar'));
|
|
it('strips .cjs', () => expect(stripJsExtension('foo/bar.cjs')).toBe('foo/bar'));
|
|
it('returns null for .ts', () => expect(stripJsExtension('foo/bar.ts')).toBeNull());
|
|
it('returns null for no extension', () => expect(stripJsExtension('foo/bar')).toBeNull());
|
|
});
|
|
|
|
describe('ESM extension resolution — path aliases with .js extensions', () => {
|
|
const aliasAtToSrc = new Map<string, string>([['@/', 'src/']]);
|
|
const aliasTildeToSrc = new Map<string, string>([['~/', 'src/']]);
|
|
|
|
function resolveWithAlias(
|
|
currentFile: string,
|
|
importPath: string,
|
|
ctx: ReturnType<typeof makeCtx>,
|
|
aliases: Map<string, string>,
|
|
baseUrl = '.',
|
|
): string | null {
|
|
return resolveImportPath(
|
|
currentFile,
|
|
importPath,
|
|
ctx.allFilesSet,
|
|
ctx.files,
|
|
ctx.normalized,
|
|
ctx.cache,
|
|
SupportedLanguages.TypeScript,
|
|
{ aliases, baseUrl },
|
|
ctx.index,
|
|
);
|
|
}
|
|
|
|
it('resolves @/utils.js to src/utils.ts via alias', () => {
|
|
const ctx = makeCtx(['src/index.ts', 'src/utils.ts']);
|
|
const result = resolveWithAlias('src/index.ts', '@/utils.js', ctx, aliasAtToSrc, '.');
|
|
expect(result).toBe('src/utils.ts');
|
|
});
|
|
|
|
it('resolves @/component.jsx to src/component.tsx via alias', () => {
|
|
const ctx = makeCtx(['src/index.ts', 'src/component.tsx']);
|
|
const result = resolveWithAlias('src/index.ts', '@/component.jsx', ctx, aliasAtToSrc, '.');
|
|
expect(result).toBe('src/component.tsx');
|
|
});
|
|
|
|
it('resolves @/config.mjs to src/config.mts via alias', () => {
|
|
const ctx = makeCtx(['src/index.ts', 'src/config.mts']);
|
|
const result = resolveWithAlias('src/index.ts', '@/config.mjs', ctx, aliasAtToSrc, '.');
|
|
expect(result).toBe('src/config.mts');
|
|
});
|
|
|
|
it('resolves @/legacy.cjs to src/legacy.cts via alias', () => {
|
|
const ctx = makeCtx(['src/index.ts', 'src/legacy.cts']);
|
|
const result = resolveWithAlias('src/index.ts', '@/legacy.cjs', ctx, aliasAtToSrc, '.');
|
|
expect(result).toBe('src/legacy.cts');
|
|
});
|
|
|
|
it('prefers actual .js file over TS fallback in alias resolution', () => {
|
|
const ctx = makeCtx(['src/index.ts', 'src/utils.js', 'src/utils.ts']);
|
|
const result = resolveWithAlias('src/index.ts', '@/utils.js', ctx, aliasAtToSrc, '.');
|
|
expect(result).toBe('src/utils.js');
|
|
});
|
|
|
|
it('resolves alias with baseUrl prefix', () => {
|
|
const ctx = makeCtx(['app/src/index.ts', 'app/src/helpers/token.ts']);
|
|
const result = resolveWithAlias(
|
|
'app/src/index.ts',
|
|
'~/helpers/token.js',
|
|
ctx,
|
|
aliasTildeToSrc,
|
|
'app',
|
|
);
|
|
expect(result).toBe('app/src/helpers/token.ts');
|
|
});
|
|
|
|
it('returns null when alias .js import has no matching source', () => {
|
|
const ctx = makeCtx(['src/index.ts']);
|
|
const result = resolveWithAlias('src/index.ts', '@/missing.js', ctx, aliasAtToSrc, '.');
|
|
expect(result).toBeNull();
|
|
});
|
|
});
|