diff --git a/.changeset/calm-fences-preserve.md b/.changeset/calm-fences-preserve.md new file mode 100644 index 0000000000..2115a8ce6f --- /dev/null +++ b/.changeset/calm-fences-preserve.md @@ -0,0 +1,7 @@ +--- +"@fission-ai/openspec": patch +--- + +### Bug Fixes + +- Preserve internal literal blank-line runs in backtick- and tilde-fenced code blocks when applying unrelated spec changes. diff --git a/src/core/specs-apply.ts b/src/core/specs-apply.ts index 81d5516cf4..a00fd8ee60 100644 --- a/src/core/specs-apply.ts +++ b/src/core/specs-apply.ts @@ -561,11 +561,11 @@ export async function buildUpdatedSpec( // glued the heading to the Purpose paragraph and the first requirement, so // every archive rewrote a well-formatted spec into that shape. Separate // non-empty slices with one blank line instead. - const rebuilt = [parts.before.trimEnd(), parts.headerLine, reqBody, parts.after.trim()] - .filter((s) => s !== '') - .join('\n\n') - .replace(/\n{3,}/g, '\n\n') - .trimEnd() + '\n'; + const rebuilt = collapseExcessiveBlankLinesOutsideFences( + [parts.before.trimEnd(), parts.headerLine, reqBody, parts.after.trim()] + .filter((s) => s !== '') + .join('\n\n') + ).trimEnd() + '\n'; return { rebuilt, @@ -586,6 +586,32 @@ export async function buildUpdatedSpec( }; } +/** + * Match the serializer's established blank-line collapse outside code fences + * while preserving runs of literal empty lines inside recognized fences. + */ +function collapseExcessiveBlankLinesOutsideFences(content: string): string { + const lines = content.split('\n'); + const fenceMask = buildCodeFenceMask(lines); + const output = [lines[0] ?? '']; + let consecutiveOutsideNewlines = 0; + + for (let index = 1; index < lines.length; index++) { + const insideFence = fenceMask[index - 1] && fenceMask[index]; + if (insideFence || consecutiveOutsideNewlines < 2) { + output.push('\n'); + } + + consecutiveOutsideNewlines = insideFence ? 0 : consecutiveOutsideNewlines + 1; + output.push(lines[index]); + if (lines[index] !== '') { + consecutiveOutsideNewlines = 0; + } + } + + return output.join(''); +} + /** * The suffix of a requirement block that begins with content the requirement * parser did not recognize as a boundary: a `#`, `##`, or `###` heading after diff --git a/test/core/specs-apply.serialization.test.ts b/test/core/specs-apply.serialization.test.ts index 3edad0260c..ba19f0e4ee 100644 --- a/test/core/specs-apply.serialization.test.ts +++ b/test/core/specs-apply.serialization.test.ts @@ -129,4 +129,69 @@ describe('spec serialization', () => { 'serializer output.\n\n## Requirements\n\n### Requirement: Existing behavior' ); }); + + it('preserves internal blank-line runs in untouched fences while collapsing prose', async () => { + const fencedSpec = [ + '# demo Specification', + '', + '## Purpose', + 'Purpose prose before.', + '', + '', + '', + 'Purpose prose after.', + '', + '```text', + 'purpose fence before', + '', + '', + 'purpose fence after', + '```', + '', + '## Requirements', + ORIGINAL_REQUIREMENT, + '', + '### Requirement: Untouched fenced example', + 'The project SHALL preserve fenced examples.', + '', + '', + '~~~text', + 'requirement fence before', + '', + '', + 'requirement fence after', + '~~~', + '', + '', + '#### Scenario: Untouched path', + '- **WHEN** an unrelated requirement is updated', + '- **THEN** the fenced example SHALL keep its internal blank lines', + '', + 'Whitespace-only prose boundary.', + ' ', + '', + '', + 'Whitespace-only prose after.', + '', + ].join('\n'); + + const result = await build(fencedSpec); + + expect(result.counts.modified).toBe(1); + expect(result.rebuilt).toContain('The project SHALL expose the updated behavior.'); + expect(result.rebuilt).toContain( + '```text\npurpose fence before\n\n\npurpose fence after\n```' + ); + expect(result.rebuilt).toContain( + '~~~text\nrequirement fence before\n\n\nrequirement fence after\n~~~' + ); + expect(result.rebuilt).toContain('fenced examples.\n\n~~~text'); + expect(result.rebuilt).toContain('~~~\n\n#### Scenario: Untouched path'); + expect(result.rebuilt).toContain('Purpose prose before.\n\nPurpose prose after.'); + expect(result.rebuilt).not.toContain('Purpose prose before.\n\n\nPurpose prose after.'); + expect(result.rebuilt).toContain( + 'Whitespace-only prose boundary.\n \n\nWhitespace-only prose after.' + ); + expectOneFinalLf(result.rebuilt); + }); });