From d6ed0e899a9eab356aff82c4f43a5a3de669b4f2 Mon Sep 17 00:00:00 2001 From: ladybluenotes Date: Sat, 12 Sep 2026 22:57:37 -0700 Subject: [PATCH 1/4] feat: prepare conservative skill repair patches --- .changeset/maintainer-repair-patches.md | 5 + .../references/maintainer-commands.md | 2 +- packages/intent/src/cli.ts | 14 + packages/intent/src/commands/repair.ts | 87 ++++++ packages/intent/src/commands/validate.ts | 195 ++++--------- packages/intent/src/shared/patch.ts | 69 +++++ packages/intent/src/validate/blocks.ts | 89 ++++++ packages/intent/src/validate/repairs.ts | 119 ++++++++ packages/intent/tests/cli.test.ts | 11 +- .../tests/integration/packed-release.test.ts | 34 +++ packages/intent/tests/repair.test.ts | 268 ++++++++++++++++++ 11 files changed, 745 insertions(+), 148 deletions(-) create mode 100644 .changeset/maintainer-repair-patches.md create mode 100644 packages/intent/src/commands/repair.ts create mode 100644 packages/intent/src/shared/patch.ts create mode 100644 packages/intent/src/validate/repairs.ts create mode 100644 packages/intent/tests/repair.test.ts diff --git a/.changeset/maintainer-repair-patches.md b/.changeset/maintainer-repair-patches.md new file mode 100644 index 0000000..41b3bfc --- /dev/null +++ b/.changeset/maintainer-repair-patches.md @@ -0,0 +1,5 @@ +--- +'@tanstack/intent': patch +--- + +Add a lightweight `intent repair` command for unambiguous frontmatter migrations and reviewable before/after example patches. Preserve conflicting metadata and refuse alias edits that could change unrelated fields. Write mode applies only safe frontmatter changes; code-example suggestions require review and are emitted as patches without changing source files. diff --git a/packages/intent/meta/generate-skill/references/maintainer-commands.md b/packages/intent/meta/generate-skill/references/maintainer-commands.md index 9efe2ef..a494cb3 100644 --- a/packages/intent/meta/generate-skill/references/maintainer-commands.md +++ b/packages/intent/meta/generate-skill/references/maintainer-commands.md @@ -6,7 +6,7 @@ Use the repository's installed Intent command for these actions, such as `pnpm e 2. For a new task, run `intent maintainer add --domain --description --source --task `. Repeat `--source`, `--requires`, and `--task` for multiple entries; each `--task` becomes an assessed developer task in the domain map. In a monorepo, run the command from the owning package directory or pass `--package packages/` relative to the repository root. Source paths are relative to the owning package; `owner/repo:path` is relative to the repository. Use `--path /SKILL.md` for an established custom layout. To register an existing skill, supply its name, domain, package, and path; its frontmatter supplies the other fields. The command prints every file it wrote. 3. Author the skill and reconcile all three records using the procedures in this skill. The command creates a skeleton and a domain-map entry; add any tasks not supplied on the command line. Write the spec's decisions/history. Remove `` only after authoring the corresponding document. Do not remove it simply to make a check pass. To retire a registered skill, run `intent maintainer remove `; it marks the tree entry `retired` and notes it in the spec without deleting the file, and refuses while the skill is selected for distribution or required by another skill. 4. Run `intent maintainer status` to see missing work, stale metadata, and pending reviews. `--json` includes the full source-review report. For a supplied PR base, use `--base `. -5. Run `intent maintainer sync` after edits. It copies descriptions, purpose, sources, and prerequisites from registered skills into the tree, repairs the tree's record links, and includes the skill directories in existing package `files` allowlists. It preserves authored map/spec content and other manifest fields. It does not change version claims or run a package release. An absent `files` allowlist stays absent so npm's default contents are preserved; check the actual packed archive as part of the package's release checks. +5. Run `intent repair --json` when mechanical validation failures are present. Use `intent repair --write` for unambiguous frontmatter migrations; resolve conflicting values from evidence. `intent repair --patch` proposes code-preserving splits for labeled before/after examples, but inspect whether they are alternatives before applying those suggestions. The repair command does not replace validation or establish that guidance is current. Run `intent maintainer sync` after edits. It copies descriptions, purpose, sources, and prerequisites from registered skills into the tree, repairs the tree's record links, and includes the skill directories in existing package `files` allowlists. It preserves authored map/spec content and other manifest fields. It does not change version claims or run a package release. An absent `files` allowlist stays absent so npm's default contents are preserved; check the actual packed archive as part of the package's release checks. 6. Follow [source review](source-review.md) with `intent maintainer review --json`, supply justified outcomes, and record them with `intent maintainer review --record .intent/review.json`. A maintainer working in a terminal can do the same with `intent maintainer review --interactive`. The command retains the existing revision and content-fingerprint checks. Run `intent maintainer check` after recording; it exits nonzero for incomplete authoring, stale generated metadata, invalid skills, missing local prerequisites, or pending reviews. Use the same check in CI, passing the actual PR base and `--github-summary` so the reasons appear in the step summary; the workflow that `setup` copies does both. Keep unimplemented future skills in the tree with `status: planned` and retired entries with `status: retired`. They remain part of the cumulative record but do not count as implemented skills or enter package publishing configuration. An active entry with a missing file is an error to resolve, not an invitation to delete the entry. Local prerequisite slugs are checked against implemented tree entries; verify external package prerequisites and the developer task through the task-quality procedure. diff --git a/packages/intent/src/cli.ts b/packages/intent/src/cli.ts index 22f3742..7b91748 100644 --- a/packages/intent/src/cli.ts +++ b/packages/intent/src/cli.ts @@ -20,6 +20,7 @@ import type { } from './commands/maintainer.js' import type { ReviewCommandOptions } from './commands/review.js' import type { ValidateCommandOptions } from './commands/validate.js' +import type { RepairCommandOptions } from './commands/repair.js' function createCli( runtime: InstallCommandRuntime & MaintainerCommandRuntime = {}, @@ -102,6 +103,19 @@ function createCli( await runMetaCommand(name, getMetaDir()) }) + cli + .command( + 'repair [dir]', + 'Plan conservative skill repairs without full validation', + ) + .option('--write', 'Apply unambiguous frontmatter repairs') + .option('--json', 'Output the repair report as JSON') + .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) + }) + cli .command('validate [dir]', 'Validate skill files') .usage( diff --git a/packages/intent/src/commands/repair.ts b/packages/intent/src/commands/repair.ts new file mode 100644 index 0000000..8b018ae --- /dev/null +++ b/packages/intent/src/commands/repair.ts @@ -0,0 +1,87 @@ +import { existsSync, readFileSync } from 'node:fs' +import { relative, resolve } from 'node:path' +import { resolveProjectContext } from '../core/project-context.js' +import { createIntentFsCache } from '../discovery/fs-cache.js' +import { writeChanges } from '../maintainer/files.js' +import { fail } from '../shared/cli-error.js' +import { repositoryWritePath } from '../shared/write-path.js' +import { renderRepairPatch } from '../shared/patch.js' +import { planExampleRepairs } from '../validate/blocks.js' +import { planFrontmatterRepair } from '../validate/repairs.js' +import { collectDefaultSkillsDirs } from './validate.js' +import type { FileChange } from '../maintainer/files.js' + +export interface RepairCommandOptions { + write?: boolean + json?: boolean + patch?: boolean +} + +export function runRepairCommand( + dir: string | undefined, + options: RepairCommandOptions, +) { + if (options.patch && (options.write || options.json)) + fail('Cannot combine --patch with --write or --json') + const context = resolveProjectContext({ cwd: process.cwd() }) + const root = context.workspaceRoot ?? context.packageRoot ?? context.cwd + const { findSkillFiles } = createIntentFsCache() + const directories = + dir === undefined + ? collectDefaultSkillsDirs(context, findSkillFiles) + : [ + resolveProjectContext({ cwd: process.cwd(), targetPath: dir }) + .targetSkillsDir ?? resolve(dir), + ] + if (dir !== undefined && !existsSync(directories[0]!)) + fail(`Skills directory not found: ${dir}`) + const paths = [...new Set(directories.flatMap(findSkillFiles))] + if (dir !== undefined && !paths.length) fail('No SKILL.md files found') + const report = { + version: 1, + repairs: [] as Array<{ file: string; changes: Array }>, + suggestions: [] as Array<{ file: string; line: number; message: string }>, + problems: [] as Array<{ file: string; message: string }>, + } + const changes: Array = [] + const proposed: Array = [] + for (const path of paths) { + const destination = repositoryWritePath(root, path) + const file = relative(root, destination).replaceAll('\\', '/') + const source = readFileSync(destination, 'utf8') + const plan = planFrontmatterRepair(destination, source) + report.problems.push(...plan.problems.map((message) => ({ file, message }))) + if (plan.change) { + changes.push(plan.change) + report.repairs.push({ file, changes: plan.changes }) + } + const examples = planExampleRepairs(root, plan.change?.content ?? source) + report.suggestions.push( + ...examples.suggestions.map((suggestion) => ({ file, ...suggestion })), + ) + if (examples.skipped) + report.problems.push({ file, message: examples.skipped }) + if (examples.content !== source) + proposed.push({ path: destination, source, content: examples.content }) + } + if (options.write) writeChanges(root, changes) + if (options.patch) process.stdout.write(renderRepairPatch(root, proposed)) + else if (options.json) console.log(JSON.stringify(report, null, 2)) + else { + console.log( + `${report.repairs.length} file(s) ${options.write ? 'repaired' : 'with mechanical repairs'}, ${report.suggestions.length} suggestion(s), ${report.problems.length} problem(s).`, + ) + for (const repair of report.repairs) + console.log(`${repair.file}: ${repair.changes.join('; ')}`) + for (const problem of report.problems) + console.log(`${problem.file}: ${problem.message}`) + for (const suggestion of report.suggestions) + console.log( + `${suggestion.file}:${suggestion.line}: ${suggestion.message}`, + ) + } + if (report.problems.length) + fail( + 'Some repairs need assessment; resolve the reported problems and run repair again.', + ) +} diff --git a/packages/intent/src/commands/validate.ts b/packages/intent/src/commands/validate.ts index 6cc0c00..058363e 100644 --- a/packages/intent/src/commands/validate.ts +++ b/packages/intent/src/commands/validate.ts @@ -1,16 +1,18 @@ -import { - appendFileSync, - existsSync, - readFileSync, - writeFileSync, -} from 'node:fs' +import { appendFileSync, existsSync, readFileSync } from 'node:fs' import { basename, dirname, join, relative, resolve } from 'node:path' 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, summarizeSkillExamples } from '../validate/blocks.js' +import { writeChanges } from '../maintainer/files.js' +import { repositoryWritePath } from '../shared/write-path.js' +import { + agentSkillNamePattern, + planFrontmatterRepair, +} from '../validate/repairs.js' import { printWarnings } from './support.js' +import type { FileChange } from '../maintainer/files.js' import type { ProjectContext } from '../core/project-context.js' interface ValidationError { @@ -23,9 +25,8 @@ interface ValidationWarning { message: string } -interface FrontmatterFixPlan { +interface FrontmatterFixPlan extends FileChange { file: string - filePath: string changes: Array } @@ -41,8 +42,6 @@ export interface ValidateCommandOptions { setVersion?: string } -const agentSkillNamePattern = /^[a-z0-9]+(?:-[a-z0-9]+)*$/ - // The Agent Skills spec allows exactly these six top-level frontmatter keys. const specTopLevelKeys = new Set([ 'name', @@ -57,13 +56,6 @@ const specTopLevelKeys = new Set([ // structured surface is tracked separately (#161), so they are not flagged here. const intentArrayKeys = new Set(['sources', 'requires']) -const metadataScalarKeys = [ - 'type', - 'library', - 'library_version', - 'framework', -] as const - function isScalarValue(value: unknown): boolean { return ( typeof value === 'string' || @@ -199,113 +191,24 @@ function isRecord(value: unknown): value is Record { return !!value && typeof value === 'object' && !Array.isArray(value) } -function collectFrontmatterFixPlan({ - filePath, - fm, - rel, -}: { - filePath: string - fm: Record - rel: string -}): FrontmatterFixPlan | null { - const changes: Array = [] - const parentDir = basename(dirname(filePath)) - - if ( - typeof fm.name === 'string' && - (fm.name.includes('/') || fm.name !== parentDir) && - agentSkillNamePattern.test(parentDir) - ) { - changes.push(`rewrite name to "${parentDir}"`) - } - - const metadata = fm.metadata - const canMoveMetadata = metadata === undefined || isRecord(metadata) - if (canMoveMetadata) { - const metadataRecord = isRecord(metadata) ? metadata : undefined - for (const key of metadataScalarKeys) { - if (typeof fm[key] !== 'string') continue - - if (metadataRecord && key in metadataRecord) { - changes.push( - `remove top-level "${key}"; metadata.${key} already exists`, - ) - } else { - changes.push(`move top-level "${key}" under metadata.${key}`) - } - } - } - - return changes.length > 0 ? { file: rel, filePath, changes } : null -} - function normalizeLineEndings(value: string, lineEnding: string): string { return lineEnding === '\r\n' ? value.replace(/\r?\n/g, '\r\n') : value } -async function applyFrontmatterFixes( - fixPlans: Array, -): Promise { - const { parseDocument } = await import('yaml') - - for (const plan of fixPlans) { - const content = readFileSync(plan.filePath, 'utf8') - const match = content.match( - /^---(\r?\n)([\s\S]*?)(\r?\n)---(\r?\n?)([\s\S]*)/, - ) - if (!match) continue - - const openingLineEnding = match[1] - const frontmatter = match[2] - const closingLineEnding = match[3] - const afterClose = match[4] - const body = match[5] - if ( - openingLineEnding === undefined || - frontmatter === undefined || - closingLineEnding === undefined || - afterClose === undefined || - body === undefined - ) { - continue - } - - const doc = parseDocument(frontmatter) - if (doc.errors.length > 0) continue - - const fm = doc.toJS() as Record - const parentDir = basename(dirname(plan.filePath)) - - if ( - typeof fm.name === 'string' && - (fm.name.includes('/') || fm.name !== parentDir) && - agentSkillNamePattern.test(parentDir) - ) { - doc.set('name', parentDir) - } - - const metadata = fm.metadata - const canMoveMetadata = metadata === undefined || isRecord(metadata) - if (canMoveMetadata) { - for (const key of metadataScalarKeys) { - const value = fm[key] - if (typeof value !== 'string') continue - - if (!doc.hasIn(['metadata', key])) { - const valueNode = doc.get(key, true) - doc.setIn(['metadata', key], valueNode ?? value) - } - doc.delete(key) - } - } +function repairRoot(): string { + const context = resolveProjectContext({ cwd: process.cwd() }) + return context.workspaceRoot ?? context.packageRoot ?? context.cwd +} - const nextFrontmatter = normalizeLineEndings( - doc.toString().replace(/\r?\n$/, ''), - openingLineEnding, - ) - const nextContent = `---${openingLineEnding}${nextFrontmatter}${closingLineEnding}---${afterClose}${body}` - writeFileSync(plan.filePath, nextContent) - } +function applyFrontmatterFixes(plans: Array): void { + const root = repairRoot() + writeChanges( + root, + plans.map((plan) => ({ + ...plan, + path: repositoryWritePath(root, plan.path), + })), + ) } async function applySetVersion( @@ -313,9 +216,12 @@ async function applySetVersion( version: string, ): Promise { const { parseDocument } = await import('yaml') + const root = repairRoot() + const changes: Array = [] for (const plan of plans) { - const content = readFileSync(plan.filePath, 'utf8') + const path = repositoryWritePath(root, plan.filePath) + const content = readFileSync(path, 'utf8') const match = content.match( /^---(\r?\n)([\s\S]*?)(\r?\n)---(\r?\n?)([\s\S]*)/, ) @@ -346,8 +252,9 @@ async function applySetVersion( openingLineEnding, ) const nextContent = `---${openingLineEnding}${nextFrontmatter}${closingLineEnding}---${afterClose}${body}` - writeFileSync(plan.filePath, nextContent) + changes.push({ path, source: content, content: nextContent }) } + writeChanges(root, changes) } function collectAgentSkillSpecWarnings({ @@ -544,11 +451,17 @@ async function runValidateCommandInternal( continue } - const fixPlan = collectFrontmatterFixPlan({ filePath, fm, rel }) - if (fixPlan) fixPlans.push(fixPlan) + if (!isRecord(fm)) { + errors.push({ file: rel, message: 'Frontmatter must be a mapping' }) + continue + } + const repair = planFrontmatterRepair(filePath, content) + if (repair.change) + fixPlans.push({ ...repair.change, file: rel, changes: repair.changes }) + for (const message of repair.problems) errors.push({ file: rel, message }) // Only target files whose metadata is a mapping (or absent); a - // non-mapping metadata scalar is rejected by validation below, and + // non-mapping metadata scalar is rejected by the repair planner, and // setIn cannot safely graft a key onto it. if (options.setVersion !== undefined) { const meta = fm.metadata @@ -609,20 +522,11 @@ async function runValidateCommandInternal( } } - if (fm.metadata !== undefined) { - if (!isRecord(fm.metadata)) { - errors.push({ - file: rel, - message: 'metadata must be a mapping', - }) - } else if ( - Object.values(fm.metadata).some((value) => typeof value !== 'string') - ) { - errors.push({ - file: rel, - message: 'metadata values must be strings', - }) - } + if ( + isRecord(fm.metadata) && + Object.values(fm.metadata).some((value) => typeof value !== 'string') + ) { + errors.push({ file: rel, message: 'metadata values must be strings' }) } if (typeof fm.description === 'string' && fm.description.length > 1024) { @@ -768,16 +672,21 @@ async function runValidateCommandInternal( const willFix = options.fix === true && fixPlans.length > 0 if (willSetVersion || willFix) { + if (willFix && willSetVersion) { + const root = repairRoot() + for (const plan of setVersionPlans) + repositoryWritePath(root, plan.filePath) + } + if (willFix) { + applyFrontmatterFixes(fixPlans) + console.log(`✅ Fixed ${fixPlans.length} skill files`) + } if (willSetVersion) { await applySetVersion(setVersionPlans, options.setVersion!) console.log( `✅ Set library_version to "${options.setVersion}" on ${setVersionPlans.length} skill files`, ) } - if (willFix) { - await applyFrontmatterFixes(fixPlans) - console.log(`✅ Fixed ${fixPlans.length} skill files`) - } await runValidateCommandInternal( dir, { @@ -846,7 +755,7 @@ function writeGithubValidationSummary({ appendFileSync(summaryPath, lines.join('\n')) } -function collectDefaultSkillsDirs( +export function collectDefaultSkillsDirs( context: ProjectContext, findSkillFiles: (dir: string) => Array, ): Array { diff --git a/packages/intent/src/shared/patch.ts b/packages/intent/src/shared/patch.ts new file mode 100644 index 0000000..0431fba --- /dev/null +++ b/packages/intent/src/shared/patch.ts @@ -0,0 +1,69 @@ +import { spawnSync } from 'node:child_process' +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { devNull, tmpdir } from 'node:os' +import { dirname, join, relative } from 'node:path' +import { repositoryWritePath } from './write-path.js' +import type { FileChange } from '../maintainer/files.js' + +// Let Git encode paths, hunk ranges, and newline markers. The temporary index +// contains only supplied snapshots; user/repository filters and hooks cannot run. +export function renderRepairPatch( + root: string, + changes: Array, +): string { + if (!changes.length) return '' + const temporary = mkdtempSync(join(tmpdir(), 'intent-repair-patch-')) + const git = (args: Array, diff = false) => { + const result = spawnSync( + 'git', + [ + '-c', + 'core.fsmonitor=false', + '-c', + 'core.autocrlf=false', + '-c', + `core.attributesFile=${devNull}`, + ...args, + ], + { + cwd: temporary, + env: { + PATH: process.env.PATH, + SystemRoot: process.env.SystemRoot, + GIT_CONFIG_NOSYSTEM: '1', + GIT_CONFIG_GLOBAL: devNull, + GIT_ATTR_NOSYSTEM: '1', + GIT_CONFIG_COUNT: '0', + }, + encoding: 'utf8', + timeout: 10_000, + maxBuffer: 16 * 1024 * 1024, + }, + ) + if (result.error || (result.status !== 0 && !(diff && result.status === 1))) + throw new Error( + `Cannot create repair patch: ${result.error?.message ?? result.stderr}`, + ) + return result.stdout + } + try { + const paths = changes.map((change) => + relative(root, repositoryWritePath(root, change.path)), + ) + git(['-c', `init.templateDir=${devNull}`, 'init', '-q']) + for (const [index, change] of changes.entries()) { + const path = join(temporary, paths[index]!) + mkdirSync(dirname(path), { recursive: true }) + writeFileSync(path, change.source) + } + git(['add', '--', '.']) + for (const [index, change] of changes.entries()) + writeFileSync(join(temporary, paths[index]!), change.content) + return git( + ['diff', '--no-ext-diff', '--no-textconv', '--binary', '--exit-code'], + true, + ) + } finally { + rmSync(temporary, { recursive: true, force: true }) + } +} diff --git a/packages/intent/src/validate/blocks.ts b/packages/intent/src/validate/blocks.ts index a5e732a..8ed344d 100644 --- a/packages/intent/src/validate/blocks.ts +++ b/packages/intent/src/validate/blocks.ts @@ -117,6 +117,95 @@ function extractCodeBlocks(file: string, content: string): Array { return blocks } +// 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 }> = [] + 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) + ) + 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.', + } + const extension = + fence.language === 'tsx' || fence.language === 'jsx' + ? fence.language + : fence.language.startsWith('j') + ? '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 label = /^\/\/\s*(BEFORE|AFTER)\b[^\n]*$/i.exec( + fence.code.slice(comment.pos, comment.end), + ) + const lineStart = fence.code.lastIndexOf('\n', comment.pos - 1) + 1 + return label && !fence.code.slice(lineStart, comment.pos).trim() + ? [{ label: label[1]!.toUpperCase(), pos: lineStart }] + : [] + }, + ), + ) + if ( + markers.length !== 2 || + markers[0]!.label !== 'BEFORE' || + markers[1]!.label !== 'AFTER' || + fence.code.slice(0, markers[0]!.pos).trim() + ) + 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 + 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' + const closing = lines[fence.end]!.replace(/\r?\n$/, '') + lines.splice(at, 0, `${closing}${eol}${eol}${lines[fence.start]}`) + suggestions.unshift({ + line: at + 1, + message: + 'Review splitting the labeled BEFORE/AFTER alternatives into separate code fences.', + }) + } + return { content: lines.join(''), suggestions } +} + function checkSkillLinks( root: string, file: string, diff --git a/packages/intent/src/validate/repairs.ts b/packages/intent/src/validate/repairs.ts new file mode 100644 index 0000000..6506098 --- /dev/null +++ b/packages/intent/src/validate/repairs.ts @@ -0,0 +1,119 @@ +import { basename, dirname } from 'node:path' +import { isDeepStrictEqual } from 'node:util' +import { parseDocument } from 'yaml' +import type { FileChange } from '../maintainer/files.js' + +export const agentSkillNamePattern = /^[a-z0-9]+(?:-[a-z0-9]+)*$/ +const metadataKeys = [ + 'type', + 'library', + 'library_version', + 'framework', +] as const + +export function planFrontmatterRepair(path: string, source: string) { + const changes: Array = [] + const problems: Array = [] + const match = source.match( + /^---(\r?\n)([\s\S]*?)(\r?\n)---(\r?\n?)([\s\S]*)$/, + ) + if (!match) return { changes, problems: ['Missing or invalid frontmatter'] } + const [, opening, frontmatter, closing, afterClose, body] = match + const document = parseDocument(frontmatter!) + if (document.errors.length) + return { + changes, + problems: document.errors.map( + (error) => `Invalid YAML: ${error.message}`, + ), + } + let fields: unknown + try { + fields = document.toJS() + } catch (error) { + return { + changes, + problems: [ + `Invalid YAML: ${error instanceof Error ? error.message : String(error)}`, + ], + } + } + if (!fields || typeof fields !== 'object' || Array.isArray(fields)) + return { changes, problems: ['Frontmatter must be a mapping'] } + const fm = fields as Record + const expected = { ...fm } + const metadata = fm.metadata + const canMove = + metadata === undefined || + (!!metadata && typeof metadata === 'object' && !Array.isArray(metadata)) + if (canMove) { + for (const key of metadataKeys) { + if ( + typeof fm[key] === 'string' && + document.hasIn(['metadata', key]) && + document.getIn(['metadata', key]) !== fm[key] + ) + problems.push( + `Conflicting values for "${key}" and "metadata.${key}"; choose the correct value before fixing.`, + ) + } + // Never choose between conflicting values, including while fixing another + // field in the same file. The maintainer must resolve the conflict first. + if (problems.length) return { changes, problems } + } else problems.push('metadata must be a mapping') + + let next: string + try { + const parent = basename(dirname(path)) + if ( + typeof fm.name === 'string' && + fm.name !== parent && + parent.length <= 64 && + agentSkillNamePattern.test(parent) + ) { + document.set('name', parent) + expected.name = parent + changes.push(`rewrite name to "${parent}"`) + } + if (canMove) { + const expectedMetadata = { + ...(metadata as Record | undefined), + } + for (const key of metadataKeys) { + if (typeof fm[key] !== 'string') continue + if (document.hasIn(['metadata', key])) { + changes.push( + `remove duplicate top-level "${key}"; metadata.${key} has the same value`, + ) + } else { + document.setIn(['metadata', key], document.get(key, true)) + changes.push(`move top-level "${key}" under metadata.${key}`) + } + document.delete(key) + expectedMetadata[key] = fm[key] + expected.metadata = expectedMetadata + delete expected[key] + } + } + if (!changes.length) return { changes, problems } + next = document.toString().replace(/\r?\n$/, '') + const parsed = parseDocument(next) + if (parsed.errors.length || !isDeepStrictEqual(parsed.toJS(), expected)) + throw new Error('Unexpected frontmatter change') + } catch { + return { + changes: [], + problems: [ + ...problems, + 'Repair cannot preserve unrelated frontmatter values and aliases; review this migration manually.', + ], + } + } + if (opening === '\r\n') next = next.replace(/\r?\n/g, '\r\n') + const change: FileChange = { + path, + source, + content: `---${opening}${next}${closing}---${afterClose}${body}`, + } + return { changes, problems, change } +} diff --git a/packages/intent/tests/cli.test.ts b/packages/intent/tests/cli.test.ts index d86c37a..01c6f6a 100644 --- a/packages/intent/tests/cli.test.ts +++ b/packages/intent/tests/cli.test.ts @@ -2635,7 +2635,7 @@ describe('cli commands', () => { expect(fixed).toContain('\nSkill content here.\n') }) - it('keeps existing metadata values when removing conflicting top-level scalars', async () => { + it('refuses conflicting frontmatter migrations without discarding either value', async () => { const root = mkdtempSync( join(realTmpdir, 'intent-cli-validate-fix-conflict-'), ) @@ -2660,13 +2660,16 @@ describe('cli commands', () => { ) process.chdir(root) + const original = readFileSync(skillPath, 'utf8') const exitCode = await main(['validate', '--fix']) const fixed = readFileSync(skillPath, 'utf8') - expect(exitCode).toBe(0) - expect(fixed).toContain('metadata:\n library: nested') - expect(fixed).not.toContain('\nlibrary: top') + expect(exitCode).toBe(1) + expect(fixed).toBe(original) + expect(errorSpy.mock.calls.flat().join('\n')).toContain( + 'Conflicting values for "library" and "metadata.library"', + ) }) it('fixes names while leaving scalar migrations blocked by non-mapping metadata', async () => { diff --git a/packages/intent/tests/integration/packed-release.test.ts b/packages/intent/tests/integration/packed-release.test.ts index 209c670..1499374 100644 --- a/packages/intent/tests/integration/packed-release.test.ts +++ b/packages/intent/tests/integration/packed-release.test.ts @@ -395,6 +395,40 @@ and prints the selected skill's frontmatter and body. expect(existsSync(join(cwd, '_artifacts'))).toBe(false) }) + it('prepares and applies mechanical repairs with the packed CLI', () => { + const skillDir = join(cwd, 'skills', 'with spaces', 'client') + mkdirSync(skillDir, { recursive: true }) + const path = join(skillDir, 'SKILL.md') + const original = + '---\nname: client\ndescription: Use the client.\nlibrary: consumer\n---\n\nKeep this body unchanged.\n' + writeFileSync(path, original) + const patch = run(['repair', '--patch']) + expect(patch.status, patch.stderr).toBe(0) + expect(readFileSync(path, 'utf8')).toBe(original) + expect( + spawnSync( + 'git', + ['-c', 'core.fsmonitor=false', 'apply', '--check', '-'], + { + cwd, + input: patch.stdout, + encoding: 'utf8', + timeout, + }, + ).status, + ).toBe(0) + const repaired = run(['repair', '--write', '--json']) + expect(repaired.status, repaired.stderr).toBe(0) + expect(JSON.parse(repaired.stdout).repairs).toHaveLength(1) + expect(readFileSync(path, 'utf8')).toContain( + 'metadata:\n library: consumer', + ) + const second = run(['repair', '--patch']) + expect(second.status, second.stderr).toBe(0) + expect(second.stdout).toBe('') + expect(existsSync(join(cwd, '.intent', 'review-state.json'))).toBe(false) + }) + it('routes local and generated review reports to the shipped focused procedure', () => { writeFileSync( join(cwd, 'package.json'), diff --git a/packages/intent/tests/repair.test.ts b/packages/intent/tests/repair.test.ts new file mode 100644 index 0000000..a7daa85 --- /dev/null +++ b/packages/intent/tests/repair.test.ts @@ -0,0 +1,268 @@ +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + symlinkSync, + writeFileSync, +} from 'node:fs' +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 { parse } from 'yaml' +import { main } from '../src/cli.js' +import { planExampleRepairs } from '../src/validate/blocks.js' + +let root: string +let previousCwd: string + +function write(file: string, content: string) { + mkdirSync(dirname(join(root, file)), { recursive: true }) + writeFileSync(join(root, file), content) +} + +beforeEach(() => { + previousCwd = process.cwd() + root = mkdtempSync(join(tmpdir(), 'intent-repair-')) + process.chdir(root) + write('package.json', '{"name":"@acme/client","version":"1.0.0"}\n') + vi.spyOn(console, 'log').mockImplementation(() => {}) + vi.spyOn(console, 'error').mockImplementation(() => {}) +}) + +afterEach(() => { + process.chdir(previousCwd) + vi.restoreAllMocks() + rmSync(root, { recursive: true, force: true }) +}) + +it('repairs frontmatter without rewriting or fully validating code examples', async () => { + const body = + '\nUse the client.\n\n```ts\nconst = intentionallyIncomplete\n```\n' + write( + 'skills/client/SKILL.md', + `---\nname: client\ndescription: Use the client.\nlibrary: "@acme/client"\n---\n${body}`, + ) + + expect(await main(['repair', '--write', '--json'])).toBe(0) + + const result = JSON.parse(vi.mocked(console.log).mock.calls.at(-1)![0]) + expect(result.repairs).toEqual([ + { + file: 'skills/client/SKILL.md', + changes: ['move top-level "library" under metadata.library'], + }, + ]) + expect(result.problems).toEqual([]) + const repaired = readFileSync(join(root, 'skills/client/SKILL.md'), 'utf8') + const match = repaired.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/)! + expect(parse(match[1]!)).toEqual({ + name: 'client', + description: 'Use the client.', + metadata: { library: '@acme/client' }, + }) + expect(match[2]).toBe(body) +}) + +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' + const safe = + '---\nname: other\ndescription: Other task.\ntype: core\nmetadata:\n type: core\n---\nBody\n' + write('skills/client/SKILL.md', conflicted) + write('skills/other/SKILL.md', safe) + expect(await main(['repair', '--json'])).toBe(1) + expect(readFileSync(join(root, 'skills/other/SKILL.md'), 'utf8')).toBe(safe) + expect(await main(['repair', '--write', '--json'])).toBe(1) + expect(readFileSync(join(root, 'skills/client/SKILL.md'), 'utf8')).toBe( + conflicted, + ) + const repaired = readFileSync(join(root, 'skills/other/SKILL.md'), 'utf8') + expect(repaired).not.toContain('\ntype: core') + expect(repaired).toContain(' type: core') + expect(await main(['repair', '--write', '--json'])).toBe(1) + expect(readFileSync(join(root, 'skills/other/SKILL.md'), 'utf8')).toBe( + repaired, + ) +}) + +it('preflights every write and leaves outside files and earlier safe files unchanged', async () => { + const outside = mkdtempSync(join(tmpdir(), 'intent-repair-outside-')) + const source = + '---\nname: client\ndescription: Use client.\nlibrary: client\n---\nBody\n' + try { + write('skills/a/SKILL.md', source) + writeFileSync(join(outside, 'SKILL.md'), source) + mkdirSync(join(root, 'skills/z')) + symlinkSync( + join(outside, 'SKILL.md'), + join(root, 'skills/z/SKILL.md'), + 'file', + ) + expect(await main(['repair', '--write'])).toBe(1) + expect(readFileSync(join(root, 'skills/a/SKILL.md'), 'utf8')).toBe(source) + expect(readFileSync(join(outside, 'SKILL.md'), 'utf8')).toBe(source) + } finally { + rmSync(outside, { recursive: true, force: true }) + } +}) + +it.each([ + 'null', + '[one, two]', + 'metadata: [invalid]', + 'metadata:\n library: a\n library: b', +])('reports malformed frontmatter without changing it: %s', async (fields) => { + const source = `---\n${fields}\n---\nBody\n` + write('skills/client/SKILL.md', source) + expect(await main(['repair', '--write', '--json'])).toBe(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', + (content) => { + expect(planExampleRepairs(root, content)).toMatchObject({ + content, + suggestions: [], + }) + }, +) + +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(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('requires a single output mode and prints no patch when no repairs are available', async () => { + for (const option of ['--json', '--write']) + expect(await main(['repair', '--patch', option])).toBe(1) + const stdout = vi + .spyOn(process.stdout, 'write') + .mockImplementation(() => true) + expect(await main(['repair', '--patch'])).toBe(0) + expect(stdout).toHaveBeenCalledWith('') +}) + +it('preserves an explicit validate version update when combined with migration', async () => { + write( + 'skills/client/SKILL.md', + '---\nname: client\ndescription: Use client.\nlibrary_version: "1.0.0"\n---\nBody\n', + ) + expect(await main(['validate', '--fix', '--set-version', '2.0.0'])).toBe(0) + const fields = parse( + readFileSync(join(root, 'skills/client/SKILL.md'), 'utf8').match( + /^---\n([\s\S]*?)\n---/, + )![1]!, + ) + expect(fields.metadata.library_version).toBe('2.0.0') + expect(fields.library_version).toBeUndefined() +}) + +it('preflights the version-update destinations before applying combined fixes', async () => { + const outside = mkdtempSync(join(tmpdir(), 'intent-version-outside-')) + const source = + '---\nname: client\ndescription: Use client.\nlibrary: client\n---\nBody\n' + const external = + '---\nname: versioned\ndescription: Versioned.\nmetadata:\n library_version: "1.0.0"\n---\nBody\n' + try { + write('skills/client/SKILL.md', source) + mkdirSync(join(root, 'skills/versioned')) + writeFileSync(join(outside, 'SKILL.md'), external) + symlinkSync( + join(outside, 'SKILL.md'), + join(root, 'skills/versioned/SKILL.md'), + 'file', + ) + expect(await main(['validate', '--fix', '--set-version', '2.0.0'])).toBe(1) + expect(readFileSync(join(root, 'skills/client/SKILL.md'), 'utf8')).toBe( + source, + ) + expect(readFileSync(join(outside, 'SKILL.md'), 'utf8')).toBe(external) + } finally { + rmSync(outside, { recursive: true, force: true }) + } +}) + +it.each([ + '---\nname: &identity wrong-name\ndescription: Use client.\nmetadata:\n library: *identity\n---\nBody\n', + '---\nname: client\ndescription: Use client.\ndefaults: &values { library_version: 1.0.0 }\nmetadata: *values\nlibrary: acme\n---\nBody\n', +])( + 'reports YAML alias repairs that cannot preserve other fields', + async (source) => { + write('skills/client/SKILL.md', source) + expect(await main(['repair', '--write', '--json'])).toBe(1) + expect(readFileSync(join(root, 'skills/client/SKILL.md'), 'utf8')).toBe( + source, + ) + const report = JSON.parse(vi.mocked(console.log).mock.calls.at(-1)![0]) + expect(report.repairs).toEqual([]) + expect(report.problems[0].message).toContain('unrelated frontmatter values') + }, +) From a7d15359ca9e215dcfbe3371206cf1d7dff42ba1 Mon Sep 17 00:00:00 2001 From: ladybluenotes Date: Sat, 12 Sep 2026 23:20:00 -0700 Subject: [PATCH 2/4] perf: reuse frontmatter parsing during validation --- packages/intent/src/commands/validate.ts | 20 +++----------------- packages/intent/src/validate/repairs.ts | 11 ++++++----- 2 files changed, 9 insertions(+), 22 deletions(-) diff --git a/packages/intent/src/commands/validate.ts b/packages/intent/src/commands/validate.ts index 058363e..a62f4d3 100644 --- a/packages/intent/src/commands/validate.ts +++ b/packages/intent/src/commands/validate.ts @@ -439,26 +439,12 @@ async function runValidateCommandInternal( continue } - let fm: Record - try { - fm = parseYaml(match[1]) as Record - } catch (err) { - const detail = err instanceof Error ? err.message : String(err) - errors.push({ - file: rel, - message: `Invalid YAML frontmatter: ${detail}`, - }) - continue - } - - if (!isRecord(fm)) { - errors.push({ file: rel, message: 'Frontmatter must be a mapping' }) - continue - } const repair = planFrontmatterRepair(filePath, content) + for (const message of repair.problems) errors.push({ file: rel, message }) + const fm = repair.fields + if (!fm) continue if (repair.change) fixPlans.push({ ...repair.change, file: rel, changes: repair.changes }) - for (const message of repair.problems) errors.push({ file: rel, message }) // Only target files whose metadata is a mapping (or absent); a // non-mapping metadata scalar is rejected by the repair planner, and diff --git a/packages/intent/src/validate/repairs.ts b/packages/intent/src/validate/repairs.ts index 6506098..e383191 100644 --- a/packages/intent/src/validate/repairs.ts +++ b/packages/intent/src/validate/repairs.ts @@ -24,7 +24,7 @@ export function planFrontmatterRepair(path: string, source: string) { return { changes, problems: document.errors.map( - (error) => `Invalid YAML: ${error.message}`, + (error) => `Invalid YAML frontmatter: ${error.message}`, ), } let fields: unknown @@ -34,7 +34,7 @@ export function planFrontmatterRepair(path: string, source: string) { return { changes, problems: [ - `Invalid YAML: ${error instanceof Error ? error.message : String(error)}`, + `Invalid YAML frontmatter: ${error instanceof Error ? error.message : String(error)}`, ], } } @@ -59,7 +59,7 @@ export function planFrontmatterRepair(path: string, source: string) { } // Never choose between conflicting values, including while fixing another // field in the same file. The maintainer must resolve the conflict first. - if (problems.length) return { changes, problems } + if (problems.length) return { fields: fm, changes, problems } } else problems.push('metadata must be a mapping') let next: string @@ -95,13 +95,14 @@ export function planFrontmatterRepair(path: string, source: string) { delete expected[key] } } - if (!changes.length) return { changes, problems } + if (!changes.length) return { fields: fm, changes, problems } next = document.toString().replace(/\r?\n$/, '') const parsed = parseDocument(next) if (parsed.errors.length || !isDeepStrictEqual(parsed.toJS(), expected)) throw new Error('Unexpected frontmatter change') } catch { return { + fields: fm, changes: [], problems: [ ...problems, @@ -115,5 +116,5 @@ export function planFrontmatterRepair(path: string, source: string) { source, content: `---${opening}${next}${closing}---${afterClose}${body}`, } - return { changes, problems, change } + return { fields: fm, changes, problems, change } } From 9df438058584b705b555c3f5b620321c70823ae8 Mon Sep 17 00:00:00 2001 From: Sarah Gerrard Date: Sun, 13 Sep 2026 12:18:24 -0700 Subject: [PATCH 3/4] fix: keep long frontmatter values on one line when repairing --- packages/intent/src/validate/repairs.ts | 2 +- packages/intent/tests/repair.test.ts | 19 +++++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/packages/intent/src/validate/repairs.ts b/packages/intent/src/validate/repairs.ts index e383191..54d3fe0 100644 --- a/packages/intent/src/validate/repairs.ts +++ b/packages/intent/src/validate/repairs.ts @@ -96,7 +96,7 @@ export function planFrontmatterRepair(path: string, source: string) { } } if (!changes.length) return { fields: fm, changes, problems } - next = document.toString().replace(/\r?\n$/, '') + next = document.toString({ lineWidth: 0 }).replace(/\r?\n$/, '') const parsed = parseDocument(next) if (parsed.errors.length || !isDeepStrictEqual(parsed.toJS(), expected)) throw new Error('Unexpected frontmatter change') diff --git a/packages/intent/tests/repair.test.ts b/packages/intent/tests/repair.test.ts index a7daa85..beea1e4 100644 --- a/packages/intent/tests/repair.test.ts +++ b/packages/intent/tests/repair.test.ts @@ -66,6 +66,25 @@ it('repairs frontmatter without rewriting or fully validating code examples', as expect(match[2]).toBe(body) }) +it('keeps long frontmatter values on one line when repairing', async () => { + const description = + 'Use when the client needs a long activation sentence that runs well past the default eighty-column YAML fold width.' + write( + 'skills/client/SKILL.md', + `---\nname: client\ndescription: ${description}\nlibrary: "@acme/client"\n---\nBody\n`, + ) + + expect(await main(['repair', '--write', '--json'])).toBe(0) + + const repaired = readFileSync(join(root, 'skills/client/SKILL.md'), 'utf8') + expect(repaired).toContain(`\ndescription: ${description}\n`) + expect(parse(repaired.split('---')[1]!)).toEqual({ + name: 'client', + description, + metadata: { library: '@acme/client' }, + }) +}) + 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' From dd6f82b0de6eafa6ef1d9a064c71f091a207c8c3 Mon Sep 17 00:00:00 2001 From: Sarah Gerrard Date: Sun, 13 Sep 2026 12:26:28 -0700 Subject: [PATCH 4/4] fix: verify repair patches across platforms --- packages/intent/src/shared/patch.ts | 8 ++++---- packages/intent/tests/integration/packed-release.test.ts | 1 + 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/intent/src/shared/patch.ts b/packages/intent/src/shared/patch.ts index 0431fba..91e4c30 100644 --- a/packages/intent/src/shared/patch.ts +++ b/packages/intent/src/shared/patch.ts @@ -1,6 +1,6 @@ import { spawnSync } from 'node:child_process' import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' -import { devNull, tmpdir } from 'node:os' +import { tmpdir } from 'node:os' import { dirname, join, relative } from 'node:path' import { repositoryWritePath } from './write-path.js' import type { FileChange } from '../maintainer/files.js' @@ -22,7 +22,7 @@ export function renderRepairPatch( '-c', 'core.autocrlf=false', '-c', - `core.attributesFile=${devNull}`, + 'core.attributesFile=/dev/null', ...args, ], { @@ -31,7 +31,7 @@ export function renderRepairPatch( PATH: process.env.PATH, SystemRoot: process.env.SystemRoot, GIT_CONFIG_NOSYSTEM: '1', - GIT_CONFIG_GLOBAL: devNull, + GIT_CONFIG_GLOBAL: '/dev/null', GIT_ATTR_NOSYSTEM: '1', GIT_CONFIG_COUNT: '0', }, @@ -50,7 +50,7 @@ export function renderRepairPatch( const paths = changes.map((change) => relative(root, repositoryWritePath(root, change.path)), ) - git(['-c', `init.templateDir=${devNull}`, 'init', '-q']) + git(['-c', 'init.templateDir=', 'init', '-q']) for (const [index, change] of changes.entries()) { const path = join(temporary, paths[index]!) mkdirSync(dirname(path), { recursive: true }) diff --git a/packages/intent/tests/integration/packed-release.test.ts b/packages/intent/tests/integration/packed-release.test.ts index 1499374..35a2d4f 100644 --- a/packages/intent/tests/integration/packed-release.test.ts +++ b/packages/intent/tests/integration/packed-release.test.ts @@ -404,6 +404,7 @@ and prints the selected skill's frontmatter and body. writeFileSync(path, original) const patch = run(['repair', '--patch']) expect(patch.status, patch.stderr).toBe(0) + expect(patch.stdout).toContain('SKILL.md') expect(readFileSync(path, 'utf8')).toBe(original) expect( spawnSync(