Implement computed range expansion for loops (gt-8tmz.27)

Add support for for-each expansion over computed ranges:

  loop:
    range: "1..2^{disks}-1"  # Evaluated at cook time
    var: move_num
    body: [...]

Features:
- Range field in LoopSpec for computed iterations
- Var field to expose iteration value to body steps
- Expression evaluator supporting + - * / ^ and parentheses
- Variable substitution in range expressions using {name} syntax
- Title/description variable substitution in expanded steps

Example use case: Towers of Hanoi formula where step count is 2^n-1

🤖 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 17:48:35 -08:00
parent 258d183e25
commit 030838cfde
5 changed files with 906 additions and 14 deletions

View File

@@ -59,12 +59,23 @@ func validateLoopSpec(loop *LoopSpec, stepID string) error {
return fmt.Errorf("loop %q: body is required", stepID)
}
if loop.Count > 0 && loop.Until != "" {
return fmt.Errorf("loop %q: cannot have both count and until", stepID)
// Count the number of loop types specified
loopTypes := 0
if loop.Count > 0 {
loopTypes++
}
if loop.Until != "" {
loopTypes++
}
if loop.Range != "" {
loopTypes++
}
if loop.Count == 0 && loop.Until == "" {
return fmt.Errorf("loop %q: either count or until is required", stepID)
if loopTypes == 0 {
return fmt.Errorf("loop %q: one of count, until, or range is required", stepID)
}
if loopTypes > 1 {
return fmt.Errorf("loop %q: only one of count, until, or range can be specified", stepID)
}
if loop.Until != "" && loop.Max == 0 {
@@ -86,17 +97,30 @@ func validateLoopSpec(loop *LoopSpec, stepID string) error {
}
}
// Validate range syntax if present (gt-8tmz.27)
if loop.Range != "" {
if err := ValidateRange(loop.Range); err != nil {
return fmt.Errorf("loop %q: invalid range %q: %w", stepID, loop.Range, err)
}
}
return nil
}
// expandLoop expands a loop step into its constituent steps.
func expandLoop(step *Step) ([]*Step, error) {
return expandLoopWithVars(step, nil)
}
// expandLoopWithVars expands a loop step using the given variable context.
// The vars map is used to resolve range expressions with variables.
func expandLoopWithVars(step *Step, vars map[string]string) ([]*Step, error) {
var result []*Step
if step.Loop.Count > 0 {
// Fixed-count loop: expand body N times
for i := 1; i <= step.Loop.Count; i++ {
iterSteps, err := expandLoopIteration(step, i)
iterSteps, err := expandLoopIteration(step, i, nil)
if err != nil {
return nil, err
}
@@ -115,10 +139,50 @@ func expandLoop(step *Step) ([]*Step, error) {
if step.Loop.Count > 1 {
result = chainExpandedIterations(result, step.ID, step.Loop.Count)
}
} else if step.Loop.Range != "" {
// Range loop (gt-8tmz.27): expand body for each value in the computed range
rangeSpec, err := ParseRange(step.Loop.Range, vars)
if err != nil {
return nil, fmt.Errorf("loop %q: %w", step.ID, err)
}
// Validate range
if rangeSpec.End < rangeSpec.Start {
return nil, fmt.Errorf("loop %q: range end (%d) is less than start (%d)",
step.ID, rangeSpec.End, rangeSpec.Start)
}
// Expand body for each value in range
count := rangeSpec.End - rangeSpec.Start + 1
iterNum := 0
for val := rangeSpec.Start; val <= rangeSpec.End; val++ {
iterNum++
// Build iteration vars: include the loop variable if specified
iterVars := make(map[string]string)
if step.Loop.Var != "" {
iterVars[step.Loop.Var] = fmt.Sprintf("%d", val)
}
iterSteps, err := expandLoopIteration(step, iterNum, iterVars)
if err != nil {
return nil, err
}
result = append(result, iterSteps...)
}
// Recursively expand any nested loops FIRST (gt-zn35j)
result, err = ApplyLoops(result)
if err != nil {
return nil, err
}
// THEN chain iterations on the expanded result
if count > 1 {
result = chainExpandedIterations(result, step.ID, count)
}
} else {
// Conditional loop: expand once with loop metadata
// The runtime executor will re-run until condition is met or max reached
iterSteps, err := expandLoopIteration(step, 1)
iterSteps, err := expandLoopIteration(step, 1, nil)
if err != nil {
return nil, err
}
@@ -147,7 +211,8 @@ func expandLoop(step *Step) ([]*Step, error) {
// expandLoopIteration expands a single iteration of a loop.
// The iteration index is used to generate unique step IDs.
func expandLoopIteration(step *Step, iteration int) ([]*Step, error) {
// The iterVars map contains loop variable bindings for this iteration (gt-8tmz.27).
func expandLoopIteration(step *Step, iteration int, iterVars map[string]string) ([]*Step, error) {
result := make([]*Step, 0, len(step.Loop.Body))
// Build set of step IDs within the loop body (for dependency rewriting)
@@ -157,10 +222,14 @@ func expandLoopIteration(step *Step, iteration int) ([]*Step, error) {
// Create unique ID for this iteration
iterID := fmt.Sprintf("%s.iter%d.%s", step.ID, iteration, bodyStep.ID)
// Substitute loop variables in title and description (gt-8tmz.27)
title := substituteLoopVars(bodyStep.Title, iterVars)
description := substituteLoopVars(bodyStep.Description, iterVars)
clone := &Step{
ID: iterID,
Title: bodyStep.Title,
Description: bodyStep.Description,
Title: title,
Description: description,
Type: bodyStep.Type,
Priority: bodyStep.Priority,
Assignee: bodyStep.Assignee,
@@ -170,16 +239,20 @@ func expandLoopIteration(step *Step, iteration int) ([]*Step, error) {
Gate: bodyStep.Gate,
Loop: cloneLoopSpec(bodyStep.Loop), // Support nested loops (gt-zn35j)
OnComplete: cloneOnComplete(bodyStep.OnComplete),
SourceFormula: bodyStep.SourceFormula, // Preserve source (gt-8tmz.18)
SourceFormula: bodyStep.SourceFormula, // Preserve source (gt-8tmz.18)
SourceLocation: fmt.Sprintf("%s.iter%d", bodyStep.SourceLocation, iteration), // Track iteration
}
// Clone ExpandVars if present
if len(bodyStep.ExpandVars) > 0 {
clone.ExpandVars = make(map[string]string, len(bodyStep.ExpandVars))
// Clone ExpandVars if present, adding loop vars (gt-8tmz.27)
if len(bodyStep.ExpandVars) > 0 || len(iterVars) > 0 {
clone.ExpandVars = make(map[string]string)
for k, v := range bodyStep.ExpandVars {
clone.ExpandVars[k] = v
}
// Add loop variables to ExpandVars for nested expansion
for k, v := range iterVars {
clone.ExpandVars[k] = v
}
}
// Clone labels
@@ -203,6 +276,17 @@ func expandLoopIteration(step *Step, iteration int) ([]*Step, error) {
return result, nil
}
// substituteLoopVars replaces {varname} placeholders with values from vars map.
func substituteLoopVars(s string, vars map[string]string) string {
if vars == nil || s == "" {
return s
}
for k, v := range vars {
s = strings.ReplaceAll(s, "{"+k+"}", v)
}
return s
}
// collectBodyStepIDs collects all step IDs within a loop body (including nested children).
func collectBodyStepIDs(body []*Step) map[string]bool {
ids := make(map[string]bool)
@@ -521,6 +605,8 @@ func cloneLoopSpec(loop *LoopSpec) *LoopSpec {
Count: loop.Count,
Until: loop.Until,
Max: loop.Max,
Range: loop.Range, // gt-8tmz.27
Var: loop.Var, // gt-8tmz.27
}
if len(loop.Body) > 0 {
clone.Body = make([]*Step, len(loop.Body))