Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 92 additions & 4 deletions src/core/validation/validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ export class Validator {
const result = SpecSchema.safeParse(spec);

if (!result.success) {
issues.push(...this.convertZodErrors(result.error));
issues.push(...this.convertSpecZodErrors(result.error, content));
}

issues.push(...this.applySpecRules(spec, content));
Expand Down Expand Up @@ -61,7 +61,7 @@ export class Validator {
const spec = parser.parseSpec(specName);
const result = SpecSchema.safeParse(spec);
if (!result.success) {
issues.push(...this.convertZodErrors(result.error));
issues.push(...this.convertSpecZodErrors(result.error, content));
}
issues.push(...this.applySpecRules(spec, content));
} catch (error) {
Expand Down Expand Up @@ -287,6 +287,94 @@ export class Validator {
});
}

private convertSpecZodErrors(error: ZodError, content: string): ValidationIssue[] {
const requirementNames = this.extractMainSpecRequirementNames(content);

return error.issues.map(err => {
let message = err.message;
if (message === VALIDATION_MESSAGES.REQUIREMENT_NO_SHALL) {
const requirementIndex = this.getRequirementIndexFromPath(err.path);
const requirementName = requirementIndex === undefined
? undefined
: requirementNames[requirementIndex];
if (requirementName && this.containsShallOrMust(requirementName)) {
message = this.buildMissingShallOrMustMessage('Requirement', requirementName);
}
}
return {
level: 'ERROR' as ValidationLevel,
path: err.path.join('.'),
message,
};
});
}

private getRequirementIndexFromPath(path: PropertyKey[]): number | undefined {
if (path.length >= 3 && path[0] === 'requirements' && typeof path[1] === 'number' && path[2] === 'text') {
return path[1];
}
return undefined;
}

private extractMainSpecRequirementNames(content: string): string[] {
const lines = content.replace(/\r\n?/g, '\n').split('\n');
const names: string[] = [];
let activeFence: { marker: '`' | '~'; length: number } | null = null;
let requirementsLevel: number | undefined;
let requirementLevel: number | undefined;

for (const line of lines) {
if (activeFence) {
const closingFence = line.match(/^\s*(`{3,}|~{3,})\s*$/);
if (
closingFence &&
closingFence[1][0] === activeFence.marker &&
closingFence[1].length >= activeFence.length
) {
activeFence = null;
}
continue;
}

const openingFence = line.match(/^\s*(`{3,}|~{3,})/);
if (openingFence) {
activeFence = {
marker: openingFence[1][0] as '`' | '~',
length: openingFence[1].length,
};
continue;
}

const headerMatch = line.match(/^(#{1,6})\s+(.+)$/);
if (!headerMatch) continue;

const level = headerMatch[1].length;
const title = headerMatch[2].trim();

if (requirementsLevel === undefined) {
if (title.toLowerCase() === 'requirements') {
requirementsLevel = level;
}
continue;
}

if (level <= requirementsLevel) {
break;
}

if (requirementLevel === undefined && level > requirementsLevel) {
requirementLevel = level;
}

if (level === requirementLevel) {
const requirementMatch = title.match(/^Requirement:\s*(.+)$/i);
names.push((requirementMatch ? requirementMatch[1] : title).trim());
}
}

return names;
}

private applySpecRules(spec: Spec, content: string): ValidationIssue[] {
const issues: ValidationIssue[] = [];

Expand Down Expand Up @@ -454,10 +542,10 @@ export class Validator {
* on the requirement body line (the line right after the header), so we point
* the author at that exact fix when the keyword is found in the header only.
*/
private buildMissingShallOrMustMessage(action: 'ADDED' | 'MODIFIED', blockName: string): string {
private buildMissingShallOrMustMessage(action: 'ADDED' | 'MODIFIED' | 'Requirement', blockName: string): string {
const base = `${action} "${blockName}" must contain SHALL or MUST`;
if (this.containsShallOrMust(blockName)) {
return `${base} in the requirement body, not only in the header. Move the SHALL/MUST statement to the line immediately after the "### Requirement: ..." header.`;
return `${base} in the requirement body, not only in the header. Move the SHALL/MUST statement to the line immediately after the requirement header.`;
}
return base;
}
Expand Down
38 changes: 36 additions & 2 deletions test/core/validation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,40 @@ Then they see an error message`;
expect(report.summary.errors).toBe(0);
});

it('should hint when a main spec requirement only has SHALL/MUST in the header', async () => {
for (const [caseName, requirementHeader] of [
['prefixed', '### Requirement: System SHALL only say the keyword here'],
['bare', '### System SHALL only say the keyword here'],
]) {
const specContent = `# Header Demo Specification

## Purpose
This specification checks validation guidance for requirements whose keywords only appear in headings.

## Requirements

${requirementHeader}
This body line omits the normative keyword.

#### Scenario: Missing body keyword
- **WHEN** the spec is validated
- **THEN** it should fail with a targeted hint`;

const specPath = path.join(testDir, `spec-${caseName}.md`);
await fs.writeFile(specPath, specContent);

const validator = new Validator();
const report = await validator.validateSpec(specPath);

expect(report.valid).toBe(false);
const shallMessage = report.issues.find(i => i.path === 'requirements.0.text');
expect(shallMessage?.message).toContain('not only in the header');
expect(shallMessage?.message).toContain('Move the SHALL/MUST statement');
expect(shallMessage?.message).toContain('requirement header');
expect(report.issues.some(i => i.message === 'Requirement must contain SHALL or MUST keyword')).toBe(false);
}
});

it('should detect missing overview section', async () => {
const specContent = `# User Authentication Spec

Expand Down Expand Up @@ -561,7 +595,7 @@ Error handling logic goes here.
expect(report.valid).toBe(false);
const shallMessage = report.issues.find(i => i.message.includes('must contain SHALL or MUST'));
expect(shallMessage?.message).toContain('not only in the header');
expect(shallMessage?.message).toContain('### Requirement:');
expect(shallMessage?.message).toContain('requirement header');
});

it('should hint the author when MODIFIED requirement only has SHALL/MUST in the header', async () => {
Expand Down Expand Up @@ -590,7 +624,7 @@ Please describe how validation should work here.
expect(report.valid).toBe(false);
const shallMessage = report.issues.find(i => i.message.includes('must contain SHALL or MUST'));
expect(shallMessage?.message).toContain('not only in the header');
expect(shallMessage?.message).toContain('### Requirement:');
expect(shallMessage?.message).toContain('requirement header');
});

it('should keep the generic SHALL/MUST error when neither header nor body contain the keyword', async () => {
Expand Down
Loading