// Package formula provides condition evaluation for gates and loops. // // Conditions are intentionally limited to keep evaluation decidable: // - Step status checks: step.status == 'complete' // - Step output access: step.output.approved == true // - Aggregates: children(step).all(status == 'complete') // - External checks: file.exists('go.mod'), env.CI == 'true' // // No arbitrary code execution is allowed. package formula import ( "fmt" "os" "regexp" "strconv" "strings" ) // ConditionResult represents the result of evaluating a condition. type ConditionResult struct { // Satisfied is true if the condition is met. Satisfied bool // Reason explains why the condition is satisfied or not. Reason string } // StepState represents the runtime state of a step for condition evaluation. type StepState struct { // ID is the step identifier. ID string // Status is the step status: pending, in_progress, complete, failed. Status string // Output is the structured output from the step (if complete). // Keys are dot-separated paths, values are the output values. Output map[string]interface{} // Children are the child step states (for aggregate conditions). Children []*StepState } // ConditionContext provides the evaluation context for conditions. type ConditionContext struct { // Steps maps step ID to step state. Steps map[string]*StepState // CurrentStep is the step being gated (for relative references). CurrentStep string // Vars are the formula variables (for variable substitution). Vars map[string]string } // Operator represents a comparison operator. type Operator string const ( OpEqual Operator = "==" OpNotEqual Operator = "!=" OpGreater Operator = ">" OpGreaterEqual Operator = ">=" OpLess Operator = "<" OpLessEqual Operator = "<=" ) // Condition represents a parsed condition expression. type Condition struct { // Raw is the original condition string. Raw string // Type is the condition type: field, aggregate, external. Type ConditionType // For field conditions: StepRef string // Step ID reference (e.g., "review", "step" for current) Field string // Field path (e.g., "status", "output.approved") Operator Operator // Comparison operator Value string // Expected value // For aggregate conditions: AggregateFunc string // Function: all, any, count AggregateOver string // What to aggregate: children, descendants, steps // For external conditions: ExternalType string // file.exists, env ExternalArg string // Argument (path or env var name) } // ConditionType categorizes conditions. type ConditionType string const ( ConditionTypeField ConditionType = "field" ConditionTypeAggregate ConditionType = "aggregate" ConditionTypeExternal ConditionType = "external" ) // Patterns for parsing conditions. var ( // step.status == 'complete' or review.output.approved == true or test.output.errors.count == 0 fieldPattern = regexp.MustCompile(`^(\w+(?:\.\w+)*)\s*([=!<>]+)\s*(.+)$`) // children(step).all(status == 'complete') aggregatePattern = regexp.MustCompile(`^(children|descendants|steps)\((\w+)\)\.(all|any|count)\((.+)\)(.*)$`) // file.exists('go.mod') fileExistsPattern = regexp.MustCompile(`^file\.exists\(['"](.+)['"]\)$`) // env.CI == 'true' envPattern = regexp.MustCompile(`^env\.(\w+)\s*([=!<>]+)\s*(.+)$`) // steps.complete >= 3 stepsStatPattern = regexp.MustCompile(`^steps\.(\w+)\s*([=!<>]+)\s*(\d+)$`) // children(x).count(...) >= 3 (trailing comparison) countComparePattern = regexp.MustCompile(`\s*([=!<>]+)\s*(\d+)$`) ) // ParseCondition parses a condition string into a Condition struct. func ParseCondition(expr string) (*Condition, error) { expr = strings.TrimSpace(expr) if expr == "" { return nil, fmt.Errorf("empty condition") } // Try file.exists pattern if m := fileExistsPattern.FindStringSubmatch(expr); m != nil { return &Condition{ Raw: expr, Type: ConditionTypeExternal, ExternalType: "file.exists", ExternalArg: m[1], }, nil } // Try env pattern if m := envPattern.FindStringSubmatch(expr); m != nil { return &Condition{ Raw: expr, Type: ConditionTypeExternal, ExternalType: "env", ExternalArg: m[1], Operator: Operator(m[2]), Value: unquote(m[3]), }, nil } // Try aggregate pattern: children(step).all(status == 'complete') if m := aggregatePattern.FindStringSubmatch(expr); m != nil { innerCond, err := ParseCondition(m[4]) if err != nil { return nil, fmt.Errorf("parsing aggregate inner condition: %w", err) } cond := &Condition{ Raw: expr, Type: ConditionTypeAggregate, AggregateOver: m[1], // children, descendants, steps StepRef: m[2], // step reference AggregateFunc: m[3], // all, any, count Field: innerCond.Field, Operator: innerCond.Operator, Value: innerCond.Value, } // Handle count comparison: children(x).count(...) >= 3 if m[5] != "" { if countMatch := countComparePattern.FindStringSubmatch(m[5]); countMatch != nil { cond.AggregateFunc = "count" cond.Operator = Operator(countMatch[1]) cond.Value = countMatch[2] } } return cond, nil } // Try steps.stat pattern: steps.complete >= 3 if m := stepsStatPattern.FindStringSubmatch(expr); m != nil { return &Condition{ Raw: expr, Type: ConditionTypeAggregate, AggregateOver: "steps", AggregateFunc: "count", Field: m[1], // complete, failed, etc. Operator: Operator(m[2]), Value: m[3], }, nil } // Try field pattern: step.status == 'complete' or step.output.approved == true if m := fieldPattern.FindStringSubmatch(expr); m != nil { fieldPath := m[1] parts := strings.SplitN(fieldPath, ".", 2) stepRef := "step" // default to current step field := fieldPath if len(parts) >= 2 { // Could be: // - step.status (keyword "step" + field) // - output.field (keyword "output" + path, relative to current step) // - review.status (step name + field) // - review.output.approved (step name + output.path) if parts[0] == "step" { // step.status or step.output.approved field = parts[1] } else if parts[0] == "output" { // output.field (relative to current step) field = fieldPath // keep as output.field } else { // step_name.field or step_name.output.path stepRef = parts[0] field = parts[1] } } return &Condition{ Raw: expr, Type: ConditionTypeField, StepRef: stepRef, Field: field, Operator: Operator(m[2]), Value: unquote(m[3]), }, nil } return nil, fmt.Errorf("unrecognized condition format: %s", expr) } // Evaluate evaluates the condition against the given context. func (c *Condition) Evaluate(ctx *ConditionContext) (*ConditionResult, error) { switch c.Type { case ConditionTypeField: return c.evaluateField(ctx) case ConditionTypeAggregate: return c.evaluateAggregate(ctx) case ConditionTypeExternal: return c.evaluateExternal(ctx) default: return nil, fmt.Errorf("unknown condition type: %s", c.Type) } } func (c *Condition) evaluateField(ctx *ConditionContext) (*ConditionResult, error) { // Resolve step reference stepID := c.StepRef if stepID == "step" { stepID = ctx.CurrentStep } step, ok := ctx.Steps[stepID] if !ok { return &ConditionResult{ Satisfied: false, Reason: fmt.Sprintf("step %q not found", stepID), }, nil } // Get the field value var actual interface{} if c.Field == "status" { actual = step.Status } else if strings.HasPrefix(c.Field, "output.") { path := strings.TrimPrefix(c.Field, "output.") actual = getNestedValue(step.Output, path) } else { return nil, fmt.Errorf("unknown field: %s", c.Field) } // Compare satisfied, reason := compare(actual, c.Operator, c.Value) return &ConditionResult{ Satisfied: satisfied, Reason: reason, }, nil } func (c *Condition) evaluateAggregate(ctx *ConditionContext) (*ConditionResult, error) { // Get the set of steps to aggregate over var steps []*StepState switch c.AggregateOver { case "children": stepID := c.StepRef if stepID == "step" { stepID = ctx.CurrentStep } parent, ok := ctx.Steps[stepID] if !ok { return &ConditionResult{ Satisfied: false, Reason: fmt.Sprintf("step %q not found", stepID), }, nil } steps = parent.Children case "steps": // All steps in context for _, s := range ctx.Steps { steps = append(steps, s) } case "descendants": stepID := c.StepRef if stepID == "step" { stepID = ctx.CurrentStep } parent, ok := ctx.Steps[stepID] if !ok { return &ConditionResult{ Satisfied: false, Reason: fmt.Sprintf("step %q not found", stepID), }, nil } steps = collectDescendants(parent) } // Apply the aggregate function switch c.AggregateFunc { case "all": // Empty set: "all children complete" with no children is false // (avoids gates passing prematurely before children are created) if len(steps) == 0 { return &ConditionResult{ Satisfied: false, Reason: fmt.Sprintf("no %s to evaluate", c.AggregateOver), }, nil } for _, s := range steps { satisfied, _ := matchStep(s, c.Field, c.Operator, c.Value) if !satisfied { return &ConditionResult{ Satisfied: false, Reason: fmt.Sprintf("step %q does not match: %s %s %s", s.ID, c.Field, c.Operator, c.Value), }, nil } } return &ConditionResult{ Satisfied: true, Reason: fmt.Sprintf("all %d %s match", len(steps), c.AggregateOver), }, nil case "any": for _, s := range steps { satisfied, _ := matchStep(s, c.Field, c.Operator, c.Value) if satisfied { return &ConditionResult{ Satisfied: true, Reason: fmt.Sprintf("step %q matches: %s %s %s", s.ID, c.Field, c.Operator, c.Value), }, nil } } return &ConditionResult{ Satisfied: false, Reason: fmt.Sprintf("no steps match: %s %s %s", c.Field, c.Operator, c.Value), }, nil case "count": count := 0 for _, s := range steps { // For steps.complete pattern, field is the status to count if c.AggregateOver == "steps" && (c.Field == "complete" || c.Field == "failed" || c.Field == "pending" || c.Field == "in_progress") { if s.Status == c.Field { count++ } } else { satisfied, _ := matchStep(s, c.Field, OpEqual, c.Value) if satisfied { count++ } } } expected, err := strconv.Atoi(c.Value) if err != nil { return nil, fmt.Errorf("count comparison requires integer value, got %q: %w", c.Value, err) } satisfied, reason := compareInt(count, c.Operator, expected) return &ConditionResult{ Satisfied: satisfied, Reason: reason, }, nil } return nil, fmt.Errorf("unknown aggregate function: %s", c.AggregateFunc) } func (c *Condition) evaluateExternal(ctx *ConditionContext) (*ConditionResult, error) { switch c.ExternalType { case "file.exists": path := c.ExternalArg // Substitute variables for k, v := range ctx.Vars { path = strings.ReplaceAll(path, "{{"+k+"}}", v) } _, err := os.Stat(path) exists := err == nil return &ConditionResult{ Satisfied: exists, Reason: fmt.Sprintf("file %q exists: %v", path, exists), }, nil case "env": actual := os.Getenv(c.ExternalArg) satisfied, reason := compare(actual, c.Operator, c.Value) return &ConditionResult{ Satisfied: satisfied, Reason: reason, }, nil } return nil, fmt.Errorf("unknown external type: %s", c.ExternalType) } // Helper functions func unquote(s string) string { s = strings.TrimSpace(s) 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 } func getNestedValue(m map[string]interface{}, path string) interface{} { if m == nil { return nil } parts := strings.Split(path, ".") var current interface{} = m for _, part := range parts { if cm, ok := current.(map[string]interface{}); ok { current = cm[part] } else { return nil } } return current } func compare(actual interface{}, op Operator, expected string) (bool, string) { // Handle nil values explicitly if actual == nil { switch op { case OpEqual: // nil == "" or nil == "nil" are both false unless expected is literally empty satisfied := expected == "" return satisfied, fmt.Sprintf("nil %s %q: %v", op, expected, satisfied) case OpNotEqual: satisfied := expected != "" return satisfied, fmt.Sprintf("nil %s %q: %v", op, expected, satisfied) default: return false, fmt.Sprintf("nil cannot be compared with %s", op) } } // Handle bool values (from JSON unmarshaling) if b, ok := actual.(bool); ok { actualStr := strconv.FormatBool(b) satisfied := actualStr == expected if op == OpNotEqual { satisfied = actualStr != expected } return satisfied, fmt.Sprintf("%v %s %q: %v", b, op, expected, satisfied) } actualStr := fmt.Sprintf("%v", actual) switch op { case OpEqual: satisfied := actualStr == expected return satisfied, fmt.Sprintf("%q %s %q: %v", actualStr, op, expected, satisfied) case OpNotEqual: satisfied := actualStr != expected return satisfied, fmt.Sprintf("%q %s %q: %v", actualStr, op, expected, satisfied) case OpGreater, OpGreaterEqual, OpLess, OpLessEqual: // Try numeric comparison actualNum, err1 := strconv.ParseFloat(actualStr, 64) expectedNum, err2 := strconv.ParseFloat(expected, 64) if err1 == nil && err2 == nil { return compareFloat(actualNum, op, expectedNum) } // Fall back to string comparison return compareString(actualStr, op, expected) } return false, fmt.Sprintf("unknown operator: %s", op) } func compareInt(actual int, op Operator, expected int) (bool, string) { var satisfied bool switch op { case OpEqual: satisfied = actual == expected case OpNotEqual: satisfied = actual != expected case OpGreater: satisfied = actual > expected case OpGreaterEqual: satisfied = actual >= expected case OpLess: satisfied = actual < expected case OpLessEqual: satisfied = actual <= expected } return satisfied, fmt.Sprintf("%d %s %d: %v", actual, op, expected, satisfied) } func compareFloat(actual float64, op Operator, expected float64) (bool, string) { var satisfied bool switch op { case OpEqual: satisfied = actual == expected case OpNotEqual: satisfied = actual != expected case OpGreater: satisfied = actual > expected case OpGreaterEqual: satisfied = actual >= expected case OpLess: satisfied = actual < expected case OpLessEqual: satisfied = actual <= expected } return satisfied, fmt.Sprintf("%v %s %v: %v", actual, op, expected, satisfied) } func compareString(actual string, op Operator, expected string) (bool, string) { var satisfied bool switch op { case OpEqual: satisfied = actual == expected case OpNotEqual: satisfied = actual != expected case OpGreater: satisfied = actual > expected case OpGreaterEqual: satisfied = actual >= expected case OpLess: satisfied = actual < expected case OpLessEqual: satisfied = actual <= expected } return satisfied, fmt.Sprintf("%q %s %q: %v", actual, op, expected, satisfied) } func matchStep(s *StepState, field string, op Operator, expected string) (bool, string) { var actual interface{} if field == "status" { actual = s.Status } else if strings.HasPrefix(field, "output.") { path := strings.TrimPrefix(field, "output.") actual = getNestedValue(s.Output, path) } else { // Direct field name might be a status shorthand actual = s.Status } return compare(actual, op, expected) } func collectDescendants(s *StepState) []*StepState { var result []*StepState for _, child := range s.Children { result = append(result, child) result = append(result, collectDescendants(child)...) } return result } // EvaluateCondition is a convenience function that parses and evaluates a condition. func EvaluateCondition(expr string, ctx *ConditionContext) (*ConditionResult, error) { cond, err := ParseCondition(expr) if err != nil { return nil, err } return cond.Evaluate(ctx) }