Compare commits

...
Author SHA1 Message Date
Tabish Bidiwale 76f4f4c9dc Merge branch 'main' into fix-validation 2025-10-19 23:47:07 +11:00
Tabish Bidiwale 2fec61e69c fix: improve delta spec validation with case-insensitive headers and empty section detection
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
2025-10-16 18:07:51 +11:00
3 changed files with 100 additions and 8 deletions
+34 -7
View File
@@ -101,6 +101,12 @@ export interface DeltaPlan {
modified: RequirementBlock[];
removed: string[]; // requirement names
renamed: Array<{ from: string; to: string }>;
sectionPresence: {
added: boolean;
modified: boolean;
removed: boolean;
renamed: boolean;
};
}
function normalizeLineEndings(content: string): string {
@@ -113,11 +119,26 @@ function normalizeLineEndings(content: string): string {
export function parseDeltaSpec(content: string): DeltaPlan {
const normalized = normalizeLineEndings(content);
const sections = splitTopLevelSections(normalized);
const added = parseRequirementBlocksFromSection(sections['ADDED Requirements'] || '');
const modified = parseRequirementBlocksFromSection(sections['MODIFIED Requirements'] || '');
const removedNames = parseRemovedNames(sections['REMOVED Requirements'] || '');
const renamedPairs = parseRenamedPairs(sections['RENAMED Requirements'] || '');
return { added, modified, removed: removedNames, renamed: renamedPairs };
const addedLookup = getSectionCaseInsensitive(sections, 'ADDED Requirements');
const modifiedLookup = getSectionCaseInsensitive(sections, 'MODIFIED Requirements');
const removedLookup = getSectionCaseInsensitive(sections, 'REMOVED Requirements');
const renamedLookup = getSectionCaseInsensitive(sections, 'RENAMED Requirements');
const added = parseRequirementBlocksFromSection(addedLookup.body);
const modified = parseRequirementBlocksFromSection(modifiedLookup.body);
const removedNames = parseRemovedNames(removedLookup.body);
const renamedPairs = parseRenamedPairs(renamedLookup.body);
return {
added,
modified,
removed: removedNames,
renamed: renamedPairs,
sectionPresence: {
added: addedLookup.found,
modified: modifiedLookup.found,
removed: removedLookup.found,
renamed: renamedLookup.found,
},
};
}
function splitTopLevelSections(content: string): Record<string, string> {
@@ -140,6 +161,14 @@ function splitTopLevelSections(content: string): Record<string, string> {
return result;
}
function getSectionCaseInsensitive(sections: Record<string, string>, desired: string): { body: string; found: boolean } {
const target = desired.toLowerCase();
for (const [title, body] of Object.entries(sections)) {
if (title.toLowerCase() === target) return { body, found: true };
}
return { body: '', found: false };
}
function parseRequirementBlocksFromSection(sectionBody: string): RequirementBlock[] {
if (!sectionBody) return [];
const lines = normalizeLineEndings(sectionBody).split('\n');
@@ -203,5 +232,3 @@ function parseRenamedPairs(sectionBody: string): Array<{ from: string; to: strin
}
return pairs;
}
+36
View File
@@ -114,6 +114,8 @@ export class Validator {
const issues: ValidationIssue[] = [];
const specsDir = path.join(changeDir, 'specs');
let totalDeltas = 0;
const missingHeaderSpecs: string[] = [];
const emptySectionSpecs: Array<{ path: string; sections: string[] }> = [];
try {
const entries = await fs.readdir(specsDir, { withFileTypes: true });
@@ -130,6 +132,17 @@ export class Validator {
const plan = parseDeltaSpec(content);
const entryPath = `${specName}/spec.md`;
const sectionNames: string[] = [];
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<string>();
const modifiedNames = new Set<string>();
@@ -236,6 +249,21 @@ export class Validator {
// 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) });
}
@@ -409,4 +437,12 @@ export class Validator {
const matches = blockRaw.match(/^####\s+/gm);
return matches ? matches.length : 0;
}
private formatSectionList(sections: string[]): string {
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}`;
}
}
+30 -1
View File
@@ -456,5 +456,34 @@ The system SHALL implement this feature.
expect(report.valid).toBe(true);
expect(report.summary.errors).toBe(0);
});
it('should treat delta headers case-insensitively', async () => {
const changeDir = path.join(testDir, 'test-change-mixed-case');
const specsDir = path.join(changeDir, 'specs', 'test-spec');
await fs.mkdir(specsDir, { recursive: true });
const deltaSpec = `# Test Spec
## Added Requirements
### Requirement: Mixed Case Handling
The system MUST support mixed case delta headers.
#### Scenario: Case insensitive parsing
**Given** a delta file with mixed case headers
**When** validation runs
**Then** the delta is detected`;
const specPath = path.join(specsDir, 'spec.md');
await fs.writeFile(specPath, deltaSpec);
const validator = new Validator(true);
const report = await validator.validateChangeDeltaSpecs(changeDir);
expect(report.valid).toBe(true);
expect(report.summary.errors).toBe(0);
expect(report.summary.warnings).toBe(0);
expect(report.summary.info).toBe(0);
});
});
});
});