@@ -742,6 +742,225 @@ func TestApplyExpansionsDuplicateIDs(t *testing.T) {
742742 })
743743}
744744
745+ func TestApplyExpansionsCrossExpansionDeps (t * testing.T ) {
746+ // Regression test: when two steps have a dependency chain and both are expanded,
747+ // the first substep of the second expansion must inherit the resolved dependency
748+ // on the last substep of the first expansion.
749+ tmpDir := t .TempDir ()
750+
751+ // Expansion template: target → target.sub-a, target.sub-b (chained)
752+ expansion := `{
753+ "formula": "two-step-expansion",
754+ "type": "expansion",
755+ "version": 1,
756+ "template": [
757+ {"id": "{target}.sub-a", "title": "Sub A of {target.title}"},
758+ {"id": "{target}.sub-b", "title": "Sub B of {target.title}", "needs": ["{target}.sub-a"]}
759+ ]
760+ }`
761+ err := os .WriteFile (filepath .Join (tmpDir , "two-step-expansion.formula.json" ), []byte (expansion ), 0644 )
762+ if err != nil {
763+ t .Fatal (err )
764+ }
765+
766+ parser := NewParser (tmpDir )
767+
768+ t .Run ("expand two chained steps preserves cross-expansion deps" , func (t * testing.T ) {
769+ steps := []* Step {
770+ {ID : "step-1" , Title : "Step 1" },
771+ {ID : "step-2" , Title : "Step 2" , Needs : []string {"step-1" }},
772+ }
773+
774+ compose := & ComposeRules {
775+ Expand : []* ExpandRule {
776+ {Target : "step-1" , With : "two-step-expansion" },
777+ {Target : "step-2" , With : "two-step-expansion" },
778+ },
779+ }
780+
781+ result , err := ApplyExpansions (steps , compose , parser )
782+ if err != nil {
783+ t .Fatalf ("ApplyExpansions failed: %v" , err )
784+ }
785+
786+ if len (result ) != 4 {
787+ t .Fatalf ("expected 4 steps, got %d" , len (result ))
788+ }
789+
790+ // Verify step IDs
791+ expectedIDs := []string {"step-1.sub-a" , "step-1.sub-b" , "step-2.sub-a" , "step-2.sub-b" }
792+ for i , exp := range expectedIDs {
793+ if result [i ].ID != exp {
794+ t .Errorf ("result[%d].ID = %q, want %q" , i , result [i ].ID , exp )
795+ }
796+ }
797+
798+ // KEY ASSERTION: step-2.sub-a must need step-1.sub-b (the last step of step-1's expansion)
799+ // Before the fix, this was [] (empty).
800+ step2SubA := result [2 ]
801+ if len (step2SubA .Needs ) != 1 || step2SubA .Needs [0 ] != "step-1.sub-b" {
802+ t .Errorf ("step-2.sub-a.Needs = %v, want [step-1.sub-b]" , step2SubA .Needs )
803+ }
804+
805+ // step-2.sub-b should need step-2.sub-a (internal template dep)
806+ step2SubB := result [3 ]
807+ if len (step2SubB .Needs ) != 1 || step2SubB .Needs [0 ] != "step-2.sub-a" {
808+ t .Errorf ("step-2.sub-b.Needs = %v, want [step-2.sub-a]" , step2SubB .Needs )
809+ }
810+
811+ // step-1.sub-a should have no deps (root of entire chain)
812+ if len (result [0 ].Needs ) != 0 {
813+ t .Errorf ("step-1.sub-a.Needs = %v, want []" , result [0 ].Needs )
814+ }
815+ })
816+
817+ t .Run ("expand three chained steps preserves full dependency chain" , func (t * testing.T ) {
818+ steps := []* Step {
819+ {ID : "step-1" , Title : "Step 1" },
820+ {ID : "step-2" , Title : "Step 2" , Needs : []string {"step-1" }},
821+ {ID : "step-3" , Title : "Step 3" , Needs : []string {"step-2" }},
822+ }
823+
824+ compose := & ComposeRules {
825+ Expand : []* ExpandRule {
826+ {Target : "step-1" , With : "two-step-expansion" },
827+ {Target : "step-2" , With : "two-step-expansion" },
828+ {Target : "step-3" , With : "two-step-expansion" },
829+ },
830+ }
831+
832+ result , err := ApplyExpansions (steps , compose , parser )
833+ if err != nil {
834+ t .Fatalf ("ApplyExpansions failed: %v" , err )
835+ }
836+
837+ if len (result ) != 6 {
838+ t .Fatalf ("expected 6 steps, got %d" , len (result ))
839+ }
840+
841+ // step-2.sub-a needs step-1.sub-b
842+ if len (result [2 ].Needs ) != 1 || result [2 ].Needs [0 ] != "step-1.sub-b" {
843+ t .Errorf ("step-2.sub-a.Needs = %v, want [step-1.sub-b]" , result [2 ].Needs )
844+ }
845+
846+ // step-3.sub-a needs step-2.sub-b
847+ if len (result [4 ].Needs ) != 1 || result [4 ].Needs [0 ] != "step-2.sub-b" {
848+ t .Errorf ("step-3.sub-a.Needs = %v, want [step-2.sub-b]" , result [4 ].Needs )
849+ }
850+ })
851+
852+ t .Run ("expand with depends_on preserves cross-expansion deps" , func (t * testing.T ) {
853+ steps := []* Step {
854+ {ID : "step-1" , Title : "Step 1" },
855+ {ID : "step-2" , Title : "Step 2" , DependsOn : []string {"step-1" }},
856+ }
857+
858+ compose := & ComposeRules {
859+ Expand : []* ExpandRule {
860+ {Target : "step-1" , With : "two-step-expansion" },
861+ {Target : "step-2" , With : "two-step-expansion" },
862+ },
863+ }
864+
865+ result , err := ApplyExpansions (steps , compose , parser )
866+ if err != nil {
867+ t .Fatalf ("ApplyExpansions failed: %v" , err )
868+ }
869+
870+ // step-2.sub-a must depend on step-1.sub-b via DependsOn
871+ step2SubA := result [2 ]
872+ if len (step2SubA .DependsOn ) != 1 || step2SubA .DependsOn [0 ] != "step-1.sub-b" {
873+ t .Errorf ("step-2.sub-a.DependsOn = %v, want [step-1.sub-b]" , step2SubA .DependsOn )
874+ }
875+ })
876+
877+ t .Run ("non-expanded step after expanded step gets deps rewritten" , func (t * testing.T ) {
878+ // Verify that non-expanded steps still get their refs rewritten (existing behavior)
879+ steps := []* Step {
880+ {ID : "step-1" , Title : "Step 1" },
881+ {ID : "step-2" , Title : "Step 2" , Needs : []string {"step-1" }},
882+ }
883+
884+ compose := & ComposeRules {
885+ Expand : []* ExpandRule {
886+ {Target : "step-1" , With : "two-step-expansion" },
887+ // step-2 is NOT expanded
888+ },
889+ }
890+
891+ result , err := ApplyExpansions (steps , compose , parser )
892+ if err != nil {
893+ t .Fatalf ("ApplyExpansions failed: %v" , err )
894+ }
895+
896+ if len (result ) != 3 {
897+ t .Fatalf ("expected 3 steps, got %d" , len (result ))
898+ }
899+
900+ // step-2 (non-expanded) should now need step-1.sub-b
901+ if len (result [2 ].Needs ) != 1 || result [2 ].Needs [0 ] != "step-1.sub-b" {
902+ t .Errorf ("step-2.Needs = %v, want [step-1.sub-b]" , result [2 ].Needs )
903+ }
904+ })
905+ }
906+
907+ func TestApplyInlineExpansionsCrossExpansionDeps (t * testing.T ) {
908+ tmpDir := t .TempDir ()
909+
910+ expansion := `{
911+ "formula": "inline-exp",
912+ "type": "expansion",
913+ "version": 1,
914+ "template": [
915+ {"id": "{target}.first", "title": "First of {target.title}"},
916+ {"id": "{target}.second", "title": "Second of {target.title}", "needs": ["{target}.first"]}
917+ ]
918+ }`
919+ err := os .WriteFile (filepath .Join (tmpDir , "inline-exp.formula.json" ), []byte (expansion ), 0644 )
920+ if err != nil {
921+ t .Fatal (err )
922+ }
923+
924+ parser := NewParser (tmpDir )
925+
926+ t .Run ("inline expand preserves needs on expanded step" , func (t * testing.T ) {
927+ steps := []* Step {
928+ {ID : "setup" , Title : "Setup" },
929+ {
930+ ID : "work" ,
931+ Title : "Work" ,
932+ Needs : []string {"setup" },
933+ Expand : "inline-exp" ,
934+ },
935+ }
936+
937+ result , err := ApplyInlineExpansions (steps , parser )
938+ if err != nil {
939+ t .Fatalf ("ApplyInlineExpansions failed: %v" , err )
940+ }
941+
942+ // setup + work.first + work.second = 3
943+ if len (result ) != 3 {
944+ t .Fatalf ("expected 3 steps, got %d" , len (result ))
945+ }
946+
947+ // work.first should inherit the "setup" dependency from the original step
948+ workFirst := result [1 ]
949+ if workFirst .ID != "work.first" {
950+ t .Fatalf ("result[1].ID = %q, want work.first" , workFirst .ID )
951+ }
952+ if len (workFirst .Needs ) != 1 || workFirst .Needs [0 ] != "setup" {
953+ t .Errorf ("work.first.Needs = %v, want [setup]" , workFirst .Needs )
954+ }
955+
956+ // work.second should only need work.first (internal template dep)
957+ workSecond := result [2 ]
958+ if len (workSecond .Needs ) != 1 || workSecond .Needs [0 ] != "work.first" {
959+ t .Errorf ("work.second.Needs = %v, want [work.first]" , workSecond .Needs )
960+ }
961+ })
962+ }
963+
745964func TestFindDuplicateStepIDs (t * testing.T ) {
746965 tests := []struct {
747966 name string
0 commit comments