@@ -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 ( / ^ # # # R e q u i r e m e n t : \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 ) ;
0 commit comments