From 40fea72c3b5ab94db7fc5976390cc24a17d70042 Mon Sep 17 00:00:00 2001 From: Marzx13 <28298824+Marzx13@users.noreply.github.com> Date: Thu, 6 Aug 2026 10:25:35 -0400 Subject: [PATCH 1/2] fix(core): canonicalize rebuilt spec EOF --- src/core/specs-apply.ts | 3 +- test/core/specs-apply.serialization.test.ts | 93 +++++++++++++++++++++ 2 files changed, 95 insertions(+), 1 deletion(-) create mode 100644 test/core/specs-apply.serialization.test.ts diff --git a/src/core/specs-apply.ts b/src/core/specs-apply.ts index 91b9043e7e..41bd999cbd 100644 --- a/src/core/specs-apply.ts +++ b/src/core/specs-apply.ts @@ -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'; return { rebuilt, diff --git a/test/core/specs-apply.serialization.test.ts b/test/core/specs-apply.serialization.test.ts new file mode 100644 index 0000000000..cedef9416c --- /dev/null +++ b/test/core/specs-apply.serialization.test.ts @@ -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'); + }); +}); From 1b62d8734eff139ba3293de90e100a270e2b2b72 Mon Sep 17 00:00:00 2001 From: Clay Good Date: Thu, 6 Aug 2026 12:35:28 -0500 Subject: [PATCH 2/2] chore(changeset): add patch changeset for spec EOF canonicalization Co-Authored-By: Claude Opus 4.8 --- .changeset/canonicalize-spec-eof.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/canonicalize-spec-eof.md diff --git a/.changeset/canonicalize-spec-eof.md b/.changeset/canonicalize-spec-eof.md new file mode 100644 index 0000000000..7191837326 --- /dev/null +++ b/.changeset/canonicalize-spec-eof.md @@ -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.