feat(formula): support needs and waits_for fields, add --prefix flag

- bd-hr39: Add `needs` field to Step as alias for depends_on. Converts
  to blocking dependencies between sibling steps during cooking.

- bd-j4cr: Add `waits_for` field to Step. Values: all-children or
  any-children. Preserved as gate:<value> label during cooking.

- bd-47qx: Add --prefix flag to bd cook command to prepend a prefix
  to proto IDs, enabling use of project prefixes like gt-.

Includes validation, dry-run output, and comprehensive tests.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Steve Yegge
2025-12-24 13:59:23 -08:00
parent 90421e4567
commit f28785d2dc
3 changed files with 264 additions and 18 deletions

View File

@@ -603,3 +603,172 @@ func TestFormulaType_IsValid(t *testing.T) {
}
}
}
// TestValidate_NeedsField tests validation of the needs field (bd-hr39)
func TestValidate_NeedsField(t *testing.T) {
// Valid needs reference
formula := &Formula{
Formula: "mol-needs",
Version: 1,
Type: TypeWorkflow,
Steps: []*Step{
{ID: "step1", Title: "Step 1"},
{ID: "step2", Title: "Step 2", Needs: []string{"step1"}},
},
}
if err := formula.Validate(); err != nil {
t.Errorf("Validate failed for valid needs reference: %v", err)
}
// Invalid needs reference
formulaBad := &Formula{
Formula: "mol-bad-needs",
Version: 1,
Type: TypeWorkflow,
Steps: []*Step{
{ID: "step1", Title: "Step 1"},
{ID: "step2", Title: "Step 2", Needs: []string{"nonexistent"}},
},
}
err := formulaBad.Validate()
if err == nil {
t.Error("Validate should fail for needs referencing unknown step")
}
}
// TestValidate_WaitsForField tests validation of the waits_for field (bd-j4cr)
func TestValidate_WaitsForField(t *testing.T) {
// Valid waits_for value
formula := &Formula{
Formula: "mol-waits-for",
Version: 1,
Type: TypeWorkflow,
Steps: []*Step{
{ID: "fanout", Title: "Fanout"},
{ID: "aggregate", Title: "Aggregate", Needs: []string{"fanout"}, WaitsFor: "all-children"},
},
}
if err := formula.Validate(); err != nil {
t.Errorf("Validate failed for valid waits_for: %v", err)
}
// Invalid waits_for value
formulaBad := &Formula{
Formula: "mol-bad-waits-for",
Version: 1,
Type: TypeWorkflow,
Steps: []*Step{
{ID: "step1", Title: "Step 1", WaitsFor: "invalid-gate"},
},
}
err := formulaBad.Validate()
if err == nil {
t.Error("Validate should fail for invalid waits_for value")
}
}
// TestValidate_ChildNeedsAndWaitsFor tests needs and waits_for in child steps
func TestValidate_ChildNeedsAndWaitsFor(t *testing.T) {
formula := &Formula{
Formula: "mol-child-fields",
Version: 1,
Type: TypeWorkflow,
Steps: []*Step{
{
ID: "epic1",
Title: "Epic 1",
Children: []*Step{
{ID: "child1", Title: "Child 1"},
{ID: "child2", Title: "Child 2", Needs: []string{"child1"}, WaitsFor: "any-children"},
},
},
},
}
if err := formula.Validate(); err != nil {
t.Errorf("Validate failed for valid child needs/waits_for: %v", err)
}
// Invalid child needs
formulaBadNeeds := &Formula{
Formula: "mol-bad-child-needs",
Version: 1,
Type: TypeWorkflow,
Steps: []*Step{
{
ID: "epic1",
Title: "Epic 1",
Children: []*Step{
{ID: "child1", Title: "Child 1", Needs: []string{"nonexistent"}},
},
},
},
}
if err := formulaBadNeeds.Validate(); err == nil {
t.Error("Validate should fail for child with invalid needs reference")
}
// Invalid child waits_for
formulaBadWaitsFor := &Formula{
Formula: "mol-bad-child-waits-for",
Version: 1,
Type: TypeWorkflow,
Steps: []*Step{
{
ID: "epic1",
Title: "Epic 1",
Children: []*Step{
{ID: "child1", Title: "Child 1", WaitsFor: "bad-value"},
},
},
},
}
if err := formulaBadWaitsFor.Validate(); err == nil {
t.Error("Validate should fail for child with invalid waits_for")
}
}
// TestParse_NeedsAndWaitsFor tests YAML 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
`
p := NewParser()
formula, err := p.Parse([]byte(yaml))
if err != nil {
t.Fatalf("Parse failed: %v", err)
}
// Validate parsed formula
if err := formula.Validate(); err != nil {
t.Errorf("Validate failed: %v", err)
}
// Check needs field
if len(formula.Steps[1].Needs) != 1 || formula.Steps[1].Needs[0] != "inbox-check" {
t.Errorf("Steps[1].Needs = %v, want [inbox-check]", formula.Steps[1].Needs)
}
// Check waits_for field
if formula.Steps[2].WaitsFor != "all-children" {
t.Errorf("Steps[2].WaitsFor = %q, want 'all-children'", formula.Steps[2].WaitsFor)
}
}

View File

@@ -134,6 +134,15 @@ type Step struct {
// DependsOn lists step IDs this step blocks on (within the formula).
DependsOn []string `yaml:"depends_on,omitempty" 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"`
// 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"`
// Assignee is the default assignee (supports substitution).
Assignee string `yaml:"assignee,omitempty" json:"assignee,omitempty"`
@@ -278,7 +287,20 @@ func (f *Formula) Validate() error {
errs = append(errs, fmt.Sprintf("steps[%d] (%s): depends_on references unknown step %q", i, step.ID, dep))
}
}
// Validate children's depends_on recursively
// Validate needs field (bd-hr39) - same validation as depends_on
for _, need := range step.Needs {
if _, exists := stepIDLocations[need]; !exists {
errs = append(errs, fmt.Sprintf("steps[%d] (%s): needs references unknown step %q", i, step.ID, need))
}
}
// Validate waits_for field (bd-j4cr) - must be a known gate type
if step.WaitsFor != "" {
validGates := map[string]bool{"all-children": true, "any-children": true}
if !validGates[step.WaitsFor] {
errs = append(errs, fmt.Sprintf("steps[%d] (%s): waits_for has invalid value %q (must be all-children or any-children)", i, step.ID, step.WaitsFor))
}
}
// Validate children's depends_on and needs recursively
validateChildDependsOn(step.Children, stepIDLocations, &errs, fmt.Sprintf("steps[%d]", i))
}
@@ -348,7 +370,7 @@ func collectChildIDs(children []*Step, idLocations map[string]string, errs *[]st
}
}
// validateChildDependsOn recursively validates depends_on references for children.
// validateChildDependsOn recursively validates depends_on and needs references for children.
func validateChildDependsOn(children []*Step, idLocations map[string]string, errs *[]string, prefix string) {
for i, child := range children {
childPrefix := fmt.Sprintf("%s.children[%d]", prefix, i)
@@ -357,6 +379,19 @@ func validateChildDependsOn(children []*Step, idLocations map[string]string, err
*errs = append(*errs, fmt.Sprintf("%s (%s): depends_on references unknown step %q", childPrefix, child.ID, dep))
}
}
// Validate needs field (bd-hr39)
for _, need := range child.Needs {
if _, exists := idLocations[need]; !exists {
*errs = append(*errs, fmt.Sprintf("%s (%s): needs references unknown step %q", childPrefix, child.ID, need))
}
}
// Validate waits_for field (bd-j4cr)
if child.WaitsFor != "" {
validGates := map[string]bool{"all-children": true, "any-children": true}
if !validGates[child.WaitsFor] {
*errs = append(*errs, fmt.Sprintf("%s (%s): waits_for has invalid value %q (must be all-children or any-children)", childPrefix, child.ID, child.WaitsFor))
}
}
validateChildDependsOn(child.Children, idLocations, errs, childPrefix)
}
}