fix: Chain iterations after nested loop expansion (gt-zn35j)

Fixes a bug where outer iteration chaining was lost when nested loops
were expanded. The problem was:

1. chainLoopIterations set: outer.iter2.inner depends on outer.iter1.inner
2. ApplyLoops recursively expanded nested loops
3. outer.iter2.inner became outer.iter2.inner.iter1.work, etc.
4. The dependency was lost (referenced non-existent ID)

The fix:
- Move recursive ApplyLoops BEFORE chaining
- Add chainExpandedIterations that finds iteration boundaries by ID prefix
- Works with variable step counts per iteration (nested loops expand differently)

Now outer.iter2's first step correctly depends on outer.iter1's LAST step,
ensuring sequential execution of outer iterations.

🤖 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:35:28 -08:00
parent bce4f8f2d4
commit d2f71773cb
2 changed files with 121 additions and 6 deletions

View File

@@ -12,6 +12,7 @@ package formula
import (
"encoding/json"
"fmt"
"strings"
)
// ApplyLoops expands loop bodies in a formula's steps.
@@ -102,9 +103,17 @@ func expandLoop(step *Step) ([]*Step, error) {
result = append(result, iterSteps...)
}
// Chain iterations: each iteration depends on previous
if len(step.Loop.Body) > 0 && step.Loop.Count > 1 {
result = chainLoopIterations(result, step.Loop.Body, step.Loop.Count)
// Recursively expand any nested loops FIRST (gt-zn35j)
var err error
result, err = ApplyLoops(result)
if err != nil {
return nil, err
}
// THEN chain iterations on the expanded result
// This must happen AFTER recursive expansion so we chain the final steps
if step.Loop.Count > 1 {
result = chainExpandedIterations(result, step.ID, step.Loop.Count)
}
} else {
// Conditional loop: expand once with loop metadata
@@ -126,11 +135,14 @@ func expandLoop(step *Step) ([]*Step, error) {
firstStep.Labels = append(firstStep.Labels, fmt.Sprintf("loop:%s", string(loopJSON)))
}
result = iterSteps
// Recursively expand any nested loops (gt-zn35j)
result, err = ApplyLoops(iterSteps)
if err != nil {
return nil, err
}
}
// Recursively expand any nested loops in the result (gt-zn35j)
return ApplyLoops(result)
return result, nil
}
// expandLoopIteration expands a single iteration of a loop.
@@ -271,6 +283,46 @@ func chainLoopIterations(steps []*Step, body []*Step, count int) []*Step {
return steps
}
// chainExpandedIterations chains iterations AFTER nested loop expansion (gt-zn35j).
// Unlike chainLoopIterations, this handles variable step counts per iteration
// by finding iteration boundaries via ID prefix matching.
func chainExpandedIterations(steps []*Step, loopID string, count int) []*Step {
if len(steps) == 0 || count < 2 {
return steps
}
// Find the first and last step index of each iteration
// Iteration N has steps with ID prefix: {loopID}.iter{N}.
iterFirstIdx := make(map[int]int) // iteration -> index of first step
iterLastIdx := make(map[int]int) // iteration -> index of last step
for i, s := range steps {
for iter := 1; iter <= count; iter++ {
prefix := fmt.Sprintf("%s.iter%d.", loopID, iter)
if strings.HasPrefix(s.ID, prefix) {
if _, found := iterFirstIdx[iter]; !found {
iterFirstIdx[iter] = i
}
iterLastIdx[iter] = i
break
}
}
}
// Chain: first step of iteration N+1 depends on last step of iteration N
for iter := 2; iter <= count; iter++ {
firstIdx, hasFirst := iterFirstIdx[iter]
prevLastIdx, hasPrevLast := iterLastIdx[iter-1]
if hasFirst && hasPrevLast {
lastStepID := steps[prevLastIdx].ID
steps[firstIdx].Needs = appendUnique(steps[firstIdx].Needs, lastStepID)
}
}
return steps
}
// ApplyBranches wires fork-join dependency patterns.
// For each branch rule:
// - All branch steps depend on the 'from' step