From 9c6789cfccd0d450446dab0b25b2effcd37c5eb4 Mon Sep 17 00:00:00 2001 From: Marzx13 <28298824+Marzx13@users.noreply.github.com> Date: Mon, 31 Aug 2026 01:04:26 -0400 Subject: [PATCH 1/3] fix(archive): preserve fenced blank-line runs --- .changeset/calm-fences-preserve.md | 7 +++ src/core/specs-apply.ts | 36 ++++++++++++-- test/core/specs-apply.serialization.test.ts | 52 +++++++++++++++++++++ 3 files changed, 90 insertions(+), 5 deletions(-) create mode 100644 .changeset/calm-fences-preserve.md diff --git a/.changeset/calm-fences-preserve.md b/.changeset/calm-fences-preserve.md new file mode 100644 index 0000000000..2fa6c59959 --- /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..c3f2f23b85 100644 --- a/test/core/specs-apply.serialization.test.ts +++ b/test/core/specs-apply.serialization.test.ts @@ -129,4 +129,56 @@ 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', + '', + ].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('Purpose prose before.\n\nPurpose prose after.'); + expect(result.rebuilt).not.toContain('Purpose prose before.\n\n\nPurpose prose after.'); + expectOneFinalLf(result.rebuilt); + }); }); From 3fb493c82568d8082ae4931562019df1a2be3c87 Mon Sep 17 00:00:00 2001 From: Marzx13 <28298824+Marzx13@users.noreply.github.com> Date: Mon, 31 Aug 2026 01:44:09 -0400 Subject: [PATCH 2/3] test(archive): cover fence normalization boundaries --- test/core/specs-apply.serialization.test.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/test/core/specs-apply.serialization.test.ts b/test/core/specs-apply.serialization.test.ts index c3f2f23b85..ba19f0e4ee 100644 --- a/test/core/specs-apply.serialization.test.ts +++ b/test/core/specs-apply.serialization.test.ts @@ -154,6 +154,7 @@ describe('spec serialization', () => { '### Requirement: Untouched fenced example', 'The project SHALL preserve fenced examples.', '', + '', '~~~text', 'requirement fence before', '', @@ -161,10 +162,17 @@ describe('spec serialization', () => { '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); @@ -177,8 +185,13 @@ describe('spec serialization', () => { 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); }); }); From c572572c3675ad63bb845cef1964887459c451fc Mon Sep 17 00:00:00 2001 From: Marzx13 <28298824+Marzx13@users.noreply.github.com> Date: Mon, 31 Aug 2026 10:38:38 -0400 Subject: [PATCH 3/3] docs(changeset): clarify fenced code wording --- .changeset/calm-fences-preserve.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/calm-fences-preserve.md b/.changeset/calm-fences-preserve.md index 2fa6c59959..2115a8ce6f 100644 --- a/.changeset/calm-fences-preserve.md +++ b/.changeset/calm-fences-preserve.md @@ -4,4 +4,4 @@ ### Bug Fixes -- Preserve internal literal blank-line runs in backtick and tilde fenced code blocks when applying unrelated spec changes. +- Preserve internal literal blank-line runs in backtick- and tilde-fenced code blocks when applying unrelated spec changes.