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
104 changes: 104 additions & 0 deletions tests/skill/understand/test_extract_import_map.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,110 @@ describe('extract-import-map.mjs — TypeScript / JavaScript resolver', () => {
);
});

// ── JSONC tsconfig: comments must not eat the config ────────────────────
// tsconfig.json is JSONC, and a real one carries both `//` comments and
// glob patterns. The previous regex stripper did not honor string literals,
// so the `/*` inside `"@/*"` opened a spurious block comment that the `*/`
// inside `"**/*.ts"` closed — deleting every key in between. Such a
// tsconfig failed the stripped parse AND the raw parse, so parseTsConfigText
// returned null and every alias was silently dropped from the import map.

it('resolves aliases in a tsconfig with line comments and glob patterns', () => {
// Deliberately NOT JSON.stringify: the comments are the point.
projectRoot = setupTree({
'tsconfig.json': [
'{',
' "compilerOptions": {',
' "moduleResolution": "bundler",',
' // Comment sitting between the globs and the alias.',
' "allowImportingTsExtensions": true,',
' "paths": {',
' "@/*": ["./src/*"]',
' }',
' },',
' "include": ["**/*.ts", "**/*.tsx"]',
'}',
'',
].join('\n'),
'src/app.ts': `import { x } from '@/lib/thing';\nconst _ = x;\n`,
'src/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: 'src/lib/thing.ts', language: 'typescript', fileCategory: 'code' },
],
});

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

it('resolves aliases in a tsconfig with a block comment and glob patterns', () => {
projectRoot = setupTree({
'tsconfig.json': [
'{',
' "compilerOptions": {',
' /* Block comment, multi-line.',
' Still a comment. */',
' "paths": { "@/*": ["./src/*"] }',
' },',
' "include": ["**/*.ts"]',
'}',
'',
].join('\n'),
'src/app.ts': `import { x } from '@/lib/thing';\nconst _ = x;\n`,
'src/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: 'src/lib/thing.ts', language: 'typescript', fileCategory: 'code' },
],
});

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

it('does not treat "//" inside a string value as a comment', () => {
// A URL in a tsconfig string must survive the stripper untouched;
// otherwise the value is truncated and the JSON becomes unparseable.
projectRoot = setupTree({
'tsconfig.json': [
'{',
' "compilerOptions": {',
' "paths": { "@/*": ["./src/*"] }',
' },',
' "$schema": "https://json.schemastore.org/tsconfig",',
' "include": ["**/*.ts"]',
'}',
'',
].join('\n'),
'src/app.ts': `import { x } from '@/lib/thing';\nconst _ = x;\n`,
'src/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: 'src/lib/thing.ts', language: 'typescript', fileCategory: 'code' },
],
});

expect(result.status).toBe(0);
expect(result.output.importMap['src/app.ts']).toContain('src/lib/thing.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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -153,13 +153,84 @@ function dirOf(p) {
* with the exact tsconfig path that failed; bubbling the error would
* conceal which file was at fault when many tsconfigs are loaded.
*/
/**
* Strip JSONC comments from `text`, honoring string literals.
*
* A regex stripper cannot do this safely, because tsconfig path aliases and
* include globs legitimately contain the comment delimiters. In
*
* "paths": { "@/*": ["./src/*"] },
* "include": ["**\/*.ts"]
*
* the `/*` inside `"@/*"` opens a spurious block comment that the `*\/` inside
* `"**\/*.ts"` then closes, deleting every key in between. A tsconfig carrying
* BOTH comments and globs therefore fails the stripped parse AND the raw parse,
* and `parseTsConfigText` returns null — every alias from that config is
* silently dropped from the import map.
*
* Newlines inside block comments are preserved so parse-error line numbers
* still point at the original file.
*/
function stripJsonComments(text) {
let out = '';
let inString = false;
let inLineComment = false;
let inBlockComment = false;
let escaped = false;

for (let i = 0; i < text.length; i++) {
const ch = text[i];
const next = text[i + 1];

if (inLineComment) {
if (ch === '\n') {
inLineComment = false;
out += ch;
}
continue;
}
if (inBlockComment) {
if (ch === '*' && next === '/') {
inBlockComment = false;
i++;
} else if (ch === '\n') {
out += ch;
}
continue;
}
if (inString) {
out += ch;
if (escaped) escaped = false;
else if (ch === '\\') escaped = true;
else if (ch === '"') inString = false;
continue;
}
if (ch === '"') {
inString = true;
out += ch;
continue;
}
if (ch === '/' && next === '/') {
inLineComment = true;
i++;
continue;
}
if (ch === '/' && next === '*') {
inBlockComment = true;
i++;
continue;
}
out += ch;
}
return out;
}

function parseTsConfigText(raw) {
// tsconfig.json often contains JSONC-style comments; strip line and block
// comments before parsing. The strip is naive (it doesn't honor string
// contents), so we fall back to the raw text on failure.
const stripped = raw
.replace(/\/\*[\s\S]*?\*\//g, '')
.replace(/(^|[^:])\/\/.*$/gm, '$1');
// tsconfig.json often contains JSONC-style comments; strip them before
// parsing. The stripper honors string literals, so path aliases and include
// globs that contain comment delimiters survive intact. Raw text remains the
// fallback for anything the stripper still can't handle.
const stripped = stripJsonComments(raw);
let parsed;
try {
parsed = JSON.parse(stripped);
Expand Down