@fission-ai/openspec
Version:
AI-native system for spec-driven development
409 lines • 18.4 kB
JavaScript
import { readFileSync, promises as fs } from 'fs';
import path from 'path';
import { SpecSchema, ChangeSchema } from '../schemas/index.js';
import { MarkdownParser } from '../parsers/markdown-parser.js';
import { ChangeParser } from '../parsers/change-parser.js';
import { MIN_PURPOSE_LENGTH, MAX_REQUIREMENT_TEXT_LENGTH, VALIDATION_MESSAGES } from './constants.js';
import { parseDeltaSpec, normalizeRequirementName } from '../parsers/requirement-blocks.js';
import { FileSystemUtils } from '../../utils/file-system.js';
export class Validator {
strictMode;
constructor(strictMode = false) {
this.strictMode = strictMode;
}
async validateSpec(filePath) {
const issues = [];
const specName = this.extractNameFromPath(filePath);
try {
const content = readFileSync(filePath, 'utf-8');
const parser = new MarkdownParser(content);
const spec = parser.parseSpec(specName);
const result = SpecSchema.safeParse(spec);
if (!result.success) {
issues.push(...this.convertZodErrors(result.error));
}
issues.push(...this.applySpecRules(spec, content));
}
catch (error) {
const baseMessage = error instanceof Error ? error.message : 'Unknown error';
const enriched = this.enrichTopLevelError(specName, baseMessage);
issues.push({
level: 'ERROR',
path: 'file',
message: enriched,
});
}
return this.createReport(issues);
}
/**
* Validate spec content from a string (used for pre-write validation of rebuilt specs)
*/
async validateSpecContent(specName, content) {
const issues = [];
try {
const parser = new MarkdownParser(content);
const spec = parser.parseSpec(specName);
const result = SpecSchema.safeParse(spec);
if (!result.success) {
issues.push(...this.convertZodErrors(result.error));
}
issues.push(...this.applySpecRules(spec, content));
}
catch (error) {
const baseMessage = error instanceof Error ? error.message : 'Unknown error';
const enriched = this.enrichTopLevelError(specName, baseMessage);
issues.push({ level: 'ERROR', path: 'file', message: enriched });
}
return this.createReport(issues);
}
async validateChange(filePath) {
const issues = [];
const changeName = this.extractNameFromPath(filePath);
try {
const content = readFileSync(filePath, 'utf-8');
const changeDir = path.dirname(filePath);
const parser = new ChangeParser(content, changeDir);
const change = await parser.parseChangeWithDeltas(changeName);
const result = ChangeSchema.safeParse(change);
if (!result.success) {
issues.push(...this.convertZodErrors(result.error));
}
issues.push(...this.applyChangeRules(change, content));
}
catch (error) {
const baseMessage = error instanceof Error ? error.message : 'Unknown error';
const enriched = this.enrichTopLevelError(changeName, baseMessage);
issues.push({
level: 'ERROR',
path: 'file',
message: enriched,
});
}
return this.createReport(issues);
}
/**
* Validate delta-formatted spec files under a change directory.
* Enforces:
* - At least one delta across all files
* - ADDED/MODIFIED: each requirement has SHALL/MUST and at least one scenario
* - REMOVED: names only; no scenario/description required
* - RENAMED: pairs well-formed
* - No duplicates within sections; no cross-section conflicts per spec
*/
async validateChangeDeltaSpecs(changeDir) {
const issues = [];
const specsDir = path.join(changeDir, 'specs');
let totalDeltas = 0;
const missingHeaderSpecs = [];
const emptySectionSpecs = [];
try {
const entries = await fs.readdir(specsDir, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isDirectory())
continue;
const specName = entry.name;
const specFile = path.join(specsDir, specName, 'spec.md');
let content;
try {
content = await fs.readFile(specFile, 'utf-8');
}
catch {
continue;
}
const plan = parseDeltaSpec(content);
const entryPath = `${specName}/spec.md`;
const sectionNames = [];
if (plan.sectionPresence.added)
sectionNames.push('## ADDED Requirements');
if (plan.sectionPresence.modified)
sectionNames.push('## MODIFIED Requirements');
if (plan.sectionPresence.removed)
sectionNames.push('## REMOVED Requirements');
if (plan.sectionPresence.renamed)
sectionNames.push('## RENAMED Requirements');
const hasSections = sectionNames.length > 0;
const hasEntries = plan.added.length + plan.modified.length + plan.removed.length + plan.renamed.length > 0;
if (!hasEntries) {
if (hasSections)
emptySectionSpecs.push({ path: entryPath, sections: sectionNames });
else
missingHeaderSpecs.push(entryPath);
}
const addedNames = new Set();
const modifiedNames = new Set();
const removedNames = new Set();
const renamedFrom = new Set();
const renamedTo = new Set();
// Validate ADDED
for (const block of plan.added) {
const key = normalizeRequirementName(block.name);
totalDeltas++;
if (addedNames.has(key)) {
issues.push({ level: 'ERROR', path: entryPath, message: `Duplicate requirement in ADDED: "${block.name}"` });
}
else {
addedNames.add(key);
}
const requirementText = this.extractRequirementText(block.raw);
if (!requirementText) {
issues.push({ level: 'ERROR', path: entryPath, message: `ADDED "${block.name}" is missing requirement text` });
}
else if (!this.containsShallOrMust(requirementText)) {
issues.push({ level: 'ERROR', path: entryPath, message: `ADDED "${block.name}" must contain SHALL or MUST` });
}
const scenarioCount = this.countScenarios(block.raw);
if (scenarioCount < 1) {
issues.push({ level: 'ERROR', path: entryPath, message: `ADDED "${block.name}" must include at least one scenario` });
}
}
// Validate MODIFIED
for (const block of plan.modified) {
const key = normalizeRequirementName(block.name);
totalDeltas++;
if (modifiedNames.has(key)) {
issues.push({ level: 'ERROR', path: entryPath, message: `Duplicate requirement in MODIFIED: "${block.name}"` });
}
else {
modifiedNames.add(key);
}
const requirementText = this.extractRequirementText(block.raw);
if (!requirementText) {
issues.push({ level: 'ERROR', path: entryPath, message: `MODIFIED "${block.name}" is missing requirement text` });
}
else if (!this.containsShallOrMust(requirementText)) {
issues.push({ level: 'ERROR', path: entryPath, message: `MODIFIED "${block.name}" must contain SHALL or MUST` });
}
const scenarioCount = this.countScenarios(block.raw);
if (scenarioCount < 1) {
issues.push({ level: 'ERROR', path: entryPath, message: `MODIFIED "${block.name}" must include at least one scenario` });
}
}
// Validate REMOVED (names only)
for (const name of plan.removed) {
const key = normalizeRequirementName(name);
totalDeltas++;
if (removedNames.has(key)) {
issues.push({ level: 'ERROR', path: entryPath, message: `Duplicate requirement in REMOVED: "${name}"` });
}
else {
removedNames.add(key);
}
}
// Validate RENAMED pairs
for (const { from, to } of plan.renamed) {
const fromKey = normalizeRequirementName(from);
const toKey = normalizeRequirementName(to);
totalDeltas++;
if (renamedFrom.has(fromKey)) {
issues.push({ level: 'ERROR', path: entryPath, message: `Duplicate FROM in RENAMED: "${from}"` });
}
else {
renamedFrom.add(fromKey);
}
if (renamedTo.has(toKey)) {
issues.push({ level: 'ERROR', path: entryPath, message: `Duplicate TO in RENAMED: "${to}"` });
}
else {
renamedTo.add(toKey);
}
}
// Cross-section conflicts (within the same spec file)
for (const n of modifiedNames) {
if (removedNames.has(n)) {
issues.push({ level: 'ERROR', path: entryPath, message: `Requirement present in both MODIFIED and REMOVED: "${n}"` });
}
if (addedNames.has(n)) {
issues.push({ level: 'ERROR', path: entryPath, message: `Requirement present in both MODIFIED and ADDED: "${n}"` });
}
}
for (const n of addedNames) {
if (removedNames.has(n)) {
issues.push({ level: 'ERROR', path: entryPath, message: `Requirement present in both ADDED and REMOVED: "${n}"` });
}
}
for (const { from, to } of plan.renamed) {
const fromKey = normalizeRequirementName(from);
const toKey = normalizeRequirementName(to);
if (modifiedNames.has(fromKey)) {
issues.push({ level: 'ERROR', path: entryPath, message: `MODIFIED references old name from RENAMED. Use new header for "${to}"` });
}
if (addedNames.has(toKey)) {
issues.push({ level: 'ERROR', path: entryPath, message: `RENAMED TO collides with ADDED for "${to}"` });
}
}
}
}
catch {
// If no specs dir, treat as no deltas
}
for (const { path: specPath, sections } of emptySectionSpecs) {
issues.push({
level: 'ERROR',
path: specPath,
message: `Delta sections ${this.formatSectionList(sections)} were found, but no requirement entries parsed. Ensure each section includes at least one "### Requirement:" block (REMOVED may use bullet list syntax).`,
});
}
for (const path of missingHeaderSpecs) {
issues.push({
level: 'ERROR',
path,
message: 'No delta sections found. Add headers such as "## ADDED Requirements" or move non-delta notes outside specs/.',
});
}
if (totalDeltas === 0) {
issues.push({ level: 'ERROR', path: 'file', message: this.enrichTopLevelError('change', VALIDATION_MESSAGES.CHANGE_NO_DELTAS) });
}
return this.createReport(issues);
}
convertZodErrors(error) {
return error.issues.map(err => {
let message = err.message;
if (message === VALIDATION_MESSAGES.CHANGE_NO_DELTAS) {
message = `${message}. ${VALIDATION_MESSAGES.GUIDE_NO_DELTAS}`;
}
return {
level: 'ERROR',
path: err.path.join('.'),
message,
};
});
}
applySpecRules(spec, content) {
const issues = [];
if (spec.overview.length < MIN_PURPOSE_LENGTH) {
issues.push({
level: 'WARNING',
path: 'overview',
message: VALIDATION_MESSAGES.PURPOSE_TOO_BRIEF,
});
}
spec.requirements.forEach((req, index) => {
if (req.text.length > MAX_REQUIREMENT_TEXT_LENGTH) {
issues.push({
level: 'INFO',
path: `requirements[${index}]`,
message: VALIDATION_MESSAGES.REQUIREMENT_TOO_LONG,
});
}
if (req.scenarios.length === 0) {
issues.push({
level: 'WARNING',
path: `requirements[${index}].scenarios`,
message: `${VALIDATION_MESSAGES.REQUIREMENT_NO_SCENARIOS}. ${VALIDATION_MESSAGES.GUIDE_SCENARIO_FORMAT}`,
});
}
});
return issues;
}
applyChangeRules(change, content) {
const issues = [];
const MIN_DELTA_DESCRIPTION_LENGTH = 10;
change.deltas.forEach((delta, index) => {
if (!delta.description || delta.description.length < MIN_DELTA_DESCRIPTION_LENGTH) {
issues.push({
level: 'WARNING',
path: `deltas[${index}].description`,
message: VALIDATION_MESSAGES.DELTA_DESCRIPTION_TOO_BRIEF,
});
}
if ((delta.operation === 'ADDED' || delta.operation === 'MODIFIED') &&
(!delta.requirements || delta.requirements.length === 0)) {
issues.push({
level: 'WARNING',
path: `deltas[${index}].requirements`,
message: `${delta.operation} ${VALIDATION_MESSAGES.DELTA_MISSING_REQUIREMENTS}`,
});
}
});
return issues;
}
enrichTopLevelError(itemId, baseMessage) {
const msg = baseMessage.trim();
if (msg === VALIDATION_MESSAGES.CHANGE_NO_DELTAS) {
return `${msg}. ${VALIDATION_MESSAGES.GUIDE_NO_DELTAS}`;
}
if (msg.includes('Spec must have a Purpose section') || msg.includes('Spec must have a Requirements section')) {
return `${msg}. ${VALIDATION_MESSAGES.GUIDE_MISSING_SPEC_SECTIONS}`;
}
if (msg.includes('Change must have a Why section') || msg.includes('Change must have a What Changes section')) {
return `${msg}. ${VALIDATION_MESSAGES.GUIDE_MISSING_CHANGE_SECTIONS}`;
}
return msg;
}
extractNameFromPath(filePath) {
const normalizedPath = FileSystemUtils.toPosixPath(filePath);
const parts = normalizedPath.split('/');
// Look for the directory name after 'specs' or 'changes'
for (let i = parts.length - 1; i >= 0; i--) {
if (parts[i] === 'specs' || parts[i] === 'changes') {
if (i < parts.length - 1) {
return parts[i + 1];
}
}
}
// Fallback to filename without extension if not in expected structure
const fileName = parts[parts.length - 1] ?? '';
const dotIndex = fileName.lastIndexOf('.');
return dotIndex > 0 ? fileName.slice(0, dotIndex) : fileName;
}
createReport(issues) {
const errors = issues.filter(i => i.level === 'ERROR').length;
const warnings = issues.filter(i => i.level === 'WARNING').length;
const info = issues.filter(i => i.level === 'INFO').length;
const valid = this.strictMode
? errors === 0 && warnings === 0
: errors === 0;
return {
valid,
issues,
summary: {
errors,
warnings,
info,
},
};
}
isValid(report) {
return report.valid;
}
extractRequirementText(blockRaw) {
const lines = blockRaw.split('\n');
// Skip header line (index 0)
let i = 1;
// Find the first substantial text line, skipping metadata and blank lines
for (; i < lines.length; i++) {
const line = lines[i];
// Stop at scenario headers
if (/^####\s+/.test(line))
break;
const trimmed = line.trim();
// Skip blank lines
if (trimmed.length === 0)
continue;
// Skip metadata lines (lines starting with ** like **ID**, **Priority**, etc.)
if (/^\*\*[^*]+\*\*:/.test(trimmed))
continue;
// Found first non-metadata, non-blank line - this is the requirement text
return trimmed;
}
// No requirement text found
return undefined;
}
containsShallOrMust(text) {
return /\b(SHALL|MUST)\b/.test(text);
}
countScenarios(blockRaw) {
const matches = blockRaw.match(/^####\s+/gm);
return matches ? matches.length : 0;
}
formatSectionList(sections) {
if (sections.length === 0)
return '';
if (sections.length === 1)
return sections[0];
const head = sections.slice(0, -1);
const last = sections[sections.length - 1];
return `${head.join(', ')} and ${last}`;
}
}
//# sourceMappingURL=validator.js.map