From cfb11960444914f562c7c1b3b3822d479e4957f6 Mon Sep 17 00:00:00 2001 From: Steve Yegge Date: Thu, 25 Dec 2025 02:10:41 -0800 Subject: [PATCH] Implement expansion operators for formula composition (gt-8tmz.3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add expand and map operators that apply macro-style template expansion: - expand(target, template) - replace a single step with expanded template - map(select, template) - replace all matching steps with expanded template The expansion formula type (e.g., rule-of-five) uses a template field with {target} and {target.description} placeholders that are substituted when applied to target steps. Changes: - Add Template field to Formula struct for expansion formulas - Add ExpandRule and MapRule types to ComposeRules - Implement ApplyExpansions in new expand.go - Add LoadByName method to Parser for loading expansion formulas - Integrate expansion into bd cook command - Add comprehensive tests 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- cmd/bd/cook.go | 10 + internal/formula/expand.go | 262 ++++++++++++++++++++++++++ internal/formula/expand_test.go | 314 ++++++++++++++++++++++++++++++++ internal/formula/parser.go | 14 ++ internal/formula/types.go | 38 ++++ 5 files changed, 638 insertions(+) create mode 100644 internal/formula/expand.go create mode 100644 internal/formula/expand_test.go diff --git a/cmd/bd/cook.go b/cmd/bd/cook.go index 374030af..a61d3b56 100644 --- a/cmd/bd/cook.go +++ b/cmd/bd/cook.go @@ -98,6 +98,16 @@ func runCook(cmd *cobra.Command, args []string) { resolved.Steps = formula.ApplyAdvice(resolved.Steps, resolved.Advice) } + // Apply expansion operators (gt-8tmz.3) + if resolved.Compose != nil && (len(resolved.Compose.Expand) > 0 || len(resolved.Compose.Map) > 0) { + expandedSteps, err := formula.ApplyExpansions(resolved.Steps, resolved.Compose, parser) + if err != nil { + fmt.Fprintf(os.Stderr, "Error applying expansions: %v\n", err) + os.Exit(1) + } + resolved.Steps = expandedSteps + } + // Apply prefix to proto ID if specified (bd-47qx) protoID := resolved.Formula if prefix != "" { diff --git a/internal/formula/expand.go b/internal/formula/expand.go new file mode 100644 index 00000000..d980f98f --- /dev/null +++ b/internal/formula/expand.go @@ -0,0 +1,262 @@ +// Package formula provides expansion operators for macro-style step transformation. +// +// Expansion operators replace target steps with template-expanded steps. +// Unlike advice operators which insert steps around targets, expansion +// operators completely replace the target with the expansion template. +// +// Two operators are supported: +// - expand: Apply template to a single target step +// - map: Apply template to all steps matching a pattern +// +// Templates use {target} and {target.description} placeholders that are +// substituted with the target step's values during expansion. +package formula + +import ( + "fmt" + "strings" +) + +// ApplyExpansions applies all expand and map rules to a formula's steps. +// Returns a new steps slice with expansions applied. +// The original steps slice is not modified. +// +// The parser is used to load referenced expansion formulas by name. +// If parser is nil, no expansions are applied. +func ApplyExpansions(steps []*Step, compose *ComposeRules, parser *Parser) ([]*Step, error) { + if compose == nil || parser == nil { + return steps, nil + } + + if len(compose.Expand) == 0 && len(compose.Map) == 0 { + return steps, nil + } + + // Build a map of step ID -> step for quick lookup + stepMap := buildStepMap(steps) + + // Track which steps have been expanded (to avoid double expansion) + expanded := make(map[string]bool) + + // Apply expand rules first (specific targets) + result := steps + for _, rule := range compose.Expand { + targetStep, ok := stepMap[rule.Target] + if !ok { + return nil, fmt.Errorf("expand: target step %q not found", rule.Target) + } + + if expanded[rule.Target] { + continue // Already expanded + } + + // Load the expansion formula + expFormula, err := parser.LoadByName(rule.With) + if err != nil { + return nil, fmt.Errorf("expand: loading %q: %w", rule.With, err) + } + + if expFormula.Type != TypeExpansion { + return nil, fmt.Errorf("expand: %q is not an expansion formula (type=%s)", rule.With, expFormula.Type) + } + + if len(expFormula.Template) == 0 { + return nil, fmt.Errorf("expand: %q has no template steps", rule.With) + } + + // Expand the target step + expandedSteps := expandStep(targetStep, expFormula.Template) + + // Replace the target step with expanded steps + result = replaceStep(result, rule.Target, expandedSteps) + expanded[rule.Target] = true + + // Update step map with new steps + for _, s := range expandedSteps { + stepMap[s.ID] = s + } + delete(stepMap, rule.Target) + } + + // Apply map rules (pattern matching) + for _, rule := range compose.Map { + // Load the expansion formula + expFormula, err := parser.LoadByName(rule.With) + if err != nil { + return nil, fmt.Errorf("map: loading %q: %w", rule.With, err) + } + + if expFormula.Type != TypeExpansion { + return nil, fmt.Errorf("map: %q is not an expansion formula (type=%s)", rule.With, expFormula.Type) + } + + if len(expFormula.Template) == 0 { + return nil, fmt.Errorf("map: %q has no template steps", rule.With) + } + + // Find all matching steps + var toExpand []*Step + for _, step := range result { + if MatchGlob(rule.Select, step.ID) && !expanded[step.ID] { + toExpand = append(toExpand, step) + } + } + + // Expand each matching step + for _, targetStep := range toExpand { + expandedSteps := expandStep(targetStep, expFormula.Template) + result = replaceStep(result, targetStep.ID, expandedSteps) + expanded[targetStep.ID] = true + + // Update step map + for _, s := range expandedSteps { + stepMap[s.ID] = s + } + delete(stepMap, targetStep.ID) + } + } + + return result, nil +} + +// expandStep expands a target step using the given template. +// Returns the expanded steps with placeholders substituted. +func expandStep(target *Step, template []*Step) []*Step { + result := make([]*Step, 0, len(template)) + + for _, tmpl := range template { + expanded := &Step{ + ID: substituteTargetPlaceholders(tmpl.ID, target), + Title: substituteTargetPlaceholders(tmpl.Title, target), + Description: substituteTargetPlaceholders(tmpl.Description, target), + Type: tmpl.Type, + Priority: tmpl.Priority, + Assignee: tmpl.Assignee, + } + + // Substitute placeholders in labels + if len(tmpl.Labels) > 0 { + expanded.Labels = make([]string, len(tmpl.Labels)) + for i, l := range tmpl.Labels { + expanded.Labels[i] = substituteTargetPlaceholders(l, target) + } + } + + // Substitute placeholders in dependencies + if len(tmpl.DependsOn) > 0 { + expanded.DependsOn = make([]string, len(tmpl.DependsOn)) + for i, d := range tmpl.DependsOn { + expanded.DependsOn[i] = substituteTargetPlaceholders(d, target) + } + } + + if len(tmpl.Needs) > 0 { + expanded.Needs = make([]string, len(tmpl.Needs)) + for i, n := range tmpl.Needs { + expanded.Needs[i] = substituteTargetPlaceholders(n, target) + } + } + + // Handle children recursively + if len(tmpl.Children) > 0 { + expanded.Children = expandStep(target, tmpl.Children) + } + + result = append(result, expanded) + } + + return result +} + +// substituteTargetPlaceholders replaces {target} and {target.*} placeholders. +func substituteTargetPlaceholders(s string, target *Step) string { + if s == "" { + return s + } + + // Replace {target} with target step ID + s = strings.ReplaceAll(s, "{target}", target.ID) + + // Replace {target.id} with target step ID + s = strings.ReplaceAll(s, "{target.id}", target.ID) + + // Replace {target.title} with target step title + s = strings.ReplaceAll(s, "{target.title}", target.Title) + + // Replace {target.description} with target step description + s = strings.ReplaceAll(s, "{target.description}", target.Description) + + return s +} + +// buildStepMap creates a map of step ID to step (recursive). +func buildStepMap(steps []*Step) map[string]*Step { + result := make(map[string]*Step) + for _, step := range steps { + result[step.ID] = step + // Add children recursively + for id, child := range buildStepMap(step.Children) { + result[id] = child + } + } + return result +} + +// replaceStep replaces a step with the given ID with a slice of new steps. +// This is done at the top level only; children are not searched. +func replaceStep(steps []*Step, targetID string, replacement []*Step) []*Step { + result := make([]*Step, 0, len(steps)+len(replacement)-1) + + for _, step := range steps { + if step.ID == targetID { + // Replace with expanded steps + result = append(result, replacement...) + } else { + // Keep the step, but check children + if len(step.Children) > 0 { + // Clone step and replace in children + clone := cloneStep(step) + clone.Children = replaceStep(step.Children, targetID, replacement) + result = append(result, clone) + } else { + result = append(result, step) + } + } + } + + return result +} + +// UpdateDependenciesForExpansion updates dependency references after expansion. +// When step X is expanded into X.draft, X.refine-1, etc., any step that +// depended on X should now depend on the last step in the expansion. +func UpdateDependenciesForExpansion(steps []*Step, expandedID string, lastExpandedStepID string) []*Step { + result := make([]*Step, len(steps)) + + for i, step := range steps { + clone := cloneStep(step) + + // Update DependsOn references + for j, dep := range clone.DependsOn { + if dep == expandedID { + clone.DependsOn[j] = lastExpandedStepID + } + } + + // Update Needs references + for j, need := range clone.Needs { + if need == expandedID { + clone.Needs[j] = lastExpandedStepID + } + } + + // Handle children recursively + if len(step.Children) > 0 { + clone.Children = UpdateDependenciesForExpansion(step.Children, expandedID, lastExpandedStepID) + } + + result[i] = clone + } + + return result +} diff --git a/internal/formula/expand_test.go b/internal/formula/expand_test.go new file mode 100644 index 00000000..54eb8006 --- /dev/null +++ b/internal/formula/expand_test.go @@ -0,0 +1,314 @@ +package formula + +import ( + "os" + "path/filepath" + "testing" +) + +func TestSubstituteTargetPlaceholders(t *testing.T) { + target := &Step{ + ID: "implement", + Title: "Implement the feature", + Description: "Write the code for the feature", + } + + tests := []struct { + name string + input string + expected string + }{ + { + name: "basic target substitution", + input: "{target}.draft", + expected: "implement.draft", + }, + { + name: "target.id substitution", + input: "{target.id}.refine", + expected: "implement.refine", + }, + { + name: "target.title substitution", + input: "Working on: {target.title}", + expected: "Working on: Implement the feature", + }, + { + name: "target.description substitution", + input: "Task: {target.description}", + expected: "Task: Write the code for the feature", + }, + { + name: "multiple substitutions", + input: "{target}: {target.description}", + expected: "implement: Write the code for the feature", + }, + { + name: "no placeholders", + input: "plain text", + expected: "plain text", + }, + { + name: "empty string", + input: "", + expected: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := substituteTargetPlaceholders(tt.input, target) + if result != tt.expected { + t.Errorf("substituteTargetPlaceholders(%q) = %q, want %q", tt.input, result, tt.expected) + } + }) + } +} + +func TestExpandStep(t *testing.T) { + target := &Step{ + ID: "implement", + Title: "Implement the feature", + Description: "Write the code", + } + + template := []*Step{ + { + ID: "{target}.draft", + Title: "Draft: {target.title}", + Description: "Initial attempt at: {target.description}", + }, + { + ID: "{target}.refine", + Title: "Refine: {target.title}", + Needs: []string{"{target}.draft"}, + }, + } + + result := expandStep(target, template) + + if len(result) != 2 { + t.Fatalf("expected 2 steps, got %d", len(result)) + } + + // Check first step + if result[0].ID != "implement.draft" { + t.Errorf("step 0 ID = %q, want %q", result[0].ID, "implement.draft") + } + if result[0].Title != "Draft: Implement the feature" { + t.Errorf("step 0 Title = %q, want %q", result[0].Title, "Draft: Implement the feature") + } + if result[0].Description != "Initial attempt at: Write the code" { + t.Errorf("step 0 Description = %q, want %q", result[0].Description, "Initial attempt at: Write the code") + } + + // Check second step + if result[1].ID != "implement.refine" { + t.Errorf("step 1 ID = %q, want %q", result[1].ID, "implement.refine") + } + if len(result[1].Needs) != 1 || result[1].Needs[0] != "implement.draft" { + t.Errorf("step 1 Needs = %v, want [implement.draft]", result[1].Needs) + } +} + +func TestReplaceStep(t *testing.T) { + steps := []*Step{ + {ID: "design", Title: "Design"}, + {ID: "implement", Title: "Implement"}, + {ID: "test", Title: "Test"}, + } + + replacement := []*Step{ + {ID: "implement.draft", Title: "Draft"}, + {ID: "implement.refine", Title: "Refine"}, + } + + result := replaceStep(steps, "implement", replacement) + + if len(result) != 4 { + t.Fatalf("expected 4 steps, got %d", len(result)) + } + + expected := []string{"design", "implement.draft", "implement.refine", "test"} + for i, exp := range expected { + if result[i].ID != exp { + t.Errorf("result[%d].ID = %q, want %q", i, result[i].ID, exp) + } + } +} + +func TestApplyExpansions(t *testing.T) { + // Create a temporary directory with an expansion formula + tmpDir := t.TempDir() + + // Create rule-of-five expansion formula + ruleOfFive := `{ + "formula": "rule-of-five", + "type": "expansion", + "version": 1, + "template": [ + {"id": "{target}.draft", "title": "Draft: {target.title}"}, + {"id": "{target}.refine", "title": "Refine", "needs": ["{target}.draft"]} + ] + }` + err := os.WriteFile(filepath.Join(tmpDir, "rule-of-five.formula.json"), []byte(ruleOfFive), 0644) + if err != nil { + t.Fatal(err) + } + + // Create parser with temp dir as search path + parser := NewParser(tmpDir) + + // Test expand operator + t.Run("expand single step", func(t *testing.T) { + steps := []*Step{ + {ID: "design", Title: "Design"}, + {ID: "implement", Title: "Implement the feature"}, + {ID: "test", Title: "Test"}, + } + + compose := &ComposeRules{ + Expand: []*ExpandRule{ + {Target: "implement", With: "rule-of-five"}, + }, + } + + 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)) + } + + expected := []string{"design", "implement.draft", "implement.refine", "test"} + for i, exp := range expected { + if result[i].ID != exp { + t.Errorf("result[%d].ID = %q, want %q", i, result[i].ID, exp) + } + } + }) + + // Test map operator + t.Run("map over pattern", func(t *testing.T) { + steps := []*Step{ + {ID: "design", Title: "Design"}, + {ID: "impl.auth", Title: "Implement auth"}, + {ID: "impl.api", Title: "Implement API"}, + {ID: "test", Title: "Test"}, + } + + compose := &ComposeRules{ + Map: []*MapRule{ + {Select: "impl.*", With: "rule-of-five"}, + }, + } + + result, err := ApplyExpansions(steps, compose, parser) + if err != nil { + t.Fatalf("ApplyExpansions failed: %v", err) + } + + // design + (impl.auth -> 2 steps) + (impl.api -> 2 steps) + test = 6 + if len(result) != 6 { + t.Fatalf("expected 6 steps, got %d", len(result)) + } + + // Verify the expanded IDs + expectedIDs := []string{ + "design", + "impl.auth.draft", "impl.auth.refine", + "impl.api.draft", "impl.api.refine", + "test", + } + for i, exp := range expectedIDs { + if result[i].ID != exp { + t.Errorf("result[%d].ID = %q, want %q", i, result[i].ID, exp) + } + } + }) + + // Test missing formula + t.Run("missing expansion formula", func(t *testing.T) { + steps := []*Step{{ID: "test", Title: "Test"}} + compose := &ComposeRules{ + Expand: []*ExpandRule{ + {Target: "test", With: "nonexistent"}, + }, + } + + _, err := ApplyExpansions(steps, compose, parser) + if err == nil { + t.Error("expected error for missing formula") + } + }) + + // Test missing target step + t.Run("missing target step", func(t *testing.T) { + steps := []*Step{{ID: "test", Title: "Test"}} + compose := &ComposeRules{ + Expand: []*ExpandRule{ + {Target: "nonexistent", With: "rule-of-five"}, + }, + } + + _, err := ApplyExpansions(steps, compose, parser) + if err == nil { + t.Error("expected error for missing target step") + } + }) +} + +func TestBuildStepMap(t *testing.T) { + steps := []*Step{ + { + ID: "parent", + Title: "Parent", + Children: []*Step{ + {ID: "child1", Title: "Child 1"}, + {ID: "child2", Title: "Child 2"}, + }, + }, + {ID: "sibling", Title: "Sibling"}, + } + + stepMap := buildStepMap(steps) + + if len(stepMap) != 4 { + t.Errorf("expected 4 steps in map, got %d", len(stepMap)) + } + + expectedIDs := []string{"parent", "child1", "child2", "sibling"} + for _, id := range expectedIDs { + if _, ok := stepMap[id]; !ok { + t.Errorf("step %q not found in map", id) + } + } +} + +func TestUpdateDependenciesForExpansion(t *testing.T) { + steps := []*Step{ + {ID: "design", Title: "Design"}, + {ID: "test", Title: "Test", Needs: []string{"implement"}}, + {ID: "deploy", Title: "Deploy", DependsOn: []string{"implement", "test"}}, + } + + result := UpdateDependenciesForExpansion(steps, "implement", "implement.refine") + + // Check test step + if len(result[1].Needs) != 1 || result[1].Needs[0] != "implement.refine" { + t.Errorf("test step Needs = %v, want [implement.refine]", result[1].Needs) + } + + // Check deploy step + if len(result[2].DependsOn) != 2 { + t.Fatalf("deploy step DependsOn length = %d, want 2", len(result[2].DependsOn)) + } + if result[2].DependsOn[0] != "implement.refine" { + t.Errorf("deploy step DependsOn[0] = %q, want %q", result[2].DependsOn[0], "implement.refine") + } + if result[2].DependsOn[1] != "test" { + t.Errorf("deploy step DependsOn[1] = %q, want %q", result[2].DependsOn[1], "test") + } +} diff --git a/internal/formula/parser.go b/internal/formula/parser.go index 15577412..9e8b0155 100644 --- a/internal/formula/parser.go +++ b/internal/formula/parser.go @@ -209,6 +209,12 @@ func (p *Parser) loadFormula(name string) (*Formula, error) { return nil, fmt.Errorf("formula %q not found in search paths", name) } +// LoadByName loads a formula by name from search paths. +// This is the public API for loading formulas used by expansion operators. +func (p *Parser) LoadByName(name string) (*Formula, error) { + return p.loadFormula(name) +} + // mergeComposeRules merges two compose rule sets. func mergeComposeRules(base, overlay *ComposeRules) *ComposeRules { if overlay == nil { @@ -221,6 +227,8 @@ func mergeComposeRules(base, overlay *ComposeRules) *ComposeRules { result := &ComposeRules{ BondPoints: append([]*BondPoint{}, base.BondPoints...), Hooks: append([]*Hook{}, base.Hooks...), + Expand: append([]*ExpandRule{}, base.Expand...), + Map: append([]*MapRule{}, base.Map...), } // Add overlay bond points (override by ID) @@ -239,6 +247,12 @@ func mergeComposeRules(base, overlay *ComposeRules) *ComposeRules { // Add overlay hooks (append, no override) result.Hooks = append(result.Hooks, overlay.Hooks...) + // Add overlay expand rules (append, no override) + result.Expand = append(result.Expand, overlay.Expand...) + + // Add overlay map rules (append, no override) + result.Map = append(result.Map, overlay.Map...) + return result } diff --git a/internal/formula/types.go b/internal/formula/types.go index 0e825851..b5514c6a 100644 --- a/internal/formula/types.go +++ b/internal/formula/types.go @@ -83,6 +83,11 @@ type Formula struct { // Steps defines the work items to create. Steps []*Step `json:"steps,omitempty"` + // Template defines expansion template steps (for TypeExpansion formulas). + // Template steps use {target} and {target.description} placeholders + // that get substituted when the expansion is applied to a target step. + Template []*Step `json:"template,omitempty"` + // Compose defines composition/bonding rules. Compose *ComposeRules `json:"compose,omitempty"` @@ -197,6 +202,39 @@ type ComposeRules struct { // Hooks are automatic attachments triggered by labels or conditions. Hooks []*Hook `json:"hooks,omitempty"` + + // Expand applies an expansion template to a single target step. + // The target step is replaced by the expanded template steps. + Expand []*ExpandRule `json:"expand,omitempty"` + + // Map applies an expansion template to all steps matching a pattern. + // Each matching step is replaced by the expanded template steps. + Map []*MapRule `json:"map,omitempty"` +} + +// ExpandRule applies an expansion template to a single target step. +type ExpandRule struct { + // Target is the step ID to expand. + Target string `json:"target"` + + // With is the name of the expansion formula to apply. + With string `json:"with"` + + // Vars are variable overrides for the expansion. + Vars map[string]string `json:"vars,omitempty"` +} + +// MapRule applies an expansion template to all matching steps. +type MapRule struct { + // Select is a glob pattern matching step IDs to expand. + // Examples: "*.implement", "shiny.*" + Select string `json:"select"` + + // With is the name of the expansion formula to apply. + With string `json:"with"` + + // Vars are variable overrides for the expansion. + Vars map[string]string `json:"vars,omitempty"` } // BondPoint is a named attachment site for composition.