feat: Add nested loop support in control flow (gt-zn35j)

Enables loops within loop bodies to be properly expanded:

- Copy Loop field in expandLoopIteration (was intentionally omitted)
- Add cloneLoopSpec for deep copying of LoopSpec with body steps
- Recursively call ApplyLoops at end of expandLoop to expand nested loops
- Also copy OnComplete field for consistency

Nested IDs follow natural chaining pattern:
  outer.iter1.inner.iter2.step

Tested with 2-level and 3-level nesting scenarios.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Steve Yegge
2025-12-25 16:22:36 -08:00
parent 47cc84de3a
commit b43358b600
2 changed files with 184 additions and 2 deletions

View File

@@ -129,7 +129,8 @@ func expandLoop(step *Step) ([]*Step, error) {
result = iterSteps
}
return result, nil
// Recursively expand any nested loops in the result (gt-zn35j)
return ApplyLoops(result)
}
// expandLoopIteration expands a single iteration of a loop.
@@ -155,7 +156,8 @@ func expandLoopIteration(step *Step, iteration int) ([]*Step, error) {
WaitsFor: bodyStep.WaitsFor,
Expand: bodyStep.Expand,
Gate: bodyStep.Gate,
// Note: Loop field intentionally not copied - nested loops need explicit support
Loop: cloneLoopSpec(bodyStep.Loop), // Support nested loops (gt-zn35j)
OnComplete: cloneOnComplete(bodyStep.OnComplete),
}
// Clone ExpandVars if present
@@ -408,3 +410,22 @@ func cloneStepDeep(s *Step) *Step {
return clone
}
// cloneLoopSpec creates a deep copy of a LoopSpec (gt-zn35j).
func cloneLoopSpec(loop *LoopSpec) *LoopSpec {
if loop == nil {
return nil
}
clone := &LoopSpec{
Count: loop.Count,
Until: loop.Until,
Max: loop.Max,
}
if len(loop.Body) > 0 {
clone.Body = make([]*Step, len(loop.Body))
for i, step := range loop.Body {
clone.Body[i] = cloneStepDeep(step)
}
}
return clone
}

View File

@@ -507,3 +507,164 @@ func TestApplyLoops_NestedChildren(t *testing.T) {
t.Errorf("Expected 1 child, got %d", len(result[0].Children))
}
}
// gt-zn35j: Tests for nested loop support
func TestApplyLoops_NestedLoops(t *testing.T) {
// Create a loop containing another loop
steps := []*Step{
{
ID: "outer",
Title: "Outer loop",
Loop: &LoopSpec{
Count: 2,
Body: []*Step{
{
ID: "inner",
Title: "Inner loop",
Loop: &LoopSpec{
Count: 2,
Body: []*Step{
{ID: "work", Title: "Do work"},
},
},
},
},
},
},
}
result, err := ApplyLoops(steps)
if err != nil {
t.Fatalf("ApplyLoops failed: %v", err)
}
// Should have 4 steps total (2 outer * 2 inner)
if len(result) != 4 {
t.Errorf("Expected 4 steps, got %d", len(result))
for i, s := range result {
t.Logf(" Step %d: %s", i, s.ID)
}
}
// Check step IDs follow nested pattern
expectedIDs := []string{
"outer.iter1.inner.iter1.work",
"outer.iter1.inner.iter2.work",
"outer.iter2.inner.iter1.work",
"outer.iter2.inner.iter2.work",
}
for i, expected := range expectedIDs {
if i >= len(result) {
t.Errorf("Missing step %d: %s", i, expected)
continue
}
if result[i].ID != expected {
t.Errorf("Step %d: expected ID %s, got %s", i, expected, result[i].ID)
}
}
}
func TestApplyLoops_NestedLoopsWithDependencies(t *testing.T) {
// Nested loops with dependencies between inner steps
steps := []*Step{
{
ID: "outer",
Title: "Outer loop",
Loop: &LoopSpec{
Count: 2,
Body: []*Step{
{
ID: "inner",
Title: "Inner loop",
Loop: &LoopSpec{
Count: 2,
Body: []*Step{
{ID: "fetch", Title: "Fetch data"},
{ID: "process", Title: "Process data", Needs: []string{"fetch"}},
},
},
},
},
},
},
}
result, err := ApplyLoops(steps)
if err != nil {
t.Fatalf("ApplyLoops failed: %v", err)
}
// Should have 8 steps (2 outer * 2 inner * 2 body steps)
if len(result) != 8 {
t.Errorf("Expected 8 steps, got %d", len(result))
}
// Check that inner dependencies are correctly prefixed
// Find outer.iter1.inner.iter1.process - should depend on outer.iter1.inner.iter1.fetch
var process1 *Step
for _, s := range result {
if s.ID == "outer.iter1.inner.iter1.process" {
process1 = s
break
}
}
if process1 == nil {
t.Fatal("outer.iter1.inner.iter1.process not found")
}
if len(process1.Needs) != 1 || process1.Needs[0] != "outer.iter1.inner.iter1.fetch" {
t.Errorf("process should need outer.iter1.inner.iter1.fetch, got %v", process1.Needs)
}
}
func TestApplyLoops_ThreeLevelNesting(t *testing.T) {
// Three levels of nesting
steps := []*Step{
{
ID: "l1",
Loop: &LoopSpec{
Count: 2,
Body: []*Step{
{
ID: "l2",
Loop: &LoopSpec{
Count: 2,
Body: []*Step{
{
ID: "l3",
Loop: &LoopSpec{
Count: 2,
Body: []*Step{
{ID: "leaf", Title: "Leaf step"},
},
},
},
},
},
},
},
},
},
}
result, err := ApplyLoops(steps)
if err != nil {
t.Fatalf("ApplyLoops failed: %v", err)
}
// Should have 8 steps (2 * 2 * 2)
if len(result) != 8 {
t.Errorf("Expected 8 steps, got %d", len(result))
}
// Check first and last step IDs
if result[0].ID != "l1.iter1.l2.iter1.l3.iter1.leaf" {
t.Errorf("First step ID wrong: %s", result[0].ID)
}
if result[7].ID != "l1.iter2.l2.iter2.l3.iter2.leaf" {
t.Errorf("Last step ID wrong: %s", result[7].ID)
}
}