Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 65 additions & 0 deletions tests/skill/understand/test_extract_import_map.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -528,6 +528,71 @@ describe('extract-import-map.mjs — TypeScript / JavaScript resolver', () => {
expect(result.output.importMap['src/app.ts']).toContain('lib/thing.ts');
});

// ── jsconfig.json path aliases ──────────────────────────────────────────
//
// `create-next-app` writes jsconfig.json (not tsconfig.json) when you
// decline TypeScript, carrying the identical `compilerOptions.paths`.
// Matching only the literal filename "tsconfig.json" meant every alias in a
// JS-only Next.js project resolved to nothing, so the graph lost all of its
// app→lib import edges.

it('resolves jsconfig.json paths aliases in a JS-only project', () => {
projectRoot = setupTree({
'jsconfig.json': JSON.stringify({
compilerOptions: {
baseUrl: '.',
paths: { '@/*': ['./*'] },
},
}),
'app/page.js': `import { x } from '@/lib/thing.js';\nconst _ = x;\n`,
'lib/thing.js': `export const x = 1;\n`,
});

const result = runScript(projectRoot, {
projectRoot,
files: [
{ path: 'jsconfig.json', language: 'json', fileCategory: 'config' },
{ path: 'app/page.js', language: 'javascript', fileCategory: 'code' },
{ path: 'lib/thing.js', language: 'javascript', fileCategory: 'code' },
],
});

expect(result.status).toBe(0);
expect(result.output.importMap['app/page.js']).toContain('lib/thing.js');
});

it('prefers tsconfig.json over jsconfig.json when a directory holds both', () => {
// A vestigial jsconfig alongside a real tsconfig must not win, whichever
// order the parallel reads happen to complete in. Only the tsconfig alias
// (@ts/*) resolves; the jsconfig-only alias (@js/*) does not.
projectRoot = setupTree({
'tsconfig.json': JSON.stringify({
compilerOptions: { paths: { '@ts/*': ['./lib/*'] } },
}),
'jsconfig.json': JSON.stringify({
compilerOptions: { paths: { '@js/*': ['./lib/*'] } },
}),
'src/app.ts': `import { x } from '@ts/thing';\nimport { y } from '@js/other';\nconst _ = x + y;\n`,
'lib/thing.ts': `export const x = 1;\n`,
'lib/other.ts': `export const y = 2;\n`,
});

const result = runScript(projectRoot, {
projectRoot,
files: [
{ path: 'tsconfig.json', language: 'json', fileCategory: 'config' },
{ path: 'jsconfig.json', language: 'json', fileCategory: 'config' },
{ path: 'src/app.ts', language: 'typescript', fileCategory: 'code' },
{ path: 'lib/thing.ts', language: 'typescript', fileCategory: 'code' },
{ path: 'lib/other.ts', language: 'typescript', fileCategory: 'code' },
],
});

expect(result.status).toBe(0);
expect(result.output.importMap['src/app.ts']).toContain('lib/thing.ts');
expect(result.output.importMap['src/app.ts']).not.toContain('lib/other.ts');
});

// ── #294: NodeNext / ESM TypeScript `.js → .ts` rewrite ────────────────
//
// Under `moduleResolution: NodeNext`, TypeScript does NOT rewrite import
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,12 @@ function parseTsConfigText(raw) {
* failure for a specific tsconfig, emits a Warning: pointing at the bad
* file and skips it (the rest of the project keeps working).
*
* `jsconfig.json` is read on equal footing: JavaScript-only projects
* (notably `create-next-app` without TypeScript) declare the very same
* `compilerOptions.paths` aliases there, and editors and bundlers honor it
* identically. When a directory holds both, tsconfig.json wins — a project
* carrying both is a TypeScript project whose jsconfig is vestigial.
*
* Parse strategy (per-file, in parseTsConfigText):
* 1. Try the comment-stripped text (handles JSONC-style tsconfigs).
* 2. If that fails, retry the ORIGINAL raw text — recovers the case
Expand All @@ -332,18 +338,22 @@ async function loadTsConfigs(projectRoot, files) {
for (const f of files) {
const p = toPosix(f.path);
const base = p.includes('/') ? p.slice(p.lastIndexOf('/') + 1) : p;
if (base !== 'tsconfig.json') continue;
if (base !== 'tsconfig.json' && base !== 'jsconfig.json') continue;
const absPath = join(projectRoot, p);
if (!existsSync(absPath)) continue;
candidates.push({ key: p, absPath });
}
const reads = await readFilesParallel(candidates);
// Which basename supplied each directory's config, so a tsconfig.json can
// override a jsconfig.json regardless of the order the parallel reads land.
const wonBy = new Map();
for (const { key: p, raw, err } of reads) {
const base = p.includes('/') ? p.slice(p.lastIndexOf('/') + 1) : p;
if (err) {
failures.push({ path: p, stage: 'resolver-config-read', message: err.message });
// absPath isn't carried through the helper return shape; reconstruct it.
warnings.push(
`Warning: extract-import-map: tsconfig.json at ${join(projectRoot, p)} failed ` +
`Warning: extract-import-map: ${base} at ${join(projectRoot, p)} failed ` +
`to read (${err.message}) — path aliases from this config will ` +
`not be applied — relative imports unaffected\n`,
);
Expand All @@ -354,16 +364,19 @@ async function loadTsConfigs(projectRoot, files) {
failures.push({
path: p,
stage: 'resolver-config-parse',
message: 'invalid tsconfig.json',
message: `invalid ${base}`,
});
warnings.push(
`Warning: extract-import-map: tsconfig.json at ${join(projectRoot, p)} failed ` +
`Warning: extract-import-map: ${base} at ${join(projectRoot, p)} failed ` +
`to parse — path aliases from this config will not be applied ` +
`— relative imports unaffected\n`,
);
continue;
}
out.set(dirOf(p), parsed);
const dir = dirOf(p);
if (wonBy.get(dir) === 'tsconfig.json' && base !== 'tsconfig.json') continue;
out.set(dir, parsed);
wonBy.set(dir, base);
}
return { configs: out, warnings, failures };
}
Expand Down