feat(formula): Add formula.toml parser and validation
Implement internal/formula package for parsing and validating formula.toml files: - types.go: Formula, Step, Leg, Aspect, Template, Input, Var structs - parser.go: TOML parsing using BurntSushi/toml, validation, dependency resolution - Supports convoy, workflow, expansion, and aspect formula types - Infers type from content when not explicitly set - Validates required fields, unique IDs, and dependency references - Detects circular dependencies in workflow steps - Provides TopologicalSort and ReadySteps for execution planning (gt-5chbk) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
420
internal/formula/parser.go
Normal file
420
internal/formula/parser.go
Normal file
@@ -0,0 +1,420 @@
|
||||
package formula
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/BurntSushi/toml"
|
||||
)
|
||||
|
||||
// ParseFile reads and parses a formula.toml file.
|
||||
func ParseFile(path string) (*Formula, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading formula file: %w", err)
|
||||
}
|
||||
return Parse(data)
|
||||
}
|
||||
|
||||
// Parse parses formula.toml content from bytes.
|
||||
func Parse(data []byte) (*Formula, error) {
|
||||
var f Formula
|
||||
if _, err := toml.Decode(string(data), &f); err != nil {
|
||||
return nil, fmt.Errorf("parsing TOML: %w", err)
|
||||
}
|
||||
|
||||
// Infer type from content if not explicitly set
|
||||
f.inferType()
|
||||
|
||||
if err := f.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &f, nil
|
||||
}
|
||||
|
||||
// inferType sets the formula type based on content when not explicitly set.
|
||||
func (f *Formula) inferType() {
|
||||
if f.Type != "" {
|
||||
return // Type already set
|
||||
}
|
||||
|
||||
// Infer from content
|
||||
if len(f.Steps) > 0 {
|
||||
f.Type = TypeWorkflow
|
||||
} else if len(f.Legs) > 0 {
|
||||
f.Type = TypeConvoy
|
||||
} else if len(f.Template) > 0 {
|
||||
f.Type = TypeExpansion
|
||||
} else if len(f.Aspects) > 0 {
|
||||
f.Type = TypeAspect
|
||||
}
|
||||
}
|
||||
|
||||
// Validate checks that the formula has all required fields and valid structure.
|
||||
func (f *Formula) Validate() error {
|
||||
// Check required common fields
|
||||
if f.Name == "" {
|
||||
return fmt.Errorf("formula field is required")
|
||||
}
|
||||
|
||||
if !f.Type.IsValid() {
|
||||
return fmt.Errorf("invalid formula type %q (must be convoy, workflow, expansion, or aspect)", f.Type)
|
||||
}
|
||||
|
||||
// Type-specific validation
|
||||
switch f.Type {
|
||||
case TypeConvoy:
|
||||
return f.validateConvoy()
|
||||
case TypeWorkflow:
|
||||
return f.validateWorkflow()
|
||||
case TypeExpansion:
|
||||
return f.validateExpansion()
|
||||
case TypeAspect:
|
||||
return f.validateAspect()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *Formula) validateConvoy() error {
|
||||
if len(f.Legs) == 0 {
|
||||
return fmt.Errorf("convoy formula requires at least one leg")
|
||||
}
|
||||
|
||||
// Check leg IDs are unique
|
||||
seen := make(map[string]bool)
|
||||
for _, leg := range f.Legs {
|
||||
if leg.ID == "" {
|
||||
return fmt.Errorf("leg missing required id field")
|
||||
}
|
||||
if seen[leg.ID] {
|
||||
return fmt.Errorf("duplicate leg id: %s", leg.ID)
|
||||
}
|
||||
seen[leg.ID] = true
|
||||
}
|
||||
|
||||
// Validate synthesis depends_on references valid legs
|
||||
if f.Synthesis != nil {
|
||||
for _, dep := range f.Synthesis.DependsOn {
|
||||
if !seen[dep] {
|
||||
return fmt.Errorf("synthesis depends_on references unknown leg: %s", dep)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *Formula) validateWorkflow() error {
|
||||
if len(f.Steps) == 0 {
|
||||
return fmt.Errorf("workflow formula requires at least one step")
|
||||
}
|
||||
|
||||
// Check step IDs are unique
|
||||
seen := make(map[string]bool)
|
||||
for _, step := range f.Steps {
|
||||
if step.ID == "" {
|
||||
return fmt.Errorf("step missing required id field")
|
||||
}
|
||||
if seen[step.ID] {
|
||||
return fmt.Errorf("duplicate step id: %s", step.ID)
|
||||
}
|
||||
seen[step.ID] = true
|
||||
}
|
||||
|
||||
// Validate step needs references
|
||||
for _, step := range f.Steps {
|
||||
for _, need := range step.Needs {
|
||||
if !seen[need] {
|
||||
return fmt.Errorf("step %q needs unknown step: %s", step.ID, need)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for cycles
|
||||
if err := f.checkCycles(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *Formula) validateExpansion() error {
|
||||
if len(f.Template) == 0 {
|
||||
return fmt.Errorf("expansion formula requires at least one template")
|
||||
}
|
||||
|
||||
// Check template IDs are unique
|
||||
seen := make(map[string]bool)
|
||||
for _, tmpl := range f.Template {
|
||||
if tmpl.ID == "" {
|
||||
return fmt.Errorf("template missing required id field")
|
||||
}
|
||||
if seen[tmpl.ID] {
|
||||
return fmt.Errorf("duplicate template id: %s", tmpl.ID)
|
||||
}
|
||||
seen[tmpl.ID] = true
|
||||
}
|
||||
|
||||
// Validate template needs references
|
||||
for _, tmpl := range f.Template {
|
||||
for _, need := range tmpl.Needs {
|
||||
if !seen[need] {
|
||||
return fmt.Errorf("template %q needs unknown template: %s", tmpl.ID, need)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *Formula) validateAspect() error {
|
||||
if len(f.Aspects) == 0 {
|
||||
return fmt.Errorf("aspect formula requires at least one aspect")
|
||||
}
|
||||
|
||||
// Check aspect IDs are unique
|
||||
seen := make(map[string]bool)
|
||||
for _, aspect := range f.Aspects {
|
||||
if aspect.ID == "" {
|
||||
return fmt.Errorf("aspect missing required id field")
|
||||
}
|
||||
if seen[aspect.ID] {
|
||||
return fmt.Errorf("duplicate aspect id: %s", aspect.ID)
|
||||
}
|
||||
seen[aspect.ID] = true
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkCycles detects circular dependencies in steps.
|
||||
func (f *Formula) checkCycles() error {
|
||||
// Build adjacency list
|
||||
deps := make(map[string][]string)
|
||||
for _, step := range f.Steps {
|
||||
deps[step.ID] = step.Needs
|
||||
}
|
||||
|
||||
// DFS for cycle detection
|
||||
visited := make(map[string]bool)
|
||||
inStack := make(map[string]bool)
|
||||
|
||||
var visit func(id string) error
|
||||
visit = func(id string) error {
|
||||
if inStack[id] {
|
||||
return fmt.Errorf("cycle detected involving step: %s", id)
|
||||
}
|
||||
if visited[id] {
|
||||
return nil
|
||||
}
|
||||
visited[id] = true
|
||||
inStack[id] = true
|
||||
|
||||
for _, dep := range deps[id] {
|
||||
if err := visit(dep); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
inStack[id] = false
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, step := range f.Steps {
|
||||
if err := visit(step.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// TopologicalSort returns steps in dependency order (dependencies before dependents).
|
||||
// Only applicable to workflow and expansion formulas.
|
||||
// Returns an error if there are cycles.
|
||||
func (f *Formula) TopologicalSort() ([]string, error) {
|
||||
var items []string
|
||||
var deps map[string][]string
|
||||
|
||||
switch f.Type {
|
||||
case TypeWorkflow:
|
||||
for _, step := range f.Steps {
|
||||
items = append(items, step.ID)
|
||||
}
|
||||
deps = make(map[string][]string)
|
||||
for _, step := range f.Steps {
|
||||
deps[step.ID] = step.Needs
|
||||
}
|
||||
case TypeExpansion:
|
||||
for _, tmpl := range f.Template {
|
||||
items = append(items, tmpl.ID)
|
||||
}
|
||||
deps = make(map[string][]string)
|
||||
for _, tmpl := range f.Template {
|
||||
deps[tmpl.ID] = tmpl.Needs
|
||||
}
|
||||
case TypeConvoy:
|
||||
// Convoy legs are parallel; return all leg IDs
|
||||
for _, leg := range f.Legs {
|
||||
items = append(items, leg.ID)
|
||||
}
|
||||
return items, nil
|
||||
case TypeAspect:
|
||||
// Aspect aspects are parallel; return all aspect IDs
|
||||
for _, aspect := range f.Aspects {
|
||||
items = append(items, aspect.ID)
|
||||
}
|
||||
return items, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported formula type for topological sort")
|
||||
}
|
||||
|
||||
// Kahn's algorithm
|
||||
inDegree := make(map[string]int)
|
||||
for _, id := range items {
|
||||
inDegree[id] = 0
|
||||
}
|
||||
for _, id := range items {
|
||||
for _, dep := range deps[id] {
|
||||
inDegree[id]++
|
||||
_ = dep // dep already exists (validated)
|
||||
}
|
||||
}
|
||||
|
||||
// Find all nodes with no dependencies
|
||||
var queue []string
|
||||
for _, id := range items {
|
||||
if inDegree[id] == 0 {
|
||||
queue = append(queue, id)
|
||||
}
|
||||
}
|
||||
|
||||
// Build reverse adjacency (who depends on me)
|
||||
dependents := make(map[string][]string)
|
||||
for _, id := range items {
|
||||
for _, dep := range deps[id] {
|
||||
dependents[dep] = append(dependents[dep], id)
|
||||
}
|
||||
}
|
||||
|
||||
var result []string
|
||||
for len(queue) > 0 {
|
||||
// Pop from queue
|
||||
id := queue[0]
|
||||
queue = queue[1:]
|
||||
result = append(result, id)
|
||||
|
||||
// Reduce in-degree of dependents
|
||||
for _, dependent := range dependents[id] {
|
||||
inDegree[dependent]--
|
||||
if inDegree[dependent] == 0 {
|
||||
queue = append(queue, dependent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(result) != len(items) {
|
||||
return nil, fmt.Errorf("cycle detected in dependencies")
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ReadySteps returns steps that have no unmet dependencies.
|
||||
// completed is a set of step IDs that have been completed.
|
||||
func (f *Formula) ReadySteps(completed map[string]bool) []string {
|
||||
var ready []string
|
||||
|
||||
switch f.Type {
|
||||
case TypeWorkflow:
|
||||
for _, step := range f.Steps {
|
||||
if completed[step.ID] {
|
||||
continue
|
||||
}
|
||||
allMet := true
|
||||
for _, need := range step.Needs {
|
||||
if !completed[need] {
|
||||
allMet = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if allMet {
|
||||
ready = append(ready, step.ID)
|
||||
}
|
||||
}
|
||||
case TypeExpansion:
|
||||
for _, tmpl := range f.Template {
|
||||
if completed[tmpl.ID] {
|
||||
continue
|
||||
}
|
||||
allMet := true
|
||||
for _, need := range tmpl.Needs {
|
||||
if !completed[need] {
|
||||
allMet = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if allMet {
|
||||
ready = append(ready, tmpl.ID)
|
||||
}
|
||||
}
|
||||
case TypeConvoy:
|
||||
// All legs are ready unless already completed
|
||||
for _, leg := range f.Legs {
|
||||
if !completed[leg.ID] {
|
||||
ready = append(ready, leg.ID)
|
||||
}
|
||||
}
|
||||
case TypeAspect:
|
||||
// All aspects are ready unless already completed
|
||||
for _, aspect := range f.Aspects {
|
||||
if !completed[aspect.ID] {
|
||||
ready = append(ready, aspect.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ready
|
||||
}
|
||||
|
||||
// GetStep returns a step by ID, or nil if not found.
|
||||
func (f *Formula) GetStep(id string) *Step {
|
||||
for i := range f.Steps {
|
||||
if f.Steps[i].ID == id {
|
||||
return &f.Steps[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetLeg returns a leg by ID, or nil if not found.
|
||||
func (f *Formula) GetLeg(id string) *Leg {
|
||||
for i := range f.Legs {
|
||||
if f.Legs[i].ID == id {
|
||||
return &f.Legs[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetTemplate returns a template by ID, or nil if not found.
|
||||
func (f *Formula) GetTemplate(id string) *Template {
|
||||
for i := range f.Template {
|
||||
if f.Template[i].ID == id {
|
||||
return &f.Template[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetAspect returns an aspect by ID, or nil if not found.
|
||||
func (f *Formula) GetAspect(id string) *Aspect {
|
||||
for i := range f.Aspects {
|
||||
if f.Aspects[i].ID == id {
|
||||
return &f.Aspects[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user