refactor: Switch formula format from YAML to JSON
- 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 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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-<name> for molecules, exp-<name> 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:<value>" 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.
|
||||
|
||||
Reference in New Issue
Block a user