From 1bb4da6f93d40a83afe95289c0ace510c4674172 Mon Sep 17 00:00:00 2001 From: ladybluenotes Date: Sat, 12 Sep 2026 08:56:26 -0700 Subject: [PATCH 01/15] feat: typecheck skill code blocks against the library's own source `validate` now extracts every fenced TypeScript and JavaScript block from each SKILL.md and typechecks it in one in-memory program per skills directory, with the library resolved to the owning package's source. A renamed export, a removed option, or a changed shape fails validation with the skill file and line. Names, modules, and globals a partial snippet leaves out are filtered out, so examples do not have to be complete. Imports of exports marked @deprecated produce warnings, and relative Markdown links must resolve. TypeScript is loaded from the maintainer's repository, with a fallback to Intent's own location; without it the code checks are skipped with one notice. Pending review items for skills report whether their examples still compile, so a maintainer can tell a shape change from a behavior change before opening the diff. --- .changeset/skill-code-block-checks.md | 5 + packages/intent/src/commands/maintainer.ts | 9 +- packages/intent/src/commands/validate.ts | 53 ++++ packages/intent/src/validate/blocks.ts | 296 ++++++++++++++++++ packages/intent/tests/validate-blocks.test.ts | 226 +++++++++++++ 5 files changed, 588 insertions(+), 1 deletion(-) create mode 100644 .changeset/skill-code-block-checks.md create mode 100644 packages/intent/src/validate/blocks.ts create mode 100644 packages/intent/tests/validate-blocks.test.ts diff --git a/.changeset/skill-code-block-checks.md b/.changeset/skill-code-block-checks.md new file mode 100644 index 00000000..c0baa203 --- /dev/null +++ b/.changeset/skill-code-block-checks.md @@ -0,0 +1,5 @@ +--- +'@tanstack/intent': minor +--- + +`validate` checks every fenced TypeScript and JavaScript block in a `SKILL.md` against the owning package's own types when TypeScript is available in the repository. A renamed export, a removed option, or a changed shape fails validation with the skill file and line. Imports of exports marked `@deprecated` produce warnings, and relative Markdown links must resolve. Names, modules, and globals a partial snippet leaves out are not reported. Repositories without TypeScript skip the code checks with a notice. Pending review items for skills report whether their examples still compile. diff --git a/packages/intent/src/commands/maintainer.ts b/packages/intent/src/commands/maintainer.ts index 168e8e7c..25de1949 100644 --- a/packages/intent/src/commands/maintainer.ts +++ b/packages/intent/src/commands/maintainer.ts @@ -20,6 +20,7 @@ import { } from '../maintainer/distribution.js' import { runSetupGithubActions } from '../setup/index.js' import { detectIntentCommandPackageManager } from '../shared/command-runner.js' +import { describeSkillExamples } from '../validate/blocks.js' import { getMetaDir } from './support.js' import { buildMaintainerGuidanceBlock, @@ -455,7 +456,13 @@ export async function runMaintainerCommand( : item.changedFiles.length ? `changed ${item.changedFiles.join(', ')}` : 'no recorded review' - console.log(` ${label} ${item.path}: ${detail}`) + const examples = + item.kind === 'skill' && !item.problems.length + ? describeSkillExamples(project.root, item.path) + : null + console.log( + ` ${label} ${item.path}: ${detail}${examples ? `; ${examples}` : ''}`, + ) } } if (action === 'check') { diff --git a/packages/intent/src/commands/validate.ts b/packages/intent/src/commands/validate.ts index a8f5ba04..612483fd 100644 --- a/packages/intent/src/commands/validate.ts +++ b/packages/intent/src/commands/validate.ts @@ -9,6 +9,7 @@ import { fail, isCliFailure } from '../shared/cli-error.js' import { resolveProjectContext } from '../core/project-context.js' import { findWorkspacePackages } from '../setup/workspace-patterns.js' import { createIntentFsCache } from '../discovery/fs-cache.js' +import { checkSkillBlocks } from '../validate/blocks.js' import { printWarnings } from './support.js' import type { ProjectContext } from '../core/project-context.js' @@ -462,6 +463,7 @@ async function runValidateCommandInternal( const errors: Array = [] const warnings: Array = [] + const skippedBlockChecks = new Set() const fixPlans: Array = [] const setVersionPlans: Array = [] let validatedCount = 0 @@ -482,6 +484,11 @@ async function runValidateCommandInternal( targetPath: skillsDir, }) + const checkedSkills: Array<{ + file: string + content: string + library: string | undefined + }> = [] for (const filePath of skillFiles) { const rel = relative(process.cwd(), filePath) const content = readFileSync(filePath, 'utf8') @@ -611,6 +618,12 @@ async function runValidateCommandInternal( ...collectAgentSkillSpecWarnings({ fm, rel }).map(formatWarning), ) + checkedSkills.push({ + file: rel, + content, + library: readScalarField(fm, 'library'), + }) + const lineCount = content.split(/\r?\n/).length if (lineCount > 500) { errors.push({ @@ -620,6 +633,43 @@ async function runValidateCommandInternal( } } + // Code blocks and links are checked against the owning package's own + // source, so a renamed export or option fails here with a skill line. + if (validateContext.packageRoot && checkedSkills.length) { + let packageName: string | undefined + try { + packageName = JSON.parse( + readFileSync(validateContext.targetPackageJsonPath!, 'utf8'), + ).name + } catch { + packageName = undefined + } + const byLibrary = new Map() + for (const skill of checkedSkills) { + const library = skill.library ?? packageName + if (!library) continue + byLibrary.set(library, [...(byLibrary.get(library) ?? []), skill]) + } + for (const [library, skills] of byLibrary) { + const result = checkSkillBlocks({ + root: process.cwd(), + packageDir: validateContext.packageRoot, + library, + skills, + }) + if (result.skipped) skippedBlockChecks.add(result.skipped) + for (const finding of result.findings) { + if (finding.severity === 'error') + errors.push({ + file: `${finding.file}:${finding.line}`, + message: finding.message, + }) + else + warnings.push(`${finding.file}:${finding.line}: ${finding.message}`) + } + } + } + // In monorepos, _artifacts lives at the workspace root, not under each package's skills/ dir. const artifactsDir = join(skillsDir, '_artifacts') if (!validateContext.isMonorepo && existsSync(artifactsDir)) { @@ -668,6 +718,9 @@ async function runValidateCommandInternal( ) } + for (const reason of skippedBlockChecks) + warnings.push(`Skill code blocks were not typechecked: ${reason}`) + if (options.check) { for (const plan of fixPlans) { errors.push({ diff --git a/packages/intent/src/validate/blocks.ts b/packages/intent/src/validate/blocks.ts new file mode 100644 index 00000000..73abeaf5 --- /dev/null +++ b/packages/intent/src/validate/blocks.ts @@ -0,0 +1,296 @@ +import { existsSync, readFileSync } from 'node:fs' +import { createRequire } from 'node:module' +import { dirname, join, relative, resolve } from 'node:path' +import { resolveProjectContext } from '../core/project-context.js' +import { parseFrontmatter, readScalarField } from '../shared/utils.js' +import type TS from 'typescript' + +export interface SkillBlockFinding { + file: string + line: number + message: string + severity: 'error' | 'warning' +} + +export interface SkillBlockCheck { + blocks: number + findings: Array + // Set when the code blocks could not be typechecked; links are still checked. + skipped?: string +} + +interface CodeBlock { + file: string + line: number + code: string +} + +const codeFence = + /^ {0,3}(`{3,}|~{3,})[ \t]*([A-Za-z0-9_-]*)[^\n]*\n([\s\S]*?)\n {0,3}\1[ \t]*$/gm +const checkedLanguages = new Set([ + 'ts', + 'tsx', + 'typescript', + 'js', + 'jsx', + 'javascript', +]) +const markdownLink = /\[[^\]]*\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g + +// Diagnostics that a deliberately partial example produces: names, modules, +// and globals the snippet leaves out. Everything else describes the library +// contract or a genuinely broken example. +const partialSnippetCodes = new Set([ + 1375, 2304, 2318, 2503, 2552, 2580, 2581, 2582, 2583, 2584, 2591, 2592, 2593, + 2602, 2686, 2688, 7006, 7026, 7031, 17004, +]) +const missingModuleCodes = new Set([2307, 2792]) + +export function loadTypeScript(root: string): typeof TS | null { + for (const from of [join(root, 'package.json'), import.meta.url]) { + try { + return createRequire(from)('typescript') as typeof TS + } catch { + // Try the next location. + } + } + return null +} + +export function extractCodeBlocks( + file: string, + content: string, +): Array { + const blocks: Array = [] + for (const match of content.matchAll(codeFence)) { + const language = match[2]!.toLowerCase() + if (!checkedLanguages.has(language)) continue + const line = content.slice(0, match.index).split('\n').length + 1 + blocks.push({ file, line, code: match[3]! }) + } + return blocks +} + +export function checkSkillLinks( + root: string, + file: string, + content: string, +): Array { + const findings: Array = [] + const absolute = resolve(root, file) + for (const match of content.matchAll(markdownLink)) { + const target = match[1]! + if (/^[a-z][a-z0-9+.-]*:/i.test(target) || target.startsWith('#')) continue + const path = target.replace(/[#?].*$/, '') + if (!path) continue + if (!existsSync(resolve(dirname(absolute), path))) + findings.push({ + file, + line: content.slice(0, match.index).split('\n').length, + message: `Link target not found: ${target}`, + severity: 'error', + }) + } + return findings +} + +// The file that declares the library's public types: the conventional source +// entry, then package metadata. +export function libraryEntry(packageDir: string): string | null { + let manifest: Record + try { + manifest = JSON.parse( + readFileSync(join(packageDir, 'package.json'), 'utf8'), + ) + } catch { + return null + } + const exportsRoot = isRecord(manifest.exports) + ? (manifest.exports['.'] ?? manifest.exports) + : manifest.exports + // Source first: a maintainer's build output can be stale or absent. + const candidates = [ + 'src/index.ts', + 'src/index.tsx', + manifest.types, + manifest.typings, + typeof exportsRoot === 'string' ? exportsRoot : undefined, + isRecord(exportsRoot) ? exportsRoot.types : undefined, + isRecord(exportsRoot) && isRecord(exportsRoot.import) + ? exportsRoot.import.types + : undefined, + 'index.ts', + 'index.d.ts', + ] + for (const candidate of candidates) { + if (typeof candidate !== 'string') continue + const path = resolve(packageDir, candidate) + if (existsSync(path)) return path + } + return null +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +export function checkSkillBlocks( + options: { + root: string + packageDir: string + library: string + skills: Array<{ file: string; content: string }> + }, + ts: typeof TS | null = loadTypeScript(options.root), +): SkillBlockCheck { + const { root, packageDir, library, skills } = options + const findings: Array = [] + for (const skill of skills) + findings.push(...checkSkillLinks(root, skill.file, skill.content)) + const blocks = skills.flatMap((skill) => + extractCodeBlocks(skill.file, skill.content), + ) + if (blocks.length === 0) return { blocks: 0, findings } + if (!ts) + return { + blocks: blocks.length, + findings, + skipped: 'TypeScript is not installed in this repository', + } + const entry = libraryEntry(packageDir) + if (!entry) + return { + blocks: blocks.length, + findings, + skipped: `no type entry found for ${library} in ${relative(root, packageDir) || '.'}`, + } + + const virtualDir = join(root, '.intent', 'skill-examples') + const virtual = new Map() + blocks.forEach((block, index) => + virtual.set(join(virtualDir, `block-${index}.tsx`), block), + ) + const compilerOptions: TS.CompilerOptions = { + noEmit: true, + strict: false, + skipLibCheck: true, + allowJs: true, + checkJs: false, + resolveJsonModule: true, + esModuleInterop: true, + allowSyntheticDefaultImports: true, + target: ts.ScriptTarget.ESNext, + module: ts.ModuleKind.ESNext, + moduleResolution: ts.ModuleResolutionKind.Bundler, + jsx: ts.JsxEmit.Preserve, + lib: ['lib.esnext.d.ts', 'lib.dom.d.ts'], + baseUrl: root, + paths: { [library]: [entry], [`${library}/*`]: [join(packageDir, '*')] }, + types: [], + } + const host = ts.createCompilerHost(compilerOptions, true) + const readFile = host.readFile.bind(host) + const fileExists = host.fileExists.bind(host) + host.fileExists = (path) => virtual.has(path) || fileExists(path) + host.readFile = (path) => virtual.get(path)?.code ?? readFile(path) + host.getSourceFile = (path, languageVersion) => { + const code = virtual.get(path)?.code ?? readFile(path) + return code === undefined + ? undefined + : ts.createSourceFile(path, code, languageVersion, true) + } + const program = ts.createProgram([...virtual.keys()], compilerOptions, 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) + if (!source) continue + const at = (position: number) => + block.line + source.getLineAndCharacterOfPosition(position).line + for (const diagnostic of program.getSemanticDiagnostics(source)) { + if (partialSnippetCodes.has(diagnostic.code)) continue + const message = ts.flattenDiagnosticMessageText( + diagnostic.messageText, + ' ', + ) + 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', + }) + } + for (const statement of source.statements) { + if ( + !ts.isImportDeclaration(statement) || + !ts.isStringLiteral(statement.moduleSpecifier) || + !fromLibrary(statement.moduleSpecifier.text) + ) + continue + const bindings = statement.importClause?.namedBindings + if (!bindings || !ts.isNamedImports(bindings)) continue + for (const element of bindings.elements) { + let symbol = checker.getSymbolAtLocation(element.name) + if (symbol && symbol.flags & ts.SymbolFlags.Alias) + symbol = checker.getAliasedSymbol(symbol) + 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', + }) + } + } + } + return { blocks: blocks.length, findings } +} + +// One-line summary of a skill's examples for a review item, or null when the +// skill has no code blocks or they could not be checked. +export function describeSkillExamples( + root: string, + file: string, +): string | null { + const absolute = resolve(root, file) + const { packageRoot } = resolveProjectContext({ + cwd: root, + targetPath: absolute, + }) + if (!packageRoot) return null + let library = readScalarField(parseFrontmatter(absolute), 'library') + if (!library) { + try { + library = JSON.parse( + readFileSync(join(packageRoot, 'package.json'), 'utf8'), + ).name + } catch { + return null + } + } + if (typeof library !== 'string') return null + const result = checkSkillBlocks({ + root, + packageDir: packageRoot, + library, + skills: [{ file, content: readFileSync(absolute, 'utf8') }], + }) + if (result.blocks === 0 || result.skipped) return null + const errors = result.findings.filter( + (finding) => finding.severity === 'error', + ) + return errors.length + ? `${errors.length} example error(s), first at line ${errors[0]!.line}` + : 'examples still compile' +} diff --git a/packages/intent/tests/validate-blocks.test.ts b/packages/intent/tests/validate-blocks.test.ts new file mode 100644 index 00000000..dd56fbf1 --- /dev/null +++ b/packages/intent/tests/validate-blocks.test.ts @@ -0,0 +1,226 @@ +import { execFileSync } from 'node:child_process' +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { main } from '../src/cli.js' +import { checkSkillBlocks } from '../src/validate/blocks.js' + +// Typechecking examples against a real package takes longer than a unit test. +vi.setConfig({ testTimeout: 30_000 }) + +let root: string +let previousCwd: string + +function write(path: string, content: string) { + mkdirSync(dirname(join(root, path)), { recursive: true }) + writeFileSync(join(root, path), content) +} + +function skill(body: string) { + write( + 'skills/retries/SKILL.md', + `---\nname: retries\ndescription: Use when retrying requests.\nsources: [src/index.ts]\n---\n# Retries\n\n${body}`, + ) +} + +function check() { + return checkSkillBlocks({ + root, + packageDir: root, + library: '@acme/client', + skills: [ + { + file: 'skills/retries/SKILL.md', + content: readFileSync(join(root, 'skills/retries/SKILL.md'), 'utf8'), + }, + ], + }) +} + +beforeEach(() => { + previousCwd = process.cwd() + root = mkdtempSync(join(tmpdir(), 'intent-blocks-')) + process.chdir(root) + vi.spyOn(console, 'log').mockImplementation(() => {}) + vi.spyOn(console, 'error').mockImplementation(() => {}) + write('package.json', '{"name":"@acme/client","version":"1.0.0"}\n') + write( + 'src/index.ts', + [ + 'export interface RetryOptions { max: number }', + 'export function retry(fn: () => Promise, options: RetryOptions): Promise<{ ok: boolean }> {', + ' return fn().then(() => ({ ok: options.max > 0 }))', + '}', + '/** @deprecated Use retry. */', + 'export function legacyRetry(): void {}', + '', + ].join('\n'), + ) +}) +afterEach(() => { + process.chdir(previousCwd) + vi.restoreAllMocks() + 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('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"', + '```', + '', + ].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/), + }), + ]) +}) + +it('warns on deprecated imports and fails broken relative links', () => { + write('skills/retries/references/backoff.md', '# Backoff\n') + skill( + [ + 'See [backoff](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('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/) + rmSync(join(root, 'src'), { recursive: true }) + expect(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 '@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')).toMatch( + /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', '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', + ) + expect(existsSync(join(root, '.intent/skill-examples'))).toBe(false) +}) + +function read(path: string) { + return readFileSync(join(root, path), 'utf8') +} From c8d1a0cb49de874daa348b151e8981b64e53e5e1 Mon Sep 17 00:00:00 2001 From: ladybluenotes Date: Sat, 12 Sep 2026 09:01:39 -0700 Subject: [PATCH 02/15] fix: resolve the library entry from declared types, mapping build output to source --- packages/intent/src/validate/blocks.ts | 47 ++++++++++++++----- packages/intent/tests/validate-blocks.test.ts | 26 ++++++++++ 2 files changed, 60 insertions(+), 13 deletions(-) diff --git a/packages/intent/src/validate/blocks.ts b/packages/intent/src/validate/blocks.ts index 73abeaf5..523dc13a 100644 --- a/packages/intent/src/validate/blocks.ts +++ b/packages/intent/src/validate/blocks.ts @@ -1,3 +1,4 @@ +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' @@ -94,42 +95,62 @@ export function checkSkillLinks( return findings } -// The file that declares the library's public types: the conventional source -// entry, then package metadata. +// The file that declares the library's public types. A declared entry that +// Git tracks is hand-written and used as-is; a missing or ignored one is +// build output, so the matching source file stands in for it. export function libraryEntry(packageDir: string): string | null { - let manifest: Record + let manifest: Record = {} try { manifest = JSON.parse( readFileSync(join(packageDir, 'package.json'), 'utf8'), ) } catch { - return null + // Fall through to the conventional source entry. } const exportsRoot = isRecord(manifest.exports) ? (manifest.exports['.'] ?? manifest.exports) : manifest.exports - // Source first: a maintainer's build output can be stale or absent. - const candidates = [ - 'src/index.ts', - 'src/index.tsx', + const declared = [ manifest.types, manifest.typings, - typeof exportsRoot === 'string' ? exportsRoot : undefined, isRecord(exportsRoot) ? exportsRoot.types : undefined, isRecord(exportsRoot) && isRecord(exportsRoot.import) ? exportsRoot.import.types : undefined, - 'index.ts', - 'index.d.ts', - ] + typeof exportsRoot === 'string' ? exportsRoot : undefined, + ].find((value): value is string => typeof value === 'string') + const candidates: Array = [] + if (declared) { + const path = resolve(packageDir, declared) + if (existsSync(path) && isTracked(packageDir, path)) return path + const name = declared + .split('/') + .at(-1)! + .replace(/\.d\.(c|m)?ts$/, '') + .replace(/\.(c|m)?[jt]sx?$/, '') + candidates.push(`src/${name}.ts`, `src/${name}.tsx`) + } + candidates.push('src/index.ts', 'src/index.tsx', 'index.ts', 'index.d.ts') for (const candidate of candidates) { - if (typeof candidate !== 'string') continue const path = resolve(packageDir, candidate) if (existsSync(path)) return path } return null } +function isTracked(packageDir: string, path: string): boolean { + try { + execFileSync( + 'git', + ['-c', 'core.fsmonitor=false', 'ls-files', '--error-unmatch', '--', path], + { cwd: packageDir, stdio: 'ignore' }, + ) + return true + } catch { + return false + } +} + function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value) } diff --git a/packages/intent/tests/validate-blocks.test.ts b/packages/intent/tests/validate-blocks.test.ts index dd56fbf1..0327d474 100644 --- a/packages/intent/tests/validate-blocks.test.ts +++ b/packages/intent/tests/validate-blocks.test.ts @@ -145,6 +145,32 @@ it('warns on deprecated imports and fails broken relative links', () => { ]) }) +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') + 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('skips typechecking with a reason when TypeScript or a type entry is unavailable', () => { skill("```ts\nimport { retry } from '@acme/client'\n```\n") expect( From cec11232454667bcb0891363f8dff1848851c9bb Mon Sep 17 00:00:00 2001 From: ladybluenotes Date: Sat, 12 Sep 2026 09:10:26 -0700 Subject: [PATCH 03/15] fix: resolve sibling workspace packages when checking skill examples Map every workspace package to its own entry in the typecheck program. An example that imports a sibling package, such as an adapter or a framework binding, was resolving to nothing, which hid missing exports there and widened generics parameterized on those values so that wrong options and model names passed silently. --- packages/intent/src/validate/blocks.ts | 36 ++++++++++++- packages/intent/tests/validate-blocks.test.ts | 53 +++++++++++++++++++ 2 files changed, 88 insertions(+), 1 deletion(-) diff --git a/packages/intent/src/validate/blocks.ts b/packages/intent/src/validate/blocks.ts index 523dc13a..9880ba6a 100644 --- a/packages/intent/src/validate/blocks.ts +++ b/packages/intent/src/validate/blocks.ts @@ -3,6 +3,7 @@ import { existsSync, readFileSync } from 'node:fs' import { createRequire } from 'node:module' import { dirname, join, relative, resolve } from 'node:path' 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' @@ -138,6 +139,35 @@ export function libraryEntry(packageDir: string): string | null { return null } +// Every workspace package mapped to its own entry, so an example that imports +// a sibling package (an adapter, a framework binding) is checked against it +// instead of silently resolving to nothing. +const workspaceEntries = new Map>>() +function workspacePaths(root: string): Record> { + const context = resolveProjectContext({ cwd: root }) + const workspaceRoot = context.workspaceRoot ?? root + const cached = workspaceEntries.get(workspaceRoot) + if (cached) return cached + const paths: Record> = {} + for (const dir of resolveWorkspacePackages( + workspaceRoot, + context.workspacePatterns, + )) { + let name: unknown + try { + name = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')).name + } catch { + continue + } + const entry = typeof name === 'string' ? libraryEntry(dir) : null + if (!entry) continue + paths[name as string] = [entry] + paths[`${name}/*`] = [join(dir, '*')] + } + workspaceEntries.set(workspaceRoot, paths) + return paths +} + function isTracked(packageDir: string, path: string): boolean { try { execFileSync( @@ -206,7 +236,11 @@ export function checkSkillBlocks( jsx: ts.JsxEmit.Preserve, lib: ['lib.esnext.d.ts', 'lib.dom.d.ts'], baseUrl: root, - paths: { [library]: [entry], [`${library}/*`]: [join(packageDir, '*')] }, + paths: { + ...workspacePaths(root), + [library]: [entry], + [`${library}/*`]: [join(packageDir, '*')], + }, types: [], } const host = ts.createCompilerHost(compilerOptions, true) diff --git a/packages/intent/tests/validate-blocks.test.ts b/packages/intent/tests/validate-blocks.test.ts index 0327d474..6caa330e 100644 --- a/packages/intent/tests/validate-blocks.test.ts +++ b/packages/intent/tests/validate-blocks.test.ts @@ -171,6 +171,59 @@ it('uses tracked hand-written declarations and maps build output back to source' ]) }) +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", + ) + 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 = 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('skips typechecking with a reason when TypeScript or a type entry is unavailable', () => { skill("```ts\nimport { retry } from '@acme/client'\n```\n") expect( From 36d55f394a8bdcf998c6b19368f936e49af20b30 Mon Sep 17 00:00:00 2001 From: ladybluenotes Date: Sat, 12 Sep 2026 09:14:58 -0700 Subject: [PATCH 04/15] perf: typecheck each skill once per process and batch review summaries `maintainer check` validated every skill and then built a second program for each pending review item. Keep each skill's result for the process, keyed on the resolved library entry and its mtime, and describe all pending skills in one program per package. --- packages/intent/src/commands/maintainer.ts | 13 +- packages/intent/src/validate/blocks.ts | 159 ++++++++++++++------- 2 files changed, 119 insertions(+), 53 deletions(-) diff --git a/packages/intent/src/commands/maintainer.ts b/packages/intent/src/commands/maintainer.ts index 25de1949..d51cbc3d 100644 --- a/packages/intent/src/commands/maintainer.ts +++ b/packages/intent/src/commands/maintainer.ts @@ -444,6 +444,12 @@ export async function runMaintainerCommand( for (const problem of status.problems) console.log(` ${problem}`) for (const path of status.staleFiles) console.log(` Run intent maintainer sync: ${path}`) + const examples = describeSkillExamples( + project.root, + review.items + .filter((item) => item.kind === 'skill' && !item.problems.length) + .map((item) => item.path), + ) for (const item of review.items) { const label = item.kind === 'skill' @@ -456,12 +462,9 @@ export async function runMaintainerCommand( : item.changedFiles.length ? `changed ${item.changedFiles.join(', ')}` : 'no recorded review' - const examples = - item.kind === 'skill' && !item.problems.length - ? describeSkillExamples(project.root, item.path) - : null + const example = examples.get(item.path) console.log( - ` ${label} ${item.path}: ${detail}${examples ? `; ${examples}` : ''}`, + ` ${label} ${item.path}: ${detail}${example ? `; ${example}` : ''}`, ) } } diff --git a/packages/intent/src/validate/blocks.ts b/packages/intent/src/validate/blocks.ts index 9880ba6a..2787dc1f 100644 --- a/packages/intent/src/validate/blocks.ts +++ b/packages/intent/src/validate/blocks.ts @@ -1,5 +1,6 @@ import { execFileSync } from 'node:child_process' -import { existsSync, readFileSync } from 'node:fs' +import { createHash } from 'node:crypto' +import { existsSync, readFileSync, statSync } from 'node:fs' import { createRequire } from 'node:module' import { dirname, join, relative, resolve } from 'node:path' import { resolveProjectContext } from '../core/project-context.js' @@ -194,27 +195,49 @@ export function checkSkillBlocks( }, ts: typeof TS | null = loadTypeScript(options.root), ): SkillBlockCheck { - const { root, packageDir, library, skills } = options + const { root, packageDir, library } = options + // `check` validates every skill and then describes the pending ones, so a + // skill's result is kept for the rest of the process instead of building a + // second program for the same content. const findings: Array = [] + const skills: Array<{ file: string; content: string; key: string }> = [] + let blockCount = 0 + const entry = ts ? libraryEntry(packageDir) : null + const stamp = entry ? `${entry}\0${statSync(entry).mtimeMs}` : '' + for (const skill of options.skills) { + const key = [ + packageDir, + library, + stamp, + skill.file, + digest(skill.content), + ].join('\0') + const cached = checked.get(key) + if (cached) { + findings.push(...cached.findings) + blockCount += cached.blocks + } else skills.push({ ...skill, key }) + } for (const skill of skills) findings.push(...checkSkillLinks(root, skill.file, skill.content)) const blocks = skills.flatMap((skill) => extractCodeBlocks(skill.file, skill.content), ) - if (blocks.length === 0) return { blocks: 0, findings } - if (!ts) - return { - blocks: blocks.length, - findings, - skipped: 'TypeScript is not installed in this repository', - } - const entry = libraryEntry(packageDir) + const remember = (skipped?: string) => { + if (!skipped) + for (const skill of skills) + checked.set(skill.key, { + blocks: blocks.filter((block) => block.file === skill.file).length, + findings: findings.filter((finding) => finding.file === skill.file), + }) + return { blocks: blockCount + blocks.length, findings, skipped } + } + if (blocks.length === 0) return remember() + if (!ts) return remember('TypeScript is not installed in this repository') if (!entry) - return { - blocks: blocks.length, - findings, - skipped: `no type entry found for ${library} in ${relative(root, packageDir) || '.'}`, - } + return remember( + `no type entry found for ${library} in ${relative(root, packageDir) || '.'}`, + ) const virtualDir = join(root, '.intent', 'skill-examples') const virtual = new Map() @@ -309,43 +332,83 @@ export function checkSkillBlocks( } } } - return { blocks: blocks.length, findings } + return remember() } -// One-line summary of a skill's examples for a review item, or null when the -// skill has no code blocks or they could not be checked. +const checked = new Map< + string, + { blocks: number; findings: Array } +>() +const digest = (value: string) => + createHash('sha256').update(value).digest('hex') + +// 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( root: string, - file: string, -): string | null { - const absolute = resolve(root, file) - const { packageRoot } = resolveProjectContext({ - cwd: root, - targetPath: absolute, - }) - if (!packageRoot) return null - let library = readScalarField(parseFrontmatter(absolute), 'library') - if (!library) { - try { - library = JSON.parse( - readFileSync(join(packageRoot, 'package.json'), 'utf8'), - ).name - } catch { - return null + files: Array, +): Map { + const groups = new Map< + string, + { packageDir: string; library: string; files: Array } + >() + for (const file of files) { + const absolute = resolve(root, file) + const { packageRoot } = resolveProjectContext({ + cwd: root, + targetPath: absolute, + }) + if (!packageRoot) continue + let library: unknown = readScalarField( + parseFrontmatter(absolute), + 'library', + ) + if (!library) { + try { + library = JSON.parse( + readFileSync(join(packageRoot, 'package.json'), 'utf8'), + ).name + } catch { + continue + } + } + if (typeof library !== 'string') continue + const key = `${packageRoot}\0${library}` + const group = groups.get(key) ?? { + packageDir: packageRoot, + library, + files: [], } + group.files.push(file) + groups.set(key, group) } - if (typeof library !== 'string') return null - const result = checkSkillBlocks({ - root, - packageDir: packageRoot, - library, - skills: [{ file, content: readFileSync(absolute, 'utf8') }], - }) - if (result.blocks === 0 || result.skipped) return null - const errors = result.findings.filter( - (finding) => finding.severity === 'error', - ) - return errors.length - ? `${errors.length} example error(s), first at line ${errors[0]!.line}` - : 'examples still compile' + const summaries = new Map() + for (const group of groups.values()) { + const skills = group.files.map((file) => ({ + file, + content: readFileSync(resolve(root, file), 'utf8'), + })) + const result = checkSkillBlocks({ + root, + packageDir: group.packageDir, + library: group.library, + skills, + }) + if (result.skipped) continue + for (const skill of skills) { + if (!extractCodeBlocks(skill.file, skill.content).length) continue + const errors = result.findings.filter( + (finding) => + finding.file === skill.file && finding.severity === 'error', + ) + summaries.set( + skill.file, + errors.length + ? `${errors.length} example error(s), first at line ${errors[0]!.line}` + : 'examples still compile', + ) + } + } + return summaries } From 0156db6873e9e4389bc8e33f27358315511ab963 Mon Sep 17 00:00:00 2001 From: ladybluenotes Date: Sat, 12 Sep 2026 09:17:05 -0700 Subject: [PATCH 05/15] fix: address review findings on skill example checks Skip links inside fenced examples, map library subpaths to the entry directory before the package root, resolve a non-owning metadata.library through the workspace or node_modules instead of this package's entry, and require TypeScript 5.0 or newer rather than falling back to the classic resolver. --- packages/intent/src/validate/blocks.ts | 39 ++++++++++-- packages/intent/tests/validate-blocks.test.ts | 62 +++++++++++++++++++ 2 files changed, 96 insertions(+), 5 deletions(-) diff --git a/packages/intent/src/validate/blocks.ts b/packages/intent/src/validate/blocks.ts index 2787dc1f..a41d44bf 100644 --- a/packages/intent/src/validate/blocks.ts +++ b/packages/intent/src/validate/blocks.ts @@ -81,7 +81,11 @@ export function checkSkillLinks( ): Array { const findings: Array = [] const absolute = resolve(root, file) - for (const match of content.matchAll(markdownLink)) { + // Blank out fenced examples, keeping newlines so line numbers still match. + const prose = content.replace(codeFence, (block) => + block.replace(/[^\n]/g, ' '), + ) + for (const match of prose.matchAll(markdownLink)) { const target = match[1]! if (/^[a-z][a-z0-9+.-]*:/i.test(target) || target.startsWith('#')) continue const path = target.replace(/[#?].*$/, '') @@ -163,7 +167,7 @@ function workspacePaths(root: string): Record> { const entry = typeof name === 'string' ? libraryEntry(dir) : null if (!entry) continue paths[name as string] = [entry] - paths[`${name}/*`] = [join(dir, '*')] + paths[`${name}/*`] = [join(dirname(entry), '*'), join(dir, '*')] } workspaceEntries.set(workspaceRoot, paths) return paths @@ -182,6 +186,17 @@ function isTracked(packageDir: string, path: string): boolean { } } +function packageName(packageDir: string): string | undefined { + try { + const name: unknown = JSON.parse( + readFileSync(join(packageDir, 'package.json'), 'utf8'), + ).name + return typeof name === 'string' ? name : undefined + } catch { + return undefined + } +} + function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value) } @@ -234,7 +249,12 @@ export function checkSkillBlocks( } if (blocks.length === 0) return remember() if (!ts) return remember('TypeScript is not installed in this repository') - if (!entry) + if (Number(ts.versionMajorMinor.split('.')[0]) < 5) + return remember( + `TypeScript ${ts.version} is installed; 5.0 or newer is required`, + ) + const ownsLibrary = packageName(packageDir) === library + if (ownsLibrary && !entry) return remember( `no type entry found for ${library} in ${relative(root, packageDir) || '.'}`, ) @@ -261,8 +281,17 @@ export function checkSkillBlocks( baseUrl: root, paths: { ...workspacePaths(root), - [library]: [entry], - [`${library}/*`]: [join(packageDir, '*')], + // A skill documenting another package (metadata.library) resolves that + // package through the workspace or node_modules, not this package's entry. + ...(ownsLibrary + ? { + [library]: [entry!], + [`${library}/*`]: [ + join(dirname(entry!), '*'), + join(packageDir, '*'), + ], + } + : {}), }, types: [], } diff --git a/packages/intent/tests/validate-blocks.test.ts b/packages/intent/tests/validate-blocks.test.ts index 6caa330e..cc95e3a0 100644 --- a/packages/intent/tests/validate-blocks.test.ts +++ b/packages/intent/tests/validate-blocks.test.ts @@ -224,6 +224,52 @@ it('checks imports from sibling workspace packages against their own source', () ]) }) +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'), + ) + 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( @@ -245,6 +291,22 @@ it('skips typechecking with a reason when TypeScript or a type entry is unavaila 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/) }) From 1a8d4677f5bbc82378eb04ed5d5e08f20c66d158 Mon Sep 17 00:00:00 2001 From: ladybluenotes Date: Sat, 12 Sep 2026 09:21:29 -0700 Subject: [PATCH 06/15] fix: accept angle-bracket link destinations in skill link checks CommonMark allows a link destination wrapped in <...>, which the loader already emits for shared references. The link check treated the brackets as part of the path and failed the packed-release lifecycle test. --- packages/intent/src/validate/blocks.ts | 5 +++-- packages/intent/tests/validate-blocks.test.ts | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/intent/src/validate/blocks.ts b/packages/intent/src/validate/blocks.ts index a41d44bf..80653671 100644 --- a/packages/intent/src/validate/blocks.ts +++ b/packages/intent/src/validate/blocks.ts @@ -38,7 +38,8 @@ const checkedLanguages = new Set([ 'jsx', 'javascript', ]) -const markdownLink = /\[[^\]]*\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g +// A destination is either <...>, which may contain spaces, or a bare path. +const markdownLink = /\[[^\]]*\]\((?:<([^>]*)>|([^)\s]+))(?:\s+"[^"]*")?\)/g // Diagnostics that a deliberately partial example produces: names, modules, // and globals the snippet leaves out. Everything else describes the library @@ -86,7 +87,7 @@ export function checkSkillLinks( block.replace(/[^\n]/g, ' '), ) for (const match of prose.matchAll(markdownLink)) { - const target = match[1]! + const target = match[1] ?? match[2]! if (/^[a-z][a-z0-9+.-]*:/i.test(target) || target.startsWith('#')) continue const path = target.replace(/[#?].*$/, '') if (!path) continue diff --git a/packages/intent/tests/validate-blocks.test.ts b/packages/intent/tests/validate-blocks.test.ts index cc95e3a0..4e6e0079 100644 --- a/packages/intent/tests/validate-blocks.test.ts +++ b/packages/intent/tests/validate-blocks.test.ts @@ -121,7 +121,7 @@ it('warns on deprecated imports and fails broken relative links', () => { write('skills/retries/references/backoff.md', '# Backoff\n') skill( [ - 'See [backoff](references/backoff.md) and [missing](references/missing.md#top).', + 'See [backoff](), [again](<../retries/references/backoff.md>), and [missing](references/missing.md#top).', 'External [docs](https://example.com/x) are not checked.', '', '```ts', From 9f06be960a3bcfb372c3da83d726dd41b81eb680 Mon Sep 17 00:00:00 2001 From: ladybluenotes Date: Sat, 12 Sep 2026 09:25:30 -0700 Subject: [PATCH 07/15] chore: keep skill example helpers module-private for knip --- packages/intent/src/validate/blocks.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/intent/src/validate/blocks.ts b/packages/intent/src/validate/blocks.ts index 80653671..fceabda6 100644 --- a/packages/intent/src/validate/blocks.ts +++ b/packages/intent/src/validate/blocks.ts @@ -8,7 +8,7 @@ import { resolveWorkspacePackages } from '../setup/workspace-patterns.js' import { parseFrontmatter, readScalarField } from '../shared/utils.js' import type TS from 'typescript' -export interface SkillBlockFinding { +interface SkillBlockFinding { file: string line: number message: string @@ -50,7 +50,7 @@ const partialSnippetCodes = new Set([ ]) const missingModuleCodes = new Set([2307, 2792]) -export function loadTypeScript(root: string): typeof TS | null { +function loadTypeScript(root: string): typeof TS | null { for (const from of [join(root, 'package.json'), import.meta.url]) { try { return createRequire(from)('typescript') as typeof TS @@ -61,7 +61,7 @@ export function loadTypeScript(root: string): typeof TS | null { return null } -export function extractCodeBlocks( +function extractCodeBlocks( file: string, content: string, ): Array { @@ -75,7 +75,7 @@ export function extractCodeBlocks( return blocks } -export function checkSkillLinks( +function checkSkillLinks( root: string, file: string, content: string, @@ -105,7 +105,7 @@ export function checkSkillLinks( // The file that declares the library's public types. A declared entry that // Git tracks is hand-written and used as-is; a missing or ignored one is // build output, so the matching source file stands in for it. -export function libraryEntry(packageDir: string): string | null { +function libraryEntry(packageDir: string): string | null { let manifest: Record = {} try { manifest = JSON.parse( From 09beb7a0bcb5ce4ebb7f3ab1a5e4cfb0d29b3ebf Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Sat, 12 Sep 2026 16:26:11 +0000 Subject: [PATCH 08/15] ci: apply automated fixes --- packages/intent/src/validate/blocks.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/intent/src/validate/blocks.ts b/packages/intent/src/validate/blocks.ts index fceabda6..4e5c8ece 100644 --- a/packages/intent/src/validate/blocks.ts +++ b/packages/intent/src/validate/blocks.ts @@ -61,10 +61,7 @@ function loadTypeScript(root: string): typeof TS | null { return null } -function extractCodeBlocks( - file: string, - content: string, -): Array { +function extractCodeBlocks(file: string, content: string): Array { const blocks: Array = [] for (const match of content.matchAll(codeFence)) { const language = match[2]!.toLowerCase() From 05320fba679d678b66fc98de178e731590a7b5b4 Mon Sep 17 00:00:00 2001 From: Sarah Gerrard Date: Sat, 12 Sep 2026 09:58:17 -0700 Subject: [PATCH 09/15] fix: key virtual example files the way TypeScript reads them and drop the deprecated baseUrl TypeScript hands the compiler host forward-slash paths, so on Windows the backslash-keyed virtual files were never found and every example passed unchecked. TypeScript 6 reports baseUrl as an error, and the path mappings are absolute, so it is removed. Syntax errors are now reported, since an example that does not parse is not checked at all, and declaration-only source entries are accepted when build output is ignored. --- packages/intent/src/validate/blocks.ts | 35 +++++++++++++------ packages/intent/tests/validate-blocks.test.ts | 14 ++++++-- 2 files changed, 37 insertions(+), 12 deletions(-) diff --git a/packages/intent/src/validate/blocks.ts b/packages/intent/src/validate/blocks.ts index 4e5c8ece..3c936ec7 100644 --- a/packages/intent/src/validate/blocks.ts +++ b/packages/intent/src/validate/blocks.ts @@ -132,7 +132,13 @@ function libraryEntry(packageDir: string): string | null { .at(-1)! .replace(/\.d\.(c|m)?ts$/, '') .replace(/\.(c|m)?[jt]sx?$/, '') - candidates.push(`src/${name}.ts`, `src/${name}.tsx`) + candidates.push( + `src/${name}.ts`, + `src/${name}.tsx`, + `src/${name}.d.ts`, + `src/${name}.d.cts`, + `src/${name}.d.mts`, + ) } candidates.push('src/index.ts', 'src/index.tsx', 'index.ts', 'index.d.ts') for (const candidate of candidates) { @@ -164,8 +170,11 @@ function workspacePaths(root: string): Record> { } const entry = typeof name === 'string' ? libraryEntry(dir) : null if (!entry) continue - paths[name as string] = [entry] - paths[`${name}/*`] = [join(dirname(entry), '*'), join(dir, '*')] + paths[name as string] = [slash(entry)] + paths[`${name}/*`] = [ + slash(join(dirname(entry), '*')), + slash(join(dir, '*')), + ] } workspaceEntries.set(workspaceRoot, paths) return paths @@ -195,6 +204,10 @@ function packageName(packageDir: string): string | undefined { } } +// TypeScript normalizes every path it hands back to forward slashes, so the +// virtual files and path mappings are keyed the same way on Windows. +const slash = (path: string) => path.replace(/\\/g, '/') + function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value) } @@ -257,10 +270,10 @@ export function checkSkillBlocks( `no type entry found for ${library} in ${relative(root, packageDir) || '.'}`, ) - const virtualDir = join(root, '.intent', 'skill-examples') + const virtualDir = slash(join(root, '.intent', 'skill-examples')) const virtual = new Map() blocks.forEach((block, index) => - virtual.set(join(virtualDir, `block-${index}.tsx`), block), + virtual.set(`${virtualDir}/block-${index}.tsx`, block), ) const compilerOptions: TS.CompilerOptions = { noEmit: true, @@ -276,17 +289,16 @@ export function checkSkillBlocks( moduleResolution: ts.ModuleResolutionKind.Bundler, jsx: ts.JsxEmit.Preserve, lib: ['lib.esnext.d.ts', 'lib.dom.d.ts'], - baseUrl: root, paths: { ...workspacePaths(root), // A skill documenting another package (metadata.library) resolves that // package through the workspace or node_modules, not this package's entry. ...(ownsLibrary ? { - [library]: [entry!], + [library]: [slash(entry!)], [`${library}/*`]: [ - join(dirname(entry!), '*'), - join(packageDir, '*'), + slash(join(dirname(entry!), '*')), + slash(join(packageDir, '*')), ], } : {}), @@ -314,7 +326,10 @@ export function checkSkillBlocks( if (!source) continue const at = (position: number) => block.line + source.getLineAndCharacterOfPosition(position).line - for (const diagnostic of program.getSemanticDiagnostics(source)) { + for (const diagnostic of [ + ...program.getSyntacticDiagnostics(source), + ...program.getSemanticDiagnostics(source), + ]) { if (partialSnippetCodes.has(diagnostic.code)) continue const message = ts.flattenDiagnosticMessageText( diagnostic.messageText, diff --git a/packages/intent/tests/validate-blocks.test.ts b/packages/intent/tests/validate-blocks.test.ts index 4e6e0079..be70729b 100644 --- a/packages/intent/tests/validate-blocks.test.ts +++ b/packages/intent/tests/validate-blocks.test.ts @@ -95,6 +95,10 @@ it('reports a removed option, a missing export, and a broken example with the sk 'const count: number = "three"', '```', '', + '```ts', + 'const broken = {', + '```', + '', ].join('\n'), ) const findings = check().findings @@ -114,6 +118,12 @@ it('reports a removed option, a missing export, and a broken example with the sk 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/), + }), ]) }) @@ -316,8 +326,8 @@ it('fails validate on a broken example and reports compile status on pending rev "```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')).toMatch( - /skills\/retries\/SKILL\.md:10: TS2322/, + 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", From 2667fddfdce069c56df2d57c6c82367a06f2dde9 Mon Sep 17 00:00:00 2001 From: Sarah Gerrard Date: Sat, 12 Sep 2026 10:06:19 -0700 Subject: [PATCH 10/15] test: give every fixture commit an author so the suite passes without a global Git identity --- packages/intent/tests/validate-blocks.test.ts | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/packages/intent/tests/validate-blocks.test.ts b/packages/intent/tests/validate-blocks.test.ts index be70729b..dc4eb0db 100644 --- a/packages/intent/tests/validate-blocks.test.ts +++ b/packages/intent/tests/validate-blocks.test.ts @@ -359,10 +359,18 @@ it('fails validate on a broken example and reports compile status on pending rev ) execFileSync( 'git', - ['-c', 'core.fsmonitor=false', 'commit', '-qam', 'add delay'], - { - cwd: root, - }, + [ + '-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) From 1c518b18b2d007f7d9f2b2bdb2c60dedb8cf626d Mon Sep 17 00:00:00 2001 From: Sarah Gerrard Date: Sat, 12 Sep 2026 11:05:58 -0700 Subject: [PATCH 11/15] perf: group skills by library in one pass instead of re-spreading per skill --- packages/intent/src/commands/validate.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/intent/src/commands/validate.ts b/packages/intent/src/commands/validate.ts index 612483fd..d7407a8a 100644 --- a/packages/intent/src/commands/validate.ts +++ b/packages/intent/src/commands/validate.ts @@ -644,13 +644,12 @@ async function runValidateCommandInternal( } catch { packageName = undefined } - const byLibrary = new Map() + const byLibrary: Record = {} for (const skill of checkedSkills) { const library = skill.library ?? packageName - if (!library) continue - byLibrary.set(library, [...(byLibrary.get(library) ?? []), skill]) + if (library) (byLibrary[library] ??= []).push(skill) } - for (const [library, skills] of byLibrary) { + for (const [library, skills] of Object.entries(byLibrary)) { const result = checkSkillBlocks({ root: process.cwd(), packageDir: validateContext.packageRoot, From 43a1c393b3ac5eb1a8175c3a53f0a28ff5ed1a2a Mon Sep 17 00:00:00 2001 From: ladybluenotes Date: Sat, 12 Sep 2026 20:19:38 -0700 Subject: [PATCH 12/15] fix: give each virtual example file the extension of its fence language --- packages/intent/src/validate/blocks.ts | 13 +++++++++++-- packages/intent/tests/validate-blocks.test.ts | 7 +++++++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/packages/intent/src/validate/blocks.ts b/packages/intent/src/validate/blocks.ts index 3c936ec7..c9c54501 100644 --- a/packages/intent/src/validate/blocks.ts +++ b/packages/intent/src/validate/blocks.ts @@ -26,6 +26,9 @@ interface CodeBlock { file: string line: number code: string + // Virtual file extension: a plain ts block must not parse as JSX, or a + // generic arrow like (x: T) => x reads as an unclosed element. + extension: 'ts' | 'tsx' | 'js' | 'jsx' } const codeFence = @@ -67,7 +70,13 @@ function extractCodeBlocks(file: string, content: string): Array { const language = match[2]!.toLowerCase() if (!checkedLanguages.has(language)) continue const line = content.slice(0, match.index).split('\n').length + 1 - blocks.push({ file, line, code: match[3]! }) + const extension = + language === 'tsx' || language === 'jsx' + ? language + : language.startsWith('j') + ? 'js' + : 'ts' + blocks.push({ file, line, code: match[3]!, extension }) } return blocks } @@ -273,7 +282,7 @@ export function checkSkillBlocks( const virtualDir = slash(join(root, '.intent', 'skill-examples')) const virtual = new Map() blocks.forEach((block, index) => - virtual.set(`${virtualDir}/block-${index}.tsx`, block), + virtual.set(`${virtualDir}/block-${index}.${block.extension}`, block), ) const compilerOptions: TS.CompilerOptions = { noEmit: true, diff --git a/packages/intent/tests/validate-blocks.test.ts b/packages/intent/tests/validate-blocks.test.ts index dc4eb0db..dc32a92f 100644 --- a/packages/intent/tests/validate-blocks.test.ts +++ b/packages/intent/tests/validate-blocks.test.ts @@ -80,6 +80,13 @@ it('accepts a partial example whose only gaps are names the snippet leaves out', expect(result.findings).toEqual([]) }) +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([]) +}) + it('reports a removed option, a missing export, and a broken example with the skill line', () => { skill( [ From 070fb8fb0235840ef5ae7222fd3cad8a9f373315 Mon Sep 17 00:00:00 2001 From: ladybluenotes Date: Sat, 12 Sep 2026 20:45:12 -0700 Subject: [PATCH 13/15] fix: validate independent examples against current source --- benchmarks/intent/startup.bench.ts | 29 ++++++- packages/intent/src/commands/validate.ts | 3 +- packages/intent/src/validate/blocks.ts | 76 ++++++------------- packages/intent/tests/validate-blocks.test.ts | 66 ++++++++++++++++ 4 files changed, 117 insertions(+), 57 deletions(-) diff --git a/benchmarks/intent/startup.bench.ts b/benchmarks/intent/startup.bench.ts index cf0159d2..0b4288bb 100644 --- a/benchmarks/intent/startup.bench.ts +++ b/benchmarks/intent/startup.bench.ts @@ -1,6 +1,9 @@ import { spawnSync } from 'node:child_process' +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' import { fileURLToPath } from 'node:url' -import { bench, describe } from 'vitest' +import { afterAll, beforeAll, bench, describe } from 'vitest' const cliPath = fileURLToPath( new URL('../../packages/intent/dist/cli.mjs', import.meta.url), @@ -11,8 +14,9 @@ const coldStartBenchOptions = { time: 3_000, } -function runNode(args: Array): void { +function runNode(args: Array, cwd?: string): void { const result = spawnSync(process.execPath, args, { + cwd, stdio: 'ignore', timeout: 10_000, }) @@ -24,6 +28,21 @@ function runNode(args: Array): void { } describe('cold start', () => { + let root: string + beforeAll(() => { + root = mkdtempSync(join(tmpdir(), 'intent-prose-startup-')) + writeFileSync( + join(root, 'package.json'), + '{"name":"prose-library","version":"1.0.0"}\n', + ) + mkdirSync(join(root, 'skills', 'guide'), { recursive: true }) + writeFileSync( + join(root, 'skills', 'guide', 'SKILL.md'), + '---\nname: guide\ndescription: Use when reading the guide.\n---\nProse-only guidance.\n', + ) + }) + afterAll(() => rmSync(root, { recursive: true, force: true })) + bench( 'empty node process (baseline)', () => { @@ -39,4 +58,10 @@ describe('cold start', () => { }, coldStartBenchOptions, ) + + bench( + 'intent validate prose-only skills', + () => runNode([cliPath, 'validate'], root), + coldStartBenchOptions, + ) }) diff --git a/packages/intent/src/commands/validate.ts b/packages/intent/src/commands/validate.ts index d7407a8a..cccaead6 100644 --- a/packages/intent/src/commands/validate.ts +++ b/packages/intent/src/commands/validate.ts @@ -644,7 +644,8 @@ async function runValidateCommandInternal( } catch { packageName = undefined } - const byLibrary: Record = {} + const byLibrary: Record = + Object.create(null) for (const skill of checkedSkills) { const library = skill.library ?? packageName if (library) (byLibrary[library] ??= []).push(skill) diff --git a/packages/intent/src/validate/blocks.ts b/packages/intent/src/validate/blocks.ts index c9c54501..1eb9ecf6 100644 --- a/packages/intent/src/validate/blocks.ts +++ b/packages/intent/src/validate/blocks.ts @@ -1,6 +1,5 @@ import { execFileSync } from 'node:child_process' -import { createHash } from 'node:crypto' -import { existsSync, readFileSync, statSync } from 'node:fs' +import { existsSync, readFileSync } from 'node:fs' import { createRequire } from 'node:module' import { dirname, join, relative, resolve } from 'node:path' import { resolveProjectContext } from '../core/project-context.js' @@ -160,12 +159,9 @@ function libraryEntry(packageDir: string): string | null { // Every workspace package mapped to its own entry, so an example that imports // a sibling package (an adapter, a framework binding) is checked against it // instead of silently resolving to nothing. -const workspaceEntries = new Map>>() function workspacePaths(root: string): Record> { const context = resolveProjectContext({ cwd: root }) const workspaceRoot = context.workspaceRoot ?? root - const cached = workspaceEntries.get(workspaceRoot) - if (cached) return cached const paths: Record> = {} for (const dir of resolveWorkspacePackages( workspaceRoot, @@ -185,7 +181,6 @@ function workspacePaths(root: string): Record> { slash(join(dir, '*')), ] } - workspaceEntries.set(workspaceRoot, paths) return paths } @@ -228,54 +223,32 @@ export function checkSkillBlocks( library: string skills: Array<{ file: string; content: string }> }, - ts: typeof TS | null = loadTypeScript(options.root), + ts?: typeof TS | null, ): SkillBlockCheck { const { root, packageDir, library } = options - // `check` validates every skill and then describes the pending ones, so a - // skill's result is kept for the rest of the process instead of building a - // second program for the same content. - const findings: Array = [] - const skills: Array<{ file: string; content: string; key: string }> = [] - let blockCount = 0 - const entry = ts ? libraryEntry(packageDir) : null - const stamp = entry ? `${entry}\0${statSync(entry).mtimeMs}` : '' - for (const skill of options.skills) { - const key = [ - packageDir, - library, - stamp, - skill.file, - digest(skill.content), - ].join('\0') - const cached = checked.get(key) - if (cached) { - findings.push(...cached.findings) - blockCount += cached.blocks - } else skills.push({ ...skill, key }) - } - for (const skill of skills) - findings.push(...checkSkillLinks(root, skill.file, skill.content)) - const blocks = skills.flatMap((skill) => + const findings = options.skills.flatMap((skill) => + checkSkillLinks(root, skill.file, skill.content), + ) + const blocks = options.skills.flatMap((skill) => extractCodeBlocks(skill.file, skill.content), ) - const remember = (skipped?: string) => { - if (!skipped) - for (const skill of skills) - checked.set(skill.key, { - blocks: blocks.filter((block) => block.file === skill.file).length, - findings: findings.filter((finding) => finding.file === skill.file), - }) - return { blocks: blockCount + blocks.length, findings, skipped } - } - if (blocks.length === 0) return remember() - if (!ts) return remember('TypeScript is not installed in this repository') + const result = (skipped?: string): SkillBlockCheck => ({ + blocks: blocks.length, + findings, + skipped, + }) + if (blocks.length === 0) return result() + // Prose and link validation do not need the TypeScript runtime. + if (ts === undefined) ts = loadTypeScript(root) + if (!ts) return result('TypeScript is not installed in this repository') if (Number(ts.versionMajorMinor.split('.')[0]) < 5) - return remember( + return result( `TypeScript ${ts.version} is installed; 5.0 or newer is required`, ) + const entry = libraryEntry(packageDir) const ownsLibrary = packageName(packageDir) === library if (ownsLibrary && !entry) - return remember( + return result( `no type entry found for ${library} in ${relative(root, packageDir) || '.'}`, ) @@ -289,12 +262,14 @@ export function checkSkillBlocks( strict: false, skipLibCheck: true, allowJs: true, - checkJs: false, + 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'], @@ -383,16 +358,9 @@ export function checkSkillBlocks( } } } - return remember() + return result() } -const checked = new Map< - string, - { blocks: number; findings: Array } ->() -const digest = (value: string) => - createHash('sha256').update(value).digest('hex') - // 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. diff --git a/packages/intent/tests/validate-blocks.test.ts b/packages/intent/tests/validate-blocks.test.ts index dc32a92f..8cde1c19 100644 --- a/packages/intent/tests/validate-blocks.test.ts +++ b/packages/intent/tests/validate-blocks.test.ts @@ -87,6 +87,26 @@ it('parses a plain ts block as TypeScript rather than TSX', () => { expect(check().findings).toEqual([]) }) +it('keeps declarations in separate examples independent', () => { + skill('```ts\nconst count = 1\n```\n\n```ts\nconst count = 2\n```\n') + expect(check().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('reports a removed option, a missing export, and a broken example with the skill line', () => { skill( [ @@ -162,6 +182,52 @@ it('warns on deprecated imports and fails broken relative links', () => { ]) }) +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.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) + }, +) + +it('checks prose links without loading TypeScript', () => { + 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.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, From 8b6874584e6c4d6ff27dd27cd325b89645efc0cb Mon Sep 17 00:00:00 2001 From: ladybluenotes Date: Sat, 12 Sep 2026 20:45:21 -0700 Subject: [PATCH 14/15] test: keep startup benchmark imports ordered --- benchmarks/intent/startup.bench.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/benchmarks/intent/startup.bench.ts b/benchmarks/intent/startup.bench.ts index 0b4288bb..e5ca6d1b 100644 --- a/benchmarks/intent/startup.bench.ts +++ b/benchmarks/intent/startup.bench.ts @@ -1,5 +1,5 @@ import { spawnSync } from 'node:child_process' -import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs' +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { fileURLToPath } from 'node:url' From 668bdab12fe3b70b596499334a78653f3949dda5 Mon Sep 17 00:00:00 2001 From: ladybluenotes Date: Sat, 12 Sep 2026 20:48:32 -0700 Subject: [PATCH 15/15] test: initialize the prose fixture before each benchmark run --- benchmarks/intent/startup.bench.ts | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/benchmarks/intent/startup.bench.ts b/benchmarks/intent/startup.bench.ts index e5ca6d1b..241f5bf5 100644 --- a/benchmarks/intent/startup.bench.ts +++ b/benchmarks/intent/startup.bench.ts @@ -28,8 +28,9 @@ function runNode(args: Array, cwd?: string): void { } describe('cold start', () => { - let root: string - beforeAll(() => { + let root: string | undefined + function setup() { + if (root) return root = mkdtempSync(join(tmpdir(), 'intent-prose-startup-')) writeFileSync( join(root, 'package.json'), @@ -40,8 +41,13 @@ describe('cold start', () => { join(root, 'skills', 'guide', 'SKILL.md'), '---\nname: guide\ndescription: Use when reading the guide.\n---\nProse-only guidance.\n', ) - }) - afterAll(() => rmSync(root, { recursive: true, force: true })) + } + function teardown() { + if (root) rmSync(root, { recursive: true, force: true }) + root = undefined + } + beforeAll(setup) + afterAll(teardown) bench( 'empty node process (baseline)', @@ -61,7 +67,10 @@ describe('cold start', () => { bench( 'intent validate prose-only skills', - () => runNode([cliPath, 'validate'], root), - coldStartBenchOptions, + () => { + setup() + runNode([cliPath, 'validate'], root) + }, + { ...coldStartBenchOptions, setup, teardown }, ) })