feat(formula): add formula parser and bd cook command (bd-weu8, bd-wa2l)
Implements a new formula system for defining workflow templates in YAML: - internal/formula/types.go: YAML schema types for .formula.yaml files - Formula, Step, VarDef, ComposeRules, BondPoint, Gate types - Support for workflow, expansion, and aspect formula types - Variable definitions with required, default, enum, and pattern - internal/formula/parser.go: Parser with extends/inheritance support - Parse formula files from multiple search paths - Resolve extends references for formula inheritance - Variable extraction and substitution - Variable validation (required, enum, pattern) - cmd/bd/cook.go: bd cook command to compile formulas into protos - Parse and resolve formula YAML - Create proto bead with template label - Create child issues for each step - Set up parent-child and blocking dependencies - Support --dry-run, --force, --search-path flags Example workflow: bd cook mol-feature.formula.yaml bd pour mol-feature --var component=Auth --var framework=react 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
367
internal/formula/parser.go
Normal file
367
internal/formula/parser.go
Normal file
@@ -0,0 +1,367 @@
|
||||
package formula
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// FormulaExt is the file extension for formula files.
|
||||
const FormulaExt = ".formula.yaml"
|
||||
|
||||
// Parser handles loading and resolving formulas.
|
||||
type Parser struct {
|
||||
// searchPaths are directories to search for formulas (in order).
|
||||
searchPaths []string
|
||||
|
||||
// cache stores loaded formulas by name.
|
||||
cache map[string]*Formula
|
||||
|
||||
// resolving tracks formulas currently being resolved (for cycle detection).
|
||||
resolving map[string]bool
|
||||
}
|
||||
|
||||
// NewParser creates a new formula parser.
|
||||
// searchPaths are directories to search for formulas when resolving extends.
|
||||
// Default paths are: .beads/formulas, ~/.beads/formulas, ~/gt/.beads/formulas
|
||||
func NewParser(searchPaths ...string) *Parser {
|
||||
paths := searchPaths
|
||||
if len(paths) == 0 {
|
||||
paths = defaultSearchPaths()
|
||||
}
|
||||
return &Parser{
|
||||
searchPaths: paths,
|
||||
cache: make(map[string]*Formula),
|
||||
resolving: make(map[string]bool),
|
||||
}
|
||||
}
|
||||
|
||||
// defaultSearchPaths returns the default formula search paths.
|
||||
func defaultSearchPaths() []string {
|
||||
var paths []string
|
||||
|
||||
// Project-level formulas
|
||||
if cwd, err := os.Getwd(); err == nil {
|
||||
paths = append(paths, filepath.Join(cwd, ".beads", "formulas"))
|
||||
}
|
||||
|
||||
// User-level formulas
|
||||
if home, err := os.UserHomeDir(); err == nil {
|
||||
paths = append(paths, filepath.Join(home, ".beads", "formulas"))
|
||||
|
||||
// Gas Town formulas
|
||||
paths = append(paths, filepath.Join(home, "gt", ".beads", "formulas"))
|
||||
}
|
||||
|
||||
return paths
|
||||
}
|
||||
|
||||
// ParseFile parses a formula from a file path.
|
||||
func (p *Parser) ParseFile(path string) (*Formula, error) {
|
||||
// Check cache first
|
||||
absPath, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("resolve path: %w", err)
|
||||
}
|
||||
|
||||
if cached, ok := p.cache[absPath]; ok {
|
||||
return cached, nil
|
||||
}
|
||||
|
||||
// Read and parse the file
|
||||
data, err := os.ReadFile(absPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read %s: %w", path, err)
|
||||
}
|
||||
|
||||
formula, err := p.Parse(data)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse %s: %w", path, err)
|
||||
}
|
||||
|
||||
formula.Source = absPath
|
||||
p.cache[absPath] = formula
|
||||
|
||||
// Also cache by name for extends resolution
|
||||
p.cache[formula.Formula] = formula
|
||||
|
||||
return formula, nil
|
||||
}
|
||||
|
||||
// Parse parses a formula from YAML bytes.
|
||||
func (p *Parser) Parse(data []byte) (*Formula, error) {
|
||||
var formula Formula
|
||||
if err := yaml.Unmarshal(data, &formula); err != nil {
|
||||
return nil, fmt.Errorf("yaml: %w", err)
|
||||
}
|
||||
|
||||
// Set defaults
|
||||
if formula.Version == 0 {
|
||||
formula.Version = 1
|
||||
}
|
||||
if formula.Type == "" {
|
||||
formula.Type = TypeWorkflow
|
||||
}
|
||||
|
||||
return &formula, nil
|
||||
}
|
||||
|
||||
// Resolve fully resolves a formula, processing extends and expansions.
|
||||
// Returns a new formula with all inheritance applied.
|
||||
func (p *Parser) Resolve(formula *Formula) (*Formula, error) {
|
||||
// Check for cycles
|
||||
if p.resolving[formula.Formula] {
|
||||
return nil, fmt.Errorf("circular extends detected: %s", formula.Formula)
|
||||
}
|
||||
p.resolving[formula.Formula] = true
|
||||
defer delete(p.resolving, formula.Formula)
|
||||
|
||||
// If no extends, just validate and return
|
||||
if len(formula.Extends) == 0 {
|
||||
if err := formula.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return formula, nil
|
||||
}
|
||||
|
||||
// Build merged formula from parents
|
||||
merged := &Formula{
|
||||
Formula: formula.Formula,
|
||||
Description: formula.Description,
|
||||
Version: formula.Version,
|
||||
Type: formula.Type,
|
||||
Source: formula.Source,
|
||||
Vars: make(map[string]*VarDef),
|
||||
Steps: nil,
|
||||
Compose: nil,
|
||||
}
|
||||
|
||||
// Apply each parent in order
|
||||
for _, parentName := range formula.Extends {
|
||||
parent, err := p.loadFormula(parentName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("extends %s: %w", parentName, err)
|
||||
}
|
||||
|
||||
// Resolve parent recursively
|
||||
parent, err = p.Resolve(parent)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("resolve parent %s: %w", parentName, err)
|
||||
}
|
||||
|
||||
// Merge parent vars (parent vars are inherited, child overrides)
|
||||
for name, varDef := range parent.Vars {
|
||||
if _, exists := merged.Vars[name]; !exists {
|
||||
merged.Vars[name] = varDef
|
||||
}
|
||||
}
|
||||
|
||||
// Merge parent steps (append, child steps come after)
|
||||
merged.Steps = append(merged.Steps, parent.Steps...)
|
||||
|
||||
// Merge parent compose rules
|
||||
merged.Compose = mergeComposeRules(merged.Compose, parent.Compose)
|
||||
}
|
||||
|
||||
// Apply child overrides
|
||||
for name, varDef := range formula.Vars {
|
||||
merged.Vars[name] = varDef
|
||||
}
|
||||
merged.Steps = append(merged.Steps, formula.Steps...)
|
||||
merged.Compose = mergeComposeRules(merged.Compose, formula.Compose)
|
||||
|
||||
// Use child description if set
|
||||
if formula.Description != "" {
|
||||
merged.Description = formula.Description
|
||||
}
|
||||
|
||||
if err := merged.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return merged, nil
|
||||
}
|
||||
|
||||
// loadFormula loads a formula by name from search paths.
|
||||
func (p *Parser) loadFormula(name string) (*Formula, error) {
|
||||
// Check cache first
|
||||
if cached, ok := p.cache[name]; ok {
|
||||
return cached, nil
|
||||
}
|
||||
|
||||
// Search for the formula file
|
||||
filename := name + FormulaExt
|
||||
for _, dir := range p.searchPaths {
|
||||
path := filepath.Join(dir, filename)
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
return p.ParseFile(path)
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("formula %q not found in search paths", name)
|
||||
}
|
||||
|
||||
// mergeComposeRules merges two compose rule sets.
|
||||
func mergeComposeRules(base, overlay *ComposeRules) *ComposeRules {
|
||||
if overlay == nil {
|
||||
return base
|
||||
}
|
||||
if base == nil {
|
||||
return overlay
|
||||
}
|
||||
|
||||
result := &ComposeRules{
|
||||
BondPoints: append([]*BondPoint{}, base.BondPoints...),
|
||||
Hooks: append([]*Hook{}, base.Hooks...),
|
||||
}
|
||||
|
||||
// Add overlay bond points (override by ID)
|
||||
existingBP := make(map[string]int)
|
||||
for i, bp := range result.BondPoints {
|
||||
existingBP[bp.ID] = i
|
||||
}
|
||||
for _, bp := range overlay.BondPoints {
|
||||
if idx, exists := existingBP[bp.ID]; exists {
|
||||
result.BondPoints[idx] = bp
|
||||
} else {
|
||||
result.BondPoints = append(result.BondPoints, bp)
|
||||
}
|
||||
}
|
||||
|
||||
// Add overlay hooks (append, no override)
|
||||
result.Hooks = append(result.Hooks, overlay.Hooks...)
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// varPattern matches {{variable}} placeholders.
|
||||
var varPattern = regexp.MustCompile(`\{\{([a-zA-Z_][a-zA-Z0-9_]*)\}\}`)
|
||||
|
||||
// ExtractVariables finds all {{variable}} references in a formula.
|
||||
func ExtractVariables(formula *Formula) []string {
|
||||
seen := make(map[string]bool)
|
||||
var vars []string
|
||||
|
||||
// Helper to extract vars from a string
|
||||
extract := func(s string) {
|
||||
matches := varPattern.FindAllStringSubmatch(s, -1)
|
||||
for _, match := range matches {
|
||||
if len(match) >= 2 && !seen[match[1]] {
|
||||
seen[match[1]] = true
|
||||
vars = append(vars, match[1])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Extract from formula fields
|
||||
extract(formula.Description)
|
||||
|
||||
// Extract from steps
|
||||
var extractFromStep func(*Step)
|
||||
extractFromStep = func(step *Step) {
|
||||
extract(step.Title)
|
||||
extract(step.Description)
|
||||
extract(step.Assignee)
|
||||
extract(step.Condition)
|
||||
for _, child := range step.Children {
|
||||
extractFromStep(child)
|
||||
}
|
||||
}
|
||||
|
||||
for _, step := range formula.Steps {
|
||||
extractFromStep(step)
|
||||
}
|
||||
|
||||
return vars
|
||||
}
|
||||
|
||||
// Substitute replaces {{variable}} placeholders with values.
|
||||
func Substitute(s string, vars map[string]string) string {
|
||||
return varPattern.ReplaceAllStringFunc(s, func(match string) string {
|
||||
// Extract variable name from {{name}}
|
||||
name := match[2 : len(match)-2]
|
||||
if val, ok := vars[name]; ok {
|
||||
return val
|
||||
}
|
||||
return match // Keep unresolved placeholders
|
||||
})
|
||||
}
|
||||
|
||||
// ValidateVars checks that all required variables are provided
|
||||
// and all values pass their constraints.
|
||||
func ValidateVars(formula *Formula, values map[string]string) error {
|
||||
var errs []string
|
||||
|
||||
for name, def := range formula.Vars {
|
||||
val, provided := values[name]
|
||||
|
||||
// Check required
|
||||
if def.Required && !provided {
|
||||
errs = append(errs, fmt.Sprintf("variable %q is required", name))
|
||||
continue
|
||||
}
|
||||
|
||||
// Use default if not provided
|
||||
if !provided && def.Default != "" {
|
||||
val = def.Default
|
||||
}
|
||||
|
||||
// Skip further validation if no value
|
||||
if val == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check enum constraint
|
||||
if len(def.Enum) > 0 {
|
||||
found := false
|
||||
for _, allowed := range def.Enum {
|
||||
if val == allowed {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
errs = append(errs, fmt.Sprintf("variable %q: value %q not in allowed values %v", name, val, def.Enum))
|
||||
}
|
||||
}
|
||||
|
||||
// Check pattern constraint
|
||||
if def.Pattern != "" {
|
||||
re, err := regexp.Compile(def.Pattern)
|
||||
if err != nil {
|
||||
errs = append(errs, fmt.Sprintf("variable %q: invalid pattern %q: %v", name, def.Pattern, err))
|
||||
} else if !re.MatchString(val) {
|
||||
errs = append(errs, fmt.Sprintf("variable %q: value %q does not match pattern %q", name, val, def.Pattern))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(errs) > 0 {
|
||||
return fmt.Errorf("variable validation failed:\n - %s", strings.Join(errs, "\n - "))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ApplyDefaults returns a new map with default values filled in.
|
||||
func ApplyDefaults(formula *Formula, values map[string]string) map[string]string {
|
||||
result := make(map[string]string)
|
||||
|
||||
// Copy provided values
|
||||
for k, v := range values {
|
||||
result[k] = v
|
||||
}
|
||||
|
||||
// Apply defaults for missing values
|
||||
for name, def := range formula.Vars {
|
||||
if _, exists := result[name]; !exists && def.Default != "" {
|
||||
result[name] = def.Default
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
559
internal/formula/parser_test.go
Normal file
559
internal/formula/parser_test.go
Normal file
@@ -0,0 +1,559 @@
|
||||
package formula
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParse_BasicFormula(t *testing.T) {
|
||||
yaml := `
|
||||
formula: mol-test
|
||||
description: Test workflow
|
||||
version: 1
|
||||
type: workflow
|
||||
vars:
|
||||
component:
|
||||
description: Component name
|
||||
required: true
|
||||
framework:
|
||||
description: Target framework
|
||||
default: react
|
||||
enum: [react, vue, angular]
|
||||
steps:
|
||||
- id: design
|
||||
title: "Design {{component}}"
|
||||
type: task
|
||||
priority: 1
|
||||
- id: implement
|
||||
title: "Implement {{component}}"
|
||||
type: task
|
||||
depends_on: [design]
|
||||
- id: test
|
||||
title: "Test {{component}} with {{framework}}"
|
||||
type: task
|
||||
depends_on: [implement]
|
||||
`
|
||||
p := NewParser()
|
||||
formula, err := p.Parse([]byte(yaml))
|
||||
if err != nil {
|
||||
t.Fatalf("Parse failed: %v", err)
|
||||
}
|
||||
|
||||
// Check basic fields
|
||||
if formula.Formula != "mol-test" {
|
||||
t.Errorf("Formula = %q, want mol-test", formula.Formula)
|
||||
}
|
||||
if formula.Description != "Test workflow" {
|
||||
t.Errorf("Description = %q, want 'Test workflow'", formula.Description)
|
||||
}
|
||||
if formula.Version != 1 {
|
||||
t.Errorf("Version = %d, want 1", formula.Version)
|
||||
}
|
||||
if formula.Type != TypeWorkflow {
|
||||
t.Errorf("Type = %q, want workflow", formula.Type)
|
||||
}
|
||||
|
||||
// Check vars
|
||||
if len(formula.Vars) != 2 {
|
||||
t.Fatalf("len(Vars) = %d, want 2", len(formula.Vars))
|
||||
}
|
||||
if v := formula.Vars["component"]; v == nil || !v.Required {
|
||||
t.Error("component var should be required")
|
||||
}
|
||||
if v := formula.Vars["framework"]; v == nil || v.Default != "react" {
|
||||
t.Error("framework var should have default 'react'")
|
||||
}
|
||||
if v := formula.Vars["framework"]; v == nil || len(v.Enum) != 3 {
|
||||
t.Error("framework var should have 3 enum values")
|
||||
}
|
||||
|
||||
// Check steps
|
||||
if len(formula.Steps) != 3 {
|
||||
t.Fatalf("len(Steps) = %d, want 3", len(formula.Steps))
|
||||
}
|
||||
if formula.Steps[0].ID != "design" {
|
||||
t.Errorf("Steps[0].ID = %q, want 'design'", formula.Steps[0].ID)
|
||||
}
|
||||
if formula.Steps[1].DependsOn[0] != "design" {
|
||||
t.Errorf("Steps[1].DependsOn = %v, want [design]", formula.Steps[1].DependsOn)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidate_ValidFormula(t *testing.T) {
|
||||
formula := &Formula{
|
||||
Formula: "mol-valid",
|
||||
Version: 1,
|
||||
Type: TypeWorkflow,
|
||||
Steps: []*Step{
|
||||
{ID: "step1", Title: "Step 1"},
|
||||
{ID: "step2", Title: "Step 2", DependsOn: []string{"step1"}},
|
||||
},
|
||||
}
|
||||
|
||||
if err := formula.Validate(); err != nil {
|
||||
t.Errorf("Validate failed for valid formula: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidate_MissingName(t *testing.T) {
|
||||
formula := &Formula{
|
||||
Version: 1,
|
||||
Type: TypeWorkflow,
|
||||
Steps: []*Step{{ID: "step1", Title: "Step 1"}},
|
||||
}
|
||||
|
||||
err := formula.Validate()
|
||||
if err == nil {
|
||||
t.Error("Validate should fail for formula without name")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidate_DuplicateStepID(t *testing.T) {
|
||||
formula := &Formula{
|
||||
Formula: "mol-dup",
|
||||
Version: 1,
|
||||
Type: TypeWorkflow,
|
||||
Steps: []*Step{
|
||||
{ID: "step1", Title: "Step 1"},
|
||||
{ID: "step1", Title: "Step 1 again"}, // duplicate
|
||||
},
|
||||
}
|
||||
|
||||
err := formula.Validate()
|
||||
if err == nil {
|
||||
t.Error("Validate should fail for duplicate step IDs")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidate_InvalidDependency(t *testing.T) {
|
||||
formula := &Formula{
|
||||
Formula: "mol-bad-dep",
|
||||
Version: 1,
|
||||
Type: TypeWorkflow,
|
||||
Steps: []*Step{
|
||||
{ID: "step1", Title: "Step 1", DependsOn: []string{"nonexistent"}},
|
||||
},
|
||||
}
|
||||
|
||||
err := formula.Validate()
|
||||
if err == nil {
|
||||
t.Error("Validate should fail for dependency on nonexistent step")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidate_RequiredWithDefault(t *testing.T) {
|
||||
formula := &Formula{
|
||||
Formula: "mol-bad-var",
|
||||
Version: 1,
|
||||
Type: TypeWorkflow,
|
||||
Vars: map[string]*VarDef{
|
||||
"bad": {Required: true, Default: "value"}, // can't have both
|
||||
},
|
||||
Steps: []*Step{{ID: "step1", Title: "Step 1"}},
|
||||
}
|
||||
|
||||
err := formula.Validate()
|
||||
if err == nil {
|
||||
t.Error("Validate should fail for required var with default")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidate_InvalidPriority(t *testing.T) {
|
||||
p := 10 // invalid: must be 0-4
|
||||
formula := &Formula{
|
||||
Formula: "mol-bad-priority",
|
||||
Version: 1,
|
||||
Type: TypeWorkflow,
|
||||
Steps: []*Step{
|
||||
{ID: "step1", Title: "Step 1", Priority: &p},
|
||||
},
|
||||
}
|
||||
|
||||
err := formula.Validate()
|
||||
if err == nil {
|
||||
t.Error("Validate should fail for priority > 4")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidate_ChildSteps(t *testing.T) {
|
||||
formula := &Formula{
|
||||
Formula: "mol-children",
|
||||
Version: 1,
|
||||
Type: TypeWorkflow,
|
||||
Steps: []*Step{
|
||||
{
|
||||
ID: "epic1",
|
||||
Title: "Epic 1",
|
||||
Children: []*Step{
|
||||
{ID: "child1", Title: "Child 1"},
|
||||
{ID: "child2", Title: "Child 2", DependsOn: []string{"child1"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
if err := formula.Validate(); err != nil {
|
||||
t.Errorf("Validate failed for valid nested formula: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidate_BondPoints(t *testing.T) {
|
||||
formula := &Formula{
|
||||
Formula: "mol-compose",
|
||||
Version: 1,
|
||||
Type: TypeWorkflow,
|
||||
Steps: []*Step{
|
||||
{ID: "step1", Title: "Step 1"},
|
||||
{ID: "step2", Title: "Step 2"},
|
||||
},
|
||||
Compose: &ComposeRules{
|
||||
BondPoints: []*BondPoint{
|
||||
{ID: "after-step1", AfterStep: "step1"},
|
||||
{ID: "before-step2", BeforeStep: "step2"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
if err := formula.Validate(); err != nil {
|
||||
t.Errorf("Validate failed for valid bond points: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidate_BondPointBothAnchors(t *testing.T) {
|
||||
formula := &Formula{
|
||||
Formula: "mol-bad-bond",
|
||||
Version: 1,
|
||||
Type: TypeWorkflow,
|
||||
Steps: []*Step{{ID: "step1", Title: "Step 1"}},
|
||||
Compose: &ComposeRules{
|
||||
BondPoints: []*BondPoint{
|
||||
{ID: "bad", AfterStep: "step1", BeforeStep: "step1"}, // can't have both
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
err := formula.Validate()
|
||||
if err == nil {
|
||||
t.Error("Validate should fail for bond point with both after_step and before_step")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractVariables(t *testing.T) {
|
||||
formula := &Formula{
|
||||
Formula: "mol-vars",
|
||||
Description: "Build {{project}} for {{env}}",
|
||||
Steps: []*Step{
|
||||
{ID: "s1", Title: "Deploy {{project}} to {{env}}"},
|
||||
{ID: "s2", Title: "Notify {{owner}}"},
|
||||
},
|
||||
}
|
||||
|
||||
vars := ExtractVariables(formula)
|
||||
want := map[string]bool{"project": true, "env": true, "owner": true}
|
||||
|
||||
if len(vars) != len(want) {
|
||||
t.Errorf("ExtractVariables found %d vars, want %d", len(vars), len(want))
|
||||
}
|
||||
for _, v := range vars {
|
||||
if !want[v] {
|
||||
t.Errorf("Unexpected variable: %q", v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubstitute(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
vars map[string]string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
input: "Deploy {{project}} to {{env}}",
|
||||
vars: map[string]string{"project": "myapp", "env": "prod"},
|
||||
want: "Deploy myapp to prod",
|
||||
},
|
||||
{
|
||||
input: "{{name}} version {{version}}",
|
||||
vars: map[string]string{"name": "beads"},
|
||||
want: "beads version {{version}}", // unresolved kept
|
||||
},
|
||||
{
|
||||
input: "No variables here",
|
||||
vars: map[string]string{"unused": "value"},
|
||||
want: "No variables here",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
got := Substitute(tt.input, tt.vars)
|
||||
if got != tt.want {
|
||||
t.Errorf("Substitute(%q, %v) = %q, want %q", tt.input, tt.vars, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateVars(t *testing.T) {
|
||||
formula := &Formula{
|
||||
Formula: "mol-vars",
|
||||
Vars: map[string]*VarDef{
|
||||
"required_var": {Required: true},
|
||||
"enum_var": {Enum: []string{"a", "b", "c"}},
|
||||
"pattern_var": {Pattern: `^[a-z]+$`},
|
||||
"optional_var": {Default: "default"},
|
||||
},
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
values map[string]string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "missing required",
|
||||
values: map[string]string{},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "all provided",
|
||||
values: map[string]string{"required_var": "value"},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "valid enum",
|
||||
values: map[string]string{"required_var": "x", "enum_var": "a"},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "invalid enum",
|
||||
values: map[string]string{"required_var": "x", "enum_var": "invalid"},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "valid pattern",
|
||||
values: map[string]string{"required_var": "x", "pattern_var": "abc"},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "invalid pattern",
|
||||
values: map[string]string{"required_var": "x", "pattern_var": "123"},
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := ValidateVars(formula, tt.values)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("ValidateVars() error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyDefaults(t *testing.T) {
|
||||
formula := &Formula{
|
||||
Formula: "mol-defaults",
|
||||
Vars: map[string]*VarDef{
|
||||
"with_default": {Default: "default_value"},
|
||||
"without_default": {},
|
||||
},
|
||||
}
|
||||
|
||||
values := map[string]string{"without_default": "provided"}
|
||||
result := ApplyDefaults(formula, values)
|
||||
|
||||
if result["with_default"] != "default_value" {
|
||||
t.Errorf("with_default = %q, want 'default_value'", result["with_default"])
|
||||
}
|
||||
if result["without_default"] != "provided" {
|
||||
t.Errorf("without_default = %q, want 'provided'", result["without_default"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseFile_AndResolve(t *testing.T) {
|
||||
// Create temp directory with test formulas
|
||||
dir := t.TempDir()
|
||||
formulaDir := filepath.Join(dir, ".beads", "formulas")
|
||||
if err := os.MkdirAll(formulaDir, 0755); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
|
||||
// Write parent formula
|
||||
parent := `
|
||||
formula: base-workflow
|
||||
version: 1
|
||||
type: workflow
|
||||
vars:
|
||||
project:
|
||||
description: Project name
|
||||
required: true
|
||||
steps:
|
||||
- id: init
|
||||
title: "Initialize {{project}}"
|
||||
`
|
||||
if err := os.WriteFile(filepath.Join(formulaDir, "base-workflow.formula.yaml"), []byte(parent), 0644); err != nil {
|
||||
t.Fatalf("write parent: %v", err)
|
||||
}
|
||||
|
||||
// Write child formula that extends parent
|
||||
child := `
|
||||
formula: extended-workflow
|
||||
version: 1
|
||||
type: workflow
|
||||
extends:
|
||||
- base-workflow
|
||||
vars:
|
||||
env:
|
||||
default: dev
|
||||
steps:
|
||||
- id: deploy
|
||||
title: "Deploy {{project}} to {{env}}"
|
||||
depends_on: [init]
|
||||
`
|
||||
childPath := filepath.Join(formulaDir, "extended-workflow.formula.yaml")
|
||||
if err := os.WriteFile(childPath, []byte(child), 0644); err != nil {
|
||||
t.Fatalf("write child: %v", err)
|
||||
}
|
||||
|
||||
// Parse and resolve
|
||||
p := NewParser(formulaDir)
|
||||
formula, err := p.ParseFile(childPath)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseFile: %v", err)
|
||||
}
|
||||
|
||||
resolved, err := p.Resolve(formula)
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve: %v", err)
|
||||
}
|
||||
|
||||
// Check inheritance
|
||||
if len(resolved.Vars) != 2 {
|
||||
t.Errorf("len(Vars) = %d, want 2 (inherited + child)", len(resolved.Vars))
|
||||
}
|
||||
if resolved.Vars["project"] == nil {
|
||||
t.Error("inherited var 'project' not found")
|
||||
}
|
||||
if resolved.Vars["env"] == nil {
|
||||
t.Error("child var 'env' not found")
|
||||
}
|
||||
|
||||
// Check steps (parent + child)
|
||||
if len(resolved.Steps) != 2 {
|
||||
t.Errorf("len(Steps) = %d, want 2", len(resolved.Steps))
|
||||
}
|
||||
if resolved.Steps[0].ID != "init" {
|
||||
t.Errorf("Steps[0].ID = %q, want 'init' (inherited)", resolved.Steps[0].ID)
|
||||
}
|
||||
if resolved.Steps[1].ID != "deploy" {
|
||||
t.Errorf("Steps[1].ID = %q, want 'deploy' (child)", resolved.Steps[1].ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolve_CircularExtends(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
formulaDir := filepath.Join(dir, ".beads", "formulas")
|
||||
if err := os.MkdirAll(formulaDir, 0755); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
|
||||
// Write formulas that extend each other (cycle)
|
||||
formulaA := `
|
||||
formula: cycle-a
|
||||
version: 1
|
||||
type: workflow
|
||||
extends: [cycle-b]
|
||||
steps: [{id: a, title: A}]
|
||||
`
|
||||
formulaB := `
|
||||
formula: cycle-b
|
||||
version: 1
|
||||
type: workflow
|
||||
extends: [cycle-a]
|
||||
steps: [{id: b, title: B}]
|
||||
`
|
||||
if err := os.WriteFile(filepath.Join(formulaDir, "cycle-a.formula.yaml"), []byte(formulaA), 0644); err != nil {
|
||||
t.Fatalf("write a: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(formulaDir, "cycle-b.formula.yaml"), []byte(formulaB), 0644); err != nil {
|
||||
t.Fatalf("write b: %v", err)
|
||||
}
|
||||
|
||||
p := NewParser(formulaDir)
|
||||
formula, err := p.ParseFile(filepath.Join(formulaDir, "cycle-a.formula.yaml"))
|
||||
if err != nil {
|
||||
t.Fatalf("ParseFile: %v", err)
|
||||
}
|
||||
|
||||
_, err = p.Resolve(formula)
|
||||
if err == nil {
|
||||
t.Error("Resolve should fail for circular extends")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetStepByID(t *testing.T) {
|
||||
formula := &Formula{
|
||||
Formula: "mol-nested",
|
||||
Steps: []*Step{
|
||||
{
|
||||
ID: "epic1",
|
||||
Title: "Epic 1",
|
||||
Children: []*Step{
|
||||
{ID: "child1", Title: "Child 1"},
|
||||
{
|
||||
ID: "child2",
|
||||
Title: "Child 2",
|
||||
Children: []*Step{
|
||||
{ID: "grandchild", Title: "Grandchild"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{ID: "step2", Title: "Step 2"},
|
||||
},
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
id string
|
||||
want string
|
||||
}{
|
||||
{"epic1", "Epic 1"},
|
||||
{"child1", "Child 1"},
|
||||
{"grandchild", "Grandchild"},
|
||||
{"step2", "Step 2"},
|
||||
{"nonexistent", ""},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
step := formula.GetStepByID(tt.id)
|
||||
if tt.want == "" {
|
||||
if step != nil {
|
||||
t.Errorf("GetStepByID(%q) = %v, want nil", tt.id, step)
|
||||
}
|
||||
} else {
|
||||
if step == nil || step.Title != tt.want {
|
||||
t.Errorf("GetStepByID(%q).Title = %v, want %q", tt.id, step, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormulaType_IsValid(t *testing.T) {
|
||||
tests := []struct {
|
||||
t FormulaType
|
||||
want bool
|
||||
}{
|
||||
{TypeWorkflow, true},
|
||||
{TypeExpansion, true},
|
||||
{TypeAspect, true},
|
||||
{"invalid", false},
|
||||
{"", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
if got := tt.t.IsValid(); got != tt.want {
|
||||
t.Errorf("%q.IsValid() = %v, want %v", tt.t, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
376
internal/formula/types.go
Normal file
376
internal/formula/types.go
Normal file
@@ -0,0 +1,376 @@
|
||||
// Package formula provides parsing and validation for .formula.yaml files.
|
||||
//
|
||||
// Formulas are high-level workflow templates that compile down to proto beads.
|
||||
// They support:
|
||||
// - Variable definitions with defaults and validation
|
||||
// - Step definitions that become issue hierarchies
|
||||
// - Composition rules for bonding formulas together
|
||||
// - Inheritance via extends
|
||||
//
|
||||
// Example .formula.yaml:
|
||||
//
|
||||
// formula: mol-feature
|
||||
// description: Standard feature workflow
|
||||
// version: 1
|
||||
// type: workflow
|
||||
// vars:
|
||||
// component:
|
||||
// description: "Component name"
|
||||
// required: true
|
||||
// steps:
|
||||
// - id: design
|
||||
// title: "Design {{component}}"
|
||||
// type: task
|
||||
// - id: implement
|
||||
// title: "Implement {{component}}"
|
||||
// depends_on: [design]
|
||||
package formula
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// FormulaType categorizes formulas by their purpose.
|
||||
type FormulaType string
|
||||
|
||||
const (
|
||||
// TypeWorkflow is a standard workflow template (sequence of steps).
|
||||
TypeWorkflow FormulaType = "workflow"
|
||||
|
||||
// TypeExpansion is a macro that expands into multiple steps.
|
||||
// Used for common patterns like "test + lint + build".
|
||||
TypeExpansion FormulaType = "expansion"
|
||||
|
||||
// TypeAspect is a cross-cutting concern that can be applied to other formulas.
|
||||
// Examples: add logging steps, add approval gates.
|
||||
TypeAspect FormulaType = "aspect"
|
||||
)
|
||||
|
||||
// IsValid checks if the formula type is recognized.
|
||||
func (t FormulaType) IsValid() bool {
|
||||
switch t {
|
||||
case TypeWorkflow, TypeExpansion, TypeAspect:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Formula is the root structure for .formula.yaml files.
|
||||
type Formula struct {
|
||||
// Formula is the unique identifier/name for this formula.
|
||||
// Convention: mol-<name> for molecules, exp-<name> for expansions.
|
||||
Formula string `yaml:"formula" json:"formula"`
|
||||
|
||||
// Description explains what this formula does.
|
||||
Description string `yaml:"description,omitempty" json:"description,omitempty"`
|
||||
|
||||
// Version is the schema version (currently 1).
|
||||
Version int `yaml:"version" json:"version"`
|
||||
|
||||
// Type categorizes the formula: workflow, expansion, or aspect.
|
||||
Type FormulaType `yaml:"type" json:"type"`
|
||||
|
||||
// Extends is a list of parent formulas to inherit from.
|
||||
// The child formula inherits all vars, steps, and compose rules.
|
||||
// Child definitions override parent definitions with the same ID.
|
||||
Extends []string `yaml:"extends,omitempty" json:"extends,omitempty"`
|
||||
|
||||
// Vars defines template variables with defaults and validation.
|
||||
Vars map[string]*VarDef `yaml:"vars,omitempty" json:"vars,omitempty"`
|
||||
|
||||
// Steps defines the work items to create.
|
||||
Steps []*Step `yaml:"steps,omitempty" json:"steps,omitempty"`
|
||||
|
||||
// Compose defines composition/bonding rules.
|
||||
Compose *ComposeRules `yaml:"compose,omitempty" json:"compose,omitempty"`
|
||||
|
||||
// Source tracks where this formula was loaded from (set by parser).
|
||||
Source string `yaml:"-" json:"source,omitempty"`
|
||||
}
|
||||
|
||||
// VarDef defines a template variable with optional validation.
|
||||
type VarDef struct {
|
||||
// Description explains what this variable is for.
|
||||
Description string `yaml:"description,omitempty" json:"description,omitempty"`
|
||||
|
||||
// Default is the value to use if not provided.
|
||||
Default string `yaml:"default,omitempty" json:"default,omitempty"`
|
||||
|
||||
// Required indicates the variable must be provided (no default).
|
||||
Required bool `yaml:"required,omitempty" json:"required,omitempty"`
|
||||
|
||||
// Enum lists the allowed values (if non-empty).
|
||||
Enum []string `yaml:"enum,omitempty" json:"enum,omitempty"`
|
||||
|
||||
// Pattern is a regex pattern the value must match.
|
||||
Pattern string `yaml:"pattern,omitempty" json:"pattern,omitempty"`
|
||||
|
||||
// Type is the expected value type: string (default), int, bool.
|
||||
Type string `yaml:"type,omitempty" json:"type,omitempty"`
|
||||
}
|
||||
|
||||
// Step defines a work item to create when the formula is instantiated.
|
||||
type Step struct {
|
||||
// ID is the unique identifier within this formula.
|
||||
// Used for dependency references and bond points.
|
||||
ID string `yaml:"id" json:"id"`
|
||||
|
||||
// Title is the issue title (supports {{variable}} substitution).
|
||||
Title string `yaml:"title" json:"title"`
|
||||
|
||||
// Description is the issue description (supports substitution).
|
||||
Description string `yaml:"description,omitempty" json:"description,omitempty"`
|
||||
|
||||
// Type is the issue type: task, bug, feature, epic, chore.
|
||||
Type string `yaml:"type,omitempty" json:"type,omitempty"`
|
||||
|
||||
// Priority is the issue priority (0-4).
|
||||
Priority *int `yaml:"priority,omitempty" json:"priority,omitempty"`
|
||||
|
||||
// Labels are applied to the created issue.
|
||||
Labels []string `yaml:"labels,omitempty" json:"labels,omitempty"`
|
||||
|
||||
// DependsOn lists step IDs this step blocks on (within the formula).
|
||||
DependsOn []string `yaml:"depends_on,omitempty" json:"depends_on,omitempty"`
|
||||
|
||||
// Assignee is the default assignee (supports substitution).
|
||||
Assignee string `yaml:"assignee,omitempty" json:"assignee,omitempty"`
|
||||
|
||||
// Expand references an expansion formula to inline here.
|
||||
// When set, this step is replaced by the expansion's steps.
|
||||
Expand string `yaml:"expand,omitempty" json:"expand,omitempty"`
|
||||
|
||||
// ExpandVars are variable overrides for the expansion.
|
||||
ExpandVars map[string]string `yaml:"expand_vars,omitempty" json:"expand_vars,omitempty"`
|
||||
|
||||
// Condition makes this step optional based on a variable.
|
||||
// Format: "{{var}}" (truthy) or "{{var}} == value".
|
||||
Condition string `yaml:"condition,omitempty" json:"condition,omitempty"`
|
||||
|
||||
// Children are nested steps (for creating epic hierarchies).
|
||||
Children []*Step `yaml:"children,omitempty" json:"children,omitempty"`
|
||||
|
||||
// Gate defines an async wait condition for this step.
|
||||
Gate *Gate `yaml:"gate,omitempty" json:"gate,omitempty"`
|
||||
}
|
||||
|
||||
// Gate defines an async wait condition (integrates with bd-udsi).
|
||||
type Gate struct {
|
||||
// Type is the condition type: gh:run, gh:pr, timer, human, mail.
|
||||
Type string `yaml:"type" json:"type"`
|
||||
|
||||
// ID is the condition identifier (e.g., workflow name for gh:run).
|
||||
ID string `yaml:"id,omitempty" json:"id,omitempty"`
|
||||
|
||||
// Timeout is how long to wait before escalation (e.g., "1h", "24h").
|
||||
Timeout string `yaml:"timeout,omitempty" json:"timeout,omitempty"`
|
||||
}
|
||||
|
||||
// ComposeRules define how formulas can be bonded together.
|
||||
type ComposeRules struct {
|
||||
// BondPoints are named locations where other formulas can attach.
|
||||
BondPoints []*BondPoint `yaml:"bond_points,omitempty" json:"bond_points,omitempty"`
|
||||
|
||||
// Hooks are automatic attachments triggered by labels or conditions.
|
||||
Hooks []*Hook `yaml:"hooks,omitempty" json:"hooks,omitempty"`
|
||||
}
|
||||
|
||||
// BondPoint is a named attachment site for composition.
|
||||
type BondPoint struct {
|
||||
// ID is the unique identifier for this bond point.
|
||||
ID string `yaml:"id" json:"id"`
|
||||
|
||||
// Description explains what should be attached here.
|
||||
Description string `yaml:"description,omitempty" json:"description,omitempty"`
|
||||
|
||||
// AfterStep is the step ID after which to attach.
|
||||
// Mutually exclusive with BeforeStep.
|
||||
AfterStep string `yaml:"after_step,omitempty" json:"after_step,omitempty"`
|
||||
|
||||
// BeforeStep is the step ID before which to attach.
|
||||
// Mutually exclusive with AfterStep.
|
||||
BeforeStep string `yaml:"before_step,omitempty" json:"before_step,omitempty"`
|
||||
|
||||
// Parallel makes attached steps run in parallel with the anchor step.
|
||||
Parallel bool `yaml:"parallel,omitempty" json:"parallel,omitempty"`
|
||||
}
|
||||
|
||||
// Hook defines automatic formula attachment based on conditions.
|
||||
type Hook struct {
|
||||
// Trigger is what activates this hook.
|
||||
// Formats: "label:security", "type:bug", "priority:0-1".
|
||||
Trigger string `yaml:"trigger" json:"trigger"`
|
||||
|
||||
// Attach is the formula to attach when triggered.
|
||||
Attach string `yaml:"attach" json:"attach"`
|
||||
|
||||
// At is the bond point to attach at (default: end).
|
||||
At string `yaml:"at,omitempty" json:"at,omitempty"`
|
||||
|
||||
// Vars are variable overrides for the attached formula.
|
||||
Vars map[string]string `yaml:"vars,omitempty" json:"vars,omitempty"`
|
||||
}
|
||||
|
||||
// Validate checks the formula for structural errors.
|
||||
func (f *Formula) Validate() error {
|
||||
var errs []string
|
||||
|
||||
if f.Formula == "" {
|
||||
errs = append(errs, "formula: name is required")
|
||||
}
|
||||
|
||||
if f.Version < 1 {
|
||||
errs = append(errs, "version: must be >= 1")
|
||||
}
|
||||
|
||||
if f.Type != "" && !f.Type.IsValid() {
|
||||
errs = append(errs, fmt.Sprintf("type: invalid value %q (must be workflow, expansion, or aspect)", f.Type))
|
||||
}
|
||||
|
||||
// Validate variables
|
||||
for name, v := range f.Vars {
|
||||
if name == "" {
|
||||
errs = append(errs, "vars: variable name cannot be empty")
|
||||
continue
|
||||
}
|
||||
if v.Required && v.Default != "" {
|
||||
errs = append(errs, fmt.Sprintf("vars.%s: cannot have both required:true and default", name))
|
||||
}
|
||||
}
|
||||
|
||||
// Validate steps
|
||||
stepIDs := make(map[string]bool)
|
||||
for i, step := range f.Steps {
|
||||
if step.ID == "" {
|
||||
errs = append(errs, fmt.Sprintf("steps[%d]: id is required", i))
|
||||
continue
|
||||
}
|
||||
if stepIDs[step.ID] {
|
||||
errs = append(errs, fmt.Sprintf("steps[%d]: duplicate id %q", i, step.ID))
|
||||
}
|
||||
stepIDs[step.ID] = true
|
||||
|
||||
if step.Title == "" && step.Expand == "" {
|
||||
errs = append(errs, fmt.Sprintf("steps[%d] (%s): title is required (unless using expand)", i, step.ID))
|
||||
}
|
||||
|
||||
// Validate priority range
|
||||
if step.Priority != nil && (*step.Priority < 0 || *step.Priority > 4) {
|
||||
errs = append(errs, fmt.Sprintf("steps[%d] (%s): priority must be 0-4", i, step.ID))
|
||||
}
|
||||
|
||||
// Collect child IDs (for dependency validation)
|
||||
collectChildIDs(step.Children, stepIDs, &errs, fmt.Sprintf("steps[%d]", i))
|
||||
}
|
||||
|
||||
// Validate step dependencies reference valid IDs
|
||||
for i, step := range f.Steps {
|
||||
for _, dep := range step.DependsOn {
|
||||
if !stepIDs[dep] {
|
||||
errs = append(errs, fmt.Sprintf("steps[%d] (%s): depends_on references unknown step %q", i, step.ID, dep))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate compose rules
|
||||
if f.Compose != nil {
|
||||
for i, bp := range f.Compose.BondPoints {
|
||||
if bp.ID == "" {
|
||||
errs = append(errs, fmt.Sprintf("compose.bond_points[%d]: id is required", i))
|
||||
}
|
||||
if bp.AfterStep != "" && bp.BeforeStep != "" {
|
||||
errs = append(errs, fmt.Sprintf("compose.bond_points[%d] (%s): cannot have both after_step and before_step", i, bp.ID))
|
||||
}
|
||||
if bp.AfterStep != "" && !stepIDs[bp.AfterStep] {
|
||||
errs = append(errs, fmt.Sprintf("compose.bond_points[%d] (%s): after_step references unknown step %q", i, bp.ID, bp.AfterStep))
|
||||
}
|
||||
if bp.BeforeStep != "" && !stepIDs[bp.BeforeStep] {
|
||||
errs = append(errs, fmt.Sprintf("compose.bond_points[%d] (%s): before_step references unknown step %q", i, bp.ID, bp.BeforeStep))
|
||||
}
|
||||
}
|
||||
|
||||
for i, hook := range f.Compose.Hooks {
|
||||
if hook.Trigger == "" {
|
||||
errs = append(errs, fmt.Sprintf("compose.hooks[%d]: trigger is required", i))
|
||||
}
|
||||
if hook.Attach == "" {
|
||||
errs = append(errs, fmt.Sprintf("compose.hooks[%d]: attach is required", i))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(errs) > 0 {
|
||||
return fmt.Errorf("formula validation failed:\n - %s", strings.Join(errs, "\n - "))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// collectChildIDs recursively collects step IDs from children.
|
||||
func collectChildIDs(children []*Step, ids map[string]bool, errs *[]string, prefix string) {
|
||||
for i, child := range children {
|
||||
childPrefix := fmt.Sprintf("%s.children[%d]", prefix, i)
|
||||
if child.ID == "" {
|
||||
*errs = append(*errs, fmt.Sprintf("%s: id is required", childPrefix))
|
||||
continue
|
||||
}
|
||||
if ids[child.ID] {
|
||||
*errs = append(*errs, fmt.Sprintf("%s: duplicate id %q", childPrefix, child.ID))
|
||||
}
|
||||
ids[child.ID] = true
|
||||
|
||||
if child.Title == "" && child.Expand == "" {
|
||||
*errs = append(*errs, fmt.Sprintf("%s (%s): title is required", childPrefix, child.ID))
|
||||
}
|
||||
|
||||
collectChildIDs(child.Children, ids, errs, childPrefix)
|
||||
}
|
||||
}
|
||||
|
||||
// GetRequiredVars returns the names of all required variables.
|
||||
func (f *Formula) GetRequiredVars() []string {
|
||||
var required []string
|
||||
for name, v := range f.Vars {
|
||||
if v.Required {
|
||||
required = append(required, name)
|
||||
}
|
||||
}
|
||||
return required
|
||||
}
|
||||
|
||||
// GetStepByID finds a step by its ID (searches recursively).
|
||||
func (f *Formula) GetStepByID(id string) *Step {
|
||||
for _, step := range f.Steps {
|
||||
if found := findStepByID(step, id); found != nil {
|
||||
return found
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// findStepByID recursively searches for a step by ID.
|
||||
func findStepByID(step *Step, id string) *Step {
|
||||
if step.ID == id {
|
||||
return step
|
||||
}
|
||||
for _, child := range step.Children {
|
||||
if found := findStepByID(child, id); found != nil {
|
||||
return found
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetBondPoint finds a bond point by ID.
|
||||
func (f *Formula) GetBondPoint(id string) *BondPoint {
|
||||
if f.Compose == nil {
|
||||
return nil
|
||||
}
|
||||
for _, bp := range f.Compose.BondPoints {
|
||||
if bp.ID == id {
|
||||
return bp
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user