Merge pull request #227 from ZebangCheng/fix/tsconfig-path-leading-dot-slash

fix(extract-import-map): normalize tsconfig path-alias candidates with leading "./" (#214)
This commit is contained in:
ZebangCheng
2026-05-26 13:09:25 +08:00
committed by GitHub
Unverified
2 changed files with 117 additions and 3 deletions
@@ -237,6 +237,111 @@ describe('extract-import-map.mjs — TypeScript / JavaScript resolver', () => {
'packages/foo/src/y.ts',
);
});
// ── Issue #214: tsconfig path-alias targets with leading "./" ───────────
// create-next-app ships `"@/*": ["./*"]` as the default. With a root
// tsconfig the candidate would stay as "./lib/thing" while ctx.fileSet
// stores normalized "lib/thing", silently dropping every cross-module
// import edge. Three originally broken cases plus one regression guard
// for the already working `["*"]` form.
it('resolves tsconfig paths with leading "./" target and no baseUrl (#214)', () => {
projectRoot = setupTree({
'tsconfig.json': JSON.stringify({
compilerOptions: {
paths: { '@/*': ['./*'] },
},
}),
'src/app.ts': `import { x } from '@/lib/thing';\nconst _ = x;\n`,
'lib/thing.ts': `export const x = 1;\n`,
});
const result = runScript(projectRoot, {
projectRoot,
files: [
{ path: 'tsconfig.json', language: 'json', fileCategory: 'config' },
{ path: 'src/app.ts', language: 'typescript', fileCategory: 'code' },
{ path: 'lib/thing.ts', language: 'typescript', fileCategory: 'code' },
],
});
expect(result.status).toBe(0);
expect(result.output.importMap['src/app.ts']).toContain('lib/thing.ts');
});
it('resolves tsconfig paths with leading "./" target and baseUrl "." (#214)', () => {
projectRoot = setupTree({
'tsconfig.json': JSON.stringify({
compilerOptions: {
baseUrl: '.',
paths: { '@/*': ['./*'] },
},
}),
'src/app.ts': `import { x } from '@/lib/thing';\nconst _ = x;\n`,
'lib/thing.ts': `export const x = 1;\n`,
});
const result = runScript(projectRoot, {
projectRoot,
files: [
{ path: 'tsconfig.json', language: 'json', fileCategory: 'config' },
{ path: 'src/app.ts', language: 'typescript', fileCategory: 'code' },
{ path: 'lib/thing.ts', language: 'typescript', fileCategory: 'code' },
],
});
expect(result.status).toBe(0);
expect(result.output.importMap['src/app.ts']).toContain('lib/thing.ts');
});
it('resolves tsconfig paths with leading "./" target and baseUrl "src" (#214)', () => {
projectRoot = setupTree({
'tsconfig.json': JSON.stringify({
compilerOptions: {
baseUrl: 'src',
paths: { '@/*': ['./*'] },
},
}),
'src/app.ts': `import { x } from '@/thing';\nconst _ = x;\n`,
'src/thing.ts': `export const x = 1;\n`,
});
const result = runScript(projectRoot, {
projectRoot,
files: [
{ path: 'tsconfig.json', language: 'json', fileCategory: 'config' },
{ path: 'src/app.ts', language: 'typescript', fileCategory: 'code' },
{ path: 'src/thing.ts', language: 'typescript', fileCategory: 'code' },
],
});
expect(result.status).toBe(0);
expect(result.output.importMap['src/app.ts']).toContain('src/thing.ts');
});
it('keeps resolving tsconfig paths with bare "*" target (#214 regression guard)', () => {
projectRoot = setupTree({
'tsconfig.json': JSON.stringify({
compilerOptions: {
paths: { '@/*': ['*'] },
},
}),
'src/app.ts': `import { x } from '@/lib/thing';\nconst _ = x;\n`,
'lib/thing.ts': `export const x = 1;\n`,
});
const result = runScript(projectRoot, {
projectRoot,
files: [
{ path: 'tsconfig.json', language: 'json', fileCategory: 'config' },
{ path: 'src/app.ts', language: 'typescript', fileCategory: 'code' },
{ path: 'lib/thing.ts', language: 'typescript', fileCategory: 'code' },
],
});
expect(result.status).toBe(0);
expect(result.output.importMap['src/app.ts']).toContain('lib/thing.ts');
});
});
describe('extract-import-map.mjs — Python resolver', () => {
@@ -430,9 +430,18 @@ export function resolveTsJsImport(rawImport, file, ctx) {
const relativeToConfig = normalizedBase
? posix.join(normalizedBase, mapped)
: mapped;
const candidate = tsConfigDir
? posix.join(tsConfigDir, relativeToConfig)
: relativeToConfig;
// posix.normalize strips a leading "./" left over when both
// tsConfigDir and normalizedBase are empty (root tsconfig with
// `"@/*": ["./*"]`, the create-next-app default). Without this the
// candidate stays as "./foo" while ctx.fileSet stores "foo", and
// probeWithExtensions silently drops every cross-module edge.
const candidate = posix.normalize(
tsConfigDir
? posix.join(tsConfigDir, relativeToConfig)
: relativeToConfig,
);
// Defensive: tsconfig targets shouldn't escape the project root.
if (candidate.startsWith('..')) continue;
const probed = probeWithExtensions(candidate, ctx.fileSet);
if (probed) return probed;
}