UNPKG

@fission-ai/openspec

Version:

AI-native system for spec-driven development

384 lines 16.2 kB
/** * Spec Application Logic * * Extracted from ArchiveCommand to enable standalone spec application. * Applies delta specs from a change to main specs without archiving. */ import { promises as fs } from 'fs'; import path from 'path'; import chalk from 'chalk'; import { extractRequirementsSection, parseDeltaSpec, normalizeRequirementName, } from './parsers/requirement-blocks.js'; import { Validator } from './validation/validator.js'; // ----------------------------------------------------------------------------- // Public API // ----------------------------------------------------------------------------- /** * Find all delta spec files that need to be applied from a change. */ export async function findSpecUpdates(changeDir, mainSpecsDir) { const updates = []; const changeSpecsDir = path.join(changeDir, 'specs'); try { const entries = await fs.readdir(changeSpecsDir, { withFileTypes: true }); for (const entry of entries) { if (entry.isDirectory()) { const specFile = path.join(changeSpecsDir, entry.name, 'spec.md'); const targetFile = path.join(mainSpecsDir, entry.name, 'spec.md'); try { await fs.access(specFile); // Check if target exists let exists = false; try { await fs.access(targetFile); exists = true; } catch { exists = false; } updates.push({ source: specFile, target: targetFile, exists, }); } catch { // Source spec doesn't exist, skip } } } } catch { // No specs directory in change } return updates; } /** * Build an updated spec by applying delta operations. * Returns the rebuilt content and counts of operations. */ export async function buildUpdatedSpec(update, changeName) { // Read change spec content (delta-format expected) const changeContent = await fs.readFile(update.source, 'utf-8'); // Parse deltas from the change spec file const plan = parseDeltaSpec(changeContent); const specName = path.basename(path.dirname(update.target)); // Pre-validate duplicates within sections const addedNames = new Set(); for (const add of plan.added) { const name = normalizeRequirementName(add.name); if (addedNames.has(name)) { throw new Error(`${specName} validation failed - duplicate requirement in ADDED for header "### Requirement: ${add.name}"`); } addedNames.add(name); } const modifiedNames = new Set(); for (const mod of plan.modified) { const name = normalizeRequirementName(mod.name); if (modifiedNames.has(name)) { throw new Error(`${specName} validation failed - duplicate requirement in MODIFIED for header "### Requirement: ${mod.name}"`); } modifiedNames.add(name); } const removedNamesSet = new Set(); for (const rem of plan.removed) { const name = normalizeRequirementName(rem); if (removedNamesSet.has(name)) { throw new Error(`${specName} validation failed - duplicate requirement in REMOVED for header "### Requirement: ${rem}"`); } removedNamesSet.add(name); } const renamedFromSet = new Set(); const renamedToSet = new Set(); for (const { from, to } of plan.renamed) { const fromNorm = normalizeRequirementName(from); const toNorm = normalizeRequirementName(to); if (renamedFromSet.has(fromNorm)) { throw new Error(`${specName} validation failed - duplicate FROM in RENAMED for header "### Requirement: ${from}"`); } if (renamedToSet.has(toNorm)) { throw new Error(`${specName} validation failed - duplicate TO in RENAMED for header "### Requirement: ${to}"`); } renamedFromSet.add(fromNorm); renamedToSet.add(toNorm); } // Pre-validate cross-section conflicts const conflicts = []; for (const n of modifiedNames) { if (removedNamesSet.has(n)) conflicts.push({ name: n, a: 'MODIFIED', b: 'REMOVED' }); if (addedNames.has(n)) conflicts.push({ name: n, a: 'MODIFIED', b: 'ADDED' }); } for (const n of addedNames) { if (removedNamesSet.has(n)) conflicts.push({ name: n, a: 'ADDED', b: 'REMOVED' }); } // Renamed interplay: MODIFIED must reference the NEW header, not FROM for (const { from, to } of plan.renamed) { const fromNorm = normalizeRequirementName(from); const toNorm = normalizeRequirementName(to); if (modifiedNames.has(fromNorm)) { throw new Error(`${specName} validation failed - when a rename exists, MODIFIED must reference the NEW header "### Requirement: ${to}"`); } // Detect ADDED colliding with a RENAMED TO if (addedNames.has(toNorm)) { throw new Error(`${specName} validation failed - RENAMED TO header collides with ADDED for "### Requirement: ${to}"`); } } if (conflicts.length > 0) { const c = conflicts[0]; throw new Error(`${specName} validation failed - requirement present in multiple sections (${c.a} and ${c.b}) for header "### Requirement: ${c.name}"`); } const hasAnyDelta = plan.added.length + plan.modified.length + plan.removed.length + plan.renamed.length > 0; if (!hasAnyDelta) { throw new Error(`Delta parsing found no operations for ${path.basename(path.dirname(update.source))}. ` + `Provide ADDED/MODIFIED/REMOVED/RENAMED sections in change spec.`); } // Load or create base target content let targetContent; let isNewSpec = false; try { targetContent = await fs.readFile(update.target, 'utf-8'); } catch { // Target spec does not exist; MODIFIED and RENAMED are not allowed for new specs // REMOVED will be ignored with a warning since there's nothing to remove if (plan.modified.length > 0 || plan.renamed.length > 0) { throw new Error(`${specName}: target spec does not exist; only ADDED requirements are allowed for new specs. MODIFIED and RENAMED operations require an existing spec.`); } // Warn about REMOVED requirements being ignored for new specs if (plan.removed.length > 0) { console.log(chalk.yellow(`⚠️ Warning: ${specName} - ${plan.removed.length} REMOVED requirement(s) ignored for new spec (nothing to remove).`)); } isNewSpec = true; targetContent = buildSpecSkeleton(specName, changeName); } // Extract requirements section and build name->block map const parts = extractRequirementsSection(targetContent); const nameToBlock = new Map(); for (const block of parts.bodyBlocks) { nameToBlock.set(normalizeRequirementName(block.name), block); } // Apply operations in order: RENAMED → REMOVED → MODIFIED → ADDED // RENAMED for (const r of plan.renamed) { const from = normalizeRequirementName(r.from); const to = normalizeRequirementName(r.to); if (!nameToBlock.has(from)) { throw new Error(`${specName} RENAMED failed for header "### Requirement: ${r.from}" - source not found`); } if (nameToBlock.has(to)) { throw new Error(`${specName} RENAMED failed for header "### Requirement: ${r.to}" - target already exists`); } const block = nameToBlock.get(from); const newHeader = `### Requirement: ${to}`; const rawLines = block.raw.split('\n'); rawLines[0] = newHeader; const renamedBlock = { headerLine: newHeader, name: to, raw: rawLines.join('\n'), }; nameToBlock.delete(from); nameToBlock.set(to, renamedBlock); } // REMOVED for (const name of plan.removed) { const key = normalizeRequirementName(name); if (!nameToBlock.has(key)) { // For new specs, REMOVED requirements are already warned about and ignored // For existing specs, missing requirements are an error if (!isNewSpec) { throw new Error(`${specName} REMOVED failed for header "### Requirement: ${name}" - not found`); } // Skip removal for new specs (already warned above) continue; } nameToBlock.delete(key); } // MODIFIED for (const mod of plan.modified) { const key = normalizeRequirementName(mod.name); if (!nameToBlock.has(key)) { throw new Error(`${specName} MODIFIED failed for header "### Requirement: ${mod.name}" - not found`); } // Replace block with provided raw (ensure header line matches key) const modHeaderMatch = mod.raw.split('\n')[0].match(/^###\s*Requirement:\s*(.+)\s*$/); if (!modHeaderMatch || normalizeRequirementName(modHeaderMatch[1]) !== key) { throw new Error(`${specName} MODIFIED failed for header "### Requirement: ${mod.name}" - header mismatch in content`); } nameToBlock.set(key, mod); } // ADDED for (const add of plan.added) { const key = normalizeRequirementName(add.name); if (nameToBlock.has(key)) { throw new Error(`${specName} ADDED failed for header "### Requirement: ${add.name}" - already exists`); } nameToBlock.set(key, add); } // Duplicates within resulting map are implicitly prevented by key uniqueness. // Recompose requirements section preserving original ordering where possible const keptOrder = []; const seen = new Set(); for (const block of parts.bodyBlocks) { const key = normalizeRequirementName(block.name); const replacement = nameToBlock.get(key); if (replacement) { keptOrder.push(replacement); seen.add(key); } } // Append any newly added that were not in original order for (const [key, block] of nameToBlock.entries()) { if (!seen.has(key)) { keptOrder.push(block); } } const reqBody = [parts.preamble && parts.preamble.trim() ? parts.preamble.trimEnd() : ''] .filter(Boolean) .concat(keptOrder.map((b) => b.raw)) .join('\n\n') .trimEnd(); const rebuilt = [parts.before.trimEnd(), parts.headerLine, reqBody, parts.after] .filter((s, idx) => !(idx === 0 && s === '')) .join('\n') .replace(/\n{3,}/g, '\n\n'); return { rebuilt, counts: { added: plan.added.length, modified: plan.modified.length, removed: plan.removed.length, renamed: plan.renamed.length, }, }; } /** * Write an updated spec to disk. */ export async function writeUpdatedSpec(update, rebuilt, counts) { // Create target directory if needed const targetDir = path.dirname(update.target); await fs.mkdir(targetDir, { recursive: true }); await fs.writeFile(update.target, rebuilt); const specName = path.basename(path.dirname(update.target)); console.log(`Applying changes to openspec/specs/${specName}/spec.md:`); if (counts.added) console.log(` + ${counts.added} added`); if (counts.modified) console.log(` ~ ${counts.modified} modified`); if (counts.removed) console.log(` - ${counts.removed} removed`); if (counts.renamed) console.log(` → ${counts.renamed} renamed`); } /** * Build a skeleton spec for new capabilities. */ export function buildSpecSkeleton(specFolderName, changeName) { const titleBase = specFolderName; return `# ${titleBase} Specification\n\n## Purpose\nTBD - created by archiving change ${changeName}. Update Purpose after archive.\n\n## Requirements\n`; } /** * Apply all delta specs from a change to main specs. * * @param projectRoot - The project root directory * @param changeName - The name of the change to apply * @param options - Options for the operation * @returns Result of the operation with counts */ export async function applySpecs(projectRoot, changeName, options = {}) { const changeDir = path.join(projectRoot, 'openspec', 'changes', changeName); const mainSpecsDir = path.join(projectRoot, 'openspec', 'specs'); // Verify change exists try { const stat = await fs.stat(changeDir); if (!stat.isDirectory()) { throw new Error(`Change '${changeName}' not found.`); } } catch { throw new Error(`Change '${changeName}' not found.`); } // Find specs to update const specUpdates = await findSpecUpdates(changeDir, mainSpecsDir); if (specUpdates.length === 0) { return { changeName, capabilities: [], totals: { added: 0, modified: 0, removed: 0, renamed: 0 }, noChanges: true, }; } // Prepare all updates first (validation pass, no writes) const prepared = []; for (const update of specUpdates) { const built = await buildUpdatedSpec(update, changeName); prepared.push({ update, rebuilt: built.rebuilt, counts: built.counts }); } // Validate rebuilt specs unless validation is skipped if (!options.skipValidation) { const validator = new Validator(); for (const p of prepared) { const specName = path.basename(path.dirname(p.update.target)); const report = await validator.validateSpecContent(specName, p.rebuilt); if (!report.valid) { const errors = report.issues .filter((i) => i.level === 'ERROR') .map((i) => ` ✗ ${i.message}`) .join('\n'); throw new Error(`Validation errors in rebuilt spec for ${specName}:\n${errors}`); } } } // Build results const capabilities = []; const totals = { added: 0, modified: 0, removed: 0, renamed: 0 }; for (const p of prepared) { const capability = path.basename(path.dirname(p.update.target)); if (!options.dryRun) { // Write the updated spec const targetDir = path.dirname(p.update.target); await fs.mkdir(targetDir, { recursive: true }); await fs.writeFile(p.update.target, p.rebuilt); if (!options.silent) { console.log(`Applying changes to openspec/specs/${capability}/spec.md:`); if (p.counts.added) console.log(` + ${p.counts.added} added`); if (p.counts.modified) console.log(` ~ ${p.counts.modified} modified`); if (p.counts.removed) console.log(` - ${p.counts.removed} removed`); if (p.counts.renamed) console.log(` → ${p.counts.renamed} renamed`); } } else if (!options.silent) { console.log(`Would apply changes to openspec/specs/${capability}/spec.md:`); if (p.counts.added) console.log(` + ${p.counts.added} added`); if (p.counts.modified) console.log(` ~ ${p.counts.modified} modified`); if (p.counts.removed) console.log(` - ${p.counts.removed} removed`); if (p.counts.renamed) console.log(` → ${p.counts.renamed} renamed`); } capabilities.push({ capability, ...p.counts, }); totals.added += p.counts.added; totals.modified += p.counts.modified; totals.removed += p.counts.removed; totals.renamed += p.counts.renamed; } return { changeName, capabilities, totals, noChanges: false, }; } //# sourceMappingURL=specs-apply.js.map