feat: Add condition evaluator for gates and loops (gt-8tmz.7)
Implements mechanical condition evaluation for formula control flow:
- Step status checks: step.status == 'complete'
- Step output access: step.output.approved == true
- Aggregates: children(step).all(status == 'complete'), steps.complete >= 3
- External checks: file.exists('go.mod'), env.CI == 'true'
Conditions are intentionally limited to keep evaluation decidable.
No arbitrary code execution allowed.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
536
internal/formula/condition.go
Normal file
536
internal/formula/condition.go
Normal file
@@ -0,0 +1,536 @@
|
|||||||
|
// 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+)$`)
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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] != "" {
|
||||||
|
countMatch := regexp.MustCompile(`\s*([=!<>]+)\s*(\d+)$`).FindStringSubmatch(m[5])
|
||||||
|
if 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":
|
||||||
|
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 steps match", len(steps)),
|
||||||
|
}, 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, _ := strconv.Atoi(c.Value)
|
||||||
|
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) {
|
||||||
|
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 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)
|
||||||
|
}
|
||||||
437
internal/formula/condition_test.go
Normal file
437
internal/formula/condition_test.go
Normal file
@@ -0,0 +1,437 @@
|
|||||||
|
package formula
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseCondition_FieldConditions(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
expr string
|
||||||
|
wantType ConditionType
|
||||||
|
wantStep string
|
||||||
|
wantOp Operator
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "step status",
|
||||||
|
expr: "step.status == 'complete'",
|
||||||
|
wantType: ConditionTypeField,
|
||||||
|
wantStep: "step",
|
||||||
|
wantOp: OpEqual,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "named step status",
|
||||||
|
expr: "review.status == 'complete'",
|
||||||
|
wantType: ConditionTypeField,
|
||||||
|
wantStep: "review",
|
||||||
|
wantOp: OpEqual,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "step output field",
|
||||||
|
expr: "review.output.approved == true",
|
||||||
|
wantType: ConditionTypeField,
|
||||||
|
wantStep: "review",
|
||||||
|
wantOp: OpEqual,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "not equal",
|
||||||
|
expr: "step.status != 'failed'",
|
||||||
|
wantType: ConditionTypeField,
|
||||||
|
wantStep: "step",
|
||||||
|
wantOp: OpNotEqual,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "nested output",
|
||||||
|
expr: "test.output.errors.count == 0",
|
||||||
|
wantType: ConditionTypeField,
|
||||||
|
wantStep: "test",
|
||||||
|
wantOp: OpEqual,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
cond, err := ParseCondition(tt.expr)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseCondition(%q) error: %v", tt.expr, err)
|
||||||
|
}
|
||||||
|
if cond.Type != tt.wantType {
|
||||||
|
t.Errorf("Type = %v, want %v", cond.Type, tt.wantType)
|
||||||
|
}
|
||||||
|
if cond.StepRef != tt.wantStep {
|
||||||
|
t.Errorf("StepRef = %v, want %v", cond.StepRef, tt.wantStep)
|
||||||
|
}
|
||||||
|
if cond.Operator != tt.wantOp {
|
||||||
|
t.Errorf("Operator = %v, want %v", cond.Operator, tt.wantOp)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseCondition_AggregateConditions(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
expr string
|
||||||
|
wantFunc string
|
||||||
|
wantOver string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "children all complete",
|
||||||
|
expr: "children(step).all(status == 'complete')",
|
||||||
|
wantFunc: "all",
|
||||||
|
wantOver: "children",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "children any failed",
|
||||||
|
expr: "children(review).any(status == 'failed')",
|
||||||
|
wantFunc: "any",
|
||||||
|
wantOver: "children",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "steps count",
|
||||||
|
expr: "steps.complete >= 3",
|
||||||
|
wantFunc: "count",
|
||||||
|
wantOver: "steps",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
cond, err := ParseCondition(tt.expr)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseCondition(%q) error: %v", tt.expr, err)
|
||||||
|
}
|
||||||
|
if cond.Type != ConditionTypeAggregate {
|
||||||
|
t.Errorf("Type = %v, want %v", cond.Type, ConditionTypeAggregate)
|
||||||
|
}
|
||||||
|
if cond.AggregateFunc != tt.wantFunc {
|
||||||
|
t.Errorf("AggregateFunc = %v, want %v", cond.AggregateFunc, tt.wantFunc)
|
||||||
|
}
|
||||||
|
if cond.AggregateOver != tt.wantOver {
|
||||||
|
t.Errorf("AggregateOver = %v, want %v", cond.AggregateOver, tt.wantOver)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseCondition_ExternalConditions(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
expr string
|
||||||
|
wantExtType string
|
||||||
|
wantExtArg string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "file exists single quotes",
|
||||||
|
expr: "file.exists('go.mod')",
|
||||||
|
wantExtType: "file.exists",
|
||||||
|
wantExtArg: "go.mod",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "file exists double quotes",
|
||||||
|
expr: `file.exists("package.json")`,
|
||||||
|
wantExtType: "file.exists",
|
||||||
|
wantExtArg: "package.json",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "env var",
|
||||||
|
expr: "env.CI == 'true'",
|
||||||
|
wantExtType: "env",
|
||||||
|
wantExtArg: "CI",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
cond, err := ParseCondition(tt.expr)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseCondition(%q) error: %v", tt.expr, err)
|
||||||
|
}
|
||||||
|
if cond.Type != ConditionTypeExternal {
|
||||||
|
t.Errorf("Type = %v, want %v", cond.Type, ConditionTypeExternal)
|
||||||
|
}
|
||||||
|
if cond.ExternalType != tt.wantExtType {
|
||||||
|
t.Errorf("ExternalType = %v, want %v", cond.ExternalType, tt.wantExtType)
|
||||||
|
}
|
||||||
|
if cond.ExternalArg != tt.wantExtArg {
|
||||||
|
t.Errorf("ExternalArg = %v, want %v", cond.ExternalArg, tt.wantExtArg)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEvaluateCondition_Field(t *testing.T) {
|
||||||
|
ctx := &ConditionContext{
|
||||||
|
CurrentStep: "test",
|
||||||
|
Steps: map[string]*StepState{
|
||||||
|
"design": {
|
||||||
|
ID: "design",
|
||||||
|
Status: "complete",
|
||||||
|
Output: map[string]interface{}{
|
||||||
|
"approved": true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"test": {
|
||||||
|
ID: "test",
|
||||||
|
Status: "in_progress",
|
||||||
|
Output: map[string]interface{}{
|
||||||
|
"errors": map[string]interface{}{
|
||||||
|
"count": float64(0),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"review": {
|
||||||
|
ID: "review",
|
||||||
|
Status: "pending",
|
||||||
|
Output: map[string]interface{}{
|
||||||
|
"approved": false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
expr string
|
||||||
|
wantSatis bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "step complete - true",
|
||||||
|
expr: "design.status == 'complete'",
|
||||||
|
wantSatis: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "step complete - false",
|
||||||
|
expr: "review.status == 'complete'",
|
||||||
|
wantSatis: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "current step status",
|
||||||
|
expr: "step.status == 'in_progress'",
|
||||||
|
wantSatis: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "output bool true",
|
||||||
|
expr: "design.output.approved == true",
|
||||||
|
wantSatis: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "output bool false",
|
||||||
|
expr: "review.output.approved == true",
|
||||||
|
wantSatis: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "nested output",
|
||||||
|
expr: "test.output.errors.count == 0",
|
||||||
|
wantSatis: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "not equal satisfied",
|
||||||
|
expr: "design.status != 'failed'",
|
||||||
|
wantSatis: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
result, err := EvaluateCondition(tt.expr, ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("EvaluateCondition(%q) error: %v", tt.expr, err)
|
||||||
|
}
|
||||||
|
if result.Satisfied != tt.wantSatis {
|
||||||
|
t.Errorf("Satisfied = %v, want %v (reason: %s)", result.Satisfied, tt.wantSatis, result.Reason)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEvaluateCondition_Aggregate(t *testing.T) {
|
||||||
|
ctx := &ConditionContext{
|
||||||
|
CurrentStep: "aggregate",
|
||||||
|
Steps: map[string]*StepState{
|
||||||
|
"parent": {
|
||||||
|
ID: "parent",
|
||||||
|
Status: "in_progress",
|
||||||
|
Children: []*StepState{
|
||||||
|
{ID: "child1", Status: "complete"},
|
||||||
|
{ID: "child2", Status: "complete"},
|
||||||
|
{ID: "child3", Status: "complete"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"mixed": {
|
||||||
|
ID: "mixed",
|
||||||
|
Status: "in_progress",
|
||||||
|
Children: []*StepState{
|
||||||
|
{ID: "m1", Status: "complete"},
|
||||||
|
{ID: "m2", Status: "failed"},
|
||||||
|
{ID: "m3", Status: "pending"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"step1": {ID: "step1", Status: "complete"},
|
||||||
|
"step2": {ID: "step2", Status: "complete"},
|
||||||
|
"step3": {ID: "step3", Status: "complete"},
|
||||||
|
"step4": {ID: "step4", Status: "pending"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
expr string
|
||||||
|
wantSatis bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "all children complete - true",
|
||||||
|
expr: "children(parent).all(status == 'complete')",
|
||||||
|
wantSatis: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "all children complete - false",
|
||||||
|
expr: "children(mixed).all(status == 'complete')",
|
||||||
|
wantSatis: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "any children failed - true",
|
||||||
|
expr: "children(mixed).any(status == 'failed')",
|
||||||
|
wantSatis: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "any children failed - false",
|
||||||
|
expr: "children(parent).any(status == 'failed')",
|
||||||
|
wantSatis: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "steps count >= satisfied",
|
||||||
|
expr: "steps.complete >= 3",
|
||||||
|
wantSatis: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "steps count >= not satisfied",
|
||||||
|
expr: "steps.complete >= 5",
|
||||||
|
wantSatis: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
result, err := EvaluateCondition(tt.expr, ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("EvaluateCondition(%q) error: %v", tt.expr, err)
|
||||||
|
}
|
||||||
|
if result.Satisfied != tt.wantSatis {
|
||||||
|
t.Errorf("Satisfied = %v, want %v (reason: %s)", result.Satisfied, tt.wantSatis, result.Reason)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEvaluateCondition_External(t *testing.T) {
|
||||||
|
// Create a temp file for testing
|
||||||
|
tmpFile, err := os.CreateTemp("", "condition_test_*.txt")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
tmpFile.Close()
|
||||||
|
defer os.Remove(tmpFile.Name())
|
||||||
|
|
||||||
|
// Set an env var for testing
|
||||||
|
os.Setenv("TEST_CONDITION_VAR", "test_value")
|
||||||
|
defer os.Unsetenv("TEST_CONDITION_VAR")
|
||||||
|
|
||||||
|
ctx := &ConditionContext{
|
||||||
|
Vars: map[string]string{
|
||||||
|
"tempfile": tmpFile.Name(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
expr string
|
||||||
|
wantSatis bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "file exists - true",
|
||||||
|
expr: "file.exists('" + tmpFile.Name() + "')",
|
||||||
|
wantSatis: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "file exists - false",
|
||||||
|
expr: "file.exists('/nonexistent/file/path')",
|
||||||
|
wantSatis: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "env var equals",
|
||||||
|
expr: "env.TEST_CONDITION_VAR == 'test_value'",
|
||||||
|
wantSatis: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "env var not equals",
|
||||||
|
expr: "env.TEST_CONDITION_VAR == 'wrong'",
|
||||||
|
wantSatis: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
result, err := EvaluateCondition(tt.expr, ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("EvaluateCondition(%q) error: %v", tt.expr, err)
|
||||||
|
}
|
||||||
|
if result.Satisfied != tt.wantSatis {
|
||||||
|
t.Errorf("Satisfied = %v, want %v (reason: %s)", result.Satisfied, tt.wantSatis, result.Reason)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseCondition_Errors(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
expr string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "empty",
|
||||||
|
expr: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "whitespace only",
|
||||||
|
expr: " ",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
_, err := ParseCondition(tt.expr)
|
||||||
|
if err == nil {
|
||||||
|
t.Errorf("ParseCondition(%q) expected error, got nil", tt.expr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCompareOperators(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
actual string
|
||||||
|
op Operator
|
||||||
|
expected string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"5", OpGreater, "3", true},
|
||||||
|
{"5", OpGreater, "5", false},
|
||||||
|
{"5", OpGreaterEqual, "5", true},
|
||||||
|
{"3", OpLess, "5", true},
|
||||||
|
{"5", OpLess, "5", false},
|
||||||
|
{"5", OpLessEqual, "5", true},
|
||||||
|
{"abc", OpEqual, "abc", true},
|
||||||
|
{"abc", OpNotEqual, "def", true},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(string(tt.op), func(t *testing.T) {
|
||||||
|
got, _ := compare(tt.actual, tt.op, tt.expected)
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("compare(%q, %s, %q) = %v, want %v", tt.actual, tt.op, tt.expected, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user