Skip to content

Commit 821b4a9

Browse files
PluviobytecursoragentTabishB
authored
fix(validator): hint when SHALL/MUST appears only in requirement header (Fission-AI#1135)
When a change delta has a requirement whose body is missing SHALL/MUST but whose header (the text after `### Requirement:`) already contains the keyword, the validator emitted the generic error "must contain SHALL or MUST". Authors then re-read the spec, see SHALL right there in the header, and have no idea what the validator wants. Per the OpenSpec conventions the keyword has to live on the requirement body line (the line immediately after the header). When the keyword is present in the header only, append guidance explaining exactly where to move it. The fix is scoped to the two `validateChangeDeltaSpecs` call sites (ADDED + MODIFIED) so behaviour for requirements that lack the keyword everywhere stays unchanged. Adds three vitest cases under `test/core/validation.test.ts`: - ADDED block with header-only SHALL → enriched hint - MODIFIED block with header-only MUST → enriched hint - Neither header nor body contain SHALL/MUST → generic message preserved Verified by reproducing the spec from Fission-AI#356, running `openspec validate <change>` against the rebuilt CLI, and confirming the new diagnostic guides the author to the fix. Reverting `validator.ts` makes the two enriched-hint cases fail, so the tests guard the regression. Fixes Fission-AI#356 Co-authored-by: Pluviobyte <Pluviobyte@users.noreply.github.com> Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: Tabish Bidiwale <30385142+TabishB@users.noreply.github.com>
1 parent 993425b commit 821b4a9

2 files changed

Lines changed: 106 additions & 2 deletions

File tree

src/core/validation/validator.ts

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -165,7 +165,7 @@ export class Validator {
165165
if (!requirementText) {
166166
issues.push({ level: 'ERROR', path: entryPath, message: `ADDED "${block.name}" is missing requirement text` });
167167
} else if (!this.containsShallOrMust(requirementText)) {
168-
issues.push({ level: 'ERROR', path: entryPath, message: `ADDED "${block.name}" must contain SHALL or MUST` });
168+
issues.push({ level: 'ERROR', path: entryPath, message: this.buildMissingShallOrMustMessage('ADDED', block.name) });
169169
}
170170
const scenarioCount = this.countScenarios(block.raw);
171171
if (scenarioCount < 1) {
@@ -186,7 +186,7 @@ export class Validator {
186186
if (!requirementText) {
187187
issues.push({ level: 'ERROR', path: entryPath, message: `MODIFIED "${block.name}" is missing requirement text` });
188188
} else if (!this.containsShallOrMust(requirementText)) {
189-
issues.push({ level: 'ERROR', path: entryPath, message: `MODIFIED "${block.name}" must contain SHALL or MUST` });
189+
issues.push({ level: 'ERROR', path: entryPath, message: this.buildMissingShallOrMustMessage('MODIFIED', block.name) });
190190
}
191191
const scenarioCount = this.countScenarios(block.raw);
192192
if (scenarioCount < 1) {
@@ -444,6 +444,24 @@ export class Validator {
444444
return /\b(SHALL|MUST)\b/.test(text);
445445
}
446446

447+
/**
448+
* Build an error message for a requirement block whose body lacks SHALL/MUST.
449+
*
450+
* When the SHALL/MUST keyword already appears in the requirement header (e.g.
451+
* `### Requirement: The system SHALL ...`) the original generic error
452+
* ("must contain SHALL or MUST") is confusing because the keyword is visibly
453+
* present in the spec. Per the OpenSpec conventions the keyword has to live
454+
* on the requirement body line (the line right after the header), so we point
455+
* the author at that exact fix when the keyword is found in the header only.
456+
*/
457+
private buildMissingShallOrMustMessage(action: 'ADDED' | 'MODIFIED', blockName: string): string {
458+
const base = `${action} "${blockName}" must contain SHALL or MUST`;
459+
if (this.containsShallOrMust(blockName)) {
460+
return `${base} in the requirement body, not only in the header. Move the SHALL/MUST statement to the line immediately after the "### Requirement: ..." header.`;
461+
}
462+
return base;
463+
}
464+
447465
private countScenarios(blockRaw: string): number {
448466
const matches = blockRaw.match(/^####\s+/gm);
449467
return matches ? matches.length : 0;

test/core/validation.test.ts

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -535,6 +535,92 @@ The system will log all events.
535535
expect(report.issues.some(i => i.message.includes('must contain SHALL or MUST'))).toBe(true);
536536
});
537537

538+
it('should hint the author when ADDED requirement only has SHALL/MUST in the header', async () => {
539+
const changeDir = path.join(testDir, 'test-change-shall-in-header-added');
540+
const specsDir = path.join(changeDir, 'specs', 'test-spec');
541+
await fs.mkdir(specsDir, { recursive: true });
542+
543+
const deltaSpec = `# Test Spec
544+
545+
## ADDED Requirements
546+
547+
### Requirement: The system SHALL log all errors
548+
Error handling logic goes here.
549+
550+
#### Scenario: Error occurs
551+
**Given** an error
552+
**When** it occurs
553+
**Then** it is logged`;
554+
555+
const specPath = path.join(specsDir, 'spec.md');
556+
await fs.writeFile(specPath, deltaSpec);
557+
558+
const validator = new Validator(true);
559+
const report = await validator.validateChangeDeltaSpecs(changeDir);
560+
561+
expect(report.valid).toBe(false);
562+
const shallMessage = report.issues.find(i => i.message.includes('must contain SHALL or MUST'));
563+
expect(shallMessage?.message).toContain('not only in the header');
564+
expect(shallMessage?.message).toContain('### Requirement:');
565+
});
566+
567+
it('should hint the author when MODIFIED requirement only has SHALL/MUST in the header', async () => {
568+
const changeDir = path.join(testDir, 'test-change-shall-in-header-modified');
569+
const specsDir = path.join(changeDir, 'specs', 'test-spec');
570+
await fs.mkdir(specsDir, { recursive: true });
571+
572+
const deltaSpec = `# Test Spec
573+
574+
## MODIFIED Requirements
575+
576+
### Requirement: The system MUST validate user input
577+
Please describe how validation should work here.
578+
579+
#### Scenario: Invalid input
580+
**Given** invalid input
581+
**When** validation runs
582+
**Then** an error surfaces`;
583+
584+
const specPath = path.join(specsDir, 'spec.md');
585+
await fs.writeFile(specPath, deltaSpec);
586+
587+
const validator = new Validator(true);
588+
const report = await validator.validateChangeDeltaSpecs(changeDir);
589+
590+
expect(report.valid).toBe(false);
591+
const shallMessage = report.issues.find(i => i.message.includes('must contain SHALL or MUST'));
592+
expect(shallMessage?.message).toContain('not only in the header');
593+
expect(shallMessage?.message).toContain('### Requirement:');
594+
});
595+
596+
it('should keep the generic SHALL/MUST error when neither header nor body contain the keyword', async () => {
597+
const changeDir = path.join(testDir, 'test-change-shall-nowhere');
598+
const specsDir = path.join(changeDir, 'specs', 'test-spec');
599+
await fs.mkdir(specsDir, { recursive: true });
600+
601+
const deltaSpec = `# Test Spec
602+
603+
## ADDED Requirements
604+
605+
### Requirement: Logging Feature
606+
The system will log all events.
607+
608+
#### Scenario: Event occurs
609+
**Given** an event
610+
**When** it occurs
611+
**Then** it is logged`;
612+
613+
const specPath = path.join(specsDir, 'spec.md');
614+
await fs.writeFile(specPath, deltaSpec);
615+
616+
const validator = new Validator(true);
617+
const report = await validator.validateChangeDeltaSpecs(changeDir);
618+
619+
expect(report.valid).toBe(false);
620+
const shallMessage = report.issues.find(i => i.message.includes('must contain SHALL or MUST'));
621+
expect(shallMessage?.message).not.toContain('not only in the header');
622+
});
623+
538624
it('should handle requirements without metadata fields', async () => {
539625
const changeDir = path.join(testDir, 'test-change-4');
540626
const specsDir = path.join(changeDir, 'specs', 'test-spec');

0 commit comments

Comments
 (0)