From 10bc21ba81aa090749dd62e9ce8d418982932a86 Mon Sep 17 00:00:00 2001 From: ladybluenotes Date: Sat, 3 Oct 2026 08:57:33 -0700 Subject: [PATCH] fix: check skill examples in TypeScript 7 repositories --- .changeset/typescript-7-compiler-api.md | 5 + docs/cli/intent-repair.md | 2 +- docs/cli/intent-validate.md | 2 +- packages/intent/package.json | 3 +- packages/intent/src/cli.ts | 2 +- packages/intent/src/commands/maintainer.ts | 2 +- packages/intent/src/commands/repair.ts | 7 +- packages/intent/src/commands/validate.ts | 2 +- packages/intent/src/validate/blocks.ts | 549 ++++++-- packages/intent/tests/repair.test.ts | 241 ++-- packages/intent/tests/validate-blocks.test.ts | 1122 +++++++++-------- pnpm-lock.yaml | 211 ++++ 12 files changed, 1472 insertions(+), 676 deletions(-) create mode 100644 .changeset/typescript-7-compiler-api.md diff --git a/.changeset/typescript-7-compiler-api.md b/.changeset/typescript-7-compiler-api.md new file mode 100644 index 00000000..bd50dc1e --- /dev/null +++ b/.changeset/typescript-7-compiler-api.md @@ -0,0 +1,5 @@ +--- +'@tanstack/intent': patch +--- + +Check code examples in repositories that use TypeScript 7.0. `intent validate`, `intent repair`, and maintainer review summaries crashed there because the TypeScript 7 `typescript` package exports no compiler API. Intent now checks examples through the native compiler API that TypeScript 7 publishes, or through `@typescript/typescript6` when it is installed beside TypeScript 7. If neither API can run, Intent reports that the examples were not typechecked and runs the other checks. TypeScript 7.1 is not supported at this time. diff --git a/docs/cli/intent-repair.md b/docs/cli/intent-repair.md index 3c706071..3d0e52a6 100644 --- a/docs/cli/intent-repair.md +++ b/docs/cli/intent-repair.md @@ -38,7 +38,7 @@ Use `intent maintainer sync` afterward when the repository has valid maintainer Some skills put complete alternative implementations in one fence, each labeled with a top-level `// BEFORE` or `// AFTER` comment. Repair can propose splitting that pair into separate fences when both halves parse. It preserves the code, language, fence style, and title. Markers in strings, nested examples, function bodies, incomplete syntax, unmatched fences, and more than one pair are not split. -The split is always a suggestion: the comments might describe sequential steps instead of alternatives. Check the teaching intent and run validation after applying it. TypeScript 5.0 or newer must be available for these suggestions; the parser does not load a project configuration or execute examples. +The split is always a suggestion: the comments might describe sequential steps instead of alternatives. Check the teaching intent and run validation after applying it. TypeScript 5.0 or newer must be available for these suggestions; the parser does not load a project configuration or execute examples. TypeScript 7 works under the same conditions as code checking in `intent validate`. Fragments containing `...`, top-level `return`/`yield`, or deliberate `WRONG` examples require assessment and explicit context. Repair does not guess wrappers, insert casts or missing APIs, or suppress diagnostics. Separate fences also do not isolate module augmentations in the validator's package compiler context. diff --git a/docs/cli/intent-validate.md b/docs/cli/intent-validate.md index 609a6c15..917ce973 100644 --- a/docs/cli/intent-validate.md +++ b/docs/cli/intent-validate.md @@ -96,7 +96,7 @@ The checker enables strict null checks because some library APIs require them, w Module augmentations and global declarations still share the package compiler context. Two examples that pass separately can conflict when checked together. Verify those examples in isolated fixtures before treating the combined diagnostics as defects in the guidance; separate fences alone do not isolate their augmentations. -TypeScript 5.0 or newer must be available in the repository for code checking. If it or the library type entry is unavailable, Intent reports why those checks were skipped; this is not a successful typecheck. Prose-only skills do not load TypeScript. +TypeScript 5.0 or newer must be available in the repository for code checking. If it or the library type entry is unavailable, Intent reports why those checks were skipped; this is not a successful typecheck. Prose-only skills do not load TypeScript. With TypeScript 7.0, Intent checks examples through the compiler API that TypeScript 7 publishes as unstable; Node.js 24 or newer is supported. When `@typescript/typescript6` is installed beside TypeScript 7, Intent uses that package instead. If neither API can run, Intent reports that the checks were skipped. TypeScript 7.1 is not supported at this time. Relative Markdown links outside fenced examples must point to an existing file or directory. External URLs and anchors are not checked. Link checks still run when TypeScript is unavailable. Repeated validations read current source files and link targets. diff --git a/packages/intent/package.json b/packages/intent/package.json index a1909549..5b8129a8 100644 --- a/packages/intent/package.json +++ b/packages/intent/package.json @@ -36,6 +36,7 @@ "semver": "^7.8.4", "std-env": "^4.1.0", "tsdown": "^0.22.2", + "typescript7": "npm:typescript@7.0.2", "verdaccio": "^6.7.2", "yaml": "2.9.0" }, @@ -46,7 +47,7 @@ "test:lib": "vitest run --exclude 'tests/integration/**'", "test:integration": "vitest run tests/integration/", "test:distribution": "pnpm run build && vitest run tests/integration/distribution-installers.test.ts", - "test:types": "tsc --noEmit", + "test:types": "node ../../node_modules/typescript/bin/tsc --noEmit", "test:eslint": "eslint ." } } diff --git a/packages/intent/src/cli.ts b/packages/intent/src/cli.ts index 7b917482..8f09b36c 100644 --- a/packages/intent/src/cli.ts +++ b/packages/intent/src/cli.ts @@ -113,7 +113,7 @@ function createCli( .option('--patch', 'Print a reviewable patch without editing skill files') .action(async (dir: string | undefined, options: RepairCommandOptions) => { const { runRepairCommand } = await import('./commands/repair.js') - runRepairCommand(dir, options) + await runRepairCommand(dir, options) }) cli diff --git a/packages/intent/src/commands/maintainer.ts b/packages/intent/src/commands/maintainer.ts index 9874faf1..a9a65158 100644 --- a/packages/intent/src/commands/maintainer.ts +++ b/packages/intent/src/commands/maintainer.ts @@ -488,7 +488,7 @@ export async function runMaintainerCommand( ? validatedExamples : options.json ? new Map() - : describeSkillExamples( + : await describeSkillExamples( project.root, review.items .filter((item) => item.kind === 'skill' && !item.problems.length) diff --git a/packages/intent/src/commands/repair.ts b/packages/intent/src/commands/repair.ts index 8b018aee..dd12898c 100644 --- a/packages/intent/src/commands/repair.ts +++ b/packages/intent/src/commands/repair.ts @@ -17,7 +17,7 @@ export interface RepairCommandOptions { patch?: boolean } -export function runRepairCommand( +export async function runRepairCommand( dir: string | undefined, options: RepairCommandOptions, ) { @@ -55,7 +55,10 @@ export function runRepairCommand( changes.push(plan.change) report.repairs.push({ file, changes: plan.changes }) } - const examples = planExampleRepairs(root, plan.change?.content ?? source) + const examples = await planExampleRepairs( + root, + plan.change?.content ?? source, + ) report.suggestions.push( ...examples.suggestions.map((suggestion) => ({ file, ...suggestion })), ) diff --git a/packages/intent/src/commands/validate.ts b/packages/intent/src/commands/validate.ts index a62f4d30..95f25bc3 100644 --- a/packages/intent/src/commands/validate.ts +++ b/packages/intent/src/commands/validate.ts @@ -569,7 +569,7 @@ async function runValidateCommandInternal( if (library) (byLibrary[library] ??= []).push(skill) } for (const [library, skills] of Object.entries(byLibrary)) { - const result = checkSkillBlocks({ + const result = await checkSkillBlocks({ root: process.cwd(), packageDir: validateContext.packageRoot, library, diff --git a/packages/intent/src/validate/blocks.ts b/packages/intent/src/validate/blocks.ts index 8ed344d9..b8acf348 100644 --- a/packages/intent/src/validate/blocks.ts +++ b/packages/intent/src/validate/blocks.ts @@ -2,10 +2,15 @@ import { execFileSync } from 'node:child_process' import { existsSync, readFileSync } from 'node:fs' import { createRequire } from 'node:module' import { dirname, join, relative, resolve } from 'node:path' +import { pathToFileURL } from 'node:url' import { resolveProjectContext } from '../core/project-context.js' import { resolveWorkspacePackages } from '../setup/workspace-patterns.js' import { parseFrontmatter, readScalarField } from '../shared/utils.js' import type TS from 'typescript' +import type * as NativeApi from 'typescript7/unstable/async' +import type * as NativeAst from 'typescript7/unstable/ast' +import type * as NativeIs from 'typescript7/unstable/ast/is' +import type * as NativeScanner from 'typescript7/unstable/ast/scanner' interface SkillBlockFinding { file: string @@ -89,10 +94,83 @@ const partialSnippetCodes = new Set([ ]) const missingModuleCodes = new Set([2307, 2792]) +// In tsconfig form, so both compiler APIs check examples with the same options. +const exampleCompilerOptions = { + noEmit: true, + strict: false, + // Router and other conditional APIs require null and undefined to stay + // distinct. Partial examples still tolerate omitted names and implicit any. + strictNullChecks: true, + skipLibCheck: true, + allowJs: true, + checkJs: true, + resolveJsonModule: true, + esModuleInterop: true, + allowSyntheticDefaultImports: true, + target: 'esnext', + module: 'esnext', + // Each fence is a standalone example, even when it has no imports. + moduleDetection: 'force', + moduleResolution: 'bundler', + jsx: 'preserve', + lib: ['esnext', 'dom'], + types: [], +} + +// TypeScript 7 publishes its compiler API only under these unstable entries. +interface NativeTypeScript { + api: typeof NativeApi + ast: typeof NativeAst + is: typeof NativeIs + scanner: typeof NativeScanner +} +type NativeProject = NonNullable> +type NativeDiagnostic = Awaited< + ReturnType +>[number] + function loadTypeScript(root: string): typeof TS | null { for (const from of [join(root, 'package.json'), import.meta.url]) { + const load = createRequire(from) + let ts: typeof TS + try { + ts = load('typescript') as typeof TS + } catch { + continue // Try the next location. + } + if (hasCompilerApi(ts)) return ts + // TypeScript 7 exports no compiler API. Its documented side-by-side + // package keeps the TypeScript 6 API installed next to it. + try { + return load('@typescript/typescript6') as typeof TS + } catch { + return ts + } + } + return null +} + +// A version check is not enough: TypeScript 7 passes the 5.0 minimum but its +// package root exports only version fields, not the compiler API. +const hasCompilerApi = (ts: typeof TS) => typeof ts.createProgram === 'function' + +// These entries are ES modules. They are imported by the path they resolve +// to from the repository, so the repository's TypeScript is used rather than +// one installed near Intent. +async function loadNativeTypeScript( + root: string, +): Promise { + for (const from of [join(root, 'package.json'), import.meta.url]) { + const load = createRequire(from) + const entry = (name: string) => + import(pathToFileURL(load.resolve(`typescript/unstable/${name}`)).href) try { - return createRequire(from)('typescript') as typeof TS + return { + api: await entry('async'), + ast: await entry('ast'), + is: await entry('ast/is'), + scanner: await entry('ast/scanner'), + } } catch { // Try the next location. } @@ -100,6 +178,68 @@ function loadTypeScript(root: string): typeof TS | null { return null } +// TypeScript 7 compiles in a separate process. Examples reach it as virtual +// files listed by a virtual project configuration, so nothing is written. +async function withNativeCompiler( + native: NativeTypeScript, + root: string, + virtualDir: string, + run: ( + open: ( + sources: Map, + compilerOptions: Record, + ) => Promise, + ) => Promise, +): Promise { + const files = new Map() + let projects = 0 + const api = new native.api.API({ + cwd: root, + // Answers for virtual paths; undefined falls back to the real file system + // for library sources and node_modules. The virtual directory is not on + // disk, so it and its parents are reported as existing. Paths are keyed + // with forward slashes whatever separator the compiler process uses. + fs: { + readFile: (path) => files.get(slash(path)), + fileExists: (path) => files.has(slash(path)) || undefined, + directoryExists: (path) => + `${virtualDir}/`.startsWith(`${slash(path)}/`) || undefined, + }, + }) + try { + return await run(async (sources, compilerOptions) => { + // A new configuration per call opens a new project that lists only + // this call's files, whatever the compiler process kept from earlier. + const config = `${virtualDir}/tsconfig-${projects++}.json` + for (const [path, code] of sources) files.set(path, code) + files.set( + config, + JSON.stringify({ compilerOptions, files: [...sources.keys()] }), + ) + const snapshot = await api.updateSnapshot({ openProjects: [config] }) + const project = snapshot.getProject(config) + if (!project) throw new Error(`could not open ${config}`) + return project + }) + } finally { + await api.close() + } +} + +// The same text as flattenDiagnosticMessageText(messageText, ' ') in the +// TypeScript 6 API. +function nativeMessage(diagnostic: NativeDiagnostic, indent = 0): string { + return [ + `${indent ? ` ${' '.repeat(indent)}` : ''}${diagnostic.text}`, + ...(diagnostic.messageChain ?? []).map((next) => + nativeMessage(next, indent + 1), + ), + ].join('') +} + +const errorMessage = (error: unknown) => + error instanceof Error ? error.message : String(error) + function extractCodeBlocks(file: string, content: string): Array { const blocks: Array = [] for (const fence of codeFences(content)) { @@ -117,28 +257,135 @@ function extractCodeBlocks(file: string, content: string): Array { return blocks } +// Parsing for example repair suggestions, from either compiler API. +interface ExampleParser { + // Full start of each top-level statement, including leading comments. + statements: (filename: string, code: string) => Promise> + comments: ( + code: string, + position: number, + ) => ReadonlyArray<{ pos: number; end: number }> + parses: (filename: string, code: string) => Promise +} + +function typeScriptParser(ts: typeof TS): ExampleParser { + return { + statements: (filename, code) => + Promise.resolve( + ts + .createSourceFile(filename, code, ts.ScriptTarget.Latest, true) + .statements.map((statement) => statement.pos), + ), + comments: (code, position) => + ts.getLeadingCommentRanges(code, position) ?? [], + parses: (filename, code) => + Promise.resolve( + !( + ts.transpileModule(code, { + fileName: filename, + reportDiagnostics: true, + compilerOptions: { + target: ts.ScriptTarget.ESNext, + module: ts.ModuleKind.ESNext, + jsx: ts.JsxEmit.Preserve, + }, + }).diagnostics ?? [] + ).some( + (diagnostic) => diagnostic.category === ts.DiagnosticCategory.Error, + ), + ), + } +} + +function nativeParser( + native: NativeTypeScript, + virtualDir: string, + open: Parameters[3]>[0], +): ExampleParser { + let files = 0 + const program = async (filename: string, code: string) => { + // A new name per parse, so no parse depends on whether the compiler + // process re-reads a path it has already read. The filename keeps the + // extension, which decides whether the code parses as JSX. + const path = `${virtualDir}/${files++}-${filename}` + const project = await open(new Map([[path, code]]), exampleCompilerOptions) + return { path, program: project.program } + } + return { + statements: async (filename, code) => { + const { path, program: parsed } = await program(filename, code) + const source = await parsed.getSourceFile(path) + if (!source) throw new Error(`could not read ${path}`) + return source.statements.map((statement) => statement.pos) + }, + comments: (code, position) => + native.scanner.getLeadingCommentRanges(code, position) ?? [], + parses: async (filename, code) => { + const { path, program: parsed } = await program(filename, code) + return !(await parsed.getSyntacticDiagnostics(path)).some( + (diagnostic) => + diagnostic.category === native.api.DiagnosticCategory.Error, + ) + }, + } +} + // These are suggestions for review, not automatic fixes: BEFORE/AFTER can // describe sequential work as well as alternative implementations. -export function planExampleRepairs(root: string, content: string) { - const suggestions: Array<{ line: number; message: string }> = [] +export async function planExampleRepairs( + root: string, + content: string, +): Promise<{ + content: string + suggestions: Array<{ line: number; message: string }> + skipped?: string +}> { const lines = content.split(/(?<=\n)/) - let ts: typeof TS | null | undefined - for (const fence of codeFences(content).reverse()) { - if ( - !checkedLanguages.has(fence.language) || - fence.end >= lines.length || - !/^\s*\/\/\s*BEFORE\b/i.test(fence.code) || - !/\/\/\s*AFTER\b/i.test(fence.code) + const fences = codeFences(content) + .reverse() + .filter( + (fence) => + checkedLanguages.has(fence.language) && + fence.end < lines.length && + /^\s*\/\/\s*BEFORE\b/i.test(fence.code) && + /\/\/\s*AFTER\b/i.test(fence.code), ) - continue - ts ??= loadTypeScript(root) - if (!ts || Number(ts.versionMajorMinor.split('.')[0]) < 5) - return { - content, - suggestions: [], - skipped: - 'TypeScript 5.0 or newer is required to suggest example repairs.', - } + if (!fences.length) return { content, suggestions: [] } + const skip = (skipped: string) => ({ content, suggestions: [], skipped }) + const ts = loadTypeScript(root) + if (!ts || Number(ts.versionMajorMinor.split('.')[0]) < 5) + return skip( + 'TypeScript 5.0 or newer is required to suggest example repairs.', + ) + if (hasCompilerApi(ts)) + return splitExamples(lines, fences, typeScriptParser(ts)) + // TypeScript 7 without @typescript/typescript6 beside it. + const native = await loadNativeTypeScript(root) + if (!native) + return skip( + `TypeScript ${ts.version} has no compiler API that Intent can use; install @typescript/typescript6 beside it to suggest example repairs.`, + ) + const virtualDir = slash(join(root, '.intent', 'skill-examples')) + // The native API is unstable, so a failure reports the skipped suggestions + // instead of stopping the repair command. + try { + return await withNativeCompiler(native, root, virtualDir, (open) => + splitExamples(lines, fences, nativeParser(native, virtualDir, open)), + ) + } catch (error) { + return skip( + `TypeScript ${ts.version} could not parse the examples: ${errorMessage(error)}`, + ) + } +} + +async function splitExamples( + lines: Array, + fences: ReturnType, + parser: ExampleParser, +) { + const suggestions: Array<{ line: number; message: string }> = [] + for (const fence of fences) { const extension = fence.language === 'tsx' || fence.language === 'jsx' ? fence.language @@ -146,15 +393,9 @@ export function planExampleRepairs(root: string, content: string) { ? 'js' : 'ts' const filename = `example.${extension}` - const source = ts.createSourceFile( - filename, - fence.code, - ts.ScriptTarget.Latest, - true, - ) - const markers = source.statements.flatMap((statement) => - (ts!.getLeadingCommentRanges(fence.code, statement.pos) ?? []).flatMap( - (comment) => { + const markers = (await parser.statements(filename, fence.code)).flatMap( + (position) => + parser.comments(fence.code, position).flatMap((comment) => { const label = /^\/\/\s*(BEFORE|AFTER)\b[^\n]*$/i.exec( fence.code.slice(comment.pos, comment.end), ) @@ -162,8 +403,7 @@ export function planExampleRepairs(root: string, content: string) { return label && !fence.code.slice(lineStart, comment.pos).trim() ? [{ label: label[1]!.toUpperCase(), pos: lineStart }] : [] - }, - ), + }), ) if ( markers.length !== 2 || @@ -174,24 +414,10 @@ export function planExampleRepairs(root: string, content: string) { continue const split = markers[1]!.pos const halves = [fence.code.slice(0, split), fence.code.slice(split)] - if ( - halves.some((code) => - ( - ts!.transpileModule(code, { - fileName: filename, - reportDiagnostics: true, - compilerOptions: { - target: ts!.ScriptTarget.ESNext, - module: ts!.ModuleKind.ESNext, - jsx: ts!.JsxEmit.Preserve, - }, - }).diagnostics ?? [] - ).some( - (diagnostic) => diagnostic.category === ts!.DiagnosticCategory.Error, - ), - ) - ) - continue + let parses = true + for (const code of halves) + if (parses) parses = await parser.parses(filename, code) + if (!parses) continue const at = fence.start + 1 + fence.code.slice(0, split).split('\n').length - 1 const eol = lines[fence.start]!.endsWith('\r\n') ? '\r\n' : '\n' @@ -373,7 +599,7 @@ function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value) } -export function checkSkillBlocks( +export async function checkSkillBlocks( options: { root: string packageDir: string @@ -381,7 +607,7 @@ export function checkSkillBlocks( skills: Array<{ file: string; content: string }> }, ts?: typeof TS | null, -): SkillBlockCheck { +): Promise { const { root, packageDir, library } = options const findings = options.skills.flatMap((skill) => checkSkillLinks(root, skill.file, skill.content), @@ -402,6 +628,12 @@ export function checkSkillBlocks( return result( `TypeScript ${ts.version} is installed; 5.0 or newer is required`, ) + // TypeScript 7 without @typescript/typescript6 beside it. + const native = hasCompilerApi(ts) ? null : await loadNativeTypeScript(root) + if (!hasCompilerApi(ts) && !native) + return result( + `TypeScript ${ts.version} has no compiler API that Intent can use; install @typescript/typescript6 beside it`, + ) const entry = libraryEntry(packageDir) const ownsLibrary = packageName(packageDir) === library if (ownsLibrary && !entry) @@ -414,25 +646,8 @@ export function checkSkillBlocks( blocks.forEach((block, index) => virtual.set(`${virtualDir}/block-${index}.${block.extension}`, block), ) - const compilerOptions: TS.CompilerOptions = { - noEmit: true, - strict: false, - // Router and other conditional APIs require null and undefined to stay - // distinct. Partial examples still tolerate omitted names and implicit any. - strictNullChecks: true, - skipLibCheck: true, - allowJs: true, - checkJs: true, - resolveJsonModule: true, - esModuleInterop: true, - allowSyntheticDefaultImports: true, - target: ts.ScriptTarget.ESNext, - module: ts.ModuleKind.ESNext, - // Each fence is a standalone example, even when it has no imports. - moduleDetection: ts.ModuleDetectionKind.Force, - moduleResolution: ts.ModuleResolutionKind.Bundler, - jsx: ts.JsxEmit.Preserve, - lib: ['lib.esnext.d.ts', 'lib.dom.d.ts'], + const compilerOptions = { + ...exampleCompilerOptions, paths: { ...workspacePaths(root), // A skill documenting another package (metadata.library) resolves that @@ -447,9 +662,37 @@ export function checkSkillBlocks( } : {}), }, - types: [], } - const host = ts.createCompilerHost(compilerOptions, true) + const fromLibrary = (specifier: string) => + specifier === library || specifier.startsWith(`${library}/`) + + if (native) { + // The native API is unstable, so a failure reports the skipped checks + // instead of stopping validation. + try { + findings.push( + ...(await checkNativeBlocks( + native, + root, + virtualDir, + virtual, + compilerOptions, + fromLibrary, + )), + ) + } catch (error) { + return result( + `TypeScript ${ts.version} could not check the examples: ${errorMessage(error)}`, + ) + } + return result() + } + + const parsedOptions = ts.convertCompilerOptionsFromJson( + compilerOptions, + root, + ).options + const host = ts.createCompilerHost(parsedOptions, true) const readFile = host.readFile.bind(host) const fileExists = host.fileExists.bind(host) host.fileExists = (path) => virtual.has(path) || fileExists(path) @@ -460,10 +703,8 @@ export function checkSkillBlocks( ? undefined : ts.createSourceFile(path, code, languageVersion, true) } - const program = ts.createProgram([...virtual.keys()], compilerOptions, host) + const program = ts.createProgram([...virtual.keys()], parsedOptions, host) const checker = program.getTypeChecker() - const fromLibrary = (specifier: string) => - specifier === library || specifier.startsWith(`${library}/`) for (const [path, block] of virtual) { const source = program.getSourceFile(path) @@ -474,22 +715,14 @@ export function checkSkillBlocks( ...program.getSyntacticDiagnostics(source), ...program.getSemanticDiagnostics(source), ]) { - if (partialSnippetCodes.has(diagnostic.code)) continue - const message = ts.flattenDiagnosticMessageText( - diagnostic.messageText, - ' ', + const finding = diagnosticFinding( + block, + diagnostic.start === undefined ? block.line : at(diagnostic.start), + diagnostic.code, + ts.flattenDiagnosticMessageText(diagnostic.messageText, ' '), + fromLibrary, ) - if (missingModuleCodes.has(diagnostic.code)) { - const specifier = /Cannot find module '([^']+)'/.exec(message)?.[1] - if (!specifier || !fromLibrary(specifier)) continue - } - findings.push({ - file: block.file, - line: - diagnostic.start === undefined ? block.line : at(diagnostic.start), - message: `TS${diagnostic.code}: ${message}`, - severity: 'error', - }) + if (finding) findings.push(finding) } for (const statement of source.statements) { if ( @@ -507,27 +740,143 @@ export function checkSkillBlocks( const tag = symbol ?.getJsDocTags(checker) .find((entry) => entry.name === 'deprecated') - if (!tag) continue - const detail = ts.displayPartsToString(tag.text).trim() - findings.push({ - file: block.file, - line: at(element.getStart(source)), - message: `${element.name.text} is deprecated${detail ? `: ${detail}` : ''}`, - severity: 'warning', - }) + if (tag) + findings.push( + deprecationFinding( + block, + at(element.getStart(source)), + element.name.text, + ts.displayPartsToString(tag.text), + ), + ) } } } return result() } +function checkNativeBlocks( + native: NativeTypeScript, + root: string, + virtualDir: string, + virtual: Map, + compilerOptions: Record, + fromLibrary: (specifier: string) => boolean, +): Promise> { + return withNativeCompiler(native, root, virtualDir, async (open) => { + const findings: Array = [] + const { program, checker } = await open( + new Map([...virtual].map(([path, block]) => [path, block.code])), + compilerOptions, + ) + for (const [path, block] of virtual) { + const source = await program.getSourceFile(path) + if (!source) continue + // The same line breaks as getLineAndCharacterOfPosition in TypeScript 6. + const lineStarts = native.scanner.computeLineStarts(block.code) + const at = (position: number) => { + let line = 0 + while ( + lineStarts[line + 1] !== undefined && + lineStarts[line + 1]! <= position + ) + line++ + return block.line + line + } + for (const diagnostic of [ + ...(await program.getSyntacticDiagnostics(path)), + ...(await program.getSemanticDiagnostics(path)), + ]) { + const finding = diagnosticFinding( + block, + // A native diagnostic's pos is where the error starts, like start + // in TypeScript 6; it does not include leading trivia. + at(diagnostic.pos), + diagnostic.code, + nativeMessage(diagnostic), + fromLibrary, + ) + if (finding) findings.push(finding) + } + for (const statement of source.statements) { + if ( + !native.is.isImportDeclaration(statement) || + !native.is.isStringLiteral(statement.moduleSpecifier) || + !fromLibrary(statement.moduleSpecifier.text) + ) + continue + const bindings = statement.importClause?.namedBindings + if (!bindings || !native.is.isNamedImports(bindings)) continue + for (const element of bindings.elements) { + let symbol = await checker.getSymbolAtLocation(element.name) + if (symbol && symbol.flags & native.api.SymbolFlags.Alias) + symbol = await checker.getAliasedSymbol(symbol) + const tag = symbol + ? (await symbol.getJsDocTags(checker)).find( + (entry) => entry.name === 'deprecated', + ) + : undefined + if (tag) + findings.push( + deprecationFinding( + block, + // Skips leading trivia, like getStart in TypeScript 6. + at(native.ast.getTokenPosOfNode(element, source)), + element.name.text, + tag.text ?? '', + ), + ) + } + } + } + return findings + }) +} + +// Partial examples leave out names, globals, and external modules on purpose; +// only a missing module from the documented library is reported. +function diagnosticFinding( + block: CodeBlock, + line: number, + code: number, + message: string, + fromLibrary: (specifier: string) => boolean, +): SkillBlockFinding | null { + if (partialSnippetCodes.has(code)) return null + if (missingModuleCodes.has(code)) { + const specifier = /Cannot find module '([^']+)'/.exec(message)?.[1] + if (!specifier || !fromLibrary(specifier)) return null + } + return { + file: block.file, + line, + message: `TS${code}: ${message}`, + severity: 'error', + } +} + +function deprecationFinding( + block: CodeBlock, + line: number, + name: string, + detail: string, +): SkillBlockFinding { + detail = detail.trim() + return { + file: block.file, + line, + message: `${name} is deprecated${detail ? `: ${detail}` : ''}`, + severity: 'warning', + } +} + // One-line summary per skill for review items, in one program per package. // Skills without code blocks, or whose blocks could not be checked, are left // out of the result. -export function describeSkillExamples( +export async function describeSkillExamples( root: string, files: Array, -): Map { +): Promise> { const groups = new Map< string, { packageDir: string; library: string; files: Array } @@ -568,7 +917,7 @@ export function describeSkillExamples( file, content: readFileSync(resolve(root, file), 'utf8'), })) - const result = checkSkillBlocks({ + const result = await checkSkillBlocks({ root, packageDir: group.packageDir, library: group.library, diff --git a/packages/intent/tests/repair.test.ts b/packages/intent/tests/repair.test.ts index beea1e44..79f10c36 100644 --- a/packages/intent/tests/repair.test.ts +++ b/packages/intent/tests/repair.test.ts @@ -7,10 +7,11 @@ import { symlinkSync, writeFileSync, } from 'node:fs' +import { createRequire } from 'node:module' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { spawnSync } from 'node:child_process' -import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { parse } from 'yaml' import { main } from '../src/cli.js' import { planExampleRepairs } from '../src/validate/blocks.js' @@ -85,33 +86,6 @@ it('keeps long frontmatter values on one line when repairing', async () => { }) }) -it('prints an applicable patch for labeled alternatives without applying the suggestion', async () => { - const file = 'skills/client/SKILL.md' - const before = '// BEFORE (classic)\nfunction Client() { return 1 }\n\n' - const after = '// AFTER (new)\nfunction Client() { return 2 }\n' - const source = `---\nname: client\ndescription: Use the client.\n---\n\n\`\`\`tsx title="Client.tsx"\n${before}${after}\`\`\`\n` - write(file, source) - const stdout = vi - .spyOn(process.stdout, 'write') - .mockImplementation(() => true) - - expect(await main(['repair', '--patch'])).toBe(0) - const patch = stdout.mock.calls.map((call) => String(call[0])).join('') - expect(readFileSync(join(root, file), 'utf8')).toBe(source) - const applied = spawnSync( - 'git', - ['-c', 'core.fsmonitor=false', 'apply', '-'], - { cwd: root, input: patch, encoding: 'utf8' }, - ) - expect(applied.status, applied.stderr).toBe(0) - expect(readFileSync(join(root, file), 'utf8')).toBe( - source.replace( - before + after, - `${before}\`\`\`\n\n\`\`\`tsx title="Client.tsx"\n${after}`, - ), - ) -}) - it('plans by default, refuses conflicts, and leaves unrelated safe migrations available', async () => { const conflicted = '---\nname: wrong-name\ndescription: Use the client.\nlibrary: old\nmetadata:\n library: chosen\n---\nBody\n' @@ -169,54 +143,124 @@ it.each([ ) }) -it.each([ - '```ts\nconst value = `\n// BEFORE (classic)\nconst count = 1\n// AFTER (new)\nconst count = 2\n`\n```\n', - '````markdown\n```ts\n// BEFORE\nconst count = 1\n// AFTER\nconst count = 2\n```\n````\n', - '```ts\nfunction example() {\n// BEFORE\nconst count = 1\n// AFTER\nconst count = 2\n}\n```\n', - '```ts\n// BEFORE\nconst count = {\n// AFTER\nconst count = 2\n```\n', - '```ts\n// BEFORE\nconst count = 1\n// AFTER\nconst count = 2\n// AFTER again\nconst count = 3\n```\n', - '```ts\n// WRONG\nconst count = ...\n// CORRECT\nconst count = 2\n```\n', - '```ts\n// BEFORE\nconst count = 1\n// AFTER\nconst count = 2\n', - '```ts\n// BEFORE\nconst value = `\n// AFTER\nconst count = 2\n`\n```\n', - '```ts\n// BEFORE\nfunction run() {\n// AFTER\nconst count = 2\n}\n```\n', -])( - 'leaves ambiguous, nested, incomplete, and negative examples unchanged', - (content) => { - expect(planExampleRepairs(root, content)).toMatchObject({ - content, - suggestions: [], - }) - }, -) +describe.each(['TypeScript 6', 'TypeScript 7'])('with %s', (compiler) => { + // The fixture has no compiler of its own: TypeScript 6 resolves from this + // repository, and TypeScript 7 is linked in as the fixture's typescript. + beforeEach(() => { + if (compiler === 'TypeScript 7') linkTypeScript7() + }) + + it('prints an applicable patch for labeled alternatives without applying the suggestion', async () => { + const file = 'skills/client/SKILL.md' + const before = '// BEFORE (classic)\nfunction Client() { return 1 }\n\n' + const after = '// AFTER (new)\nfunction Client() { return 2 }\n' + const source = `---\nname: client\ndescription: Use the client.\n---\n\n\`\`\`tsx title="Client.tsx"\n${before}${after}\`\`\`\n` + write(file, source) + const stdout = vi + .spyOn(process.stdout, 'write') + .mockImplementation(() => true) -it.each(['ts', 'tsx', 'js', 'jsx'])( - 'preserves CRLF and code bytes when suggesting separate %s examples', - (language) => { - const first = '// BEFORE\r\nconst count = 1\r\n' - const second = '// AFTER\r\nconst count = 2\r\n' - const opening = `~~~${language} title="sample"\r\n` - const content = `${opening}${first}${second}~~~~\r\n` - const result = planExampleRepairs(root, content) - expect(result.suggestions).toHaveLength(1) - expect(result.content).toBe( - `${opening}${first}~~~~\r\n\r\n${opening}${second}~~~~\r\n`, + expect(await main(['repair', '--patch'])).toBe(0) + const patch = stdout.mock.calls.map((call) => String(call[0])).join('') + expect(readFileSync(join(root, file), 'utf8')).toBe(source) + const applied = spawnSync( + 'git', + ['-c', 'core.fsmonitor=false', 'apply', '-'], + { cwd: root, input: patch, encoding: 'utf8' }, ) - expect(planExampleRepairs(root, result.content).suggestions).toEqual([]) - }, -) + expect(applied.status, applied.stderr).toBe(0) + expect(readFileSync(join(root, file), 'utf8')).toBe( + source.replace( + before + after, + `${before}\`\`\`\n\n\`\`\`tsx title="Client.tsx"\n${after}`, + ), + ) + }) -it('never applies a code suggestion or creates review state in write mode', async () => { - const source = - '---\nname: client\ndescription: Client\n---\n```js\n// BEFORE\nconst value = 1\n// AFTER\nconst value = 2\n```\n' - write('skills/client/SKILL.md', source) - expect(await main(['repair', '--write', '--json'])).toBe(0) - expect( - JSON.parse(vi.mocked(console.log).mock.calls.at(-1)![0]).suggestions, - ).toHaveLength(1) - expect(readFileSync(join(root, 'skills/client/SKILL.md'), 'utf8')).toBe( - source, + it.each([ + '```ts\nconst value = `\n// BEFORE (classic)\nconst count = 1\n// AFTER (new)\nconst count = 2\n`\n```\n', + '````markdown\n```ts\n// BEFORE\nconst count = 1\n// AFTER\nconst count = 2\n```\n````\n', + '```ts\nfunction example() {\n// BEFORE\nconst count = 1\n// AFTER\nconst count = 2\n}\n```\n', + '```ts\n// BEFORE\nconst count = {\n// AFTER\nconst count = 2\n```\n', + '```ts\n// BEFORE\nconst count = 1\n// AFTER\nconst count = 2\n// AFTER again\nconst count = 3\n```\n', + '```ts\n// WRONG\nconst count = ...\n// CORRECT\nconst count = 2\n```\n', + '```ts\n// BEFORE\nconst count = 1\n// AFTER\nconst count = 2\n', + '```ts\n// BEFORE\nconst value = `\n// AFTER\nconst count = 2\n`\n```\n', + '```ts\n// BEFORE\nfunction run() {\n// AFTER\nconst count = 2\n}\n```\n', + ])( + 'leaves ambiguous, nested, incomplete, and negative examples unchanged', + async (content) => { + expect(await planExampleRepairs(root, content)).toMatchObject({ + content, + suggestions: [], + }) + }, + ) + + it.each(['ts', 'tsx', 'js', 'jsx'])( + 'preserves CRLF and code bytes when suggesting separate %s examples', + async (language) => { + const first = '// BEFORE\r\nconst count = 1\r\n' + const second = '// AFTER\r\nconst count = 2\r\n' + const opening = `~~~${language} title="sample"\r\n` + const content = `${opening}${first}${second}~~~~\r\n` + const result = await planExampleRepairs(root, content) + expect(result.suggestions).toHaveLength(1) + expect(result.content).toBe( + `${opening}${first}~~~~\r\n\r\n${opening}${second}~~~~\r\n`, + ) + expect( + (await planExampleRepairs(root, result.content)).suggestions, + ).toEqual([]) + }, + ) + + it('never applies a code suggestion or creates review state in write mode', async () => { + const source = + '---\nname: client\ndescription: Client\n---\n```js\n// BEFORE\nconst value = 1\n// AFTER\nconst value = 2\n```\n' + write('skills/client/SKILL.md', source) + expect(await main(['repair', '--write', '--json'])).toBe(0) + expect( + JSON.parse(vi.mocked(console.log).mock.calls.at(-1)![0]).suggestions, + ).toHaveLength(1) + expect(readFileSync(join(root, 'skills/client/SKILL.md'), 'utf8')).toBe( + source, + ) + expect(existsSync(join(root, '.intent'))).toBe(false) + }) +}) + +it('reports example suggestions as unavailable when TypeScript 7 has no compiler API Intent can use', async () => { + fakeTypeScript7() + const content = + '```ts\n// BEFORE\nconst count = 1\n// AFTER\nconst count = 2\n```\n' + expect(await planExampleRepairs(root, content)).toEqual({ + content, + suggestions: [], + skipped: + 'TypeScript 7.0.2 has no compiler API that Intent can use; install @typescript/typescript6 beside it to suggest example repairs.', + }) +}) + +it('reports example suggestions as unavailable when the TypeScript 7 compiler API fails', async () => { + fakeTypeScript7({ nativeApi: true }) + const content = + '```ts\n// BEFORE\nconst count = 1\n// AFTER\nconst count = 2\n```\n' + expect(await planExampleRepairs(root, content)).toEqual({ + content, + suggestions: [], + skipped: 'TypeScript 7.0.2 could not parse the examples: API changed', + }) +}) + +it('suggests separate examples with @typescript/typescript6 installed beside TypeScript 7', async () => { + fakeTypeScript7({ typescript6: true }) + const result = await planExampleRepairs( + root, + '```ts\n// BEFORE\nconst count = 1\n// AFTER\nconst count = 2\n```\n', ) - expect(existsSync(join(root, '.intent'))).toBe(false) + expect(result.skipped).toBeUndefined() + expect(result.suggestions).toHaveLength(1) }) it('requires a single output mode and prints no patch when no repairs are available', async () => { @@ -285,3 +329,56 @@ it.each([ expect(report.problems[0].message).toContain('unrelated frontmatter values') }, ) + +// Links the TypeScript 7 devDependency in as the fixture's own typescript. +// The typescript7 alias also links TypeScript 7's tsc into this package's +// .bin, so test:types runs the workspace's TypeScript 6 by path. +function linkTypeScript7() { + mkdirSync(join(root, 'node_modules'), { recursive: true }) + symlinkSync( + dirname(createRequire(import.meta.url).resolve('typescript7/package.json')), + join(root, 'node_modules', 'typescript'), + 'junction', + ) +} + +// A TypeScript 7 package root exports only its version. `nativeApi` adds the +// unstable compiler entries with an API that fails to start, and the +// @typescript/typescript6 stand-in re-exports this repository's TypeScript 6. +function fakeTypeScript7( + options: { nativeApi?: boolean; typescript6?: boolean } = {}, +) { + const native = ['async', 'ast', 'ast/is', 'ast/scanner'].map((entry) => [ + `./unstable/${entry}`, + './native.mjs', + ]) + write( + 'node_modules/typescript/package.json', + JSON.stringify({ + name: 'typescript', + version: '7.0.2', + exports: { + '.': './version.cjs', + ...(options.nativeApi ? Object.fromEntries(native) : {}), + }, + }), + ) + write( + 'node_modules/typescript/version.cjs', + "exports.version = '7.0.2'\nexports.versionMajorMinor = '7.0'\n", + ) + if (options.nativeApi) + write( + 'node_modules/typescript/native.mjs', + "export class API { constructor() { throw new Error('API changed') } }\n", + ) + if (!options.typescript6) return + write( + 'node_modules/@typescript/typescript6/package.json', + '{"name":"@typescript/typescript6","main":"index.cjs"}\n', + ) + write( + 'node_modules/@typescript/typescript6/index.cjs', + `module.exports = require(${JSON.stringify(createRequire(import.meta.url).resolve('typescript'))})\n`, + ) +} diff --git a/packages/intent/tests/validate-blocks.test.ts b/packages/intent/tests/validate-blocks.test.ts index 45d1e06a..4b2888f3 100644 --- a/packages/intent/tests/validate-blocks.test.ts +++ b/packages/intent/tests/validate-blocks.test.ts @@ -5,13 +5,18 @@ import { mkdtempSync, readFileSync, rmSync, + symlinkSync, writeFileSync, } from 'node:fs' +import { createRequire } from 'node:module' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' -import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { main } from '../src/cli.js' -import { checkSkillBlocks } from '../src/validate/blocks.js' +import { + checkSkillBlocks, + describeSkillExamples, +} from '../src/validate/blocks.js' import * as blockChecks from '../src/validate/blocks.js' // Typechecking examples against a real package takes longer than a unit test. @@ -72,194 +77,155 @@ afterEach(() => { rmSync(root, { recursive: true, force: true }) }) -it('accepts a partial example whose only gaps are names the snippet leaves out', () => { - skill( - '```ts\nimport { retry } from \'@acme/client\'\n\nconst result = await retry(() => fetchItems(), { max: 3 })\nresult.ok\n```\n\n```json\n{ "not": "checked" }\n```\n', - ) - const result = check() - expect(result.blocks).toBe(1) - expect(result.findings).toEqual([]) -}) - -it('supports APIs that require strict null checks while rejecting null arguments', () => { - write( - 'src/index.ts', - "export declare function createRouter(options: undefined extends number ? 'strictNullChecks must be enabled' : { routeTree: object }): void\n", - ) - skill( - "```ts\nimport { createRouter } from '@acme/client'\ncreateRouter({ routeTree: {} })\n```\n", - ) - expect(check().findings).toEqual([]) - skill( - "```ts\nimport { createRouter } from '@acme/client'\ncreateRouter({ routeTree: null })\n```\n", - ) - expect(check().findings).toContainEqual( - expect.objectContaining({ message: expect.stringMatching(/TS2322/) }), - ) -}) - -it('tolerates omitted shorthand values without suppressing incompatible options', () => { - skill( - "```ts\nimport { retry } from '@acme/client'\nconst context = { createContext }\nretry(() => fetchItems(context), { max: 'many' })\n```\n", - ) - expect(check().findings.map((finding) => finding.message)).toEqual([ - expect.stringMatching(/TS2322/), - ]) -}) - -it('parses a plain ts block as TypeScript rather than TSX', () => { - skill( - "```ts\nimport { retry } from '@acme/client'\nconst pick = (value: T) => value\nawait retry(() => Promise.resolve(), { max: pick(3) })\n```\n\n```tsx\nconst view =
{String(1)}
\n```\n", - ) - expect(check().findings).toEqual([]) -}) +describe.each(['TypeScript 6', 'TypeScript 7'])('with %s', (compiler) => { + // The fixture has no compiler of its own: TypeScript 6 resolves from this + // repository, and TypeScript 7 is linked in as the fixture's typescript. + beforeEach(() => { + if (compiler === 'TypeScript 7') linkTypeScript7() + }) -it.each(['ts', 'tsx', 'js', 'jsx'])( - 'keeps declarations in separate %s examples independent', - (language) => { + it('accepts a partial example whose only gaps are names the snippet leaves out', async () => { skill( - `\`\`\`${language}\nconst count = 1\n\`\`\`\n\n\`\`\`${language}\nconst count = 2\n\`\`\`\n`, + '```ts\nimport { retry } from \'@acme/client\'\n\nconst result = await retry(() => fetchItems(), { max: 3 })\nresult.ok\n```\n\n```json\n{ "not": "checked" }\n```\n', ) - const result = check() - expect(result.blocks).toBe(2) - expect(result.skipped).toBeUndefined() + const result = await check() + expect(result.blocks).toBe(1) expect(result.findings).toEqual([]) - }, -) - -it.each(['js', 'jsx'])( - 'checks library option types in %s examples', - (language) => { - skill( - `\`\`\`${language}\nimport { retry } from '@acme/client'\nretry(() => fetchItems(), { max: 'many' })\n\`\`\`\n`, - ) - expect(check().findings).toEqual([ - expect.objectContaining({ - line: 10, - message: expect.stringMatching(/TS2322/), - }), - ]) - }, -) - -it.each([ - ['javascript with CRLF', '```javascript\r\n', '\r\n```\r\n'], - ['JSX with a longer closing fence', '```jsx\n', '\n````\n'], - ['TypeScript with a tilde fence', '~~~typescript\n', '\n~~~~\n'], - ['an unclosed JavaScript fence', '```js\n', '\n'], -])('does not skip invalid examples in %s', (_name, opening, closing) => { - skill( - `${opening}import { retry } from '@acme/client'\nretry(() => Promise.resolve(), { max: 'many' })${closing}`, - ) - const result = check() - expect(result.blocks).toBe(1) - expect(result.skipped).toBeUndefined() - expect(result.findings).toContainEqual( - expect.objectContaining({ - line: 10, - message: expect.stringMatching(/TS2322/), - }), - ) -}) + }) -it.each(['jsx', 'tsx'])( - 'checks actual component props and syntax in %s', - async (language) => { + it('supports APIs that require strict null checks while rejecting null arguments', async () => { write( 'src/index.ts', - 'export function Counter(props: { count: number; children?: unknown }) { return null }\n', + "export declare function createRouter(options: undefined extends number ? 'strictNullChecks must be enabled' : { routeTree: object }): void\n", ) - const example = (expression: string) => - `\`\`\`${language}\nimport { Counter } from '@acme/client'\nconst view = ${expression}\n\`\`\`\n` - skill(example('Ready')) - expect(check()).toMatchObject({ blocks: 1, findings: [] }) - expect(check().skipped).toBeUndefined() - expect(await main(['validate'])).toBe(0) - skill(example('')) - expect(check().findings).toContainEqual( - expect.objectContaining({ - line: 10, - message: expect.stringMatching(/TS2322/), - }), + skill( + "```ts\nimport { createRouter } from '@acme/client'\ncreateRouter({ routeTree: {} })\n```\n", ) - expect(await main(['validate'])).toBe(1) - skill(example('')) - expect(check().findings).toContainEqual( - expect.objectContaining({ - line: 10, - message: expect.stringMatching(/TS17008/), - }), + expect((await check()).findings).toEqual([]) + skill( + "```ts\nimport { createRouter } from '@acme/client'\ncreateRouter({ routeTree: null })\n```\n", + ) + expect((await check()).findings).toContainEqual( + expect.objectContaining({ message: expect.stringMatching(/TS2322/) }), ) - }, -) + }) -it('checks JSDoc contracts from a JavaScript library instead of skipping it', () => { - rmSync(join(root, 'src/index.ts')) - write( - 'package.json', - JSON.stringify({ - name: '@acme/client', - version: '1.0.0', - exports: './src/index.js', - }), - ) - write( - 'src/index.js', - '/** @param {{ max: number }} options */\nexport function retry(options) { return options.max }\n', - ) - skill( - "```javascript\nimport { retry } from '@acme/client'\nretry({ max: 'many' })\n```\n", + it('tolerates omitted shorthand values without suppressing incompatible options', async () => { + skill( + "```ts\nimport { retry } from '@acme/client'\nconst context = { createContext }\nretry(() => fetchItems(context), { max: 'many' })\n```\n", + ) + expect((await check()).findings.map((finding) => finding.message)).toEqual([ + expect.stringMatching(/TS2322/), + ]) + }) + + it('parses a plain ts block as TypeScript rather than TSX', async () => { + skill( + "```ts\nimport { retry } from '@acme/client'\nconst pick = (value: T) => value\nawait retry(() => Promise.resolve(), { max: pick(3) })\n```\n\n```tsx\nconst view =
{String(1)}
\n```\n", + ) + expect((await check()).findings).toEqual([]) + }) + + it.each(['ts', 'tsx', 'js', 'jsx'])( + 'keeps declarations in separate %s examples independent', + async (language) => { + skill( + `\`\`\`${language}\nconst count = 1\n\`\`\`\n\n\`\`\`${language}\nconst count = 2\n\`\`\`\n`, + ) + const result = await check() + expect(result.blocks).toBe(2) + expect(result.skipped).toBeUndefined() + expect(result.findings).toEqual([]) + }, ) - const result = check() - expect(result.skipped).toBeUndefined() - expect(result.findings).toContainEqual( - expect.objectContaining({ - line: 10, - message: expect.stringMatching(/TS2322/), - }), + + it.each(['js', 'jsx'])( + 'checks library option types in %s examples', + async (language) => { + skill( + `\`\`\`${language}\nimport { retry } from '@acme/client'\nretry(() => fetchItems(), { max: 'many' })\n\`\`\`\n`, + ) + expect((await check()).findings).toEqual([ + expect.objectContaining({ + line: 10, + message: expect.stringMatching(/TS2322/), + }), + ]) + }, ) -}) -it('never executes examples or the library while validating them', () => { - write( - 'src/index.ts', - `${read('src/index.ts')}\nthrow new Error('The validator executed the library')\n`, + it.each([ + ['javascript with CRLF', '```javascript\r\n', '\r\n```\r\n'], + ['JSX with a longer closing fence', '```jsx\n', '\n````\n'], + ['TypeScript with a tilde fence', '~~~typescript\n', '\n~~~~\n'], + ['an unclosed JavaScript fence', '```js\n', '\n'], + ])( + 'does not skip invalid examples in %s', + async (_name, opening, closing) => { + skill( + `${opening}import { retry } from '@acme/client'\nretry(() => Promise.resolve(), { max: 'many' })${closing}`, + ) + const result = await check() + expect(result.blocks).toBe(1) + expect(result.skipped).toBeUndefined() + expect(result.findings).toContainEqual( + expect.objectContaining({ + line: 10, + message: expect.stringMatching(/TS2322/), + }), + ) + }, ) - skill( - "```js\nimport { retry } from '@acme/client'\nimport { writeFileSync } from 'node:fs'\nwriteFileSync('example-executed', 'unsafe')\nretry(() => Promise.resolve(), { max: 3 })\n```\n", + + it.each(['jsx', 'tsx'])( + 'checks actual component props and syntax in %s', + async (language) => { + write( + 'src/index.ts', + 'export function Counter(props: { count: number; children?: unknown }) { return null }\n', + ) + const example = (expression: string) => + `\`\`\`${language}\nimport { Counter } from '@acme/client'\nconst view = ${expression}\n\`\`\`\n` + skill(example('Ready')) + expect(await check()).toMatchObject({ blocks: 1, findings: [] }) + expect((await check()).skipped).toBeUndefined() + expect(await main(['validate'])).toBe(0) + skill(example('')) + expect((await check()).findings).toContainEqual( + expect.objectContaining({ + line: 10, + message: expect.stringMatching(/TS2322/), + }), + ) + expect(await main(['validate'])).toBe(1) + skill(example('')) + expect((await check()).findings).toContainEqual( + expect.objectContaining({ + line: 10, + message: expect.stringMatching(/TS17008/), + }), + ) + }, ) - expect(check()).toMatchObject({ blocks: 1, findings: [] }) - expect(existsSync(join(root, 'example-executed'))).toBe(false) -}) -it.each(['js', 'jsx'])( - 'checks a tracked %s entry declared outside src/index', - (extension) => { - rmSync(join(root, 'src'), { recursive: true }) - const entry = `lib/client.${extension}` + it('checks JSDoc contracts from a JavaScript library instead of skipping it', async () => { + rmSync(join(root, 'src/index.ts')) write( 'package.json', JSON.stringify({ name: '@acme/client', - ...(extension === 'js' - ? { exports: { '.': { import: `./${entry}` } } } - : { main: entry }), + version: '1.0.0', + exports: './src/index.js', }), ) write( - entry, + 'src/index.js', '/** @param {{ max: number }} options */\nexport function retry(options) { return options.max }\n', ) - execFileSync('git', ['-c', 'core.fsmonitor=false', 'init', '-q'], { - cwd: root, - }) - execFileSync('git', ['-c', 'core.fsmonitor=false', 'add', entry], { - cwd: root, - }) skill( - `\`\`\`${extension}\nimport { retry } from '@acme/client'\nretry({ max: 'many' })\n\`\`\`\n`, + "```javascript\nimport { retry } from '@acme/client'\nretry({ max: 'many' })\n```\n", ) - const result = check() + const result = await check() expect(result.skipped).toBeUndefined() expect(result.findings).toContainEqual( expect.objectContaining({ @@ -267,387 +233,551 @@ it.each(['js', 'jsx'])( message: expect.stringMatching(/TS2322/), }), ) - }, -) + }) -it('keeps nested examples inside a Markdown fence and still checks following prose links', () => { - skill( - '````markdown\n```jsx\nconst view = \n```\n[example](not-a-real-link.md)\n`````\n\nSee [missing](missing.md).\n', + it('never executes examples or the library while validating them', async () => { + write( + 'src/index.ts', + `${read('src/index.ts')}\nthrow new Error('The validator executed the library')\n`, + ) + skill( + "```js\nimport { retry } from '@acme/client'\nimport { writeFileSync } from 'node:fs'\nwriteFileSync('example-executed', 'unsafe')\nretry(() => Promise.resolve(), { max: 3 })\n```\n", + ) + expect(await check()).toMatchObject({ blocks: 1, findings: [] }) + expect(existsSync(join(root, 'example-executed'))).toBe(false) + }) + + it.each(['js', 'jsx'])( + 'checks a tracked %s entry declared outside src/index', + async (extension) => { + rmSync(join(root, 'src'), { recursive: true }) + const entry = `lib/client.${extension}` + write( + 'package.json', + JSON.stringify({ + name: '@acme/client', + ...(extension === 'js' + ? { exports: { '.': { import: `./${entry}` } } } + : { main: entry }), + }), + ) + write( + entry, + '/** @param {{ max: number }} options */\nexport function retry(options) { return options.max }\n', + ) + execFileSync('git', ['-c', 'core.fsmonitor=false', 'init', '-q'], { + cwd: root, + }) + execFileSync('git', ['-c', 'core.fsmonitor=false', 'add', entry], { + cwd: root, + }) + skill( + `\`\`\`${extension}\nimport { retry } from '@acme/client'\nretry({ max: 'many' })\n\`\`\`\n`, + ) + const result = await check() + expect(result.skipped).toBeUndefined() + expect(result.findings).toContainEqual( + expect.objectContaining({ + line: 10, + message: expect.stringMatching(/TS2322/), + }), + ) + }, ) - expect(check()).toMatchObject({ - blocks: 0, - findings: [ + + it('keeps nested examples inside a Markdown fence and still checks following prose links', async () => { + skill( + '````markdown\n```jsx\nconst view = \n```\n[example](not-a-real-link.md)\n`````\n\nSee [missing](missing.md).\n', + ) + expect(await check()).toMatchObject({ + blocks: 0, + findings: [ + expect.objectContaining({ + line: 15, + message: 'Link target not found: missing.md', + }), + ], + }) + }) + + it('reports a removed option, a missing export, and a broken example with the skill line', async () => { + skill( + [ + 'Intro line.', + '', + '```ts', + "import { retry, backoff } from '@acme/client'", + '', + 'await retry(() => fetch("/x"), { attempts: 3 })', + '```', + '', + '```tsx', + 'const count: number = "three"', + '```', + '', + '```ts', + 'const broken = {', + '```', + '', + ].join('\n'), + ) + const findings = (await check()).findings + expect(findings).toEqual([ expect.objectContaining({ - line: 15, - message: 'Link target not found: missing.md', + line: 11, + severity: 'error', + message: expect.stringMatching(/TS2305: .*'backoff'/), }), - ], + expect.objectContaining({ + line: 13, + severity: 'error', + message: expect.stringMatching(/TS2353: .*'attempts'/), + }), + expect.objectContaining({ + line: 17, + severity: 'error', + message: expect.stringMatching(/TS2322/), + }), + // An example that does not parse is reported instead of passing unchecked. + expect.objectContaining({ + line: 21, + severity: 'error', + message: expect.stringMatching(/TS1005/), + }), + ]) }) -}) -it('reports a removed option, a missing export, and a broken example with the skill line', () => { - skill( - [ - 'Intro line.', - '', - '```ts', - "import { retry, backoff } from '@acme/client'", - '', - 'await retry(() => fetch("/x"), { attempts: 3 })', - '```', - '', - '```tsx', - 'const count: number = "three"', - '```', - '', - '```ts', - 'const broken = {', - '```', - '', - ].join('\n'), - ) - const findings = check().findings - expect(findings).toEqual([ - expect.objectContaining({ - line: 11, - severity: 'error', - message: expect.stringMatching(/TS2305: .*'backoff'/), - }), - expect.objectContaining({ - line: 13, - severity: 'error', - message: expect.stringMatching(/TS2353: .*'attempts'/), - }), - expect.objectContaining({ - line: 17, - severity: 'error', - message: expect.stringMatching(/TS2322/), - }), - // An example that does not parse is reported instead of passing unchecked. - expect.objectContaining({ - line: 21, - severity: 'error', - message: expect.stringMatching(/TS1005/), - }), - ]) -}) + it('warns on deprecated imports and fails broken relative links', async () => { + write('skills/retries/references/backoff.md', '# Backoff\n') + skill( + [ + 'See [backoff](), [again](<../retries/references/backoff.md>), and [missing](references/missing.md#top).', + 'External [docs](https://example.com/x) are not checked.', + '', + '```ts', + "import { legacyRetry } from '@acme/client'", + 'legacyRetry()', + '```', + '', + ].join('\n'), + ) + expect((await check()).findings).toEqual([ + expect.objectContaining({ + line: 8, + severity: 'error', + message: 'Link target not found: references/missing.md#top', + }), + expect.objectContaining({ + line: 12, + severity: 'warning', + message: 'legacyRetry is deprecated: Use retry.', + }), + ]) + }) -it('warns on deprecated imports and fails broken relative links', () => { - write('skills/retries/references/backoff.md', '# Backoff\n') - skill( - [ - 'See [backoff](), [again](<../retries/references/backoff.md>), and [missing](references/missing.md#top).', - 'External [docs](https://example.com/x) are not checked.', - '', - '```ts', - "import { legacyRetry } from '@acme/client'", - 'legacyRetry()', - '```', - '', - ].join('\n'), - ) - expect(check().findings).toEqual([ - expect.objectContaining({ - line: 8, - severity: 'error', - message: 'Link target not found: references/missing.md#top', - }), - expect.objectContaining({ - line: 12, - severity: 'warning', - message: 'legacyRetry is deprecated: Use retry.', - }), - ]) -}) + it('revalidates unchanged skills after an imported source or link target changes', async () => { + write('src/index.ts', "export { retry } from './retry'\n") + write( + 'src/retry.ts', + 'export function retry(options: { max: number }): void {}\n', + ) + write('skills/retries/reference.md', '# Reference\n') + skill( + "See [reference](reference.md).\n\n```ts\nimport { retry } from '@acme/client'\nretry({ max: 3 })\n```\n", + ) + expect(await main(['validate'])).toBe(0) + write( + 'src/retry.ts', + 'export function retry(options: { max: string }): void {}\n', + ) + rmSync(join(root, 'skills/retries/reference.md')) + expect(await main(['validate'])).toBe(1) + const errors = vi.mocked(console.error).mock.calls.flat().join('\n') + expect(errors).toContain('TS2322') + expect(errors).toContain('Link target not found: reference.md') + }) -it('revalidates unchanged skills after an imported source or link target changes', async () => { - write('src/index.ts', "export { retry } from './retry'\n") - write( - 'src/retry.ts', - 'export function retry(options: { max: number }): void {}\n', + it.each(['__proto__', 'constructor'])( + 'accepts %s as a library name without crashing', + async (library) => { + write('package.json', JSON.stringify({ name: library, version: '1.0.0' })) + skill( + `\`\`\`ts\nimport { retry } from '${library}'\nretry(() => fetchItems(), { max: 3 })\n\`\`\`\n`, + ) + expect(await main(['validate'])).toBe(0) + }, ) - write('skills/retries/reference.md', '# Reference\n') - skill( - "See [reference](reference.md).\n\n```ts\nimport { retry } from '@acme/client'\nretry({ max: 3 })\n```\n", - ) - expect(await main(['validate'])).toBe(0) - write( - 'src/retry.ts', - 'export function retry(options: { max: string }): void {}\n', - ) - rmSync(join(root, 'skills/retries/reference.md')) - expect(await main(['validate'])).toBe(1) - const errors = vi.mocked(console.error).mock.calls.flat().join('\n') - expect(errors).toContain('TS2322') - expect(errors).toContain('Link target not found: reference.md') -}) -it.each(['__proto__', 'constructor'])( - 'accepts %s as a library name without crashing', - async (library) => { - write('package.json', JSON.stringify({ name: library, version: '1.0.0' })) + it('uses tracked hand-written declarations and maps build output back to source', async () => { + execFileSync('git', ['-c', 'core.fsmonitor=false', 'init', '-q'], { + cwd: root, + }) + write('.gitignore', 'dist/\n') + write('dist/index.d.ts', 'export declare function retry(): void\n') + write('package.json', '{"name":"@acme/client","types":"dist/index.d.ts"}\n') + skill( + "```ts\nimport { retry } from '@acme/client'\nawait retry(() => Promise.resolve(), { max: 3 })\n```\n", + ) + // dist/ is ignored, so src/index.ts stands in and the call typechecks. + expect((await check()).findings).toEqual([]) + write('types/index.d.ts', 'export declare function retry(): void\n') + write( + 'package.json', + '{"name":"@acme/client","types":"types/index.d.ts"}\n', + ) + execFileSync('git', ['-c', 'core.fsmonitor=false', 'add', 'types'], { + cwd: root, + }) + // A tracked declaration file is the public surface, and the call no longer fits it. + expect((await check()).findings).toEqual([ + expect.objectContaining({ + line: 10, + message: expect.stringMatching(/TS2554/), + }), + ]) + }) + + it('checks imports from sibling workspace packages against their own source', async () => { + write('pnpm-workspace.yaml', 'packages:\n - packages/*\n') + write('packages/client/package.json', '{"name":"@acme/client"}\n') + write( + 'packages/client/src/index.ts', + "import type { Adapter } from '@acme/adapter'\nexport function run(options: { adapter: A; model: A['models'][number] }): void {}\n", + ) + write('packages/adapter/package.json', '{"name":"@acme/adapter"}\n') + write( + 'packages/adapter/src/index.ts', + "export interface Adapter { models: ReadonlyArray }\nexport function openai(): { models: readonly ['gpt-5'] } { return { models: ['gpt-5'] } }\n", + ) + write( + 'packages/client/skills/run/SKILL.md', + [ + '---', + 'name: run', + 'description: Use when running.', + '---', + '```ts', + "import { run } from '@acme/client'", + "import { openai, anthropic } from '@acme/adapter'", + "run({ adapter: openai(), model: 'gpt-9000' })", + '```', + '', + ].join('\n'), + ) + const findings = ( + await checkSkillBlocks({ + root, + packageDir: join(root, 'packages/client'), + library: '@acme/client', + skills: [ + { + file: 'packages/client/skills/run/SKILL.md', + content: readFileSync( + join(root, 'packages/client/skills/run/SKILL.md'), + 'utf8', + ), + }, + ], + }) + ).findings + expect(findings).toEqual([ + expect.objectContaining({ + line: 7, + message: expect.stringMatching(/TS2305: .*'anthropic'/), + }), + expect.objectContaining({ + line: 8, + message: expect.stringMatching(/TS2322: .*gpt-9000/), + }), + ]) + }) + + it('ignores links inside fenced examples and checks a skill that documents a sibling package', async () => { + write('pnpm-workspace.yaml', 'packages:\n - packages/*\n') + write('packages/client/package.json', '{"name":"@acme/client"}\n') + write('packages/client/src/index.ts', 'export const client = 1\n') + write('packages/adapter/package.json', '{"name":"@acme/adapter"}\n') + write( + 'packages/adapter/src/index.ts', + 'export function openai(): void {}\n', + ) + write( + 'packages/client/skills/adapters/SKILL.md', + [ + '---', + 'name: adapters', + 'description: Use when choosing an adapter.', + 'metadata:', + ' library: "@acme/adapter"', + '---', + '```md', + 'A [link inside an example](does-not-exist.md) is not checked.', + '```', + '```ts', + "import { openai, gemini } from '@acme/adapter'", + '```', + '', + ].join('\n'), + ) + const findings = ( + await checkSkillBlocks({ + root, + packageDir: join(root, 'packages/client'), + library: '@acme/adapter', + skills: [ + { + file: 'packages/client/skills/adapters/SKILL.md', + content: readFileSync( + join(root, 'packages/client/skills/adapters/SKILL.md'), + 'utf8', + ), + }, + ], + }) + ).findings + expect(findings).toEqual([ + expect.objectContaining({ + line: 11, + message: expect.stringMatching(/TS2305: .*'gemini'/), + }), + ]) + }) + + it('skips typechecking with a reason when TypeScript or a type entry is unavailable', async () => { + skill("```ts\nimport { retry } from '@acme/client'\n```\n") + expect( + ( + await checkSkillBlocks( + { + root, + packageDir: root, + library: '@acme/client', + skills: [ + { + file: 'skills/retries/SKILL.md', + content: readFileSync( + join(root, 'skills/retries/SKILL.md'), + 'utf8', + ), + }, + ], + }, + null, + ) + ).skipped, + ).toMatch(/TypeScript is not installed/) + expect( + ( + await checkSkillBlocks( + { + root, + packageDir: root, + library: '@acme/client', + skills: [ + { + file: 'skills/retries/SKILL.md', + content: read('skills/retries/SKILL.md'), + }, + ], + }, + { version: '4.9.5', versionMajorMinor: '4.9' } as never, + ) + ).skipped, + ).toMatch(/TypeScript 4\.9\.5 is installed; 5\.0 or newer/) + rmSync(join(root, 'src'), { recursive: true }) + expect((await check()).skipped).toMatch( + /no type entry found for @acme\/client/, + ) + }) + + it('fails validate on a broken example and reports compile status on pending reviews', async () => { skill( - `\`\`\`ts\nimport { retry } from '${library}'\nretry(() => fetchItems(), { max: 3 })\n\`\`\`\n`, + "```ts\nimport { retry } from '@acme/client'\nawait retry(() => Promise.resolve(), { max: 'many' })\n```\n", + ) + expect(await main(['validate'])).toBe(1) + expect(vi.mocked(console.error).mock.calls.flat().join('\n')).toContain( + `${join('skills', 'retries', 'SKILL.md')}:10: TS2322`, + ) + skill( + "```ts\nimport { retry } from '@acme/client'\nawait retry(() => Promise.resolve(), { max: 3 })\n```\n", ) expect(await main(['validate'])).toBe(0) - }, -) + execFileSync('git', ['-c', 'core.fsmonitor=false', 'init', '-q'], { + cwd: root, + }) + expect(await main(['maintainer', 'setup'])).toBe(0) + execFileSync('git', ['-c', 'core.fsmonitor=false', 'add', '.'], { + cwd: root, + }) + execFileSync( + 'git', + [ + '-c', + 'core.fsmonitor=false', + '-c', + 'user.name=T', + '-c', + 'user.email=t@e', + 'commit', + '-qm', + 'init', + ], + { cwd: root }, + ) + write( + 'src/index.ts', + read('src/index.ts').replace( + 'max: number', + 'max: number; delay?: number', + ), + ) + execFileSync( + 'git', + [ + '-c', + 'core.fsmonitor=false', + '-c', + 'user.name=T', + '-c', + 'user.email=t@e', + 'commit', + '-qam', + 'add delay', + ], + { cwd: root }, + ) + vi.mocked(console.log).mockClear() + expect(await main(['maintainer', 'status'])).toBe(0) + expect(vi.mocked(console.log).mock.calls.flat().join('\n')).toContain( + 'Review skill skills/retries/SKILL.md: changed src/index.ts; examples still compile', + ) + const descriptions = vi.spyOn(blockChecks, 'describeSkillExamples') + const checks = vi.spyOn(blockChecks, 'checkSkillBlocks') + expect(await main(['maintainer', 'check'])).toBe(1) // pending source review + expect(checks).toHaveBeenCalledTimes(1) + expect(descriptions).not.toHaveBeenCalled() + checks.mockClear() + write( + 'src/index.ts', + read('src/index.ts').replace('max: number', 'max: string'), + ) + vi.mocked(console.error).mockClear() + expect(await main(['maintainer', 'check'])).toBe(1) + expect(checks).toHaveBeenCalledTimes(1) + expect(descriptions).not.toHaveBeenCalled() + expect(vi.mocked(console.error).mock.calls.flat().join('\n')).toContain( + 'TS2322', + ) + expect(existsSync(join(root, '.intent/skill-examples'))).toBe(false) + }) +}) -it('checks prose links without loading TypeScript', () => { +it('checks prose links without loading TypeScript', async () => { write('node_modules/typescript/package.json', '{"main":"index.cjs"}\n') write( 'node_modules/typescript/index.cjs', "require('node:fs').writeFileSync('typescript-loaded', '')\n", ) skill('See [missing](missing.md).\n') - expect(check().findings).toEqual([ + expect((await check()).findings).toEqual([ expect.objectContaining({ message: 'Link target not found: missing.md' }), ]) expect(existsSync(join(root, 'typescript-loaded'))).toBe(false) }) -it('uses tracked hand-written declarations and maps build output back to source', () => { - execFileSync('git', ['-c', 'core.fsmonitor=false', 'init', '-q'], { - cwd: root, - }) - write('.gitignore', 'dist/\n') - write('dist/index.d.ts', 'export declare function retry(): void\n') - write('package.json', '{"name":"@acme/client","types":"dist/index.d.ts"}\n') +it('skips typechecking with a reason when TypeScript 7 has no compiler API Intent can use', async () => { + fakeTypeScript7() skill( "```ts\nimport { retry } from '@acme/client'\nawait retry(() => Promise.resolve(), { max: 3 })\n```\n", ) - // dist/ is ignored, so src/index.ts stands in and the call typechecks. - expect(check().findings).toEqual([]) - write('types/index.d.ts', 'export declare function retry(): void\n') - write('package.json', '{"name":"@acme/client","types":"types/index.d.ts"}\n') - execFileSync('git', ['-c', 'core.fsmonitor=false', 'add', 'types'], { - cwd: root, - }) - // A tracked declaration file is the public surface, and the call no longer fits it. - expect(check().findings).toEqual([ - expect.objectContaining({ - line: 10, - message: expect.stringMatching(/TS2554/), - }), - ]) -}) - -it('checks imports from sibling workspace packages against their own source', () => { - write('pnpm-workspace.yaml', 'packages:\n - packages/*\n') - write('packages/client/package.json', '{"name":"@acme/client"}\n') - write( - 'packages/client/src/index.ts', - "import type { Adapter } from '@acme/adapter'\nexport function run(options: { adapter: A; model: A['models'][number] }): void {}\n", + expect((await check()).skipped).toBe( + 'TypeScript 7.0.2 has no compiler API that Intent can use; install @typescript/typescript6 beside it', ) - write('packages/adapter/package.json', '{"name":"@acme/adapter"}\n') - write( - 'packages/adapter/src/index.ts', - "export interface Adapter { models: ReadonlyArray }\nexport function openai(): { models: readonly ['gpt-5'] } { return { models: ['gpt-5'] } }\n", - ) - write( - 'packages/client/skills/run/SKILL.md', - [ - '---', - 'name: run', - 'description: Use when running.', - '---', - '```ts', - "import { run } from '@acme/client'", - "import { openai, anthropic } from '@acme/adapter'", - "run({ adapter: openai(), model: 'gpt-9000' })", - '```', - '', - ].join('\n'), + expect(await main(['validate'])).toBe(0) + expect(vi.mocked(console.log).mock.calls.flat().join('\n')).toContain( + 'Skill code blocks were not typechecked: TypeScript 7.0.2 has no compiler API that Intent can use', ) - const findings = checkSkillBlocks({ - root, - packageDir: join(root, 'packages/client'), - library: '@acme/client', - skills: [ - { - file: 'packages/client/skills/run/SKILL.md', - content: readFileSync( - join(root, 'packages/client/skills/run/SKILL.md'), - 'utf8', - ), - }, - ], - }).findings - expect(findings).toEqual([ - expect.objectContaining({ - line: 7, - message: expect.stringMatching(/TS2305: .*'anthropic'/), - }), - expect.objectContaining({ - line: 8, - message: expect.stringMatching(/TS2322: .*gpt-9000/), - }), - ]) + expect( + await describeSkillExamples(root, ['skills/retries/SKILL.md']), + ).toEqual(new Map()) }) -it('ignores links inside fenced examples and checks a skill that documents a sibling package', () => { - write('pnpm-workspace.yaml', 'packages:\n - packages/*\n') - write('packages/client/package.json', '{"name":"@acme/client"}\n') - write('packages/client/src/index.ts', 'export const client = 1\n') - write('packages/adapter/package.json', '{"name":"@acme/adapter"}\n') - write('packages/adapter/src/index.ts', 'export function openai(): void {}\n') - write( - 'packages/client/skills/adapters/SKILL.md', - [ - '---', - 'name: adapters', - 'description: Use when choosing an adapter.', - 'metadata:', - ' library: "@acme/adapter"', - '---', - '```md', - 'A [link inside an example](does-not-exist.md) is not checked.', - '```', - '```ts', - "import { openai, gemini } from '@acme/adapter'", - '```', - '', - ].join('\n'), +it('skips typechecking with a reason when the TypeScript 7 compiler API fails', async () => { + fakeTypeScript7({ nativeApi: true }) + skill( + "```ts\nimport { retry } from '@acme/client'\nawait retry(() => Promise.resolve(), { max: 3 })\n```\n", ) - const findings = checkSkillBlocks({ - root, - packageDir: join(root, 'packages/client'), - library: '@acme/adapter', - skills: [ - { - file: 'packages/client/skills/adapters/SKILL.md', - content: readFileSync( - join(root, 'packages/client/skills/adapters/SKILL.md'), - 'utf8', - ), - }, - ], - }).findings - expect(findings).toEqual([ - expect.objectContaining({ - line: 11, - message: expect.stringMatching(/TS2305: .*'gemini'/), - }), - ]) -}) - -it('skips typechecking with a reason when TypeScript or a type entry is unavailable', () => { - skill("```ts\nimport { retry } from '@acme/client'\n```\n") - expect( - checkSkillBlocks( - { - root, - packageDir: root, - library: '@acme/client', - skills: [ - { - file: 'skills/retries/SKILL.md', - content: readFileSync( - join(root, 'skills/retries/SKILL.md'), - 'utf8', - ), - }, - ], - }, - null, - ).skipped, - ).toMatch(/TypeScript is not installed/) - expect( - checkSkillBlocks( - { - root, - packageDir: root, - library: '@acme/client', - skills: [ - { - file: 'skills/retries/SKILL.md', - content: read('skills/retries/SKILL.md'), - }, - ], - }, - { version: '4.9.5', versionMajorMinor: '4.9' } as never, - ).skipped, - ).toMatch(/TypeScript 4\.9\.5 is installed; 5\.0 or newer/) - rmSync(join(root, 'src'), { recursive: true }) - expect(check().skipped).toMatch(/no type entry found for @acme\/client/) + expect((await check()).skipped).toBe( + 'TypeScript 7.0.2 could not check the examples: API changed', + ) + expect(await main(['validate'])).toBe(0) }) -it('fails validate on a broken example and reports compile status on pending reviews', async () => { +it('checks examples with @typescript/typescript6 installed beside TypeScript 7', async () => { + fakeTypeScript7({ typescript6: true }) skill( "```ts\nimport { retry } from '@acme/client'\nawait retry(() => Promise.resolve(), { max: 'many' })\n```\n", ) + expect((await check()).skipped).toBeUndefined() expect(await main(['validate'])).toBe(1) expect(vi.mocked(console.error).mock.calls.flat().join('\n')).toContain( `${join('skills', 'retries', 'SKILL.md')}:10: TS2322`, ) - skill( - "```ts\nimport { retry } from '@acme/client'\nawait retry(() => Promise.resolve(), { max: 3 })\n```\n", - ) - expect(await main(['validate'])).toBe(0) - execFileSync('git', ['-c', 'core.fsmonitor=false', 'init', '-q'], { - cwd: root, - }) - expect(await main(['maintainer', 'setup'])).toBe(0) - execFileSync('git', ['-c', 'core.fsmonitor=false', 'add', '.'], { cwd: root }) - execFileSync( - 'git', - [ - '-c', - 'core.fsmonitor=false', - '-c', - 'user.name=T', - '-c', - 'user.email=t@e', - 'commit', - '-qm', - 'init', - ], - { cwd: root }, +}) + +function read(path: string) { + return readFileSync(join(root, path), 'utf8') +} + +// Links the TypeScript 7 devDependency in as the fixture's own typescript. +// The typescript7 alias also links TypeScript 7's tsc into this package's +// .bin, so test:types runs the workspace's TypeScript 6 by path. +function linkTypeScript7() { + mkdirSync(join(root, 'node_modules'), { recursive: true }) + symlinkSync( + dirname(createRequire(import.meta.url).resolve('typescript7/package.json')), + join(root, 'node_modules', 'typescript'), + 'junction', ) +} + +// A TypeScript 7 package root exports only its version. `nativeApi` adds the +// unstable compiler entries with an API that fails to start, and the +// @typescript/typescript6 stand-in re-exports this repository's TypeScript 6. +function fakeTypeScript7( + options: { nativeApi?: boolean; typescript6?: boolean } = {}, +) { + const native = ['async', 'ast', 'ast/is', 'ast/scanner'].map((entry) => [ + `./unstable/${entry}`, + './native.mjs', + ]) write( - 'src/index.ts', - read('src/index.ts').replace('max: number', 'max: number; delay?: number'), - ) - execFileSync( - 'git', - [ - '-c', - 'core.fsmonitor=false', - '-c', - 'user.name=T', - '-c', - 'user.email=t@e', - 'commit', - '-qam', - 'add delay', - ], - { cwd: root }, + 'node_modules/typescript/package.json', + JSON.stringify({ + name: 'typescript', + version: '7.0.2', + exports: { + '.': './version.cjs', + ...(options.nativeApi ? Object.fromEntries(native) : {}), + }, + }), ) - vi.mocked(console.log).mockClear() - expect(await main(['maintainer', 'status'])).toBe(0) - expect(vi.mocked(console.log).mock.calls.flat().join('\n')).toContain( - 'Review skill skills/retries/SKILL.md: changed src/index.ts; examples still compile', + write( + 'node_modules/typescript/version.cjs', + "exports.version = '7.0.2'\nexports.versionMajorMinor = '7.0'\n", ) - const descriptions = vi.spyOn(blockChecks, 'describeSkillExamples') - const checks = vi.spyOn(blockChecks, 'checkSkillBlocks') - expect(await main(['maintainer', 'check'])).toBe(1) // pending source review - expect(checks).toHaveBeenCalledTimes(1) - expect(descriptions).not.toHaveBeenCalled() - checks.mockClear() + if (options.nativeApi) + write( + 'node_modules/typescript/native.mjs', + "export class API { constructor() { throw new Error('API changed') } }\n", + ) + if (!options.typescript6) return write( - 'src/index.ts', - read('src/index.ts').replace('max: number', 'max: string'), + 'node_modules/@typescript/typescript6/package.json', + '{"name":"@typescript/typescript6","main":"index.cjs"}\n', ) - vi.mocked(console.error).mockClear() - expect(await main(['maintainer', 'check'])).toBe(1) - expect(checks).toHaveBeenCalledTimes(1) - expect(descriptions).not.toHaveBeenCalled() - expect(vi.mocked(console.error).mock.calls.flat().join('\n')).toContain( - 'TS2322', + write( + 'node_modules/@typescript/typescript6/index.cjs', + `module.exports = require(${JSON.stringify(createRequire(import.meta.url).resolve('typescript'))})\n`, ) - expect(existsSync(join(root, '.intent/skill-examples'))).toBe(false) -}) - -function read(path: string) { - return readFileSync(join(root, path), 'utf8') } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8a98e0d2..a859a42d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -102,6 +102,9 @@ importers: tsdown: specifier: ^0.22.2 version: 0.22.14(oxc-resolver@11.24.2)(typescript@6.0.3) + typescript7: + specifier: npm:typescript@7.0.2 + version: typescript@7.0.2 verdaccio: specifier: ^6.7.2 version: 6.10.1(supports-color@7.2.0)(typanion@3.14.0) @@ -1006,6 +1009,126 @@ packages: resolution: {integrity: sha512-YR65gGdGvTUAWLldC3xLOvOzamdGzB4A5/N8rehEaHs3Zvoe39BhgY+u0SPch1OvrVTfLcc55wsSgK2NcnTS/A==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript/typescript-aix-ppc64@7.0.2': + resolution: {integrity: sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==} + engines: {node: '>=16.20.0'} + cpu: [ppc64] + os: [aix] + + '@typescript/typescript-darwin-arm64@7.0.2': + resolution: {integrity: sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [darwin] + + '@typescript/typescript-darwin-x64@7.0.2': + resolution: {integrity: sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [darwin] + + '@typescript/typescript-freebsd-arm64@7.0.2': + resolution: {integrity: sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [freebsd] + + '@typescript/typescript-freebsd-x64@7.0.2': + resolution: {integrity: sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [freebsd] + + '@typescript/typescript-linux-arm64@7.0.2': + resolution: {integrity: sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [linux] + + '@typescript/typescript-linux-arm@7.0.2': + resolution: {integrity: sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==} + engines: {node: '>=16.20.0'} + cpu: [arm] + os: [linux] + + '@typescript/typescript-linux-loong64@7.0.2': + resolution: {integrity: sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==} + engines: {node: '>=16.20.0'} + cpu: [loong64] + os: [linux] + + '@typescript/typescript-linux-mips64el@7.0.2': + resolution: {integrity: sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==} + engines: {node: '>=16.20.0'} + cpu: [mips64el] + os: [linux] + + '@typescript/typescript-linux-ppc64@7.0.2': + resolution: {integrity: sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==} + engines: {node: '>=16.20.0'} + cpu: [ppc64] + os: [linux] + + '@typescript/typescript-linux-riscv64@7.0.2': + resolution: {integrity: sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==} + engines: {node: '>=16.20.0'} + cpu: [riscv64] + os: [linux] + + '@typescript/typescript-linux-s390x@7.0.2': + resolution: {integrity: sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==} + engines: {node: '>=16.20.0'} + cpu: [s390x] + os: [linux] + + '@typescript/typescript-linux-x64@7.0.2': + resolution: {integrity: sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [linux] + + '@typescript/typescript-netbsd-arm64@7.0.2': + resolution: {integrity: sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [netbsd] + + '@typescript/typescript-netbsd-x64@7.0.2': + resolution: {integrity: sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [netbsd] + + '@typescript/typescript-openbsd-arm64@7.0.2': + resolution: {integrity: sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [openbsd] + + '@typescript/typescript-openbsd-x64@7.0.2': + resolution: {integrity: sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [openbsd] + + '@typescript/typescript-sunos-x64@7.0.2': + resolution: {integrity: sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [sunos] + + '@typescript/typescript-win32-arm64@7.0.2': + resolution: {integrity: sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [win32] + + '@typescript/typescript-win32-x64@7.0.2': + resolution: {integrity: sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [win32] + '@unrs/resolver-binding-android-arm-eabi@1.12.2': resolution: {integrity: sha512-g5T90pqg1bo/7mytQx6F4iBNC0Wsh9cu+z9veDbFjc7HjpesJFWD7QMS0NGStXM075+7dJPPVvBbpZlnrdpi/w==} cpu: [arm] @@ -3604,6 +3727,11 @@ packages: engines: {node: '>=14.17'} hasBin: true + typescript@7.0.2: + resolution: {integrity: sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==} + engines: {node: '>=16.20.0'} + hasBin: true + uglify-js@3.19.3: resolution: {integrity: sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==} engines: {node: '>=0.8.0'} @@ -4748,6 +4876,66 @@ snapshots: '@typescript-eslint/types': 8.68.0 eslint-visitor-keys: 5.0.1 + '@typescript/typescript-aix-ppc64@7.0.2': + optional: true + + '@typescript/typescript-darwin-arm64@7.0.2': + optional: true + + '@typescript/typescript-darwin-x64@7.0.2': + optional: true + + '@typescript/typescript-freebsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-freebsd-x64@7.0.2': + optional: true + + '@typescript/typescript-linux-arm64@7.0.2': + optional: true + + '@typescript/typescript-linux-arm@7.0.2': + optional: true + + '@typescript/typescript-linux-loong64@7.0.2': + optional: true + + '@typescript/typescript-linux-mips64el@7.0.2': + optional: true + + '@typescript/typescript-linux-ppc64@7.0.2': + optional: true + + '@typescript/typescript-linux-riscv64@7.0.2': + optional: true + + '@typescript/typescript-linux-s390x@7.0.2': + optional: true + + '@typescript/typescript-linux-x64@7.0.2': + optional: true + + '@typescript/typescript-netbsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-netbsd-x64@7.0.2': + optional: true + + '@typescript/typescript-openbsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-openbsd-x64@7.0.2': + optional: true + + '@typescript/typescript-sunos-x64@7.0.2': + optional: true + + '@typescript/typescript-win32-arm64@7.0.2': + optional: true + + '@typescript/typescript-win32-x64@7.0.2': + optional: true + '@unrs/resolver-binding-android-arm-eabi@1.12.2': optional: true @@ -7448,6 +7636,29 @@ snapshots: typescript@6.0.3: {} + typescript@7.0.2: + optionalDependencies: + '@typescript/typescript-aix-ppc64': 7.0.2 + '@typescript/typescript-darwin-arm64': 7.0.2 + '@typescript/typescript-darwin-x64': 7.0.2 + '@typescript/typescript-freebsd-arm64': 7.0.2 + '@typescript/typescript-freebsd-x64': 7.0.2 + '@typescript/typescript-linux-arm': 7.0.2 + '@typescript/typescript-linux-arm64': 7.0.2 + '@typescript/typescript-linux-loong64': 7.0.2 + '@typescript/typescript-linux-mips64el': 7.0.2 + '@typescript/typescript-linux-ppc64': 7.0.2 + '@typescript/typescript-linux-riscv64': 7.0.2 + '@typescript/typescript-linux-s390x': 7.0.2 + '@typescript/typescript-linux-x64': 7.0.2 + '@typescript/typescript-netbsd-arm64': 7.0.2 + '@typescript/typescript-netbsd-x64': 7.0.2 + '@typescript/typescript-openbsd-arm64': 7.0.2 + '@typescript/typescript-openbsd-x64': 7.0.2 + '@typescript/typescript-sunos-x64': 7.0.2 + '@typescript/typescript-win32-arm64': 7.0.2 + '@typescript/typescript-win32-x64': 7.0.2 + uglify-js@3.19.3: optional: true