From ee34a74e902443e0862518388ea5985d91a9da60 Mon Sep 17 00:00:00 2001 From: beads/crew/emma Date: Wed, 31 Dec 2025 00:33:11 -0800 Subject: [PATCH] feat: implement Step.Condition evaluation in bd cook (bd-7zka.1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add compile-time step filtering based on formula variables: - New EvaluateStepCondition function for {{var}} truthy and equality checks - FilterStepsByCondition to exclude steps based on conditions - Integration into pour, wisp, and mol bond commands - Supports: {{var}}, {{var}} == value, {{var}} != value Steps with conditions that evaluate to false are excluded from the cooked formula, along with their children. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- cmd/bd/cook.go | 28 +++ cmd/bd/mol_bond.go | 11 +- cmd/bd/pour.go | 3 +- cmd/bd/wisp.go | 3 +- internal/formula/stepcondition.go | 141 +++++++++++ internal/formula/stepcondition_test.go | 331 +++++++++++++++++++++++++ internal/formula/types.go | 4 +- 7 files changed, 513 insertions(+), 8 deletions(-) create mode 100644 internal/formula/stepcondition.go create mode 100644 internal/formula/stepcondition_test.go diff --git a/cmd/bd/cook.go b/cmd/bd/cook.go index 113df107..4b3136c5 100644 --- a/cmd/bd/cook.go +++ b/cmd/bd/cook.go @@ -574,6 +574,13 @@ func collectSteps(steps []*formula.Step, parentID string, // and returns an in-memory TemplateSubgraph ready for instantiation. // This is the main entry point for ephemeral proto cooking. func resolveAndCookFormula(formulaName string, searchPaths []string) (*TemplateSubgraph, error) { + return resolveAndCookFormulaWithVars(formulaName, searchPaths, nil) +} + +// resolveAndCookFormulaWithVars loads a formula and optionally filters steps by condition. +// If conditionVars is provided, steps with conditions that evaluate to false are excluded. +// Pass nil for conditionVars to include all steps (condition filtering skipped). +func resolveAndCookFormulaWithVars(formulaName string, searchPaths []string, conditionVars map[string]string) (*TemplateSubgraph, error) { // Create parser with search paths parser := formula.NewParser(searchPaths...) @@ -633,6 +640,27 @@ func resolveAndCookFormula(formulaName string, searchPaths []string) (*TemplateS } } + // Apply step condition filtering if vars provided (bd-7zka.1) + // This filters out steps whose conditions evaluate to false + if conditionVars != nil { + // Merge with formula defaults for complete evaluation + mergedVars := make(map[string]string) + for name, def := range resolved.Vars { + if def != nil && def.Default != "" { + mergedVars[name] = def.Default + } + } + for k, v := range conditionVars { + mergedVars[k] = v + } + + filteredSteps, err := formula.FilterStepsByCondition(resolved.Steps, mergedVars) + if err != nil { + return nil, fmt.Errorf("filtering steps by condition: %w", err) + } + resolved.Steps = filteredSteps + } + // Cook to in-memory subgraph, including variable definitions for default handling return cookFormulaToSubgraphWithVars(resolved, resolved.Formula, resolved.Vars) } diff --git a/cmd/bd/mol_bond.go b/cmd/bd/mol_bond.go index 58cf334a..51fdcda9 100644 --- a/cmd/bd/mol_bond.go +++ b/cmd/bd/mol_bond.go @@ -208,12 +208,13 @@ func runMolBond(cmd *cobra.Command, args []string) { // Resolve both operands - can be issue IDs or formula names // Formula names are cooked inline to in-memory subgraphs - subgraphA, cookedA, err := resolveOrCookToSubgraph(ctx, store, args[0]) + // Pass vars for step condition filtering (bd-7zka.1) + subgraphA, cookedA, err := resolveOrCookToSubgraph(ctx, store, args[0], vars) if err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) } - subgraphB, cookedB, err := resolveOrCookToSubgraph(ctx, store, args[1]) + subgraphB, cookedB, err := resolveOrCookToSubgraph(ctx, store, args[1], vars) if err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) @@ -569,8 +570,9 @@ func resolveOrDescribe(ctx context.Context, s storage.Storage, operand string) ( // If it's an issue, loads the subgraph from DB. If it's a formula, cooks inline to subgraph. // Returns the subgraph, whether it was cooked from formula, and any error. // +// The vars parameter is used for step condition filtering (bd-7zka.1). // This implements gt-4v1eo: formulas are cooked to in-memory subgraphs (no DB storage). -func resolveOrCookToSubgraph(ctx context.Context, s storage.Storage, operand string) (*TemplateSubgraph, bool, error) { +func resolveOrCookToSubgraph(ctx context.Context, s storage.Storage, operand string, vars map[string]string) (*TemplateSubgraph, bool, error) { // First, try to resolve as an existing issue id, err := utils.ResolvePartialID(ctx, s, operand) if err == nil { @@ -599,7 +601,8 @@ func resolveOrCookToSubgraph(ctx context.Context, s storage.Storage, operand str } // Try to cook formula inline to in-memory subgraph - subgraph, err := resolveAndCookFormula(operand, nil) + // Pass vars for step condition filtering (bd-7zka.1) + subgraph, err := resolveAndCookFormulaWithVars(operand, nil, vars) if err != nil { return nil, false, fmt.Errorf("'%s' not found as issue or formula: %w", operand, err) } diff --git a/cmd/bd/pour.go b/cmd/bd/pour.go index bddc197f..849f44c5 100644 --- a/cmd/bd/pour.go +++ b/cmd/bd/pour.go @@ -89,7 +89,8 @@ func runPour(cmd *cobra.Command, args []string) { // Try to cook formula inline (gt-4v1eo: ephemeral protos) // This works for any valid formula name, not just "mol-" prefixed ones - sg, err := resolveAndCookFormula(args[0], nil) + // Pass vars for step condition filtering (bd-7zka.1) + sg, err := resolveAndCookFormulaWithVars(args[0], nil, vars) if err == nil { subgraph = sg protoID = sg.Root.ID diff --git a/cmd/bd/wisp.go b/cmd/bd/wisp.go index 75b9c2fe..0c9f6689 100644 --- a/cmd/bd/wisp.go +++ b/cmd/bd/wisp.go @@ -167,7 +167,8 @@ func runWispCreate(cmd *cobra.Command, args []string) { // Try to cook formula inline (ephemeral protos) // This works for any valid formula name, not just "mol-" prefixed ones - sg, err := resolveAndCookFormula(args[0], nil) + // Pass vars for step condition filtering (bd-7zka.1) + sg, err := resolveAndCookFormulaWithVars(args[0], nil, vars) if err == nil { subgraph = sg protoID = sg.Root.ID diff --git a/internal/formula/stepcondition.go b/internal/formula/stepcondition.go new file mode 100644 index 00000000..cc04e64c --- /dev/null +++ b/internal/formula/stepcondition.go @@ -0,0 +1,141 @@ +// Package formula provides Step.Condition evaluation for compile-time step filtering. +// +// Step.Condition is simpler than the runtime condition evaluation in condition.go. +// It evaluates at cook/pour time to include or exclude steps based on formula variables. +// +// Supported formats: +// - "{{var}}" - truthy check (non-empty, non-"false", non-"0") +// - "{{var}} == value" - equality check +// - "{{var}} != value" - inequality check +package formula + +import ( + "fmt" + "regexp" + "strings" +) + +// Step condition patterns +var ( + // {{var}} - simple variable reference for truthy check + stepCondVarPattern = regexp.MustCompile(`^\{\{(\w+)\}\}$`) + + // {{var}} == value or {{var}} != value + stepCondComparePattern = regexp.MustCompile(`^\{\{(\w+)\}\}\s*(==|!=)\s*(.+)$`) +) + +// EvaluateStepCondition evaluates a step's condition against variable values. +// Returns true if the step should be included, false if it should be skipped. +// +// Condition formats: +// - "" (empty) - always include +// - "{{var}}" - include if var is truthy (non-empty, non-"false", non-"0") +// - "{{var}} == value" - include if var equals value +// - "{{var}} != value" - include if var does not equal value +func EvaluateStepCondition(condition string, vars map[string]string) (bool, error) { + condition = strings.TrimSpace(condition) + + // Empty condition means always include + if condition == "" { + return true, nil + } + + // Try truthy pattern: {{var}} + if m := stepCondVarPattern.FindStringSubmatch(condition); m != nil { + varName := m[1] + value := vars[varName] + return isTruthy(value), nil + } + + // Try comparison pattern: {{var}} == value or {{var}} != value + if m := stepCondComparePattern.FindStringSubmatch(condition); m != nil { + varName := m[1] + operator := m[2] + expected := strings.TrimSpace(m[3]) + + // Remove quotes from expected value if present + expected = unquoteValue(expected) + + actual := vars[varName] + + switch operator { + case "==": + return actual == expected, nil + case "!=": + return actual != expected, nil + } + } + + return false, fmt.Errorf("invalid step condition format: %q (expected {{var}} or {{var}} == value)", condition) +} + +// isTruthy returns true if a value is considered "truthy" for step conditions. +// Falsy values: empty string, "false", "0", "no", "off" +// All other values are truthy. +func isTruthy(value string) bool { + if value == "" { + return false + } + lower := strings.ToLower(value) + switch lower { + case "false", "0", "no", "off": + return false + } + return true +} + +// unquoteValue removes surrounding quotes from a value if present. +func unquoteValue(s string) string { + if len(s) >= 2 { + if (s[0] == '"' && s[len(s)-1] == '"') || (s[0] == '\'' && s[len(s)-1] == '\'') { + return s[1 : len(s)-1] + } + } + return s +} + +// FilterStepsByCondition filters a list of steps based on their Condition field. +// Steps with conditions that evaluate to false are excluded from the result. +// Children of excluded steps are also excluded. +// +// Parameters: +// - steps: the steps to filter +// - vars: variable values for condition evaluation +// +// Returns the filtered steps and any error encountered during evaluation. +func FilterStepsByCondition(steps []*Step, vars map[string]string) ([]*Step, error) { + if vars == nil { + vars = make(map[string]string) + } + + result := make([]*Step, 0, len(steps)) + + for _, step := range steps { + // Evaluate step condition + include, err := EvaluateStepCondition(step.Condition, vars) + if err != nil { + return nil, fmt.Errorf("step %q: %w", step.ID, err) + } + + if !include { + // Skip this step and all its children + continue + } + + // Clone the step to avoid mutating input + clone := cloneStep(step) + + // Recursively filter children + if len(step.Children) > 0 { + filteredChildren, err := FilterStepsByCondition(step.Children, vars) + if err != nil { + return nil, err + } + clone.Children = filteredChildren + } + + result = append(result, clone) + } + + return result, nil +} diff --git a/internal/formula/stepcondition_test.go b/internal/formula/stepcondition_test.go new file mode 100644 index 00000000..860236b5 --- /dev/null +++ b/internal/formula/stepcondition_test.go @@ -0,0 +1,331 @@ +package formula + +import ( + "testing" +) + +func TestEvaluateStepCondition(t *testing.T) { + tests := []struct { + name string + condition string + vars map[string]string + want bool + wantErr bool + }{ + // Empty condition - always include + { + name: "empty condition", + condition: "", + vars: nil, + want: true, + wantErr: false, + }, + // Truthy checks: {{var}} + { + name: "truthy - non-empty value", + condition: "{{enabled}}", + vars: map[string]string{"enabled": "yes"}, + want: true, + wantErr: false, + }, + { + name: "truthy - empty value", + condition: "{{enabled}}", + vars: map[string]string{"enabled": ""}, + want: false, + wantErr: false, + }, + { + name: "truthy - missing variable", + condition: "{{enabled}}", + vars: map[string]string{}, + want: false, + wantErr: false, + }, + { + name: "truthy - false string", + condition: "{{enabled}}", + vars: map[string]string{"enabled": "false"}, + want: false, + wantErr: false, + }, + { + name: "truthy - FALSE string", + condition: "{{enabled}}", + vars: map[string]string{"enabled": "FALSE"}, + want: false, + wantErr: false, + }, + { + name: "truthy - 0 string", + condition: "{{enabled}}", + vars: map[string]string{"enabled": "0"}, + want: false, + wantErr: false, + }, + { + name: "truthy - no string", + condition: "{{enabled}}", + vars: map[string]string{"enabled": "no"}, + want: false, + wantErr: false, + }, + { + name: "truthy - off string", + condition: "{{enabled}}", + vars: map[string]string{"enabled": "off"}, + want: false, + wantErr: false, + }, + { + name: "truthy - true string", + condition: "{{enabled}}", + vars: map[string]string{"enabled": "true"}, + want: true, + wantErr: false, + }, + // Equality checks: {{var}} == value + { + name: "equality - match", + condition: "{{env}} == staging", + vars: map[string]string{"env": "staging"}, + want: true, + wantErr: false, + }, + { + name: "equality - no match", + condition: "{{env}} == production", + vars: map[string]string{"env": "staging"}, + want: false, + wantErr: false, + }, + { + name: "equality - quoted value match", + condition: "{{env}} == 'staging'", + vars: map[string]string{"env": "staging"}, + want: true, + wantErr: false, + }, + { + name: "equality - double quoted value match", + condition: `{{env}} == "staging"`, + vars: map[string]string{"env": "staging"}, + want: true, + wantErr: false, + }, + // Inequality checks: {{var}} != value + { + name: "inequality - different value", + condition: "{{env}} != production", + vars: map[string]string{"env": "staging"}, + want: true, + wantErr: false, + }, + { + name: "inequality - same value", + condition: "{{env}} != staging", + vars: map[string]string{"env": "staging"}, + want: false, + wantErr: false, + }, + // Invalid conditions + { + name: "invalid - no variable braces", + condition: "env == staging", + vars: map[string]string{"env": "staging"}, + want: false, + wantErr: true, + }, + { + name: "invalid - random text", + condition: "something random", + vars: map[string]string{}, + want: false, + wantErr: true, + }, + // Edge cases + { + name: "whitespace in condition", + condition: " {{env}} == staging ", + vars: map[string]string{"env": "staging"}, + want: true, + wantErr: false, + }, + { + name: "value with spaces", + condition: "{{msg}} == 'hello world'", + vars: map[string]string{"msg": "hello world"}, + want: true, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := EvaluateStepCondition(tt.condition, tt.vars) + if (err != nil) != tt.wantErr { + t.Errorf("EvaluateStepCondition() error = %v, wantErr %v", err, tt.wantErr) + return + } + if got != tt.want { + t.Errorf("EvaluateStepCondition() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestIsTruthy(t *testing.T) { + tests := []struct { + value string + want bool + }{ + {"", false}, + {"false", false}, + {"False", false}, + {"FALSE", false}, + {"0", false}, + {"no", false}, + {"No", false}, + {"NO", false}, + {"off", false}, + {"Off", false}, + {"OFF", false}, + {"true", true}, + {"True", true}, + {"TRUE", true}, + {"1", true}, + {"yes", true}, + {"on", true}, + {"anything", true}, + {"enabled", true}, + } + + for _, tt := range tests { + t.Run(tt.value, func(t *testing.T) { + if got := isTruthy(tt.value); got != tt.want { + t.Errorf("isTruthy(%q) = %v, want %v", tt.value, got, tt.want) + } + }) + } +} + +func TestFilterStepsByCondition(t *testing.T) { + tests := []struct { + name string + steps []*Step + vars map[string]string + wantIDs []string // Expected step IDs in result + wantErr bool + }{ + { + name: "no conditions - all included", + steps: []*Step{ + {ID: "step1", Title: "Step 1"}, + {ID: "step2", Title: "Step 2"}, + }, + vars: nil, + wantIDs: []string{"step1", "step2"}, + }, + { + name: "truthy condition - included", + steps: []*Step{ + {ID: "step1", Title: "Step 1", Condition: "{{enabled}}"}, + }, + vars: map[string]string{"enabled": "true"}, + wantIDs: []string{"step1"}, + }, + { + name: "truthy condition - excluded", + steps: []*Step{ + {ID: "step1", Title: "Step 1", Condition: "{{enabled}}"}, + }, + vars: map[string]string{"enabled": "false"}, + wantIDs: []string{}, + }, + { + name: "mixed conditions", + steps: []*Step{ + {ID: "step1", Title: "Step 1"}, + {ID: "step2", Title: "Step 2", Condition: "{{run_tests}}"}, + {ID: "step3", Title: "Step 3", Condition: "{{env}} == production"}, + }, + vars: map[string]string{"run_tests": "yes", "env": "staging"}, + wantIDs: []string{"step1", "step2"}, + }, + { + name: "children inherit parent filter", + steps: []*Step{ + { + ID: "parent", + Title: "Parent", + Condition: "{{include_parent}}", + Children: []*Step{ + {ID: "child1", Title: "Child 1"}, + {ID: "child2", Title: "Child 2"}, + }, + }, + }, + vars: map[string]string{"include_parent": "false"}, + wantIDs: []string{}, // Parent excluded, children go with it + }, + { + name: "child with own condition", + steps: []*Step{ + { + ID: "parent", + Title: "Parent", + Children: []*Step{ + {ID: "child1", Title: "Child 1"}, + {ID: "child2", Title: "Child 2", Condition: "{{include_child2}}"}, + }, + }, + }, + vars: map[string]string{"include_child2": "no"}, + wantIDs: []string{"parent", "child1"}, + }, + { + name: "equality condition", + steps: []*Step{ + {ID: "deploy-staging", Title: "Deploy Staging", Condition: "{{env}} == staging"}, + {ID: "deploy-prod", Title: "Deploy Prod", Condition: "{{env}} == production"}, + }, + vars: map[string]string{"env": "staging"}, + wantIDs: []string{"deploy-staging"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := FilterStepsByCondition(tt.steps, tt.vars) + if (err != nil) != tt.wantErr { + t.Errorf("FilterStepsByCondition() error = %v, wantErr %v", err, tt.wantErr) + return + } + + // Collect all IDs (including children) from result + gotIDs := collectStepIDsForTest(result) + if len(gotIDs) != len(tt.wantIDs) { + t.Errorf("FilterStepsByCondition() got %d steps %v, want %d steps %v", + len(gotIDs), gotIDs, len(tt.wantIDs), tt.wantIDs) + return + } + + for i, wantID := range tt.wantIDs { + if i >= len(gotIDs) || gotIDs[i] != wantID { + t.Errorf("FilterStepsByCondition() step[%d] = %v, want %v", i, gotIDs, tt.wantIDs) + return + } + } + }) + } +} + +// collectStepIDsForTest collects all step IDs (including children) in order. +func collectStepIDsForTest(steps []*Step) []string { + var ids []string + for _, s := range steps { + ids = append(ids, s.ID) + ids = append(ids, collectStepIDsForTest(s.Children)...) + } + return ids +} diff --git a/internal/formula/types.go b/internal/formula/types.go index f7c542ba..ab2384d4 100644 --- a/internal/formula/types.go +++ b/internal/formula/types.go @@ -175,8 +175,8 @@ type Step struct { ExpandVars map[string]string `json:"expand_vars,omitempty"` // Condition makes this step optional based on a variable. - // Format: "{{var}}" (truthy) or "{{var}} == value". - // TODO(bd-7zka): Not yet implemented in bd cook. Filed as future work. + // Format: "{{var}}" (truthy) or "{{var}} == value" or "{{var}} != value". + // Evaluated at cook/pour time via FilterStepsByCondition. Condition string `json:"condition,omitempty"` // Children are nested steps (for creating epic hierarchies).