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

### Bug Fixes

- **Archive after early sync** — `openspec archive` no longer fails with `ADDED failed … already exists` when a change's specs were already synced to the main specs before archiving (the early-sync pattern from the `sync` workflow). If an ADDED requirement already exists in the target spec with identical content, applying it is treated as a no-op; a same-named requirement with different content still aborts the archive as a genuine conflict (#1332).
17 changes: 15 additions & 2 deletions src/core/specs-apply.ts
Original file line number Diff line number Diff line change
Expand Up @@ -304,12 +304,21 @@ export async function buildUpdatedSpec(
}

// ADDED
let addedApplied = 0;
for (const add of plan.added) {
const key = normalizeRequirementName(add.name);
if (nameToBlock.has(key)) {
const existing = nameToBlock.get(key);
if (existing) {
// Identical content means the requirement was already synced to the
// baseline (early-sync pattern) — re-applying it is a no-op, not a
// conflict. Only differing content is a genuine collision.
if (normalizeBlockRaw(existing.raw) === normalizeBlockRaw(add.raw)) {
continue;
}
throw new Error(`${specName} ADDED failed for header "### Requirement: ${add.name}" - already exists`);
}
nameToBlock.set(key, add);
addedApplied++;
}

// Duplicates within resulting map are implicitly prevented by key uniqueness.
Expand Down Expand Up @@ -346,14 +355,18 @@ export async function buildUpdatedSpec(
return {
rebuilt,
counts: {
added: plan.added.length,
added: addedApplied,
modified: plan.modified.length,
removed: plan.removed.length,
renamed: plan.renamed.length,
},
};
}

function normalizeBlockRaw(raw: string): string {
return raw.replace(/\r\n?/g, '\n').trim();
}

/**
* Write an updated spec to disk.
*/
Expand Down
64 changes: 64 additions & 0 deletions test/core/archive.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,70 @@ Then expected result happens`;
expect(updatedContent).toContain('#### Scenario: Basic test');
});

it('should archive when ADDED requirements were already synced to the baseline (issue #1332)', async () => {
const changeName = 'early-synced-feature';
const changeDir = path.join(tempDir, 'openspec', 'changes', changeName);
const changeSpecDir = path.join(changeDir, 'specs', 'core-layer');
await fs.mkdir(changeSpecDir, { recursive: true });

const requirementBlock = `### Requirement: The system SHALL provide a core abstraction layer

#### Scenario: Layer is available
- **WHEN** a consumer imports the layer
- **THEN** the abstraction is available`;

await fs.writeFile(
path.join(changeSpecDir, 'spec.md'),
`# Core Layer - Changes\n\n## ADDED Requirements\n\n${requirementBlock}`
);

// Simulate the early-sync pattern: the requirement is already in the
// main spec (identical content) before archive runs.
const mainSpecDir = path.join(tempDir, 'openspec', 'specs', 'core-layer');
await fs.mkdir(mainSpecDir, { recursive: true });
const mainSpecContent = `# core-layer Specification\n\n## Purpose\nCore abstraction layer.\n\n## Requirements\n\n${requirementBlock}\n`;
await fs.writeFile(path.join(mainSpecDir, 'spec.md'), mainSpecContent);

await archiveCommand.execute(changeName, { yes: true, noValidate: true });

// Archive succeeds and the main spec keeps the requirement exactly once
const updatedContent = await fs.readFile(path.join(mainSpecDir, 'spec.md'), 'utf-8');
const occurrences = updatedContent.split('### Requirement: The system SHALL provide a core abstraction layer').length - 1;
expect(occurrences).toBe(1);

const archives = await fs.readdir(path.join(tempDir, 'openspec', 'changes', 'archive'));
expect(archives.some(a => a.includes(changeName))).toBe(true);
expect(process.exitCode).toBeUndefined();
});

it('should still abort ADDED when an existing requirement has different content', async () => {
const changeName = 'conflicting-added-feature';
const changeDir = path.join(tempDir, 'openspec', 'changes', changeName);
const changeSpecDir = path.join(changeDir, 'specs', 'core-layer');
await fs.mkdir(changeSpecDir, { recursive: true });

await fs.writeFile(
path.join(changeSpecDir, 'spec.md'),
`# Core Layer - Changes\n\n## ADDED Requirements\n\n### Requirement: The system SHALL provide a core abstraction layer\n\n#### Scenario: New behavior\n- **WHEN** a consumer imports the layer\n- **THEN** the new abstraction is available`
);

const mainSpecDir = path.join(tempDir, 'openspec', 'specs', 'core-layer');
await fs.mkdir(mainSpecDir, { recursive: true });
const mainSpecContent = `# core-layer Specification\n\n## Purpose\nCore abstraction layer.\n\n## Requirements\n\n### Requirement: The system SHALL provide a core abstraction layer\n\n#### Scenario: Old behavior\n- **WHEN** a consumer imports the layer\n- **THEN** the old abstraction is available\n`;
await fs.writeFile(path.join(mainSpecDir, 'spec.md'), mainSpecContent);

await archiveCommand.execute(changeName, { yes: true, noValidate: true });

// Genuine conflict: archive aborts, nothing moves, main spec untouched
expect(console.log).toHaveBeenCalledWith(
expect.stringContaining('ADDED failed for header "### Requirement: The system SHALL provide a core abstraction layer" - already exists')
);
expect(process.exitCode).toBe(1);
await expect(fs.access(changeDir)).resolves.toBeUndefined();
const untouched = await fs.readFile(path.join(mainSpecDir, 'spec.md'), 'utf-8');
expect(untouched).toBe(mainSpecContent);
});

it('should merge nested delta specs into the same relative path (#1353)', async () => {
const changeName = 'nested-spec-feature';
const changeDir = path.join(tempDir, 'openspec', 'changes', changeName);
Expand Down
Loading