From e6ce0d636522edca7086db88f23f9418ec9307fa Mon Sep 17 00:00:00 2001 From: Steve Yegge Date: Thu, 25 Dec 2025 01:37:10 -0800 Subject: [PATCH] refactor: Switch formula format from YAML to JSON MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Change file extension from .formula.yaml to .formula.json - Replace gopkg.in/yaml.v3 with encoding/json in parser - Remove yaml struct tags, keep json tags only - Update all test cases to use JSON format - Update documentation references 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- CHANGELOG.md | 4 +- cmd/bd/cook.go | 10 +- docs/MOLECULES.md | 2 +- internal/formula/parser.go | 11 +- internal/formula/parser_test.go | 172 +++++++++++++++----------------- internal/formula/types.go | 125 +++++++++++------------ 6 files changed, 158 insertions(+), 166 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index afb541df..bfe2c81b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,8 +13,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Formula system** (bd-weu8, bd-wa2l) - Declarative workflow templates - `bd cook ` - Execute a formula template with variable interpolation - - Formula files (`.formula.yaml`) support inheritance via `extends:` - - `needs:` and `waits_for:` fields for dependency declarations + - Formula files (`.formula.json`) support inheritance via `extends` + - `needs` and `waits_for` fields for dependency declarations - `--prefix` flag for custom issue prefix when cooking - Search paths: `.beads/formulas/`, `~/.beads/formulas/`, `~/gt/.beads/formulas/` diff --git a/cmd/bd/cook.go b/cmd/bd/cook.go index 83bd0705..d9fa5506 100644 --- a/cmd/bd/cook.go +++ b/cmd/bd/cook.go @@ -15,11 +15,11 @@ import ( "github.com/steveyegge/beads/internal/ui" ) -// cookCmd compiles a formula YAML into a proto bead. +// cookCmd compiles a formula JSON 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. + Long: `Cook transforms a .formula.json file into a proto bead. Formulas are high-level workflow templates that support: - Variable definitions with defaults and validation @@ -31,9 +31,9 @@ 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 + bd cook mol-feature.formula.json + bd cook .beads/formulas/mol-release.formula.json --force + bd cook mol-patrol.formula.json --search-path .beads/formulas Output: Creates a proto bead with: diff --git a/docs/MOLECULES.md b/docs/MOLECULES.md index df23f06f..8f519dce 100644 --- a/docs/MOLECULES.md +++ b/docs/MOLECULES.md @@ -238,7 +238,7 @@ bd wisp gc # Garbage collect old wisps For reference, here's how the layers stack: ``` -Formulas (YAML compile-time macros) ← optional, for complex composition +Formulas (JSON compile-time macros) ← optional, for complex composition ↓ Protos (template issues) ← optional, for reusable patterns ↓ diff --git a/internal/formula/parser.go b/internal/formula/parser.go index f582c487..15577412 100644 --- a/internal/formula/parser.go +++ b/internal/formula/parser.go @@ -1,17 +1,16 @@ package formula import ( + "encoding/json" "fmt" "os" "path/filepath" "regexp" "strings" - - "gopkg.in/yaml.v3" ) // FormulaExt is the file extension for formula files. -const FormulaExt = ".formula.yaml" +const FormulaExt = ".formula.json" // Parser handles loading and resolving formulas. // @@ -97,11 +96,11 @@ func (p *Parser) ParseFile(path string) (*Formula, error) { return formula, nil } -// Parse parses a formula from YAML bytes. +// Parse parses a formula from JSON 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) + if err := json.Unmarshal(data, &formula); err != nil { + return nil, fmt.Errorf("json: %w", err) } // Set defaults diff --git a/internal/formula/parser_test.go b/internal/formula/parser_test.go index c3bfdce0..773b62e1 100644 --- a/internal/formula/parser_test.go +++ b/internal/formula/parser_test.go @@ -7,35 +7,30 @@ import ( ) 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] -` + jsonData := `{ + "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)) + formula, err := p.Parse([]byte(jsonData)) if err != nil { t.Fatalf("Parse failed: %v", err) } @@ -426,38 +421,40 @@ func TestParseFile_AndResolve(t *testing.T) { } // 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 { + 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.json"), []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") + 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.json") if err := os.WriteFile(childPath, []byte(child), 0644); err != nil { t.Fatalf("write child: %v", err) } @@ -505,29 +502,29 @@ func TestResolve_CircularExtends(t *testing.T) { } // 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 { + 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.json"), []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 { + if err := os.WriteFile(filepath.Join(formulaDir, "cycle-b.formula.json"), []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")) + formula, err := p.ParseFile(filepath.Join(formulaDir, "cycle-a.formula.json")) if err != nil { t.Fatalf("ParseFile: %v", err) } @@ -734,25 +731,20 @@ func TestValidate_ChildNeedsAndWaitsFor(t *testing.T) { } } -// TestParse_NeedsAndWaitsFor tests YAML parsing of needs and waits_for fields +// TestParse_NeedsAndWaitsFor tests JSON parsing of needs and waits_for fields func TestParse_NeedsAndWaitsFor(t *testing.T) { - yaml := ` -formula: mol-deacon -version: 1 -type: workflow -steps: - - id: inbox-check - title: Check inbox - - id: health-scan - title: Check health - needs: [inbox-check] - - id: aggregate - title: Aggregate results - needs: [health-scan] - waits_for: all-children -` + jsonData := `{ + "formula": "mol-deacon", + "version": 1, + "type": "workflow", + "steps": [ + {"id": "inbox-check", "title": "Check inbox"}, + {"id": "health-scan", "title": "Check health", "needs": ["inbox-check"]}, + {"id": "aggregate", "title": "Aggregate results", "needs": ["health-scan"], "waits_for": "all-children"} + ] +}` p := NewParser() - formula, err := p.Parse([]byte(yaml)) + formula, err := p.Parse([]byte(jsonData)) if err != nil { t.Fatalf("Parse failed: %v", err) } diff --git a/internal/formula/types.go b/internal/formula/types.go index a69848a0..46867a82 100644 --- a/internal/formula/types.go +++ b/internal/formula/types.go @@ -1,4 +1,4 @@ -// Package formula provides parsing and validation for .formula.yaml files. +// Package formula provides parsing and validation for .formula.json files. // // Formulas are high-level workflow templates that compile down to proto beads. // They support: @@ -7,23 +7,24 @@ // - Composition rules for bonding formulas together // - Inheritance via extends // -// Example .formula.yaml: +// Example .formula.json: // -// 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] +// { +// "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 ( @@ -56,174 +57,174 @@ func (t FormulaType) IsValid() bool { return false } -// Formula is the root structure for .formula.yaml files. +// Formula is the root structure for .formula.json 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"` + Formula string `json:"formula"` // Description explains what this formula does. - Description string `yaml:"description,omitempty" json:"description,omitempty"` + Description string `json:"description,omitempty"` // Version is the schema version (currently 1). - Version int `yaml:"version" json:"version"` + Version int `json:"version"` // Type categorizes the formula: workflow, expansion, or aspect. - Type FormulaType `yaml:"type" json:"type"` + Type FormulaType `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"` + Extends []string `json:"extends,omitempty"` // Vars defines template variables with defaults and validation. - Vars map[string]*VarDef `yaml:"vars,omitempty" json:"vars,omitempty"` + Vars map[string]*VarDef `json:"vars,omitempty"` // Steps defines the work items to create. - Steps []*Step `yaml:"steps,omitempty" json:"steps,omitempty"` + Steps []*Step `json:"steps,omitempty"` // Compose defines composition/bonding rules. - Compose *ComposeRules `yaml:"compose,omitempty" json:"compose,omitempty"` + Compose *ComposeRules `json:"compose,omitempty"` // Source tracks where this formula was loaded from (set by parser). - Source string `yaml:"-" json:"source,omitempty"` + Source string `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"` + Description string `json:"description,omitempty"` // Default is the value to use if not provided. - Default string `yaml:"default,omitempty" json:"default,omitempty"` + Default string `json:"default,omitempty"` // Required indicates the variable must be provided (no default). - Required bool `yaml:"required,omitempty" json:"required,omitempty"` + Required bool `json:"required,omitempty"` // Enum lists the allowed values (if non-empty). - Enum []string `yaml:"enum,omitempty" json:"enum,omitempty"` + Enum []string `json:"enum,omitempty"` // Pattern is a regex pattern the value must match. - Pattern string `yaml:"pattern,omitempty" json:"pattern,omitempty"` + Pattern string `json:"pattern,omitempty"` // Type is the expected value type: string (default), int, bool. - Type string `yaml:"type,omitempty" json:"type,omitempty"` + Type string `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"` + ID string `json:"id"` // Title is the issue title (supports {{variable}} substitution). - Title string `yaml:"title" json:"title"` + Title string `json:"title"` // Description is the issue description (supports substitution). - Description string `yaml:"description,omitempty" json:"description,omitempty"` + Description string `json:"description,omitempty"` // Type is the issue type: task, bug, feature, epic, chore. - Type string `yaml:"type,omitempty" json:"type,omitempty"` + Type string `json:"type,omitempty"` // Priority is the issue priority (0-4). - Priority *int `yaml:"priority,omitempty" json:"priority,omitempty"` + Priority *int `json:"priority,omitempty"` // Labels are applied to the created issue. - Labels []string `yaml:"labels,omitempty" json:"labels,omitempty"` + Labels []string `json:"labels,omitempty"` // DependsOn lists step IDs this step blocks on (within the formula). - DependsOn []string `yaml:"depends_on,omitempty" json:"depends_on,omitempty"` + DependsOn []string `json:"depends_on,omitempty"` // Needs is a simpler alias for DependsOn - lists sibling step IDs that must complete first. // Either Needs or DependsOn can be used; they are merged during cooking. - Needs []string `yaml:"needs,omitempty" json:"needs,omitempty"` + Needs []string `json:"needs,omitempty"` // WaitsFor specifies a fanout gate type for this step. // Values: "all-children" (wait for all dynamic children) or "any-children" (wait for first). // When set, the cooked issue gets a "gate:" label. - WaitsFor string `yaml:"waits_for,omitempty" json:"waits_for,omitempty"` + WaitsFor string `json:"waits_for,omitempty"` // Assignee is the default assignee (supports substitution). - Assignee string `yaml:"assignee,omitempty" json:"assignee,omitempty"` + Assignee string `json:"assignee,omitempty"` // Expand references an expansion formula to inline here. // When set, this step is replaced by the expansion's steps. // TODO(future): Not yet implemented in bd cook. Filed as future work. - Expand string `yaml:"expand,omitempty" json:"expand,omitempty"` + Expand string `json:"expand,omitempty"` // ExpandVars are variable overrides for the expansion. // TODO(future): Not yet implemented in bd cook. Filed as future work. - ExpandVars map[string]string `yaml:"expand_vars,omitempty" json:"expand_vars,omitempty"` + ExpandVars map[string]string `json:"expand_vars,omitempty"` // Condition makes this step optional based on a variable. // Format: "{{var}}" (truthy) or "{{var}} == value". // TODO(future): Not yet implemented in bd cook. Filed as future work. - Condition string `yaml:"condition,omitempty" json:"condition,omitempty"` + Condition string `json:"condition,omitempty"` // Children are nested steps (for creating epic hierarchies). - Children []*Step `yaml:"children,omitempty" json:"children,omitempty"` + Children []*Step `json:"children,omitempty"` // Gate defines an async wait condition for this step. // TODO(future): Not yet implemented in bd cook. Will integrate with bd-udsi gates. - Gate *Gate `yaml:"gate,omitempty" json:"gate,omitempty"` + Gate *Gate `json:"gate,omitempty"` } // Gate defines an async wait condition (integrates with bd-udsi). // TODO(future): Not yet implemented in bd cook. Schema defined for future use. type Gate struct { // Type is the condition type: gh:run, gh:pr, timer, human, mail. - Type string `yaml:"type" json:"type"` + Type string `json:"type"` // ID is the condition identifier (e.g., workflow name for gh:run). - ID string `yaml:"id,omitempty" json:"id,omitempty"` + ID string `json:"id,omitempty"` // Timeout is how long to wait before escalation (e.g., "1h", "24h"). - Timeout string `yaml:"timeout,omitempty" json:"timeout,omitempty"` + Timeout string `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"` + BondPoints []*BondPoint `json:"bond_points,omitempty"` // Hooks are automatic attachments triggered by labels or conditions. - Hooks []*Hook `yaml:"hooks,omitempty" json:"hooks,omitempty"` + Hooks []*Hook `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"` + ID string `json:"id"` // Description explains what should be attached here. - Description string `yaml:"description,omitempty" json:"description,omitempty"` + Description string `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"` + AfterStep string `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"` + BeforeStep string `json:"before_step,omitempty"` // Parallel makes attached steps run in parallel with the anchor step. - Parallel bool `yaml:"parallel,omitempty" json:"parallel,omitempty"` + Parallel bool `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"` + Trigger string `json:"trigger"` // Attach is the formula to attach when triggered. - Attach string `yaml:"attach" json:"attach"` + Attach string `json:"attach"` // At is the bond point to attach at (default: end). - At string `yaml:"at,omitempty" json:"at,omitempty"` + At string `json:"at,omitempty"` // Vars are variable overrides for the attached formula. - Vars map[string]string `yaml:"vars,omitempty" json:"vars,omitempty"` + Vars map[string]string `json:"vars,omitempty"` } // Validate checks the formula for structural errors.