diff --git a/tests/skill/understand/test_extract_import_map.test.mjs b/tests/skill/understand/test_extract_import_map.test.mjs index fb5994cdf..085f4c917 100644 --- a/tests/skill/understand/test_extract_import_map.test.mjs +++ b/tests/skill/understand/test_extract_import_map.test.mjs @@ -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 diff --git a/understand-anything-plugin/skills/understand/extract-import-map.mjs b/understand-anything-plugin/skills/understand/extract-import-map.mjs index ef1034b4a..28ed1161d 100644 --- a/understand-anything-plugin/skills/understand/extract-import-map.mjs +++ b/understand-anything-plugin/skills/understand/extract-import-map.mjs @@ -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);