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:
Steve Yegge
2025-12-25 18:40:15 -08:00
parent ea9f1d2760
commit abf672243c
2 changed files with 308 additions and 6 deletions

View File

@@ -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
View 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")
}
}