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
5 changes: 5 additions & 0 deletions .changeset/canonicalize-spec-eof.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@fission-ai/openspec": patch
---

Canonicalize rebuilt specs to end with exactly one final LF. Previously a spec whose `## Requirements` section was last was rebuilt with a trailing blank line (`\n\n`), which failed Markdown whitespace checks after sync or archive. Internal spacing and content after the Requirements section are unchanged.
3 changes: 2 additions & 1 deletion src/core/specs-apply.ts
Original file line number Diff line number Diff line change
Expand Up @@ -548,7 +548,8 @@ export async function buildUpdatedSpec(
const rebuilt = [parts.before.trimEnd(), parts.headerLine, reqBody, parts.after]
.filter((s, idx) => !(idx === 0 && s === ''))
.join('\n')
.replace(/\n{3,}/g, '\n\n');
.replace(/\n{3,}/g, '\n\n')
.trimEnd() + '\n';
Comment thread
coderabbitai[bot] marked this conversation as resolved.

return {
rebuilt,
Expand Down
93 changes: 93 additions & 0 deletions test/core/specs-apply.serialization.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { promises as fs } from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { buildUpdatedSpec, findSpecUpdates } from '../../src/core/specs-apply.js';

const ORIGINAL_REQUIREMENT = [
'### Requirement: Existing behavior',
'The project SHALL expose the original behavior.',
'',
'#### Scenario: Existing path',
'- **WHEN** the behavior is exercised',
'- **THEN** it SHALL remain available',
].join('\n');

const UPDATED_REQUIREMENT = [
'### Requirement: Existing behavior',
'The project SHALL expose the updated behavior.',
'',
'It SHALL preserve this meaningful internal paragraph break.',
'',
'#### Scenario: Existing path',
'- **WHEN** the behavior is exercised',
'- **THEN** it SHALL remain available',
].join('\n');

const BASE_SPEC = [
'# demo Specification',
'',
'## Purpose',
'Demonstrates deterministic serializer output.',
'',
'## Requirements',
ORIGINAL_REQUIREMENT,
].join('\n');

const DELTA_SPEC = ['## MODIFIED Requirements', '', UPDATED_REQUIREMENT, ''].join('\n');

function expectOneFinalLf(content: string): void {
expect(content.endsWith('\n')).toBe(true);
expect(content.endsWith('\n\n')).toBe(false);
expect(content.match(/\n+$/)?.[0]).toBe('\n');
}

describe('spec serialization', () => {
let tempDir: string;
let changeDir: string;
let mainSpecsDir: string;
let source: string;
let target: string;

beforeEach(async () => {
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'openspec-spec-eof-'));
changeDir = path.join(tempDir, 'openspec', 'changes', 'serializer-test');
mainSpecsDir = path.join(tempDir, 'openspec', 'specs');
source = path.join(changeDir, 'specs', 'demo', 'spec.md');
target = path.join(mainSpecsDir, 'demo', 'spec.md');
await fs.mkdir(path.dirname(source), { recursive: true });
await fs.mkdir(path.dirname(target), { recursive: true });
await fs.writeFile(source, DELTA_SPEC);
});

afterEach(async () => {
await fs.rm(tempDir, { recursive: true, force: true });
});

async function build(targetContent: string) {
await fs.writeFile(target, targetContent);
const [update] = await findSpecUpdates(changeDir, mainSpecsDir);
return buildUpdatedSpec(update, 'serializer-test', { silent: true });
}

it.each([
['no terminal newline', BASE_SPEC],
['one terminal LF', `${BASE_SPEC}\n`],
['multiple terminal LF', `${BASE_SPEC}\n\n\n`],
['multiple terminal CRLF', `${BASE_SPEC.replaceAll('\n', '\r\n')}\r\n\r\n`],
])('emits one final LF from %s', async (_name, targetContent) => {
const result = await build(targetContent);

expectOneFinalLf(result.rebuilt);
expect(result.rebuilt).toContain(
'updated behavior.\n\nIt SHALL preserve this meaningful internal paragraph break.'
);
});

it('preserves content after Requirements', async () => {
const result = await build(`${BASE_SPEC}\n\n## Notes\n\nKeep this later section.\n\n`);

expectOneFinalLf(result.rebuilt);
expect(result.rebuilt).toContain('## Notes\n\nKeep this later section.\n');
});
});
Loading