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:
Steve Yegge
2025-12-24 13:41:46 -08:00
parent 0514e40c68
commit 4fdcda4568
4 changed files with 1729 additions and 0 deletions

367
internal/formula/parser.go Normal file
View 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
}

View 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
View 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
}