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
12 changes: 6 additions & 6 deletions openspec/changes/update-markdown-parser-crlf/tasks.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 3 additions & 2 deletions src/core/parsers/change-parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 } = {};

Expand All @@ -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[] = [];

Expand Down
7 changes: 6 additions & 1 deletion src/core/parsers/markdown-parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 || '';
Expand Down
16 changes: 11 additions & 5 deletions src/core/parsers/requirement-blocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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'] || '');
Expand Down Expand Up @@ -136,7 +142,7 @@ function splitTopLevelSections(content: string): Record<string, string> {

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) {
Expand All @@ -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) {
Expand All @@ -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*$/);
Expand Down
40 changes: 40 additions & 0 deletions test/commands/validate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
});
});


19 changes: 19 additions & 0 deletions test/core/parsers/markdown-parser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
Loading