feat(formula): Implement expansion var overrides (gt-8tmz.34)

- Add vars parameter to expandStep for {varname} substitution
- Add mergeVars to combine formula defaults with rule overrides
- Update ApplyExpansions to merge and pass vars for expand/map rules
- Apply var substitution to step ID, title, description, assignee,
  labels, and dependencies
- Add comprehensive tests for var overrides functionality

🤖 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 20:06:03 -08:00
parent f3a5e02a35
commit e8458935f9
2 changed files with 261 additions and 14 deletions

View File

@@ -72,8 +72,11 @@ func ApplyExpansions(steps []*Step, compose *ComposeRules, parser *Parser) ([]*S
return nil, fmt.Errorf("expand: %q has no template steps", rule.With)
}
// Merge formula default vars with rule overrides (gt-8tmz.34)
vars := mergeVars(expFormula, rule.Vars)
// Expand the target step (start at depth 0)
expandedSteps, err := expandStep(targetStep, expFormula.Template, 0)
expandedSteps, err := expandStep(targetStep, expFormula.Template, 0, vars)
if err != nil {
return nil, fmt.Errorf("expand %q: %w", rule.Target, err)
}
@@ -112,6 +115,9 @@ func ApplyExpansions(steps []*Step, compose *ComposeRules, parser *Parser) ([]*S
return nil, fmt.Errorf("map: %q has no template steps", rule.With)
}
// Merge formula default vars with rule overrides (gt-8tmz.34)
vars := mergeVars(expFormula, rule.Vars)
// Find all matching steps (including nested children - gt-8tmz.33)
// Rebuild stepMap to capture any changes from previous expansions
stepMap = buildStepMap(result)
@@ -124,7 +130,7 @@ func ApplyExpansions(steps []*Step, compose *ComposeRules, parser *Parser) ([]*S
// Expand each matching step
for _, targetStep := range toExpand {
expandedSteps, err := expandStep(targetStep, expFormula.Template, 0)
expandedSteps, err := expandStep(targetStep, expFormula.Template, 0, vars)
if err != nil {
return nil, fmt.Errorf("map %q -> %q: %w", rule.Select, targetStep.ID, err)
}
@@ -153,7 +159,8 @@ func ApplyExpansions(steps []*Step, compose *ComposeRules, parser *Parser) ([]*S
// Returns the expanded steps with placeholders substituted.
// The depth parameter tracks recursion depth for children; if it exceeds
// DefaultMaxExpansionDepth, an error is returned.
func expandStep(target *Step, template []*Step, depth int) ([]*Step, error) {
// The vars parameter provides variable values for {varname} substitution.
func expandStep(target *Step, template []*Step, depth int, vars map[string]string) ([]*Step, error) {
if depth > DefaultMaxExpansionDepth {
return nil, fmt.Errorf("expansion depth limit exceeded: max %d levels (currently at %d) - step %q",
DefaultMaxExpansionDepth, depth, target.ID)
@@ -163,12 +170,12 @@ func expandStep(target *Step, template []*Step, depth int) ([]*Step, error) {
for _, tmpl := range template {
expanded := &Step{
ID: substituteTargetPlaceholders(tmpl.ID, target),
Title: substituteTargetPlaceholders(tmpl.Title, target),
Description: substituteTargetPlaceholders(tmpl.Description, target),
ID: substituteVars(substituteTargetPlaceholders(tmpl.ID, target), vars),
Title: substituteVars(substituteTargetPlaceholders(tmpl.Title, target), vars),
Description: substituteVars(substituteTargetPlaceholders(tmpl.Description, target), vars),
Type: tmpl.Type,
Priority: tmpl.Priority,
Assignee: tmpl.Assignee,
Assignee: substituteVars(tmpl.Assignee, vars),
SourceFormula: tmpl.SourceFormula, // Preserve source from template (gt-8tmz.18)
SourceLocation: tmpl.SourceLocation, // Preserve source location (gt-8tmz.18)
}
@@ -177,7 +184,7 @@ func expandStep(target *Step, template []*Step, depth int) ([]*Step, error) {
if len(tmpl.Labels) > 0 {
expanded.Labels = make([]string, len(tmpl.Labels))
for i, l := range tmpl.Labels {
expanded.Labels[i] = substituteTargetPlaceholders(l, target)
expanded.Labels[i] = substituteVars(substituteTargetPlaceholders(l, target), vars)
}
}
@@ -185,20 +192,20 @@ func expandStep(target *Step, template []*Step, depth int) ([]*Step, error) {
if len(tmpl.DependsOn) > 0 {
expanded.DependsOn = make([]string, len(tmpl.DependsOn))
for i, d := range tmpl.DependsOn {
expanded.DependsOn[i] = substituteTargetPlaceholders(d, target)
expanded.DependsOn[i] = substituteVars(substituteTargetPlaceholders(d, target), vars)
}
}
if len(tmpl.Needs) > 0 {
expanded.Needs = make([]string, len(tmpl.Needs))
for i, n := range tmpl.Needs {
expanded.Needs[i] = substituteTargetPlaceholders(n, target)
expanded.Needs[i] = substituteVars(substituteTargetPlaceholders(n, target), vars)
}
}
// Handle children recursively with depth tracking
if len(tmpl.Children) > 0 {
children, err := expandStep(target, tmpl.Children, depth+1)
children, err := expandStep(target, tmpl.Children, depth+1, vars)
if err != nil {
return nil, err
}
@@ -232,6 +239,26 @@ func substituteTargetPlaceholders(s string, target *Step) string {
return s
}
// mergeVars merges formula default vars with rule overrides.
// Override values take precedence over defaults.
func mergeVars(formula *Formula, overrides map[string]string) map[string]string {
result := make(map[string]string)
// Start with formula defaults
for name, def := range formula.Vars {
if def.Default != "" {
result[name] = def.Default
}
}
// Apply overrides (these win)
for name, value := range overrides {
result[name] = value
}
return result
}
// buildStepMap creates a map of step ID to step (recursive).
func buildStepMap(steps []*Step) map[string]*Step {
result := make(map[string]*Step)

View File

@@ -87,7 +87,7 @@ func TestExpandStep(t *testing.T) {
},
}
result, err := expandStep(target, template, 0)
result, err := expandStep(target, template, 0, nil)
if err != nil {
t.Fatalf("expandStep failed: %v", err)
}
@@ -138,7 +138,7 @@ func TestExpandStepDepthLimit(t *testing.T) {
// With depth 0 start, going to level 6 means 7 levels total (0-6)
// DefaultMaxExpansionDepth is 5, so this should fail
_, err := expandStep(target, template, 0)
_, err := expandStep(target, template, 0, nil)
if err == nil {
t.Fatal("expected depth limit error, got nil")
}
@@ -159,7 +159,7 @@ func TestExpandStepDepthLimit(t *testing.T) {
}
shallowTemplate := []*Step{shallowChild}
result, err := expandStep(target, shallowTemplate, 0)
result, err := expandStep(target, shallowTemplate, 0, nil)
if err != nil {
t.Fatalf("expected shallow template to succeed, got: %v", err)
}
@@ -432,3 +432,223 @@ func getChildIDs(steps []*Step) []string {
}
return ids
}
func TestSubstituteVars(t *testing.T) {
tests := []struct {
name string
input string
vars map[string]string
expected string
}{
{
name: "single var substitution",
input: "Deploy to {environment}",
vars: map[string]string{"environment": "production"},
expected: "Deploy to production",
},
{
name: "multiple var substitution",
input: "{component} v{version}",
vars: map[string]string{"component": "auth", "version": "2.0"},
expected: "auth v2.0",
},
{
name: "unmatched placeholder stays",
input: "{known} and {unknown}",
vars: map[string]string{"known": "replaced"},
expected: "replaced and {unknown}",
},
{
name: "empty vars map",
input: "no {change}",
vars: nil,
expected: "no {change}",
},
{
name: "empty string",
input: "",
vars: map[string]string{"foo": "bar"},
expected: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := substituteVars(tt.input, tt.vars)
if result != tt.expected {
t.Errorf("substituteVars(%q, %v) = %q, want %q", tt.input, tt.vars, result, tt.expected)
}
})
}
}
func TestMergeVars(t *testing.T) {
formula := &Formula{
Vars: map[string]*VarDef{
"env": {Default: "staging"},
"version": {Default: "1.0"},
"name": {Required: true}, // No default
},
}
t.Run("overrides take precedence", func(t *testing.T) {
overrides := map[string]string{"env": "production"}
result := mergeVars(formula, overrides)
if result["env"] != "production" {
t.Errorf("env = %q, want 'production'", result["env"])
}
if result["version"] != "1.0" {
t.Errorf("version = %q, want '1.0'", result["version"])
}
})
t.Run("override adds new var", func(t *testing.T) {
overrides := map[string]string{"custom": "value"}
result := mergeVars(formula, overrides)
if result["custom"] != "value" {
t.Errorf("custom = %q, want 'value'", result["custom"])
}
})
t.Run("nil overrides uses defaults", func(t *testing.T) {
result := mergeVars(formula, nil)
if result["env"] != "staging" {
t.Errorf("env = %q, want 'staging'", result["env"])
}
})
}
func TestApplyExpansionsWithVars(t *testing.T) {
// Create a temporary directory with an expansion formula that uses vars
tmpDir := t.TempDir()
// Create an expansion formula with variables
envExpansion := `{
"formula": "env-deploy",
"type": "expansion",
"version": 1,
"vars": {
"environment": {"default": "staging"},
"replicas": {"default": "1"}
},
"template": [
{"id": "{target}.prepare-{environment}", "title": "Prepare {environment} for {target.title}"},
{"id": "{target}.deploy-{environment}", "title": "Deploy to {environment} with {replicas} replicas", "needs": ["{target}.prepare-{environment}"]}
]
}`
err := os.WriteFile(filepath.Join(tmpDir, "env-deploy.formula.json"), []byte(envExpansion), 0644)
if err != nil {
t.Fatal(err)
}
parser := NewParser(tmpDir)
t.Run("expand with var overrides", func(t *testing.T) {
steps := []*Step{
{ID: "design", Title: "Design"},
{ID: "release", Title: "Release v2"},
{ID: "test", Title: "Test"},
}
compose := &ComposeRules{
Expand: []*ExpandRule{
{
Target: "release",
With: "env-deploy",
Vars: map[string]string{"environment": "production", "replicas": "3"},
},
},
}
result, err := ApplyExpansions(steps, compose, parser)
if err != nil {
t.Fatalf("ApplyExpansions failed: %v", err)
}
if len(result) != 4 {
t.Fatalf("expected 4 steps, got %d", len(result))
}
// Check expanded step IDs include var substitution
expectedIDs := []string{"design", "release.prepare-production", "release.deploy-production", "test"}
for i, exp := range expectedIDs {
if result[i].ID != exp {
t.Errorf("result[%d].ID = %q, want %q", i, result[i].ID, exp)
}
}
// Check title includes both target and var substitution
if result[2].Title != "Deploy to production with 3 replicas" {
t.Errorf("deploy title = %q, want 'Deploy to production with 3 replicas'", result[2].Title)
}
// Check that needs was also substituted correctly
if len(result[2].Needs) != 1 || result[2].Needs[0] != "release.prepare-production" {
t.Errorf("deploy needs = %v, want [release.prepare-production]", result[2].Needs)
}
})
t.Run("expand with default vars", func(t *testing.T) {
steps := []*Step{
{ID: "release", Title: "Release"},
}
compose := &ComposeRules{
Expand: []*ExpandRule{
{Target: "release", With: "env-deploy"},
},
}
result, err := ApplyExpansions(steps, compose, parser)
if err != nil {
t.Fatalf("ApplyExpansions failed: %v", err)
}
// Check that defaults are used
if result[0].ID != "release.prepare-staging" {
t.Errorf("result[0].ID = %q, want 'release.prepare-staging'", result[0].ID)
}
if result[1].Title != "Deploy to staging with 1 replicas" {
t.Errorf("deploy title = %q, want 'Deploy to staging with 1 replicas'", result[1].Title)
}
})
t.Run("map with var overrides", func(t *testing.T) {
steps := []*Step{
{ID: "deploy.api", Title: "Deploy API"},
{ID: "deploy.web", Title: "Deploy Web"},
}
compose := &ComposeRules{
Map: []*MapRule{
{
Select: "deploy.*",
With: "env-deploy",
Vars: map[string]string{"environment": "prod"},
},
},
}
result, err := ApplyExpansions(steps, compose, parser)
if err != nil {
t.Fatalf("ApplyExpansions failed: %v", err)
}
// Each deploy.* step should expand with prod environment
expectedIDs := []string{
"deploy.api.prepare-prod", "deploy.api.deploy-prod",
"deploy.web.prepare-prod", "deploy.web.deploy-prod",
}
if len(result) != len(expectedIDs) {
t.Fatalf("expected %d steps, got %d", len(expectedIDs), len(result))
}
for i, exp := range expectedIDs {
if result[i].ID != exp {
t.Errorf("result[%d].ID = %q, want %q", i, result[i].ID, exp)
}
}
})
}