Skip to content

Commit 04b37ac

Browse files
Marzx13clay-good
andauthored
fix(archive): preserve requirement order when renaming (#1712)
* fix(archive): preserve requirement order when renaming * chore(archive): add rename-order changeset --------- Co-authored-by: Clay Good <hi@claygood.com>
1 parent 126c5d6 commit 04b37ac

4 files changed

Lines changed: 172 additions & 7 deletions

File tree

.changeset/calm-otters-order.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@fission-ai/openspec': patch
3+
---
4+
5+
archive: preserve a requirement's original position when renaming it instead of moving the renamed block to the end of the spec.

src/core/specs-apply.ts

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -375,11 +375,13 @@ export async function buildUpdatedSpec(
375375
for (const block of parts.bodyBlocks) {
376376
nameToBlock.set(normalizeRequirementName(block.name), block);
377377
}
378+
// Keep source blocks immutable for loss attribution. This parallel key list
379+
// carries only positional identity as renames change lookup keys.
380+
const orderedKeys = parts.bodyBlocks.map((block) => normalizeRequirementName(block.name));
378381

379382
// Apply operations in order: RENAMED → REMOVED → MODIFIED → ADDED
380383
// RENAMED
381384
let renamedApplied = 0;
382-
const renamedTargets = new Map<string, string>();
383385
for (const r of plan.renamed) {
384386
const from = normalizeRequirementName(r.from);
385387
const to = normalizeRequirementName(r.to);
@@ -417,7 +419,12 @@ export async function buildUpdatedSpec(
417419
};
418420
nameToBlock.delete(from);
419421
nameToBlock.set(to, renamedBlock);
420-
renamedTargets.set(from, to);
422+
// A Map delete+set moves the renamed block to insertion-order tail. Carry
423+
// its new key in the source slot instead; chained renames update it again.
424+
const orderIndex = orderedKeys.indexOf(from);
425+
if (orderIndex >= 0) {
426+
orderedKeys[orderIndex] = to;
427+
}
421428
renamedApplied++;
422429
}
423430

@@ -503,8 +510,9 @@ export async function buildUpdatedSpec(
503510
// Recompose requirements section preserving original ordering where possible
504511
const keptOrder: RequirementBlock[] = [];
505512
const seen = new Set<string>();
506-
for (const block of parts.bodyBlocks) {
507-
const key = normalizeRequirementName(block.name);
513+
for (let index = 0; index < parts.bodyBlocks.length; index++) {
514+
const block = parts.bodyBlocks[index];
515+
const key = orderedKeys[index];
508516
const replacement = nameToBlock.get(key);
509517
if (replacement) {
510518
keptOrder.push(replacement);
@@ -516,9 +524,7 @@ export async function buildUpdatedSpec(
516524
// full absorbed suffix. RENAMED carries the original raw content under a
517525
// new map key, and MODIFIED may repeat the suffix deliberately; neither is
518526
// data loss.
519-
const renamedTarget = renamedTargets.get(key);
520-
const replacementFromOriginal =
521-
replacement ?? (renamedTarget ? nameToBlock.get(renamedTarget) : undefined);
527+
const replacementFromOriginal = replacement;
522528
if (replacementFromOriginal !== block) {
523529
const foreign = firstForeignTail(block.raw);
524530
const replacementRaw = replacementFromOriginal?.raw;

test/core/archive.test.ts

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2780,6 +2780,130 @@ content D`;
27802780
expect(updated).not.toContain('### Requirement: B');
27812781
});
27822782

2783+
it('should preserve source order and lineage when renaming requirements', async () => {
2784+
const changeName = 'rename-order';
2785+
const renamed = (pairs: Array<[string, string]>): string =>
2786+
`## RENAMED Requirements\n\n${pairs
2787+
.map(
2788+
([from, to]) =>
2789+
`- FROM: \`### Requirement: ${from}\`\n- TO: \`### Requirement: ${to}\``
2790+
)
2791+
.join('\n\n')}`;
2792+
const cases = [
2793+
{ capability: 'first', names: ['A', 'B', 'C'], delta: renamed([['A', 'A2']]), expected: ['A2', 'B', 'C'] },
2794+
{ capability: 'middle', names: ['A', 'B', 'C'], delta: renamed([['B', 'B2']]), expected: ['A', 'B2', 'C'] },
2795+
{ capability: 'last', names: ['A', 'B', 'C'], delta: renamed([['C', 'C2']]), expected: ['A', 'B', 'C2'] },
2796+
{
2797+
capability: 'multiple',
2798+
names: ['A', 'B', 'C'],
2799+
delta: renamed([['B', 'B2'], ['A', 'A2']]),
2800+
expected: ['A2', 'B2', 'C'],
2801+
},
2802+
{
2803+
capability: 'chained',
2804+
names: ['X', 'A', 'Y'],
2805+
delta: renamed([['A', 'B'], ['B', 'C']]),
2806+
expected: ['X', 'C', 'Y'],
2807+
},
2808+
{
2809+
capability: 'modified',
2810+
names: ['A', 'B', 'C'],
2811+
delta: `${renamed([['B', 'B2']])}\n\n## MODIFIED Requirements\n\n### Requirement: B2\nModified body.`,
2812+
expected: ['A', 'B2', 'C'],
2813+
expectedContent: '### Requirement: B2\nModified body.',
2814+
},
2815+
{
2816+
capability: 'readded',
2817+
names: ['X', 'A', 'Y'],
2818+
delta: `${renamed([['A', 'B']])}\n\n## ADDED Requirements\n\n### Requirement: A\nNew body A.`,
2819+
expected: ['X', 'B', 'Y', 'A'],
2820+
},
2821+
{
2822+
capability: 'foreign-tail',
2823+
names: ['A', 'B', 'C'],
2824+
delta: renamed([['B', 'B2']]),
2825+
expected: ['A', 'B2', 'C'],
2826+
foreignTail: '### Notes\nAuthored note travels with B.',
2827+
expectedContent: '### Requirement: B2\nBody B.\n\n### Notes\nAuthored note travels with B.',
2828+
},
2829+
];
2830+
2831+
for (const item of cases) {
2832+
const mainSpecDir = path.join(tempDir, 'openspec', 'specs', item.capability);
2833+
const changeSpecDir = path.join(
2834+
tempDir,
2835+
'openspec',
2836+
'changes',
2837+
changeName,
2838+
'specs',
2839+
item.capability
2840+
);
2841+
await fs.mkdir(mainSpecDir, { recursive: true });
2842+
await fs.mkdir(changeSpecDir, { recursive: true });
2843+
const blocks = item.names.map(
2844+
(name) =>
2845+
`### Requirement: ${name}\nBody ${name}.` +
2846+
(name === 'B' && item.foreignTail ? `\n\n${item.foreignTail}` : '')
2847+
);
2848+
await fs.writeFile(
2849+
path.join(mainSpecDir, 'spec.md'),
2850+
`# ${item.capability} Specification\n\n## Purpose\nOrdering fixture.\n\n## Requirements\n\n${blocks.join('\n\n')}\n`
2851+
);
2852+
await fs.writeFile(
2853+
path.join(changeSpecDir, 'spec.md'),
2854+
`# ${item.capability} - Changes\n\n${item.delta}\n`
2855+
);
2856+
}
2857+
2858+
await archiveCommand.execute(changeName, { yes: true, noValidate: true });
2859+
2860+
for (const item of cases) {
2861+
const updated = await fs.readFile(
2862+
path.join(tempDir, 'openspec', 'specs', item.capability, 'spec.md'),
2863+
'utf-8'
2864+
);
2865+
const names = [...updated.matchAll(/^### Requirement:\s*(.+?)\s*$/gm)].map(
2866+
(match) => match[1]
2867+
);
2868+
expect(names).toEqual(item.expected);
2869+
if (item.expectedContent) expect(updated).toContain(item.expectedContent);
2870+
}
2871+
const output = (console.log as unknown as { mock: { calls: unknown[][] } }).mock.calls
2872+
.flat()
2873+
.map(String)
2874+
.join('\n');
2875+
expect(output).not.toContain('sits inside requirement "B"');
2876+
});
2877+
2878+
it('should keep the target and change untouched when a later rename collides', async () => {
2879+
const changeName = 'late-rename-collision';
2880+
const changeDir = path.join(tempDir, 'openspec', 'changes', changeName);
2881+
const changeSpecDir = path.join(changeDir, 'specs', 'demo');
2882+
const mainSpecDir = path.join(tempDir, 'openspec', 'specs', 'demo');
2883+
const mainSpecPath = path.join(mainSpecDir, 'spec.md');
2884+
const changeSpecPath = path.join(changeSpecDir, 'spec.md');
2885+
const mainContent = `# demo Specification\n\n## Purpose\nTransaction fixture.\n\n## Requirements\n\n### Requirement: A\nBody A.\n\n### Requirement: B\nBody B.\n\n### Requirement: C\nBody C.\n`;
2886+
const changeContent = `# demo - Changes\n\n## RENAMED Requirements\n\n- FROM: \`### Requirement: A\`\n- TO: \`### Requirement: A2\`\n\n- FROM: \`### Requirement: A2\`\n- TO: \`### Requirement: C\`\n`;
2887+
await fs.mkdir(changeSpecDir, { recursive: true });
2888+
await fs.mkdir(mainSpecDir, { recursive: true });
2889+
await fs.writeFile(mainSpecPath, mainContent);
2890+
await fs.writeFile(changeSpecPath, changeContent);
2891+
2892+
await archiveCommand.execute(changeName, { yes: true, noValidate: true });
2893+
2894+
expect(console.log).toHaveBeenCalledWith(
2895+
expect.stringContaining(
2896+
'RENAMED failed for header "### Requirement: C" - target already exists'
2897+
)
2898+
);
2899+
expect(process.exitCode).toBe(1);
2900+
await expect(fs.readFile(mainSpecPath, 'utf-8')).resolves.toBe(mainContent);
2901+
await expect(fs.readFile(changeSpecPath, 'utf-8')).resolves.toBe(changeContent);
2902+
await expect(fs.access(changeDir)).resolves.not.toThrow();
2903+
const archives = await fs.readdir(path.join(tempDir, 'openspec', 'changes', 'archive'));
2904+
expect(archives.some((entry) => entry.includes(changeName))).toBe(false);
2905+
});
2906+
27832907
it('should abort with error when MODIFIED references non-existent requirements', async () => {
27842908
const changeName = 'validate-missing';
27852909
const changeDir = path.join(tempDir, 'openspec', 'changes', changeName);

test/core/specs-apply.salvage.test.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,36 @@ describe('buildUpdatedSpec (content absorbed into a requirement)', () => {
141141
expect(warnings.join('\n')).not.toContain('goes with it');
142142
});
143143

144+
it('warns against the source requirement when a rename-plus-modify drops its tail', async () => {
145+
const tail = [' ### Notes', 'Kept by hand.'];
146+
const renamedRequirement = [...REQUIREMENT];
147+
renamedRequirement[0] = '### Requirement: Renamed';
148+
const { rebuilt, counts, warnings } = await build(SPEC(tail), [
149+
'# demo - Changes',
150+
'',
151+
'## RENAMED Requirements',
152+
'',
153+
'- FROM: `### Requirement: Target`',
154+
'- TO: `### Requirement: Renamed`',
155+
'',
156+
'## MODIFIED Requirements',
157+
'',
158+
...renamedRequirement,
159+
'',
160+
]);
161+
162+
expect([...rebuilt.matchAll(/^### Requirement:\s*(.+?)\s*$/gm)].map((m) => m[1])).toEqual([
163+
'Renamed',
164+
'Other',
165+
]);
166+
expect(rebuilt).not.toContain(tail.join('\n'));
167+
expect(counts).toMatchObject({ modified: 1, renamed: 1 });
168+
expect(warnings.join('\n')).toContain(
169+
'"### Notes" sits inside requirement "Target" and goes with it'
170+
);
171+
expect(warnings.join('\n')).not.toContain('requirement "Renamed"');
172+
});
173+
144174
it('does not warn when MODIFIED carries the full absorbed tail forward', async () => {
145175
const tail = [' ### Notes', 'Kept by hand.'];
146176
const { rebuilt, counts, warnings } = await build(SPEC(tail), [

0 commit comments

Comments
 (0)