UNPKG

@fission-ai/openspec

Version:

AI-native system for spec-driven development

277 lines 11.9 kB
import { promises as fs } from 'fs'; import path from 'path'; import { JsonConverter } from '../core/converters/json-converter.js'; import { Validator } from '../core/validation/validator.js'; import { ChangeParser } from '../core/parsers/change-parser.js'; import { isInteractive } from '../utils/interactive.js'; import { getActiveChangeIds } from '../utils/item-discovery.js'; // Constants for better maintainability const ARCHIVE_DIR = 'archive'; const TASK_PATTERN = /^[-*]\s+\[[\sx]\]/i; const COMPLETED_TASK_PATTERN = /^[-*]\s+\[x\]/i; export class ChangeCommand { converter; constructor() { this.converter = new JsonConverter(); } /** * Show a change proposal. * - Text mode: raw markdown passthrough (no filters) * - JSON mode: minimal object with deltas; --deltas-only returns same object with filtered deltas * Note: --requirements-only is deprecated alias for --deltas-only */ async show(changeName, options) { const changesPath = path.join(process.cwd(), 'openspec', 'changes'); if (!changeName) { const canPrompt = isInteractive(options); const changes = await this.getActiveChanges(changesPath); if (canPrompt && changes.length > 0) { const { select } = await import('@inquirer/prompts'); const selected = await select({ message: 'Select a change to show', choices: changes.map(id => ({ name: id, value: id })), }); changeName = selected; } else { if (changes.length === 0) { console.error('No change specified. No active changes found.'); } else { console.error(`No change specified. Available IDs: ${changes.join(', ')}`); } console.error('Hint: use "openspec change list" to view available changes.'); process.exitCode = 1; return; } } const proposalPath = path.join(changesPath, changeName, 'proposal.md'); try { await fs.access(proposalPath); } catch { throw new Error(`Change "${changeName}" not found at ${proposalPath}`); } if (options?.json) { const jsonOutput = await this.converter.convertChangeToJson(proposalPath); if (options.requirementsOnly) { console.error('Flag --requirements-only is deprecated; use --deltas-only instead.'); } const parsed = JSON.parse(jsonOutput); const contentForTitle = await fs.readFile(proposalPath, 'utf-8'); const title = this.extractTitle(contentForTitle, changeName); const id = parsed.name; const deltas = parsed.deltas || []; if (options.requirementsOnly || options.deltasOnly) { const output = { id, title, deltaCount: deltas.length, deltas }; console.log(JSON.stringify(output, null, 2)); } else { const output = { id, title, deltaCount: deltas.length, deltas, }; console.log(JSON.stringify(output, null, 2)); } } else { const content = await fs.readFile(proposalPath, 'utf-8'); console.log(content); } } /** * List active changes. * - Text default: IDs only; --long prints minimal details (title, counts) * - JSON: array of { id, title, deltaCount, taskStatus }, sorted by id */ async list(options) { const changesPath = path.join(process.cwd(), 'openspec', 'changes'); const changes = await this.getActiveChanges(changesPath); if (options?.json) { const changeDetails = await Promise.all(changes.map(async (changeName) => { const proposalPath = path.join(changesPath, changeName, 'proposal.md'); const tasksPath = path.join(changesPath, changeName, 'tasks.md'); try { const content = await fs.readFile(proposalPath, 'utf-8'); const changeDir = path.join(changesPath, changeName); const parser = new ChangeParser(content, changeDir); const change = await parser.parseChangeWithDeltas(changeName); let taskStatus = { total: 0, completed: 0 }; try { const tasksContent = await fs.readFile(tasksPath, 'utf-8'); taskStatus = this.countTasks(tasksContent); } catch (error) { // Tasks file may not exist, which is okay if (process.env.DEBUG) { console.error(`Failed to read tasks file at ${tasksPath}:`, error); } } return { id: changeName, title: this.extractTitle(content, changeName), deltaCount: change.deltas.length, taskStatus, }; } catch (error) { return { id: changeName, title: 'Unknown', deltaCount: 0, taskStatus: { total: 0, completed: 0 }, }; } })); const sorted = changeDetails.sort((a, b) => a.id.localeCompare(b.id)); console.log(JSON.stringify(sorted, null, 2)); } else { if (changes.length === 0) { console.log('No items found'); return; } const sorted = [...changes].sort(); if (!options?.long) { // IDs only sorted.forEach(id => console.log(id)); return; } // Long format: id: title and minimal counts for (const changeName of sorted) { const proposalPath = path.join(changesPath, changeName, 'proposal.md'); const tasksPath = path.join(changesPath, changeName, 'tasks.md'); try { const content = await fs.readFile(proposalPath, 'utf-8'); const title = this.extractTitle(content, changeName); let taskStatusText = ''; try { const tasksContent = await fs.readFile(tasksPath, 'utf-8'); const { total, completed } = this.countTasks(tasksContent); taskStatusText = ` [tasks ${completed}/${total}]`; } catch (error) { if (process.env.DEBUG) { console.error(`Failed to read tasks file at ${tasksPath}:`, error); } } const changeDir = path.join(changesPath, changeName); const parser = new ChangeParser(await fs.readFile(proposalPath, 'utf-8'), changeDir); const change = await parser.parseChangeWithDeltas(changeName); const deltaCountText = ` [deltas ${change.deltas.length}]`; console.log(`${changeName}: ${title}${deltaCountText}${taskStatusText}`); } catch { console.log(`${changeName}: (unable to read)`); } } } } async validate(changeName, options) { const changesPath = path.join(process.cwd(), 'openspec', 'changes'); if (!changeName) { const canPrompt = isInteractive(options); const changes = await getActiveChangeIds(); if (canPrompt && changes.length > 0) { const { select } = await import('@inquirer/prompts'); const selected = await select({ message: 'Select a change to validate', choices: changes.map(id => ({ name: id, value: id })), }); changeName = selected; } else { if (changes.length === 0) { console.error('No change specified. No active changes found.'); } else { console.error(`No change specified. Available IDs: ${changes.join(', ')}`); } console.error('Hint: use "openspec change list" to view available changes.'); process.exitCode = 1; return; } } const changeDir = path.join(changesPath, changeName); try { await fs.access(changeDir); } catch { throw new Error(`Change "${changeName}" not found at ${changeDir}`); } const validator = new Validator(options?.strict || false); const report = await validator.validateChangeDeltaSpecs(changeDir); if (options?.json) { console.log(JSON.stringify(report, null, 2)); } else { if (report.valid) { console.log(`Change "${changeName}" is valid`); } else { console.error(`Change "${changeName}" has issues`); report.issues.forEach(issue => { const label = issue.level === 'ERROR' ? 'ERROR' : 'WARNING'; const prefix = issue.level === 'ERROR' ? '✗' : '⚠'; console.error(`${prefix} [${label}] ${issue.path}: ${issue.message}`); }); // Next steps footer to guide fixing issues this.printNextSteps(); if (!options?.json) { process.exitCode = 1; } } } } async getActiveChanges(changesPath) { try { const entries = await fs.readdir(changesPath, { withFileTypes: true }); const result = []; for (const entry of entries) { if (!entry.isDirectory() || entry.name.startsWith('.') || entry.name === ARCHIVE_DIR) continue; const proposalPath = path.join(changesPath, entry.name, 'proposal.md'); try { await fs.access(proposalPath); result.push(entry.name); } catch { // skip directories without proposal.md } } return result.sort(); } catch { return []; } } extractTitle(content, changeName) { const match = content.match(/^#\s+(?:Change:\s+)?(.+)$/im); return match ? match[1].trim() : changeName; } countTasks(content) { const lines = content.split('\n'); let total = 0; let completed = 0; for (const line of lines) { if (line.match(TASK_PATTERN)) { total++; if (line.match(COMPLETED_TASK_PATTERN)) { completed++; } } } return { total, completed }; } printNextSteps() { const bullets = []; bullets.push('- Ensure change has deltas in specs/: use headers ## ADDED/MODIFIED/REMOVED/RENAMED Requirements'); bullets.push('- Each requirement MUST include at least one #### Scenario: block'); bullets.push('- Debug parsed deltas: openspec change show <id> --json --deltas-only'); console.error('Next steps:'); bullets.forEach(b => console.error(` ${b}`)); } } //# sourceMappingURL=change.js.map