Skip to content

Commit 88b260d

Browse files
authored
fix: honor --no-validate and ignore metadata during archive validation (#190)
* fix: skip metadata when validating requirement SHALL/MUST keywords Fixes validation incorrectly checking metadata lines instead of requirement text. The extractRequirementText() function was returning the first non-empty line after the requirement header, which was often metadata like **ID**: REQ-001 instead of the actual requirement statement. Changes: - Updated extractRequirementText() to skip lines matching **Key**: Value pattern - Skip blank lines between header and requirement text - Return first substantive text line for SHALL/MUST validation Added comprehensive tests for: - Requirements with metadata before SHALL/MUST text - Requirements with SHALL in text but not header - Requirements correctly failing without SHALL/MUST - Requirements without metadata fields All 20 validation tests pass. Fixes #159 * Respect --no-validate flag while archiving
1 parent ce74222 commit 88b260d

File tree

5 files changed

+195
-16
lines changed

5 files changed

+195
-16
lines changed

src/cli/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -187,7 +187,7 @@ program
187187
.option('-y, --yes', 'Skip confirmation prompts')
188188
.option('--skip-specs', 'Skip spec update operations (useful for infrastructure, tooling, or doc-only changes)')
189189
.option('--no-validate', 'Skip validation (not recommended, requires confirmation)')
190-
.action(async (changeName?: string, options?: { yes?: boolean; skipSpecs?: boolean; noValidate?: boolean }) => {
190+
.action(async (changeName?: string, options?: { yes?: boolean; skipSpecs?: boolean; noValidate?: boolean; validate?: boolean }) => {
191191
try {
192192
const archiveCommand = new ArchiveCommand();
193193
await archiveCommand.execute(changeName, options);

src/core/archive.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,10 @@ interface SpecUpdate {
1919
}
2020

2121
export class ArchiveCommand {
22-
async execute(changeName?: string, options: { yes?: boolean; skipSpecs?: boolean; noValidate?: boolean } = {}): Promise<void> {
22+
async execute(
23+
changeName?: string,
24+
options: { yes?: boolean; skipSpecs?: boolean; noValidate?: boolean; validate?: boolean } = {}
25+
): Promise<void> {
2326
const targetPath = '.';
2427
const changesDir = path.join(targetPath, 'openspec', 'changes');
2528
const archiveDir = path.join(changesDir, 'archive');
@@ -54,8 +57,10 @@ export class ArchiveCommand {
5457
throw new Error(`Change '${changeName}' not found.`);
5558
}
5659

60+
const skipValidation = options.validate === false || options.noValidate === true;
61+
5762
// Validate specs and change before archiving
58-
if (!options.noValidate) {
63+
if (!skipValidation) {
5964
const validator = new Validator();
6065
let hasValidationErrors = false;
6166

@@ -201,7 +206,7 @@ export class ArchiveCommand {
201206
let totals = { added: 0, modified: 0, removed: 0, renamed: 0 };
202207
for (const p of prepared) {
203208
const specName = path.basename(path.dirname(p.update.target));
204-
if (!options.noValidate) {
209+
if (!skipValidation) {
205210
const report = await new Validator().validateSpecContent(specName, p.rebuilt);
206211
if (!report.valid) {
207212
console.log(chalk.red(`\nValidation errors in rebuilt spec for ${specName} (will not write changes):`));
@@ -598,4 +603,4 @@ export class ArchiveCommand {
598603
// Returns date in YYYY-MM-DD format
599604
return new Date().toISOString().split('T')[0];
600605
}
601-
}
606+
}

src/core/validation/validator.ts

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -375,16 +375,30 @@ export class Validator {
375375

376376
private extractRequirementText(blockRaw: string): string | undefined {
377377
const lines = blockRaw.split('\n');
378-
// Skip header
378+
// Skip header line (index 0)
379379
let i = 1;
380-
const bodyLines: string[] = [];
380+
381+
// Find the first substantial text line, skipping metadata and blank lines
381382
for (; i < lines.length; i++) {
382383
const line = lines[i];
383-
if (/^####\s+/.test(line)) break; // scenarios start
384-
bodyLines.push(line);
384+
385+
// Stop at scenario headers
386+
if (/^####\s+/.test(line)) break;
387+
388+
const trimmed = line.trim();
389+
390+
// Skip blank lines
391+
if (trimmed.length === 0) continue;
392+
393+
// Skip metadata lines (lines starting with ** like **ID**, **Priority**, etc.)
394+
if (/^\*\*[^*]+\*\*:/.test(trimmed)) continue;
395+
396+
// Found first non-metadata, non-blank line - this is the requirement text
397+
return trimmed;
385398
}
386-
const text = bodyLines.join('\n').split('\n').map(l => l.trim()).find(l => l.length > 0);
387-
return text;
399+
400+
// No requirement text found
401+
return undefined;
388402
}
389403

390404
private containsShallOrMust(text: string): boolean {

test/core/archive.test.ts

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
22
import { ArchiveCommand } from '../../src/core/archive.js';
3+
import { Validator } from '../../src/core/validation/validator.js';
34
import { promises as fs } from 'fs';
45
import path from 'path';
56
import os from 'os';
@@ -215,6 +216,46 @@ Then expected result happens`;
215216
expect(archives[0]).toMatch(new RegExp(`\\d{4}-\\d{2}-\\d{2}-${changeName}`));
216217
});
217218

219+
it('should skip validation when commander sets validate to false (--no-validate)', async () => {
220+
const changeName = 'skip-validation-flag';
221+
const changeDir = path.join(tempDir, 'openspec', 'changes', changeName);
222+
const changeSpecDir = path.join(changeDir, 'specs', 'unstable-capability');
223+
await fs.mkdir(changeSpecDir, { recursive: true });
224+
225+
const deltaSpec = `# Unstable Capability
226+
227+
## ADDED Requirements
228+
229+
### Requirement: Logging Feature
230+
**ID**: REQ-LOG-001
231+
232+
The system will log all events.
233+
234+
#### Scenario: Event recorded
235+
- **WHEN** an event occurs
236+
- **THEN** it is captured`;
237+
await fs.writeFile(path.join(changeSpecDir, 'spec.md'), deltaSpec);
238+
await fs.writeFile(path.join(changeDir, 'tasks.md'), '- [x] Task 1\n');
239+
240+
const deltaSpy = vi.spyOn(Validator.prototype, 'validateChangeDeltaSpecs');
241+
const specContentSpy = vi.spyOn(Validator.prototype, 'validateSpecContent');
242+
243+
try {
244+
await archiveCommand.execute(changeName, { yes: true, skipSpecs: true, validate: false });
245+
246+
expect(deltaSpy).not.toHaveBeenCalled();
247+
expect(specContentSpy).not.toHaveBeenCalled();
248+
249+
const archiveDir = path.join(tempDir, 'openspec', 'changes', 'archive');
250+
const archives = await fs.readdir(archiveDir);
251+
expect(archives.length).toBe(1);
252+
expect(archives[0]).toMatch(new RegExp(`\\d{4}-\\d{2}-\\d{2}-${changeName}`));
253+
} finally {
254+
deltaSpy.mockRestore();
255+
specContentSpy.mockRestore();
256+
}
257+
});
258+
218259
it('should proceed with archive when user declines spec updates', async () => {
219260
const { confirm } = await import('@inquirer/prompts');
220261
const mockConfirm = confirm as unknown as ReturnType<typeof vi.fn>;
@@ -636,4 +677,4 @@ E1 updated`);
636677
await expect(fs.access(changeDir)).resolves.not.toThrow();
637678
});
638679
});
639-
});
680+
});

test/core/validation.test.ts

Lines changed: 123 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -306,10 +306,10 @@ Then result`;
306306

307307
const specPath = path.join(testDir, 'spec.md');
308308
await fs.writeFile(specPath, specContent);
309-
309+
310310
const validator = new Validator(true); // strict mode
311311
const report = await validator.validateSpec(specPath);
312-
312+
313313
expect(report.valid).toBe(false); // Should fail due to brief overview warning
314314
});
315315

@@ -330,12 +330,131 @@ Then result`;
330330

331331
const specPath = path.join(testDir, 'spec.md');
332332
await fs.writeFile(specPath, specContent);
333-
333+
334334
const validator = new Validator(false); // non-strict mode
335335
const report = await validator.validateSpec(specPath);
336-
336+
337337
expect(report.valid).toBe(true); // Should pass despite warnings
338338
expect(report.summary.warnings).toBeGreaterThan(0);
339339
});
340340
});
341+
342+
describe('validateChangeDeltaSpecs with metadata', () => {
343+
it('should validate requirement with metadata before SHALL/MUST text', async () => {
344+
const changeDir = path.join(testDir, 'test-change');
345+
const specsDir = path.join(changeDir, 'specs', 'test-spec');
346+
await fs.mkdir(specsDir, { recursive: true });
347+
348+
const deltaSpec = `# Test Spec
349+
350+
## ADDED Requirements
351+
352+
### Requirement: Circuit Breaker State Management SHALL be implemented
353+
**ID**: REQ-CB-001
354+
**Priority**: P1 (High)
355+
356+
The system MUST implement a circuit breaker with three states.
357+
358+
#### Scenario: Normal operation
359+
**Given** the circuit breaker is in CLOSED state
360+
**When** a request is made
361+
**Then** the request is executed normally`;
362+
363+
const specPath = path.join(specsDir, 'spec.md');
364+
await fs.writeFile(specPath, deltaSpec);
365+
366+
const validator = new Validator(true);
367+
const report = await validator.validateChangeDeltaSpecs(changeDir);
368+
369+
expect(report.valid).toBe(true);
370+
expect(report.summary.errors).toBe(0);
371+
});
372+
373+
it('should validate requirement with SHALL in text but not in header', async () => {
374+
const changeDir = path.join(testDir, 'test-change-2');
375+
const specsDir = path.join(changeDir, 'specs', 'test-spec');
376+
await fs.mkdir(specsDir, { recursive: true });
377+
378+
const deltaSpec = `# Test Spec
379+
380+
## ADDED Requirements
381+
382+
### Requirement: Error Handling
383+
**ID**: REQ-ERR-001
384+
**Priority**: P2
385+
386+
The system SHALL handle all errors gracefully.
387+
388+
#### Scenario: Error occurs
389+
**Given** an error condition
390+
**When** an error occurs
391+
**Then** the error is logged and user is notified`;
392+
393+
const specPath = path.join(specsDir, 'spec.md');
394+
await fs.writeFile(specPath, deltaSpec);
395+
396+
const validator = new Validator(true);
397+
const report = await validator.validateChangeDeltaSpecs(changeDir);
398+
399+
expect(report.valid).toBe(true);
400+
expect(report.summary.errors).toBe(0);
401+
});
402+
403+
it('should fail when requirement text lacks SHALL/MUST', async () => {
404+
const changeDir = path.join(testDir, 'test-change-3');
405+
const specsDir = path.join(changeDir, 'specs', 'test-spec');
406+
await fs.mkdir(specsDir, { recursive: true });
407+
408+
const deltaSpec = `# Test Spec
409+
410+
## ADDED Requirements
411+
412+
### Requirement: Logging Feature
413+
**ID**: REQ-LOG-001
414+
415+
The system will log all events.
416+
417+
#### Scenario: Event occurs
418+
**Given** an event
419+
**When** it occurs
420+
**Then** it is logged`;
421+
422+
const specPath = path.join(specsDir, 'spec.md');
423+
await fs.writeFile(specPath, deltaSpec);
424+
425+
const validator = new Validator(true);
426+
const report = await validator.validateChangeDeltaSpecs(changeDir);
427+
428+
expect(report.valid).toBe(false);
429+
expect(report.summary.errors).toBeGreaterThan(0);
430+
expect(report.issues.some(i => i.message.includes('must contain SHALL or MUST'))).toBe(true);
431+
});
432+
433+
it('should handle requirements without metadata fields', async () => {
434+
const changeDir = path.join(testDir, 'test-change-4');
435+
const specsDir = path.join(changeDir, 'specs', 'test-spec');
436+
await fs.mkdir(specsDir, { recursive: true });
437+
438+
const deltaSpec = `# Test Spec
439+
440+
## ADDED Requirements
441+
442+
### Requirement: Simple Feature
443+
The system SHALL implement this feature.
444+
445+
#### Scenario: Basic usage
446+
**Given** a condition
447+
**When** an action occurs
448+
**Then** a result happens`;
449+
450+
const specPath = path.join(specsDir, 'spec.md');
451+
await fs.writeFile(specPath, deltaSpec);
452+
453+
const validator = new Validator(true);
454+
const report = await validator.validateChangeDeltaSpecs(changeDir);
455+
456+
expect(report.valid).toBe(true);
457+
expect(report.summary.errors).toBe(0);
458+
});
459+
});
341460
});

0 commit comments

Comments
 (0)