fix: apply ESM .js extension fallback to tsconfig path alias resolution (#1530)

* 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>
This commit is contained in:
Harlan Zhou 2026-05-14 23:15:17 +08:00 • committed by GitHub
parent 50201b917c
commit acd03fca03
2 changed files with 80 additions and 3 deletions

View file

@ -72,6 +72,13 @@ export const resolveImportPath = (
const resolved = tryResolveWithExtensions(rewritten, allFiles);
if (resolved) return cache(resolved);
// ESM fallback: strip .js/.jsx/.mjs/.cjs and retry with TS equivalents
const strippedAlias = stripJsExtension(rewritten);
if (strippedAlias !== null) {
const esmResolved = tryResolveWithExtensions(strippedAlias, allFiles);
if (esmResolved) return cache(esmResolved);
}
// Try suffix matching as fallback
const parts = rewritten.split('/').filter(Boolean);
const suffixResult = suffixResolve(parts, normalizedFileList, allFileList, index);
@ -132,9 +139,6 @@ export const resolveImportPath = (
// TypeScript ESM: imports use .js/.jsx/.mjs/.cjs but source files are
// .ts/.tsx/.mts/.cts. Strip the JS-family extension and re-resolve.
// NOTE: This fallback only applies to relative imports. Path alias imports
// (e.g. @/utils.js via tsconfig paths) do not yet strip .js extensions —
// that is a known limitation tracked for follow-up.
if (language === SupportedLanguages.TypeScript || language === SupportedLanguages.JavaScript) {
const stripped = stripJsExtension(basePath);
if (stripped !== null) {

View file

@ -151,3 +151,76 @@ describe('stripJsExtension', () => {
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();
});
});