Skip to content

Commit ecddffc

Browse files
authored
fix: improve delta spec validation with case-insensitive headers and empty section detection (#191)
This commit enhances the validation logic for delta specs: - Delta section headers are now parsed case-insensitively (e.g., "Added Requirements" and "ADDED Requirements" both work) - Empty delta sections now produce clear error messages guiding users to add requirement entries - Specs with no delta headers at all now receive specific error messages - Added test coverage for case-insensitive delta header parsing
1 parent 822464e commit ecddffc

File tree

3 files changed

+100
-8
lines changed

3 files changed

+100
-8
lines changed

src/core/parsers/requirement-blocks.ts

Lines changed: 34 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,12 @@ export interface DeltaPlan {
101101
modified: RequirementBlock[];
102102
removed: string[]; // requirement names
103103
renamed: Array<{ from: string; to: string }>;
104+
sectionPresence: {
105+
added: boolean;
106+
modified: boolean;
107+
removed: boolean;
108+
renamed: boolean;
109+
};
104110
}
105111

106112
function normalizeLineEndings(content: string): string {
@@ -113,11 +119,26 @@ function normalizeLineEndings(content: string): string {
113119
export function parseDeltaSpec(content: string): DeltaPlan {
114120
const normalized = normalizeLineEndings(content);
115121
const sections = splitTopLevelSections(normalized);
116-
const added = parseRequirementBlocksFromSection(sections['ADDED Requirements'] || '');
117-
const modified = parseRequirementBlocksFromSection(sections['MODIFIED Requirements'] || '');
118-
const removedNames = parseRemovedNames(sections['REMOVED Requirements'] || '');
119-
const renamedPairs = parseRenamedPairs(sections['RENAMED Requirements'] || '');
120-
return { added, modified, removed: removedNames, renamed: renamedPairs };
122+
const addedLookup = getSectionCaseInsensitive(sections, 'ADDED Requirements');
123+
const modifiedLookup = getSectionCaseInsensitive(sections, 'MODIFIED Requirements');
124+
const removedLookup = getSectionCaseInsensitive(sections, 'REMOVED Requirements');
125+
const renamedLookup = getSectionCaseInsensitive(sections, 'RENAMED Requirements');
126+
const added = parseRequirementBlocksFromSection(addedLookup.body);
127+
const modified = parseRequirementBlocksFromSection(modifiedLookup.body);
128+
const removedNames = parseRemovedNames(removedLookup.body);
129+
const renamedPairs = parseRenamedPairs(renamedLookup.body);
130+
return {
131+
added,
132+
modified,
133+
removed: removedNames,
134+
renamed: renamedPairs,
135+
sectionPresence: {
136+
added: addedLookup.found,
137+
modified: modifiedLookup.found,
138+
removed: removedLookup.found,
139+
renamed: renamedLookup.found,
140+
},
141+
};
121142
}
122143

123144
function splitTopLevelSections(content: string): Record<string, string> {
@@ -140,6 +161,14 @@ function splitTopLevelSections(content: string): Record<string, string> {
140161
return result;
141162
}
142163

164+
function getSectionCaseInsensitive(sections: Record<string, string>, desired: string): { body: string; found: boolean } {
165+
const target = desired.toLowerCase();
166+
for (const [title, body] of Object.entries(sections)) {
167+
if (title.toLowerCase() === target) return { body, found: true };
168+
}
169+
return { body: '', found: false };
170+
}
171+
143172
function parseRequirementBlocksFromSection(sectionBody: string): RequirementBlock[] {
144173
if (!sectionBody) return [];
145174
const lines = normalizeLineEndings(sectionBody).split('\n');
@@ -203,5 +232,3 @@ function parseRenamedPairs(sectionBody: string): Array<{ from: string; to: strin
203232
}
204233
return pairs;
205234
}
206-
207-

src/core/validation/validator.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,8 @@ export class Validator {
114114
const issues: ValidationIssue[] = [];
115115
const specsDir = path.join(changeDir, 'specs');
116116
let totalDeltas = 0;
117+
const missingHeaderSpecs: string[] = [];
118+
const emptySectionSpecs: Array<{ path: string; sections: string[] }> = [];
117119

118120
try {
119121
const entries = await fs.readdir(specsDir, { withFileTypes: true });
@@ -130,6 +132,17 @@ export class Validator {
130132

131133
const plan = parseDeltaSpec(content);
132134
const entryPath = `${specName}/spec.md`;
135+
const sectionNames: string[] = [];
136+
if (plan.sectionPresence.added) sectionNames.push('## ADDED Requirements');
137+
if (plan.sectionPresence.modified) sectionNames.push('## MODIFIED Requirements');
138+
if (plan.sectionPresence.removed) sectionNames.push('## REMOVED Requirements');
139+
if (plan.sectionPresence.renamed) sectionNames.push('## RENAMED Requirements');
140+
const hasSections = sectionNames.length > 0;
141+
const hasEntries = plan.added.length + plan.modified.length + plan.removed.length + plan.renamed.length > 0;
142+
if (!hasEntries) {
143+
if (hasSections) emptySectionSpecs.push({ path: entryPath, sections: sectionNames });
144+
else missingHeaderSpecs.push(entryPath);
145+
}
133146

134147
const addedNames = new Set<string>();
135148
const modifiedNames = new Set<string>();
@@ -236,6 +249,21 @@ export class Validator {
236249
// If no specs dir, treat as no deltas
237250
}
238251

252+
for (const { path: specPath, sections } of emptySectionSpecs) {
253+
issues.push({
254+
level: 'ERROR',
255+
path: specPath,
256+
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).`,
257+
});
258+
}
259+
for (const path of missingHeaderSpecs) {
260+
issues.push({
261+
level: 'ERROR',
262+
path,
263+
message: 'No delta sections found. Add headers such as "## ADDED Requirements" or move non-delta notes outside specs/.',
264+
});
265+
}
266+
239267
if (totalDeltas === 0) {
240268
issues.push({ level: 'ERROR', path: 'file', message: this.enrichTopLevelError('change', VALIDATION_MESSAGES.CHANGE_NO_DELTAS) });
241269
}
@@ -409,4 +437,12 @@ export class Validator {
409437
const matches = blockRaw.match(/^####\s+/gm);
410438
return matches ? matches.length : 0;
411439
}
440+
441+
private formatSectionList(sections: string[]): string {
442+
if (sections.length === 0) return '';
443+
if (sections.length === 1) return sections[0];
444+
const head = sections.slice(0, -1);
445+
const last = sections[sections.length - 1];
446+
return `${head.join(', ')} and ${last}`;
447+
}
412448
}

test/core/validation.test.ts

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -456,5 +456,34 @@ The system SHALL implement this feature.
456456
expect(report.valid).toBe(true);
457457
expect(report.summary.errors).toBe(0);
458458
});
459+
460+
it('should treat delta headers case-insensitively', async () => {
461+
const changeDir = path.join(testDir, 'test-change-mixed-case');
462+
const specsDir = path.join(changeDir, 'specs', 'test-spec');
463+
await fs.mkdir(specsDir, { recursive: true });
464+
465+
const deltaSpec = `# Test Spec
466+
467+
## Added Requirements
468+
469+
### Requirement: Mixed Case Handling
470+
The system MUST support mixed case delta headers.
471+
472+
#### Scenario: Case insensitive parsing
473+
**Given** a delta file with mixed case headers
474+
**When** validation runs
475+
**Then** the delta is detected`;
476+
477+
const specPath = path.join(specsDir, 'spec.md');
478+
await fs.writeFile(specPath, deltaSpec);
479+
480+
const validator = new Validator(true);
481+
const report = await validator.validateChangeDeltaSpecs(changeDir);
482+
483+
expect(report.valid).toBe(true);
484+
expect(report.summary.errors).toBe(0);
485+
expect(report.summary.warnings).toBe(0);
486+
expect(report.summary.info).toBe(0);
487+
});
459488
});
460-
});
489+
});

0 commit comments

Comments
 (0)