@fission-ai/openspec
Version:
AI-native system for spec-driven development
294 lines • 13.5 kB
JavaScript
import ora from 'ora';
import path from 'path';
import { Validator } from '../core/validation/validator.js';
import { isInteractive, resolveNoInteractive } from '../utils/interactive.js';
import { getActiveChangeIds, getSpecIds } from '../utils/item-discovery.js';
import { nearestMatches } from '../utils/match.js';
export class ValidateCommand {
async execute(itemName, options = {}) {
const interactive = isInteractive(options);
// Handle bulk flags first
if (options.all || options.changes || options.specs) {
await this.runBulkValidation({
changes: !!options.all || !!options.changes,
specs: !!options.all || !!options.specs,
}, { strict: !!options.strict, json: !!options.json, concurrency: options.concurrency, noInteractive: resolveNoInteractive(options) });
return;
}
// No item and no flags
if (!itemName) {
if (interactive) {
await this.runInteractiveSelector({ strict: !!options.strict, json: !!options.json, concurrency: options.concurrency });
return;
}
this.printNonInteractiveHint();
process.exitCode = 1;
return;
}
// Direct item validation with type detection or override
const typeOverride = this.normalizeType(options.type);
await this.validateDirectItem(itemName, { typeOverride, strict: !!options.strict, json: !!options.json });
}
normalizeType(value) {
if (!value)
return undefined;
const v = value.toLowerCase();
if (v === 'change' || v === 'spec')
return v;
return undefined;
}
async runInteractiveSelector(opts) {
const { select } = await import('@inquirer/prompts');
const choice = await select({
message: 'What would you like to validate?',
choices: [
{ name: 'All (changes + specs)', value: 'all' },
{ name: 'All changes', value: 'changes' },
{ name: 'All specs', value: 'specs' },
{ name: 'Pick a specific change or spec', value: 'one' },
],
});
if (choice === 'all')
return this.runBulkValidation({ changes: true, specs: true }, opts);
if (choice === 'changes')
return this.runBulkValidation({ changes: true, specs: false }, opts);
if (choice === 'specs')
return this.runBulkValidation({ changes: false, specs: true }, opts);
// one
const [changes, specs] = await Promise.all([getActiveChangeIds(), getSpecIds()]);
const items = [];
items.push(...changes.map(id => ({ name: `change/${id}`, value: { type: 'change', id } })));
items.push(...specs.map(id => ({ name: `spec/${id}`, value: { type: 'spec', id } })));
if (items.length === 0) {
console.error('No items found to validate.');
process.exitCode = 1;
return;
}
const picked = await select({ message: 'Pick an item', choices: items });
await this.validateByType(picked.type, picked.id, opts);
}
printNonInteractiveHint() {
console.error('Nothing to validate. Try one of:');
console.error(' openspec validate --all');
console.error(' openspec validate --changes');
console.error(' openspec validate --specs');
console.error(' openspec validate <item-name>');
console.error('Or run in an interactive terminal.');
}
async validateDirectItem(itemName, opts) {
const [changes, specs] = await Promise.all([getActiveChangeIds(), getSpecIds()]);
const isChange = changes.includes(itemName);
const isSpec = specs.includes(itemName);
const type = opts.typeOverride ?? (isChange ? 'change' : isSpec ? 'spec' : undefined);
if (!type) {
console.error(`Unknown item '${itemName}'`);
const suggestions = nearestMatches(itemName, [...changes, ...specs]);
if (suggestions.length)
console.error(`Did you mean: ${suggestions.join(', ')}?`);
process.exitCode = 1;
return;
}
if (!opts.typeOverride && isChange && isSpec) {
console.error(`Ambiguous item '${itemName}' matches both a change and a spec.`);
console.error('Pass --type change|spec, or use: openspec change validate / openspec spec validate');
process.exitCode = 1;
return;
}
await this.validateByType(type, itemName, opts);
}
async validateByType(type, id, opts) {
const validator = new Validator(opts.strict);
if (type === 'change') {
const changeDir = path.join(process.cwd(), 'openspec', 'changes', id);
const start = Date.now();
const report = await validator.validateChangeDeltaSpecs(changeDir);
const durationMs = Date.now() - start;
this.printReport('change', id, report, durationMs, opts.json);
// Non-zero exit if invalid (keeps enriched output test semantics)
process.exitCode = report.valid ? 0 : 1;
return;
}
const file = path.join(process.cwd(), 'openspec', 'specs', id, 'spec.md');
const start = Date.now();
const report = await validator.validateSpec(file);
const durationMs = Date.now() - start;
this.printReport('spec', id, report, durationMs, opts.json);
process.exitCode = report.valid ? 0 : 1;
}
printReport(type, id, report, durationMs, json) {
if (json) {
const out = { items: [{ id, type, valid: report.valid, issues: report.issues, durationMs }], summary: { totals: { items: 1, passed: report.valid ? 1 : 0, failed: report.valid ? 0 : 1 }, byType: { [type]: { items: 1, passed: report.valid ? 1 : 0, failed: report.valid ? 0 : 1 } } }, version: '1.0' };
console.log(JSON.stringify(out, null, 2));
return;
}
if (report.valid) {
console.log(`${type === 'change' ? 'Change' : 'Specification'} '${id}' is valid`);
}
else {
console.error(`${type === 'change' ? 'Change' : 'Specification'} '${id}' has issues`);
for (const issue of report.issues) {
const label = issue.level === 'ERROR' ? 'ERROR' : issue.level;
const prefix = issue.level === 'ERROR' ? '✗' : issue.level === 'WARNING' ? '⚠' : 'ℹ';
console.error(`${prefix} [${label}] ${issue.path}: ${issue.message}`);
}
this.printNextSteps(type);
}
}
printNextSteps(type) {
const bullets = [];
if (type === 'change') {
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');
}
else {
bullets.push('- Ensure spec includes ## Purpose and ## Requirements sections');
bullets.push('- Each requirement MUST include at least one #### Scenario: block');
bullets.push('- Re-run with --json to see structured report');
}
console.error('Next steps:');
bullets.forEach(b => console.error(` ${b}`));
}
async runBulkValidation(scope, opts) {
const spinner = !opts.json && !opts.noInteractive ? ora('Validating...').start() : undefined;
const [changeIds, specIds] = await Promise.all([
scope.changes ? getActiveChangeIds() : Promise.resolve([]),
scope.specs ? getSpecIds() : Promise.resolve([]),
]);
const DEFAULT_CONCURRENCY = 6;
const maxSuggestions = 5; // used by nearestMatches
const concurrency = normalizeConcurrency(opts.concurrency) ?? normalizeConcurrency(process.env.OPENSPEC_CONCURRENCY) ?? DEFAULT_CONCURRENCY;
const validator = new Validator(opts.strict);
const queue = [];
for (const id of changeIds) {
queue.push(async () => {
const start = Date.now();
const changeDir = path.join(process.cwd(), 'openspec', 'changes', id);
const report = await validator.validateChangeDeltaSpecs(changeDir);
const durationMs = Date.now() - start;
return { id, type: 'change', valid: report.valid, issues: report.issues, durationMs };
});
}
for (const id of specIds) {
queue.push(async () => {
const start = Date.now();
const file = path.join(process.cwd(), 'openspec', 'specs', id, 'spec.md');
const report = await validator.validateSpec(file);
const durationMs = Date.now() - start;
return { id, type: 'spec', valid: report.valid, issues: report.issues, durationMs };
});
}
if (queue.length === 0) {
spinner?.stop();
const summary = {
totals: { items: 0, passed: 0, failed: 0 },
byType: {
...(scope.changes ? { change: { items: 0, passed: 0, failed: 0 } } : {}),
...(scope.specs ? { spec: { items: 0, passed: 0, failed: 0 } } : {}),
},
};
if (opts.json) {
const out = { items: [], summary, version: '1.0' };
console.log(JSON.stringify(out, null, 2));
}
else {
console.log('No items found to validate.');
}
process.exitCode = 0;
return;
}
const results = [];
let index = 0;
let running = 0;
let passed = 0;
let failed = 0;
await new Promise((resolve) => {
const next = () => {
while (running < concurrency && index < queue.length) {
const currentIndex = index++;
const task = queue[currentIndex];
running++;
if (spinner)
spinner.text = `Validating (${currentIndex + 1}/${queue.length})...`;
task()
.then(res => {
results.push(res);
if (res.valid)
passed++;
else
failed++;
})
.catch((error) => {
const message = error?.message || 'Unknown error';
const res = { id: getPlannedId(currentIndex, changeIds, specIds) ?? 'unknown', type: getPlannedType(currentIndex, changeIds, specIds) ?? 'change', valid: false, issues: [{ level: 'ERROR', path: 'file', message }], durationMs: 0 };
results.push(res);
failed++;
})
.finally(() => {
running--;
if (index >= queue.length && running === 0)
resolve();
else
next();
});
}
};
next();
});
spinner?.stop();
results.sort((a, b) => a.id.localeCompare(b.id));
const summary = {
totals: { items: results.length, passed, failed },
byType: {
...(scope.changes ? { change: summarizeType(results, 'change') } : {}),
...(scope.specs ? { spec: summarizeType(results, 'spec') } : {}),
},
};
if (opts.json) {
const out = { items: results, summary, version: '1.0' };
console.log(JSON.stringify(out, null, 2));
}
else {
for (const res of results) {
if (res.valid)
console.log(`✓ ${res.type}/${res.id}`);
else
console.error(`✗ ${res.type}/${res.id}`);
}
console.log(`Totals: ${summary.totals.passed} passed, ${summary.totals.failed} failed (${summary.totals.items} items)`);
}
process.exitCode = failed > 0 ? 1 : 0;
}
}
function summarizeType(results, type) {
const filtered = results.filter(r => r.type === type);
const items = filtered.length;
const passed = filtered.filter(r => r.valid).length;
const failed = items - passed;
return { items, passed, failed };
}
function normalizeConcurrency(value) {
if (!value)
return undefined;
const n = parseInt(value, 10);
if (Number.isNaN(n) || n <= 0)
return undefined;
return n;
}
function getPlannedId(index, changeIds, specIds) {
const totalChanges = changeIds.length;
if (index < totalChanges)
return changeIds[index];
const specIndex = index - totalChanges;
return specIds[specIndex];
}
function getPlannedType(index, changeIds, specIds) {
const totalChanges = changeIds.length;
if (index < totalChanges)
return 'change';
const specIndex = index - totalChanges;
if (specIndex >= 0 && specIndex < specIds.length)
return 'spec';
return undefined;
}
//# sourceMappingURL=validate.js.map