diff --git a/openspec/changes/update-markdown-parser-crlf/tasks.md b/openspec/changes/update-markdown-parser-crlf/tasks.md index a1c6706bb..1c5622611 100644 --- a/openspec/changes/update-markdown-parser-crlf/tasks.md +++ b/openspec/changes/update-markdown-parser-crlf/tasks.md @@ -1,11 +1,11 @@ ## 1. Guard the regression -- [ ] 1.1 Add a unit test that feeds a CRLF change document into `MarkdownParser.parseChange` and asserts `Why`/`What Changes` are detected. -- [ ] 1.2 Add a CLI spawn/e2e test that writes a CRLF change, runs `openspec validate`, and expects success. +- [x] 1.1 Add a unit test that feeds a CRLF change document into `MarkdownParser.parseChange` and asserts `Why`/`What Changes` are detected. +- [x] 1.2 Add a CLI spawn/e2e test that writes a CRLF change, runs `openspec validate`, and expects success. ## 2. Normalize parsing -- [ ] 2.1 Normalize line endings when constructing `MarkdownParser` so headers and content comparisons ignore `\r`. -- [ ] 2.2 Ensure all CLI entry points (validate, view, spec conversion) reuse the normalized parser path. +- [x] 2.1 Normalize line endings when constructing `MarkdownParser` so headers and content comparisons ignore `\r`. +- [x] 2.2 Ensure all CLI entry points (validate, view, spec conversion) reuse the normalized parser path. ## 3. Document and verify -- [ ] 3.1 Update the `cli-validate` spec with a scenario covering CRLF line endings. -- [ ] 3.2 Run the parser and CLI test suites (`pnpm test`, relevant spawn tests) to confirm the fix. +- [x] 3.1 Update the `cli-validate` spec with a scenario covering CRLF line endings. +- [x] 3.2 Run the parser and CLI test suites (`pnpm test`, relevant spawn tests) to confirm the fix. diff --git a/src/core/parsers/change-parser.ts b/src/core/parsers/change-parser.ts index 7ec7c20a2..0c8d1e280 100644 --- a/src/core/parsers/change-parser.ts +++ b/src/core/parsers/change-parser.ts @@ -150,7 +150,7 @@ export class ChangeParser extends MarkdownParser { private parseRenames(content: string): Array<{ from: string; to: string }> { const renames: Array<{ from: string; to: string }> = []; - const lines = content.split('\n'); + const lines = ChangeParser.normalizeContent(content).split('\n'); let currentRename: { from?: string; to?: string } = {}; @@ -177,7 +177,8 @@ export class ChangeParser extends MarkdownParser { } private parseSectionsFromContent(content: string): Section[] { - const lines = content.split('\n'); + const normalizedContent = ChangeParser.normalizeContent(content); + const lines = normalizedContent.split('\n'); const sections: Section[] = []; const stack: Section[] = []; diff --git a/src/core/parsers/markdown-parser.ts b/src/core/parsers/markdown-parser.ts index ce05df2ca..8bd59d1ae 100644 --- a/src/core/parsers/markdown-parser.ts +++ b/src/core/parsers/markdown-parser.ts @@ -12,10 +12,15 @@ export class MarkdownParser { private currentLine: number; constructor(content: string) { - this.lines = content.split('\n'); + const normalized = MarkdownParser.normalizeContent(content); + this.lines = normalized.split('\n'); this.currentLine = 0; } + protected static normalizeContent(content: string): string { + return content.replace(/\r\n?/g, '\n'); + } + parseSpec(name: string): Spec { const sections = this.parseSections(); const purpose = this.findSection(sections, 'Purpose')?.content || ''; diff --git a/src/core/parsers/requirement-blocks.ts b/src/core/parsers/requirement-blocks.ts index 7c38ab926..a0919ec41 100644 --- a/src/core/parsers/requirement-blocks.ts +++ b/src/core/parsers/requirement-blocks.ts @@ -22,7 +22,8 @@ const REQUIREMENT_HEADER_REGEX = /^###\s*Requirement:\s*(.+)\s*$/; * Extracts the Requirements section from a spec file and parses requirement blocks. */ export function extractRequirementsSection(content: string): RequirementsSectionParts { - const lines = content.split('\n'); + const normalized = normalizeLineEndings(content); + const lines = normalized.split('\n'); const reqHeaderIndex = lines.findIndex(l => /^##\s+Requirements\s*$/i.test(l)); if (reqHeaderIndex === -1) { @@ -102,11 +103,16 @@ export interface DeltaPlan { renamed: Array<{ from: string; to: string }>; } +function normalizeLineEndings(content: string): string { + return content.replace(/\r\n?/g, '\n'); +} + /** * Parse a delta-formatted spec change file content into a DeltaPlan with raw blocks. */ export function parseDeltaSpec(content: string): DeltaPlan { - const sections = splitTopLevelSections(content); + 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'] || ''); @@ -136,7 +142,7 @@ function splitTopLevelSections(content: string): Record { function parseRequirementBlocksFromSection(sectionBody: string): RequirementBlock[] { if (!sectionBody) return []; - const lines = sectionBody.split('\n'); + const lines = normalizeLineEndings(sectionBody).split('\n'); const blocks: RequirementBlock[] = []; let i = 0; while (i < lines.length) { @@ -161,7 +167,7 @@ function parseRequirementBlocksFromSection(sectionBody: string): RequirementBloc function parseRemovedNames(sectionBody: string): string[] { if (!sectionBody) return []; const names: string[] = []; - const lines = sectionBody.split('\n'); + const lines = normalizeLineEndings(sectionBody).split('\n'); for (const line of lines) { const m = line.match(REQUIREMENT_HEADER_REGEX); if (m) { @@ -180,7 +186,7 @@ function parseRemovedNames(sectionBody: string): string[] { function parseRenamedPairs(sectionBody: string): Array<{ from: string; to: string }> { if (!sectionBody) return []; const pairs: Array<{ from: string; to: string }> = []; - const lines = sectionBody.split('\n'); + const lines = normalizeLineEndings(sectionBody).split('\n'); let current: { from?: string; to?: string } = {}; for (const line of lines) { const fromMatch = line.match(/^\s*-?\s*FROM:\s*`?###\s*Requirement:\s*(.+?)`?\s*$/); diff --git a/test/commands/validate.test.ts b/test/commands/validate.test.ts index 5e4254c3b..d02175e89 100644 --- a/test/commands/validate.test.ts +++ b/test/commands/validate.test.ts @@ -117,6 +117,46 @@ Given A\nWhen B\nThen C`; process.chdir(originalCwd); } }); + + it('accepts change proposals saved with CRLF line endings', async () => { + const changeId = 'crlf-change'; + const toCrlf = (segments: string[]) => segments.join('\n').replace(/\n/g, '\r\n'); + + const crlfContent = toCrlf([ + '# CRLF Proposal', + '', + '## Why', + 'This change verifies validation works with Windows line endings.', + '', + '## What Changes', + '- **alpha:** Ensure validation passes on CRLF files', + ]); + + await fs.mkdir(path.join(changesDir, changeId), { recursive: true }); + await fs.writeFile(path.join(changesDir, changeId, 'proposal.md'), crlfContent, 'utf-8'); + + const deltaContent = toCrlf([ + '## ADDED Requirements', + '### Requirement: Parser SHALL accept CRLF change proposals', + 'The parser SHALL accept CRLF change proposals without manual edits.', + '', + '#### Scenario: Validate CRLF change', + '- **WHEN** a developer runs openspec validate on the proposal', + '- **THEN** validation succeeds without section errors', + ]); + + const deltaDir = path.join(changesDir, changeId, 'specs', 'alpha'); + await fs.mkdir(deltaDir, { recursive: true }); + await fs.writeFile(path.join(deltaDir, 'spec.md'), deltaContent, 'utf-8'); + + const originalCwd = process.cwd(); + try { + process.chdir(testDir); + expect(() => execSync(`node ${bin} validate ${changeId}`, { encoding: 'utf-8' })).not.toThrow(); + } finally { + process.chdir(originalCwd); + } + }); }); diff --git a/test/core/parsers/markdown-parser.test.ts b/test/core/parsers/markdown-parser.test.ts index de88557c8..502f575b4 100644 --- a/test/core/parsers/markdown-parser.test.ts +++ b/test/core/parsers/markdown-parser.test.ts @@ -169,6 +169,25 @@ Some general description of changes without specific deltas`; expect(change.deltas).toHaveLength(0); }); + + it('parses change documents saved with CRLF line endings', () => { + const crlfContent = [ + '# CRLF Change', + '', + '## Why', + 'Reasons on Windows editors should parse like POSIX environments.', + '', + '## What Changes', + '- **alpha:** Add cross-platform parsing coverage', + ].join('\r\n'); + + const parser = new MarkdownParser(crlfContent); + const change = parser.parseChange('crlf-change'); + + expect(change.why).toContain('Windows editors should parse'); + expect(change.deltas).toHaveLength(1); + expect(change.deltas[0].spec).toBe('alpha'); + }); }); describe('section parsing', () => {