feat: Compile-time vs runtime cooking (gt-8tmz.23)
Add --mode flag (compile/runtime) and --var flag to bd cook command.
Compile-time mode (default):
- Keeps {{variable}} placeholders intact
- Use for: modeling, estimation, contractor handoff
Runtime mode (triggered by --var or --mode=runtime):
- Substitutes variables with provided values
- Validates all required variables have values
- Use for: final validation before pour
Also updates --dry-run output to clearly show which mode is active
and display substituted values in runtime mode.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
125
cmd/bd/cook.go
125
cmd/bd/cook.go
@@ -25,6 +25,18 @@ var cookCmd = &cobra.Command{
|
|||||||
By default, cook outputs the resolved formula as JSON to stdout for
|
By default, cook outputs the resolved formula as JSON to stdout for
|
||||||
ephemeral use. The output can be inspected, piped, or saved to a file.
|
ephemeral use. The output can be inspected, piped, or saved to a file.
|
||||||
|
|
||||||
|
Two cooking modes are available (gt-8tmz.23):
|
||||||
|
|
||||||
|
COMPILE-TIME (default, --mode=compile):
|
||||||
|
Produces a proto with {{variable}} placeholders intact.
|
||||||
|
Use for: modeling, estimation, contractor handoff, planning.
|
||||||
|
Variables are NOT substituted - the output shows the template structure.
|
||||||
|
|
||||||
|
RUNTIME (--mode=runtime or when --var flags provided):
|
||||||
|
Produces a fully-resolved proto with variables substituted.
|
||||||
|
Use for: final validation before pour, seeing exact output.
|
||||||
|
Requires all variables to have values (via --var or defaults).
|
||||||
|
|
||||||
Formulas are high-level workflow templates that support:
|
Formulas are high-level workflow templates that support:
|
||||||
- Variable definitions with defaults and validation
|
- Variable definitions with defaults and validation
|
||||||
- Step definitions that become issue hierarchies
|
- Step definitions that become issue hierarchies
|
||||||
@@ -39,7 +51,9 @@ For most workflows, prefer ephemeral protos: pour and wisp commands
|
|||||||
accept formula names directly and cook inline (bd-rciw).
|
accept formula names directly and cook inline (bd-rciw).
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
bd cook mol-feature.formula.json # Output JSON to stdout
|
bd cook mol-feature.formula.json # Compile-time: keep {{vars}}
|
||||||
|
bd cook mol-feature --var name=auth # Runtime: substitute vars
|
||||||
|
bd cook mol-feature --mode=runtime --var name=auth # Explicit runtime mode
|
||||||
bd cook mol-feature --dry-run # Preview steps
|
bd cook mol-feature --dry-run # Preview steps
|
||||||
bd cook mol-release.formula.json --persist # Write to database
|
bd cook mol-release.formula.json --persist # Write to database
|
||||||
bd cook mol-release.formula.json --persist --force # Replace existing
|
bd cook mol-release.formula.json --persist --force # Replace existing
|
||||||
@@ -72,6 +86,27 @@ func runCook(cmd *cobra.Command, args []string) {
|
|||||||
force, _ := cmd.Flags().GetBool("force")
|
force, _ := cmd.Flags().GetBool("force")
|
||||||
searchPaths, _ := cmd.Flags().GetStringSlice("search-path")
|
searchPaths, _ := cmd.Flags().GetStringSlice("search-path")
|
||||||
prefix, _ := cmd.Flags().GetString("prefix")
|
prefix, _ := cmd.Flags().GetString("prefix")
|
||||||
|
varFlags, _ := cmd.Flags().GetStringSlice("var")
|
||||||
|
mode, _ := cmd.Flags().GetString("mode")
|
||||||
|
|
||||||
|
// Parse variables (gt-8tmz.23)
|
||||||
|
inputVars := make(map[string]string)
|
||||||
|
for _, v := range varFlags {
|
||||||
|
parts := strings.SplitN(v, "=", 2)
|
||||||
|
if len(parts) != 2 {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error: invalid variable format '%s', expected 'key=value'\n", v)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
inputVars[parts[0]] = parts[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine cooking mode (gt-8tmz.23)
|
||||||
|
// Runtime mode is triggered by: explicit --mode=runtime OR providing --var flags
|
||||||
|
runtimeMode := mode == "runtime" || len(inputVars) > 0
|
||||||
|
if mode != "" && mode != "compile" && mode != "runtime" {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error: invalid mode '%s', must be 'compile' or 'runtime'\n", mode)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
// Only need store access if persisting
|
// Only need store access if persisting
|
||||||
if persist {
|
if persist {
|
||||||
@@ -168,19 +203,48 @@ func runCook(cmd *cobra.Command, args []string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if dryRun {
|
if dryRun {
|
||||||
fmt.Printf("\nDry run: would cook formula %s as proto %s\n\n", resolved.Formula, protoID)
|
// Determine mode label for display
|
||||||
fmt.Printf("Steps (%d):\n", len(resolved.Steps))
|
modeLabel := "compile-time"
|
||||||
|
if runtimeMode {
|
||||||
|
modeLabel = "runtime"
|
||||||
|
// Apply defaults for runtime mode display
|
||||||
|
for name, def := range resolved.Vars {
|
||||||
|
if _, provided := inputVars[name]; !provided && def.Default != "" {
|
||||||
|
inputVars[name] = def.Default
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("\nDry run: would cook formula %s as proto %s (%s mode)\n\n", resolved.Formula, protoID, modeLabel)
|
||||||
|
|
||||||
|
// In runtime mode, show substituted steps
|
||||||
|
if runtimeMode {
|
||||||
|
// Create a copy with substituted values for display
|
||||||
|
substituteFormulaVars(resolved, inputVars)
|
||||||
|
fmt.Printf("Steps (%d) [variables substituted]:\n", len(resolved.Steps))
|
||||||
|
} else {
|
||||||
|
fmt.Printf("Steps (%d) [{{variables}} shown as placeholders]:\n", len(resolved.Steps))
|
||||||
|
}
|
||||||
printFormulaSteps(resolved.Steps, " ")
|
printFormulaSteps(resolved.Steps, " ")
|
||||||
|
|
||||||
if len(vars) > 0 {
|
if len(vars) > 0 {
|
||||||
fmt.Printf("\nVariables: %s\n", strings.Join(vars, ", "))
|
fmt.Printf("\nVariables used: %s\n", strings.Join(vars, ", "))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Show variable values in runtime mode
|
||||||
|
if runtimeMode && len(inputVars) > 0 {
|
||||||
|
fmt.Printf("\nVariable values:\n")
|
||||||
|
for name, value := range inputVars {
|
||||||
|
fmt.Printf(" {{%s}} = %s\n", name, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if len(bondPoints) > 0 {
|
if len(bondPoints) > 0 {
|
||||||
fmt.Printf("Bond points: %s\n", strings.Join(bondPoints, ", "))
|
fmt.Printf("Bond points: %s\n", strings.Join(bondPoints, ", "))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show variable definitions
|
// Show variable definitions (more useful in compile-time mode)
|
||||||
if len(resolved.Vars) > 0 {
|
if !runtimeMode && len(resolved.Vars) > 0 {
|
||||||
fmt.Printf("\nVariable definitions:\n")
|
fmt.Printf("\nVariable definitions:\n")
|
||||||
for name, def := range resolved.Vars {
|
for name, def := range resolved.Vars {
|
||||||
attrs := []string{}
|
attrs := []string{}
|
||||||
@@ -205,6 +269,32 @@ func runCook(cmd *cobra.Command, args []string) {
|
|||||||
|
|
||||||
// Ephemeral mode (default): output resolved formula as JSON to stdout (bd-rciw)
|
// Ephemeral mode (default): output resolved formula as JSON to stdout (bd-rciw)
|
||||||
if !persist {
|
if !persist {
|
||||||
|
// Runtime mode (gt-8tmz.23): substitute variables before output
|
||||||
|
if runtimeMode {
|
||||||
|
// Apply defaults from formula variable definitions
|
||||||
|
for name, def := range resolved.Vars {
|
||||||
|
if _, provided := inputVars[name]; !provided && def.Default != "" {
|
||||||
|
inputVars[name] = def.Default
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for missing required variables
|
||||||
|
var missingVars []string
|
||||||
|
for _, v := range vars {
|
||||||
|
if _, ok := inputVars[v]; !ok {
|
||||||
|
missingVars = append(missingVars, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(missingVars) > 0 {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error: runtime mode requires all variables to have values\n")
|
||||||
|
fmt.Fprintf(os.Stderr, "Missing: %s\n", strings.Join(missingVars, ", "))
|
||||||
|
fmt.Fprintf(os.Stderr, "Provide with: --var %s=<value>\n", missingVars[0])
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Substitute variables in the formula
|
||||||
|
substituteFormulaVars(resolved, inputVars)
|
||||||
|
}
|
||||||
outputJSON(resolved)
|
outputJSON(resolved)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -857,12 +947,35 @@ func printFormulaSteps(steps []*formula.Step, indent string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// substituteFormulaVars substitutes {{variable}} placeholders in a formula (gt-8tmz.23).
|
||||||
|
// This is used in runtime mode to fully resolve the formula before output.
|
||||||
|
func substituteFormulaVars(f *formula.Formula, vars map[string]string) {
|
||||||
|
// Substitute in top-level fields
|
||||||
|
f.Description = substituteVariables(f.Description, vars)
|
||||||
|
|
||||||
|
// Substitute in all steps recursively
|
||||||
|
substituteStepVars(f.Steps, vars)
|
||||||
|
}
|
||||||
|
|
||||||
|
// substituteStepVars recursively substitutes variables in step titles and descriptions.
|
||||||
|
func substituteStepVars(steps []*formula.Step, vars map[string]string) {
|
||||||
|
for _, step := range steps {
|
||||||
|
step.Title = substituteVariables(step.Title, vars)
|
||||||
|
step.Description = substituteVariables(step.Description, vars)
|
||||||
|
if len(step.Children) > 0 {
|
||||||
|
substituteStepVars(step.Children, vars)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
cookCmd.Flags().Bool("dry-run", false, "Preview what would be created")
|
cookCmd.Flags().Bool("dry-run", false, "Preview what would be created")
|
||||||
cookCmd.Flags().Bool("persist", false, "Persist proto to database (legacy behavior)")
|
cookCmd.Flags().Bool("persist", false, "Persist proto to database (legacy behavior)")
|
||||||
cookCmd.Flags().Bool("force", false, "Replace existing proto if it exists (requires --persist)")
|
cookCmd.Flags().Bool("force", false, "Replace existing proto if it exists (requires --persist)")
|
||||||
cookCmd.Flags().StringSlice("search-path", []string{}, "Additional paths to search for formula inheritance")
|
cookCmd.Flags().StringSlice("search-path", []string{}, "Additional paths to search for formula inheritance")
|
||||||
cookCmd.Flags().String("prefix", "", "Prefix to prepend to proto ID (e.g., 'gt-' creates 'gt-mol-feature')")
|
cookCmd.Flags().String("prefix", "", "Prefix to prepend to proto ID (e.g., 'gt-' creates 'gt-mol-feature')")
|
||||||
|
cookCmd.Flags().StringSlice("var", []string{}, "Variable substitution (key=value), enables runtime mode")
|
||||||
|
cookCmd.Flags().String("mode", "", "Cooking mode: compile (keep placeholders) or runtime (substitute vars)")
|
||||||
|
|
||||||
rootCmd.AddCommand(cookCmd)
|
rootCmd.AddCommand(cookCmd)
|
||||||
}
|
}
|
||||||
|
|||||||
189
cmd/bd/cook_test.go
Normal file
189
cmd/bd/cook_test.go
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/steveyegge/beads/internal/formula"
|
||||||
|
)
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Cook Tests (gt-8tmz.23: Compile-time vs Runtime Cooking)
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
// TestSubstituteFormulaVars tests variable substitution in formulas
|
||||||
|
func TestSubstituteFormulaVars(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
formula *formula.Formula
|
||||||
|
vars map[string]string
|
||||||
|
wantDesc string
|
||||||
|
wantStepTitle string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "substitute single variable in description",
|
||||||
|
formula: &formula.Formula{
|
||||||
|
Description: "Build {{feature}} feature",
|
||||||
|
Steps: []*formula.Step{},
|
||||||
|
},
|
||||||
|
vars: map[string]string{"feature": "auth"},
|
||||||
|
wantDesc: "Build auth feature",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "substitute variable in step title",
|
||||||
|
formula: &formula.Formula{
|
||||||
|
Description: "Feature work",
|
||||||
|
Steps: []*formula.Step{
|
||||||
|
{Title: "Implement {{name}}"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
vars: map[string]string{"name": "login"},
|
||||||
|
wantDesc: "Feature work",
|
||||||
|
wantStepTitle: "Implement login",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "substitute multiple variables",
|
||||||
|
formula: &formula.Formula{
|
||||||
|
Description: "Release {{version}} on {{date}}",
|
||||||
|
Steps: []*formula.Step{
|
||||||
|
{Title: "Tag {{version}}"},
|
||||||
|
{Title: "Deploy to {{env}}"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
vars: map[string]string{
|
||||||
|
"version": "1.0.0",
|
||||||
|
"date": "2024-01-15",
|
||||||
|
"env": "production",
|
||||||
|
},
|
||||||
|
wantDesc: "Release 1.0.0 on 2024-01-15",
|
||||||
|
wantStepTitle: "Tag 1.0.0",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "nested children substitution",
|
||||||
|
formula: &formula.Formula{
|
||||||
|
Description: "Epic for {{project}}",
|
||||||
|
Steps: []*formula.Step{
|
||||||
|
{
|
||||||
|
Title: "Phase 1: {{project}} design",
|
||||||
|
Children: []*formula.Step{
|
||||||
|
{Title: "Design {{component}}"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
vars: map[string]string{
|
||||||
|
"project": "checkout",
|
||||||
|
"component": "cart",
|
||||||
|
},
|
||||||
|
wantDesc: "Epic for checkout",
|
||||||
|
wantStepTitle: "Phase 1: checkout design",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unsubstituted variable left as-is",
|
||||||
|
formula: &formula.Formula{
|
||||||
|
Description: "Build {{feature}} with {{extra}}",
|
||||||
|
Steps: []*formula.Step{},
|
||||||
|
},
|
||||||
|
vars: map[string]string{"feature": "auth"},
|
||||||
|
wantDesc: "Build auth with {{extra}}", // {{extra}} unchanged
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty vars map",
|
||||||
|
formula: &formula.Formula{
|
||||||
|
Description: "Keep {{placeholder}} intact",
|
||||||
|
Steps: []*formula.Step{},
|
||||||
|
},
|
||||||
|
vars: map[string]string{},
|
||||||
|
wantDesc: "Keep {{placeholder}} intact",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
substituteFormulaVars(tt.formula, tt.vars)
|
||||||
|
|
||||||
|
if tt.formula.Description != tt.wantDesc {
|
||||||
|
t.Errorf("Description = %q, want %q", tt.formula.Description, tt.wantDesc)
|
||||||
|
}
|
||||||
|
|
||||||
|
if tt.wantStepTitle != "" && len(tt.formula.Steps) > 0 {
|
||||||
|
if tt.formula.Steps[0].Title != tt.wantStepTitle {
|
||||||
|
t.Errorf("Steps[0].Title = %q, want %q", tt.formula.Steps[0].Title, tt.wantStepTitle)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSubstituteStepVarsRecursive tests deep nesting works correctly
|
||||||
|
func TestSubstituteStepVarsRecursive(t *testing.T) {
|
||||||
|
steps := []*formula.Step{
|
||||||
|
{
|
||||||
|
Title: "Root: {{name}}",
|
||||||
|
Children: []*formula.Step{
|
||||||
|
{
|
||||||
|
Title: "Level 1: {{name}}",
|
||||||
|
Children: []*formula.Step{
|
||||||
|
{
|
||||||
|
Title: "Level 2: {{name}}",
|
||||||
|
Children: []*formula.Step{
|
||||||
|
{Title: "Level 3: {{name}}"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
vars := map[string]string{"name": "test"}
|
||||||
|
substituteStepVars(steps, vars)
|
||||||
|
|
||||||
|
// Check all levels got substituted
|
||||||
|
if steps[0].Title != "Root: test" {
|
||||||
|
t.Errorf("Root title = %q, want %q", steps[0].Title, "Root: test")
|
||||||
|
}
|
||||||
|
if steps[0].Children[0].Title != "Level 1: test" {
|
||||||
|
t.Errorf("Level 1 title = %q, want %q", steps[0].Children[0].Title, "Level 1: test")
|
||||||
|
}
|
||||||
|
if steps[0].Children[0].Children[0].Title != "Level 2: test" {
|
||||||
|
t.Errorf("Level 2 title = %q, want %q", steps[0].Children[0].Children[0].Title, "Level 2: test")
|
||||||
|
}
|
||||||
|
if steps[0].Children[0].Children[0].Children[0].Title != "Level 3: test" {
|
||||||
|
t.Errorf("Level 3 title = %q, want %q", steps[0].Children[0].Children[0].Children[0].Title, "Level 3: test")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCompileTimeVsRuntimeMode tests that compile-time preserves placeholders
|
||||||
|
// and runtime mode substitutes them
|
||||||
|
func TestCompileTimeVsRuntimeMode(t *testing.T) {
|
||||||
|
// Simulate compile-time mode (no variable substitution)
|
||||||
|
compileFormula := &formula.Formula{
|
||||||
|
Description: "Feature: {{name}}",
|
||||||
|
Steps: []*formula.Step{
|
||||||
|
{Title: "Implement {{name}}"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// In compile-time mode, don't call substituteFormulaVars
|
||||||
|
// Placeholders should remain intact
|
||||||
|
if compileFormula.Description != "Feature: {{name}}" {
|
||||||
|
t.Errorf("Compile-time: Description should preserve placeholder, got %q", compileFormula.Description)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simulate runtime mode (with variable substitution)
|
||||||
|
runtimeFormula := &formula.Formula{
|
||||||
|
Description: "Feature: {{name}}",
|
||||||
|
Steps: []*formula.Step{
|
||||||
|
{Title: "Implement {{name}}"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
vars := map[string]string{"name": "auth"}
|
||||||
|
substituteFormulaVars(runtimeFormula, vars)
|
||||||
|
|
||||||
|
if runtimeFormula.Description != "Feature: auth" {
|
||||||
|
t.Errorf("Runtime: Description = %q, want %q", runtimeFormula.Description, "Feature: auth")
|
||||||
|
}
|
||||||
|
if runtimeFormula.Steps[0].Title != "Implement auth" {
|
||||||
|
t.Errorf("Runtime: Steps[0].Title = %q, want %q", runtimeFormula.Steps[0].Title, "Implement auth")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user