Implement expansion operators for formula composition (gt-8tmz.3)
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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 != "" {
|
||||
|
||||
262
internal/formula/expand.go
Normal file
262
internal/formula/expand.go
Normal file
@@ -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
|
||||
}
|
||||
314
internal/formula/expand_test.go
Normal file
314
internal/formula/expand_test.go
Normal file
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user