Skip to content
Open
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
7 changes: 7 additions & 0 deletions .changeset/calm-fences-preserve.md
Original file line number Diff line number Diff line change
@@ -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.
36 changes: 31 additions & 5 deletions src/core/specs-apply.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand Down
65 changes: 65 additions & 0 deletions test/core/specs-apply.serialization.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
Loading