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:
427
cmd/bd/cook.go
Normal file
427
cmd/bd/cook.go
Normal file
@@ -0,0 +1,427 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
"github.com/steveyegge/beads/internal/formula"
|
||||||
|
"github.com/steveyegge/beads/internal/storage"
|
||||||
|
"github.com/steveyegge/beads/internal/storage/sqlite"
|
||||||
|
"github.com/steveyegge/beads/internal/types"
|
||||||
|
"github.com/steveyegge/beads/internal/ui"
|
||||||
|
)
|
||||||
|
|
||||||
|
// cookCmd compiles a formula YAML into a proto bead.
|
||||||
|
var cookCmd = &cobra.Command{
|
||||||
|
Use: "cook <formula-file>",
|
||||||
|
Short: "Compile a formula into a proto bead",
|
||||||
|
Long: `Cook transforms a .formula.yaml file into a proto bead.
|
||||||
|
|
||||||
|
Formulas are high-level workflow templates that support:
|
||||||
|
- Variable definitions with defaults and validation
|
||||||
|
- Step definitions that become issue hierarchies
|
||||||
|
- Composition rules for bonding formulas together
|
||||||
|
- Inheritance via extends
|
||||||
|
|
||||||
|
The cook command parses the formula, resolves inheritance, and
|
||||||
|
creates a proto bead in the database that can be poured or spawned.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
bd cook mol-feature.formula.yaml
|
||||||
|
bd cook .beads/formulas/mol-release.formula.yaml --force
|
||||||
|
bd cook mol-patrol.formula.yaml --search-path .beads/formulas
|
||||||
|
|
||||||
|
Output:
|
||||||
|
Creates a proto bead with:
|
||||||
|
- ID matching the formula name (e.g., mol-feature)
|
||||||
|
- The "template" label for proto identification
|
||||||
|
- Child issues for each step
|
||||||
|
- Dependencies matching depends_on relationships`,
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
Run: runCook,
|
||||||
|
}
|
||||||
|
|
||||||
|
// cookResult holds the result of cooking a formula
|
||||||
|
type cookResult struct {
|
||||||
|
ProtoID string `json:"proto_id"`
|
||||||
|
Formula string `json:"formula"`
|
||||||
|
Created int `json:"created"`
|
||||||
|
Variables []string `json:"variables"`
|
||||||
|
BondPoints []string `json:"bond_points,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func runCook(cmd *cobra.Command, args []string) {
|
||||||
|
CheckReadonly("cook")
|
||||||
|
|
||||||
|
ctx := rootCtx
|
||||||
|
|
||||||
|
// Cook requires direct store access for creating protos
|
||||||
|
if store == nil {
|
||||||
|
if daemonClient != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error: cook requires direct database access\n")
|
||||||
|
fmt.Fprintf(os.Stderr, "Hint: use --no-daemon flag: bd --no-daemon cook %s ...\n", args[0])
|
||||||
|
} else {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error: no database connection\n")
|
||||||
|
}
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
dryRun, _ := cmd.Flags().GetBool("dry-run")
|
||||||
|
force, _ := cmd.Flags().GetBool("force")
|
||||||
|
searchPaths, _ := cmd.Flags().GetStringSlice("search-path")
|
||||||
|
|
||||||
|
// Create parser with search paths
|
||||||
|
parser := formula.NewParser(searchPaths...)
|
||||||
|
|
||||||
|
// Parse the formula file
|
||||||
|
formulaPath := args[0]
|
||||||
|
f, err := parser.ParseFile(formulaPath)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error parsing formula: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve inheritance
|
||||||
|
resolved, err := parser.Resolve(f)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error resolving formula: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if proto already exists
|
||||||
|
existingProto, err := store.GetIssue(ctx, resolved.Formula)
|
||||||
|
if err == nil && existingProto != nil {
|
||||||
|
if !force {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error: proto %s already exists\n", resolved.Formula)
|
||||||
|
fmt.Fprintf(os.Stderr, "Hint: use --force to replace it\n")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
// Delete existing proto and its children
|
||||||
|
if err := deleteProtoSubgraph(ctx, store, resolved.Formula); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error deleting existing proto: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract variables used in the formula
|
||||||
|
vars := formula.ExtractVariables(resolved)
|
||||||
|
|
||||||
|
// Collect bond points
|
||||||
|
var bondPoints []string
|
||||||
|
if resolved.Compose != nil {
|
||||||
|
for _, bp := range resolved.Compose.BondPoints {
|
||||||
|
bondPoints = append(bondPoints, bp.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if dryRun {
|
||||||
|
fmt.Printf("\nDry run: would cook formula %s\n\n", resolved.Formula)
|
||||||
|
fmt.Printf("Steps (%d):\n", len(resolved.Steps))
|
||||||
|
printFormulaSteps(resolved.Steps, " ")
|
||||||
|
|
||||||
|
if len(vars) > 0 {
|
||||||
|
fmt.Printf("\nVariables: %s\n", strings.Join(vars, ", "))
|
||||||
|
}
|
||||||
|
if len(bondPoints) > 0 {
|
||||||
|
fmt.Printf("Bond points: %s\n", strings.Join(bondPoints, ", "))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show variable definitions
|
||||||
|
if len(resolved.Vars) > 0 {
|
||||||
|
fmt.Printf("\nVariable definitions:\n")
|
||||||
|
for name, def := range resolved.Vars {
|
||||||
|
attrs := []string{}
|
||||||
|
if def.Required {
|
||||||
|
attrs = append(attrs, "required")
|
||||||
|
}
|
||||||
|
if def.Default != "" {
|
||||||
|
attrs = append(attrs, fmt.Sprintf("default=%s", def.Default))
|
||||||
|
}
|
||||||
|
if len(def.Enum) > 0 {
|
||||||
|
attrs = append(attrs, fmt.Sprintf("enum=[%s]", strings.Join(def.Enum, ",")))
|
||||||
|
}
|
||||||
|
attrStr := ""
|
||||||
|
if len(attrs) > 0 {
|
||||||
|
attrStr = fmt.Sprintf(" (%s)", strings.Join(attrs, ", "))
|
||||||
|
}
|
||||||
|
fmt.Printf(" {{%s}}: %s%s\n", name, def.Description, attrStr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the proto bead from the formula
|
||||||
|
result, err := cookFormula(ctx, store, resolved)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error cooking formula: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schedule auto-flush
|
||||||
|
markDirtyAndScheduleFlush()
|
||||||
|
|
||||||
|
if jsonOutput {
|
||||||
|
outputJSON(cookResult{
|
||||||
|
ProtoID: result.ProtoID,
|
||||||
|
Formula: resolved.Formula,
|
||||||
|
Created: result.Created,
|
||||||
|
Variables: vars,
|
||||||
|
BondPoints: bondPoints,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("%s Cooked proto: %s\n", ui.RenderPass("✓"), result.ProtoID)
|
||||||
|
fmt.Printf(" Created %d issues\n", result.Created)
|
||||||
|
if len(vars) > 0 {
|
||||||
|
fmt.Printf(" Variables: %s\n", strings.Join(vars, ", "))
|
||||||
|
}
|
||||||
|
if len(bondPoints) > 0 {
|
||||||
|
fmt.Printf(" Bond points: %s\n", strings.Join(bondPoints, ", "))
|
||||||
|
}
|
||||||
|
fmt.Printf("\nTo use: bd pour %s --var <name>=<value>\n", result.ProtoID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// cookFormulaResult holds the result of cooking
|
||||||
|
type cookFormulaResult struct {
|
||||||
|
ProtoID string
|
||||||
|
Created int
|
||||||
|
}
|
||||||
|
|
||||||
|
// cookFormula creates a proto bead from a resolved formula.
|
||||||
|
func cookFormula(ctx context.Context, s storage.Storage, f *formula.Formula) (*cookFormulaResult, error) {
|
||||||
|
if s == nil {
|
||||||
|
return nil, fmt.Errorf("no database connection")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for SQLite store (needed for batch create with skip prefix)
|
||||||
|
sqliteStore, ok := s.(*sqlite.SQLiteStorage)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("cook requires SQLite storage")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map step ID -> created issue ID
|
||||||
|
idMapping := make(map[string]string)
|
||||||
|
|
||||||
|
// Collect all issues and dependencies
|
||||||
|
var issues []*types.Issue
|
||||||
|
var deps []*types.Dependency
|
||||||
|
var labels []struct{ issueID, label string }
|
||||||
|
|
||||||
|
// Create root proto epic
|
||||||
|
rootIssue := &types.Issue{
|
||||||
|
ID: f.Formula,
|
||||||
|
Title: f.Formula, // Title is the formula name
|
||||||
|
Description: f.Description,
|
||||||
|
Status: types.StatusOpen,
|
||||||
|
Priority: 2,
|
||||||
|
IssueType: types.TypeEpic,
|
||||||
|
IsTemplate: true,
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
UpdatedAt: time.Now(),
|
||||||
|
}
|
||||||
|
issues = append(issues, rootIssue)
|
||||||
|
labels = append(labels, struct{ issueID, label string }{f.Formula, MoleculeLabel})
|
||||||
|
|
||||||
|
// Collect issues for each step
|
||||||
|
collectStepsRecursive(f.Steps, f.Formula, idMapping, &issues, &deps, &labels)
|
||||||
|
|
||||||
|
// Collect dependencies from depends_on
|
||||||
|
for _, step := range f.Steps {
|
||||||
|
collectDependencies(step, idMapping, &deps)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create all issues using batch with skip prefix validation
|
||||||
|
opts := sqlite.BatchCreateOptions{
|
||||||
|
SkipPrefixValidation: true, // Molecules use mol-* prefix
|
||||||
|
}
|
||||||
|
if err := sqliteStore.CreateIssuesWithFullOptions(ctx, issues, actor, opts); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create issues: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add labels and dependencies in a transaction
|
||||||
|
err := s.RunInTransaction(ctx, func(tx storage.Transaction) error {
|
||||||
|
// Add labels
|
||||||
|
for _, l := range labels {
|
||||||
|
if err := tx.AddLabel(ctx, l.issueID, l.label, actor); err != nil {
|
||||||
|
return fmt.Errorf("failed to add label %s to %s: %w", l.label, l.issueID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add dependencies
|
||||||
|
for _, dep := range deps {
|
||||||
|
if err := tx.AddDependency(ctx, dep, actor); err != nil {
|
||||||
|
return fmt.Errorf("failed to create dependency: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &cookFormulaResult{
|
||||||
|
ProtoID: f.Formula,
|
||||||
|
Created: len(issues),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// collectStepsRecursive collects issues, dependencies, and labels for steps and their children.
|
||||||
|
func collectStepsRecursive(steps []*formula.Step, parentID string, idMapping map[string]string,
|
||||||
|
issues *[]*types.Issue, deps *[]*types.Dependency, labels *[]struct{ issueID, label string }) {
|
||||||
|
|
||||||
|
for _, step := range steps {
|
||||||
|
// Generate issue ID (formula-name.step-id)
|
||||||
|
issueID := fmt.Sprintf("%s.%s", parentID, step.ID)
|
||||||
|
|
||||||
|
// Determine issue type
|
||||||
|
issueType := types.TypeTask
|
||||||
|
if step.Type != "" {
|
||||||
|
switch step.Type {
|
||||||
|
case "task":
|
||||||
|
issueType = types.TypeTask
|
||||||
|
case "bug":
|
||||||
|
issueType = types.TypeBug
|
||||||
|
case "feature":
|
||||||
|
issueType = types.TypeFeature
|
||||||
|
case "epic":
|
||||||
|
issueType = types.TypeEpic
|
||||||
|
case "chore":
|
||||||
|
issueType = types.TypeChore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If step has children, it's an epic
|
||||||
|
if len(step.Children) > 0 {
|
||||||
|
issueType = types.TypeEpic
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine priority
|
||||||
|
priority := 2
|
||||||
|
if step.Priority != nil {
|
||||||
|
priority = *step.Priority
|
||||||
|
}
|
||||||
|
|
||||||
|
issue := &types.Issue{
|
||||||
|
ID: issueID,
|
||||||
|
Title: step.Title, // Keep {{variables}} for substitution at pour time
|
||||||
|
Description: step.Description,
|
||||||
|
Status: types.StatusOpen,
|
||||||
|
Priority: priority,
|
||||||
|
IssueType: issueType,
|
||||||
|
Assignee: step.Assignee,
|
||||||
|
IsTemplate: true,
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
UpdatedAt: time.Now(),
|
||||||
|
}
|
||||||
|
*issues = append(*issues, issue)
|
||||||
|
|
||||||
|
// Collect labels
|
||||||
|
for _, label := range step.Labels {
|
||||||
|
*labels = append(*labels, struct{ issueID, label string }{issueID, label})
|
||||||
|
}
|
||||||
|
|
||||||
|
idMapping[step.ID] = issueID
|
||||||
|
|
||||||
|
// Add parent-child dependency
|
||||||
|
*deps = append(*deps, &types.Dependency{
|
||||||
|
IssueID: issueID,
|
||||||
|
DependsOnID: parentID,
|
||||||
|
Type: types.DepParentChild,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Recursively collect children
|
||||||
|
if len(step.Children) > 0 {
|
||||||
|
collectStepsRecursive(step.Children, issueID, idMapping, issues, deps, labels)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// collectDependencies collects blocking dependencies from depends_on.
|
||||||
|
func collectDependencies(step *formula.Step, idMapping map[string]string, deps *[]*types.Dependency) {
|
||||||
|
issueID := idMapping[step.ID]
|
||||||
|
|
||||||
|
for _, depID := range step.DependsOn {
|
||||||
|
depIssueID, ok := idMapping[depID]
|
||||||
|
if !ok {
|
||||||
|
continue // Will be caught during validation
|
||||||
|
}
|
||||||
|
|
||||||
|
*deps = append(*deps, &types.Dependency{
|
||||||
|
IssueID: issueID,
|
||||||
|
DependsOnID: depIssueID,
|
||||||
|
Type: types.DepBlocks,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recursively handle children
|
||||||
|
for _, child := range step.Children {
|
||||||
|
collectDependencies(child, idMapping, deps)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// deleteProtoSubgraph deletes a proto and all its children.
|
||||||
|
func deleteProtoSubgraph(ctx context.Context, s storage.Storage, protoID string) error {
|
||||||
|
// Load the subgraph
|
||||||
|
subgraph, err := loadTemplateSubgraph(ctx, s, protoID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("load proto: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete in reverse order (children first)
|
||||||
|
return s.RunInTransaction(ctx, func(tx storage.Transaction) error {
|
||||||
|
for i := len(subgraph.Issues) - 1; i >= 0; i-- {
|
||||||
|
issue := subgraph.Issues[i]
|
||||||
|
if err := tx.DeleteIssue(ctx, issue.ID); err != nil {
|
||||||
|
return fmt.Errorf("delete %s: %w", issue.ID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// printFormulaSteps prints steps in a tree format.
|
||||||
|
func printFormulaSteps(steps []*formula.Step, indent string) {
|
||||||
|
for i, step := range steps {
|
||||||
|
connector := "├──"
|
||||||
|
if i == len(steps)-1 {
|
||||||
|
connector = "└──"
|
||||||
|
}
|
||||||
|
|
||||||
|
depStr := ""
|
||||||
|
if len(step.DependsOn) > 0 {
|
||||||
|
depStr = fmt.Sprintf(" [depends: %s]", strings.Join(step.DependsOn, ", "))
|
||||||
|
}
|
||||||
|
|
||||||
|
typeStr := ""
|
||||||
|
if step.Type != "" && step.Type != "task" {
|
||||||
|
typeStr = fmt.Sprintf(" (%s)", step.Type)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("%s%s %s: %s%s%s\n", indent, connector, step.ID, step.Title, typeStr, depStr)
|
||||||
|
|
||||||
|
if len(step.Children) > 0 {
|
||||||
|
childIndent := indent
|
||||||
|
if i == len(steps)-1 {
|
||||||
|
childIndent += " "
|
||||||
|
} else {
|
||||||
|
childIndent += "│ "
|
||||||
|
}
|
||||||
|
printFormulaSteps(step.Children, childIndent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
cookCmd.Flags().Bool("dry-run", false, "Preview what would be created")
|
||||||
|
cookCmd.Flags().Bool("force", false, "Replace existing proto if it exists")
|
||||||
|
cookCmd.Flags().StringSlice("search-path", []string{}, "Additional paths to search for formula inheritance")
|
||||||
|
|
||||||
|
rootCmd.AddCommand(cookCmd)
|
||||||
|
}
|
||||||
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