Skip to content
Merged
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
41 changes: 34 additions & 7 deletions src/core/parsers/requirement-blocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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> {
Expand All @@ -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');
Expand Down Expand Up @@ -203,5 +232,3 @@ function parseRenamedPairs(sectionBody: string): Array<{ from: string; to: strin
}
return pairs;
}


36 changes: 36 additions & 0 deletions src/core/validation/validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand All @@ -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>();
Expand Down Expand Up @@ -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) });
}
Expand Down Expand Up @@ -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}`;
}
}
31 changes: 30 additions & 1 deletion test/core/validation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
});
});
Loading