diff --git a/cmd/bd/cook.go b/cmd/bd/cook.go new file mode 100644 index 00000000..9a7de2fb --- /dev/null +++ b/cmd/bd/cook.go @@ -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 ", + 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 =\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) +} diff --git a/internal/formula/parser.go b/internal/formula/parser.go new file mode 100644 index 00000000..e94b990a --- /dev/null +++ b/internal/formula/parser.go @@ -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 +} diff --git a/internal/formula/parser_test.go b/internal/formula/parser_test.go new file mode 100644 index 00000000..92a8378d --- /dev/null +++ b/internal/formula/parser_test.go @@ -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) + } + } +} diff --git a/internal/formula/types.go b/internal/formula/types.go new file mode 100644 index 00000000..b918d5b5 --- /dev/null +++ b/internal/formula/types.go @@ -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- for molecules, exp- 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 +}