From 3c8d6e6d6af8ae07f759f9840483979c2c089cc3 Mon Sep 17 00:00:00 2001 From: david Date: Fri, 1 May 2026 12:30:52 +0300 Subject: [PATCH 1/2] fix: make requirement header parsing case-insensitive --- src/core/parsers/requirement-blocks.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/core/parsers/requirement-blocks.ts b/src/core/parsers/requirement-blocks.ts index 7a8161a94f..e9729fdb3a 100644 --- a/src/core/parsers/requirement-blocks.ts +++ b/src/core/parsers/requirement-blocks.ts @@ -16,7 +16,7 @@ export function normalizeRequirementName(name: string): string { return name.trim(); } -const REQUIREMENT_HEADER_REGEX = /^###\s*Requirement:\s*(.+)\s*$/; +const REQUIREMENT_HEADER_REGEX = /^###\s*Requirement:\s*(.+)\s*$/i; /** * Extracts the Requirements section from a spec file and parses requirement blocks. @@ -58,7 +58,7 @@ export function extractRequirementsSection(content: string): RequirementsSection let preambleLines: string[] = []; // Collect preamble lines until first requirement header - while (cursor < sectionBodyLines.length && !/^###\s+Requirement:/.test(sectionBodyLines[cursor])) { + while (cursor < sectionBodyLines.length && !/^###\s+Requirement:/i.test(sectionBodyLines[cursor])) { preambleLines.push(sectionBodyLines[cursor]); cursor++; } @@ -76,7 +76,7 @@ export function extractRequirementsSection(content: string): RequirementsSection cursor++; // Gather lines until next requirement header or end of section const bodyLines: string[] = [headerLineCandidate]; - while (cursor < sectionBodyLines.length && !/^###\s+Requirement:/.test(sectionBodyLines[cursor]) && !/^##\s+/.test(sectionBodyLines[cursor])) { + while (cursor < sectionBodyLines.length && !/^###\s+Requirement:/i.test(sectionBodyLines[cursor]) && !/^##\s+/.test(sectionBodyLines[cursor])) { bodyLines.push(sectionBodyLines[cursor]); cursor++; } @@ -176,7 +176,7 @@ function parseRequirementBlocksFromSection(sectionBody: string): RequirementBloc let i = 0; while (i < lines.length) { // Seek next requirement header - while (i < lines.length && !/^###\s+Requirement:/.test(lines[i])) i++; + while (i < lines.length && !/^###\s+Requirement:/i.test(lines[i])) i++; if (i >= lines.length) break; const headerLine = lines[i]; const m = headerLine.match(REQUIREMENT_HEADER_REGEX); @@ -184,7 +184,7 @@ function parseRequirementBlocksFromSection(sectionBody: string): RequirementBloc const name = normalizeRequirementName(m[1]); const buf: string[] = [headerLine]; i++; - while (i < lines.length && !/^###\s+Requirement:/.test(lines[i]) && !/^##\s+/.test(lines[i])) { + while (i < lines.length && !/^###\s+Requirement:/i.test(lines[i]) && !/^##\s+/.test(lines[i])) { buf.push(lines[i]); i++; } From d620beabe67b371366304d2c28e3ff70f4a325cb Mon Sep 17 00:00:00 2001 From: david Date: Fri, 1 May 2026 12:51:59 +0300 Subject: [PATCH 2/2] fix: add tests and cover the rest of requirement header places --- src/core/parsers/requirement-blocks.ts | 8 ++-- src/core/parsers/spec-structure.ts | 2 +- src/core/specs-apply.ts | 2 +- test/core/parsers/requirement-blocks.test.ts | 46 ++++++++++++++++++++ 4 files changed, 52 insertions(+), 6 deletions(-) create mode 100644 test/core/parsers/requirement-blocks.test.ts diff --git a/src/core/parsers/requirement-blocks.ts b/src/core/parsers/requirement-blocks.ts index e9729fdb3a..afc55f8914 100644 --- a/src/core/parsers/requirement-blocks.ts +++ b/src/core/parsers/requirement-blocks.ts @@ -58,7 +58,7 @@ export function extractRequirementsSection(content: string): RequirementsSection let preambleLines: string[] = []; // Collect preamble lines until first requirement header - while (cursor < sectionBodyLines.length && !/^###\s+Requirement:/i.test(sectionBodyLines[cursor])) { + while (cursor < sectionBodyLines.length && !REQUIREMENT_HEADER_REGEX.test(sectionBodyLines[cursor])) { preambleLines.push(sectionBodyLines[cursor]); cursor++; } @@ -76,7 +76,7 @@ export function extractRequirementsSection(content: string): RequirementsSection cursor++; // Gather lines until next requirement header or end of section const bodyLines: string[] = [headerLineCandidate]; - while (cursor < sectionBodyLines.length && !/^###\s+Requirement:/i.test(sectionBodyLines[cursor]) && !/^##\s+/.test(sectionBodyLines[cursor])) { + while (cursor < sectionBodyLines.length && !REQUIREMENT_HEADER_REGEX.test(sectionBodyLines[cursor]) && !/^##\s+/.test(sectionBodyLines[cursor])) { bodyLines.push(sectionBodyLines[cursor]); cursor++; } @@ -176,7 +176,7 @@ function parseRequirementBlocksFromSection(sectionBody: string): RequirementBloc let i = 0; while (i < lines.length) { // Seek next requirement header - while (i < lines.length && !/^###\s+Requirement:/i.test(lines[i])) i++; + while (i < lines.length && !REQUIREMENT_HEADER_REGEX.test(lines[i])) i++; if (i >= lines.length) break; const headerLine = lines[i]; const m = headerLine.match(REQUIREMENT_HEADER_REGEX); @@ -184,7 +184,7 @@ function parseRequirementBlocksFromSection(sectionBody: string): RequirementBloc const name = normalizeRequirementName(m[1]); const buf: string[] = [headerLine]; i++; - while (i < lines.length && !/^###\s+Requirement:/i.test(lines[i]) && !/^##\s+/.test(lines[i])) { + while (i < lines.length && !REQUIREMENT_HEADER_REGEX.test(lines[i]) && !/^##\s+/.test(lines[i])) { buf.push(lines[i]); i++; } diff --git a/src/core/parsers/spec-structure.ts b/src/core/parsers/spec-structure.ts index 4be14fe86e..cfcfe0b1b7 100644 --- a/src/core/parsers/spec-structure.ts +++ b/src/core/parsers/spec-structure.ts @@ -1,7 +1,7 @@ const REQUIREMENTS_SECTION_HEADER = /^##\s+Requirements\s*$/i; const TOP_LEVEL_SECTION_HEADER = /^##\s+/; const DELTA_HEADER = /^##\s+(ADDED|MODIFIED|REMOVED|RENAMED)\s+Requirements\s*$/i; -const REQUIREMENT_HEADER = /^###\s+Requirement:\s*(.+)\s*$/; +const REQUIREMENT_HEADER = /^###\s+Requirement:\s*(.+)\s*$/i; export interface MainSpecStructureIssue { kind: 'delta-header' | 'requirement-outside-requirements'; diff --git a/src/core/specs-apply.ts b/src/core/specs-apply.ts index 70cf36b870..88142ec000 100644 --- a/src/core/specs-apply.ts +++ b/src/core/specs-apply.ts @@ -287,7 +287,7 @@ export async function buildUpdatedSpec( throw new Error(`${specName} MODIFIED failed for header "### Requirement: ${mod.name}" - not found`); } // Replace block with provided raw (ensure header line matches key) - const modHeaderMatch = mod.raw.split('\n')[0].match(/^###\s*Requirement:\s*(.+)\s*$/); + const modHeaderMatch = mod.raw.split('\n')[0].match(/^###\s*Requirement:\s*(.+)\s*$/i); if (!modHeaderMatch || normalizeRequirementName(modHeaderMatch[1]) !== key) { throw new Error( `${specName} MODIFIED failed for header "### Requirement: ${mod.name}" - header mismatch in content` diff --git a/test/core/parsers/requirement-blocks.test.ts b/test/core/parsers/requirement-blocks.test.ts new file mode 100644 index 0000000000..0635939392 --- /dev/null +++ b/test/core/parsers/requirement-blocks.test.ts @@ -0,0 +1,46 @@ +import { describe, it, expect } from 'vitest'; +import { extractRequirementsSection, parseDeltaSpec } from '../../../src/core/parsers/requirement-blocks.js'; + +describe('extractRequirementsSection', () => { + it('parses canonical ### Requirement: headers', () => { + const result = extractRequirementsSection(`## Requirements\n### Requirement: Foo\nThe system SHALL foo.\n`); + expect(result.bodyBlocks.length).toBe(1); + expect(result.bodyBlocks[0].name).toBe('Foo'); + }); + + it('regression: parses mixed-case ### requirement: headers without silently dropping them', () => { + const variants = [ + '### requirement: Lowercase', + '### REQUIREMENT: Uppercase', + '### Requirement: Canonical', + ]; + for (const header of variants) { + const result = extractRequirementsSection(`## Requirements\n${header}\nThe system SHALL foo.\n`); + expect(result.bodyBlocks.length).toBeGreaterThan(0); + expect(result.bodyBlocks[0].name).toBe(header.replace(/^###\s*requirement:\s*/i, '')); + } + }); + + it('regression: parses ###Requirement: header with no space after ### without silently dropping it', () => { + const result = extractRequirementsSection(`## Requirements\n###Requirement: NoSpace\nThe system SHALL foo.\n`); + expect(result.bodyBlocks.length).toBe(1); + expect(result.bodyBlocks[0].name).toBe('NoSpace'); + }); + + it('regression: multiple blocks where first uses no-space header are all parsed', () => { + const content = `## Requirements\n###Requirement: First\nThe system SHALL first.\n\n### Requirement: Second\nThe system SHALL second.\n`; + const result = extractRequirementsSection(content); + expect(result.bodyBlocks.length).toBe(2); + expect(result.bodyBlocks[0].name).toBe('First'); + expect(result.bodyBlocks[1].name).toBe('Second'); + }); +}); + +describe('parseDeltaSpec', () => { + it('regression: parses ###Requirement: header with no space in delta ADDED section', () => { + const content = `## ADDED Requirements\n###Requirement: NoSpace\nThe system SHALL foo.\n`; + const result = parseDeltaSpec(content); + expect(result.added.length).toBe(1); + expect(result.added[0].name).toBe('NoSpace'); + }); +});