From fc8437ed08662fa8eaefea28130656839dd571b0 Mon Sep 17 00:00:00 2001 From: Peter Chanthamynavong Date: Fri, 2 Jan 2026 12:41:53 -0800 Subject: [PATCH] fix(mol): substitute title/desc vars in root molecule bead When pouring a formula with `title` and `desc` variables defined, the root molecule's title and description now use {{title}} and {{desc}} placeholders that get substituted during pour. Previously, the root was always assigned the formula name and static description, ignoring these common variables. Child beads correctly substituted variables, but the root did not. Fixes #852 --- cmd/bd/cook.go | 36 +++++++- cmd/bd/mol_test.go | 204 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 236 insertions(+), 4 deletions(-) diff --git a/cmd/bd/cook.go b/cmd/bd/cook.go index a803992c..efc2d0c5 100644 --- a/cmd/bd/cook.go +++ b/cmd/bd/cook.go @@ -443,11 +443,25 @@ func cookFormulaToSubgraph(f *formula.Formula, protoID string) (*TemplateSubgrap var issues []*types.Issue var deps []*types.Dependency + // Determine root title: use {{title}} placeholder if the variable is defined, + // otherwise fall back to formula name (GH#852) + rootTitle := f.Formula + if _, hasTitle := f.Vars["title"]; hasTitle { + rootTitle = "{{title}}" + } + + // Determine root description: use {{desc}} placeholder if the variable is defined, + // otherwise fall back to formula description (GH#852) + rootDesc := f.Description + if _, hasDesc := f.Vars["desc"]; hasDesc { + rootDesc = "{{desc}}" + } + // Create root proto epic rootIssue := &types.Issue{ ID: protoID, - Title: f.Formula, // Title is the original formula name - Description: f.Description, + Title: rootTitle, + Description: rootDesc, Status: types.StatusOpen, Priority: 2, IssueType: types.TypeEpic, @@ -783,11 +797,25 @@ func cookFormula(ctx context.Context, s storage.Storage, f *formula.Formula, pro var deps []*types.Dependency var labels []struct{ issueID, label string } + // Determine root title: use {{title}} placeholder if the variable is defined, + // otherwise fall back to formula name (GH#852) + rootTitle := f.Formula + if _, hasTitle := f.Vars["title"]; hasTitle { + rootTitle = "{{title}}" + } + + // Determine root description: use {{desc}} placeholder if the variable is defined, + // otherwise fall back to formula description (GH#852) + rootDesc := f.Description + if _, hasDesc := f.Vars["desc"]; hasDesc { + rootDesc = "{{desc}}" + } + // Create root proto epic using provided protoID (may include prefix) rootIssue := &types.Issue{ ID: protoID, - Title: f.Formula, // Title is the original formula name - Description: f.Description, + Title: rootTitle, + Description: rootDesc, Status: types.StatusOpen, Priority: 2, IssueType: types.TypeEpic, diff --git a/cmd/bd/mol_test.go b/cmd/bd/mol_test.go index b284ec7f..afe8232a 100644 --- a/cmd/bd/mol_test.go +++ b/cmd/bd/mol_test.go @@ -5,6 +5,7 @@ import ( "strings" "testing" + "github.com/steveyegge/beads/internal/formula" "github.com/steveyegge/beads/internal/storage/sqlite" "github.com/steveyegge/beads/internal/types" ) @@ -2627,3 +2628,206 @@ func TestFormatBondType(t *testing.T) { }) } } + +// TestPourRootTitleDescSubstitution verifies that the root molecule's title and description +// are substituted with {{title}} and {{desc}} variables when pouring a formula. +// This is a tracer bullet test for GitHub issue #852: +// https://github.com/steveyegge/beads/issues/852 +func TestPourRootTitleDescSubstitution(t *testing.T) { + ctx := context.Background() + dbPath := t.TempDir() + "/test.db" + s, err := sqlite.New(ctx, dbPath) + if err != nil { + t.Fatalf("Failed to create store: %v", err) + } + defer s.Close() + if err := s.SetConfig(ctx, "issue_prefix", "mol"); err != nil { + t.Fatalf("Failed to set config: %v", err) + } + + // Create a formula that has title and desc variables + f := &formula.Formula{ + Formula: "mol-task", + Description: "Standard task workflow for 2-8 hour work...", + Version: 1, + Type: formula.TypeWorkflow, + Vars: map[string]*formula.VarDef{ + "title": { + Description: "Task title", + Required: true, + }, + "desc": { + Description: "Task description", + Required: false, + Default: "No description provided", + }, + }, + Steps: []*formula.Step{ + {ID: "plan", Title: "Plan: {{title}}", Type: "task"}, + {ID: "implement", Title: "Implement: {{title}}", Type: "task", DependsOn: []string{"plan"}}, + {ID: "verify", Title: "Verify: {{title}}", Type: "task", DependsOn: []string{"implement"}}, + {ID: "review", Title: "Review: {{title}}", Type: "task", DependsOn: []string{"verify"}}, + }, + } + + // Cook the formula to a subgraph (in-memory, no DB) + subgraph, err := cookFormulaToSubgraphWithVars(f, f.Formula, f.Vars) + if err != nil { + t.Fatalf("Failed to cook formula: %v", err) + } + + // Spawn with title and desc variables + vars := map[string]string{ + "title": "My Task", + "desc": "My description", + } + + result, err := spawnMolecule(ctx, s, subgraph, vars, "", "test", false, "mol") + if err != nil { + t.Fatalf("spawnMolecule failed: %v", err) + } + + // Get the spawned root issue + spawnedRoot, err := s.GetIssue(ctx, result.NewEpicID) + if err != nil { + t.Fatalf("Failed to get spawned root: %v", err) + } + + // BUG: The root title should contain "My Task" but currently contains "mol-task" + // because cookFormulaToSubgraph sets root.Title = f.Formula instead of using + // a template that includes {{title}}. + if !strings.Contains(spawnedRoot.Title, "My Task") { + t.Errorf("Root title should contain 'My Task' from variable substitution, got: %q", spawnedRoot.Title) + } + + // BUG: The root description should contain "My description" but currently + // contains the formula's static description. + if !strings.Contains(spawnedRoot.Description, "My description") { + t.Errorf("Root description should contain 'My description' from variable substitution, got: %q", spawnedRoot.Description) + } + + // Verify child beads DO have correct substitution (this should pass) + for oldID, newID := range result.IDMapping { + if oldID == f.Formula { + continue // Skip root + } + spawned, err := s.GetIssue(ctx, newID) + if err != nil { + t.Fatalf("Failed to get spawned issue %s: %v", newID, err) + } + if !strings.Contains(spawned.Title, "My Task") { + t.Errorf("Child issue %s (from %s) title should contain 'My Task', got: %q", newID, oldID, spawned.Title) + } + } +} + +// TestPourRootTitleOnly verifies edge case: only title var defined, no desc. +// Root should use {{title}} for title, but keep formula description. +func TestPourRootTitleOnly(t *testing.T) { + ctx := context.Background() + dbPath := t.TempDir() + "/test.db" + s, err := sqlite.New(ctx, dbPath) + if err != nil { + t.Fatalf("Failed to create store: %v", err) + } + defer s.Close() + if err := s.SetConfig(ctx, "issue_prefix", "mol"); err != nil { + t.Fatalf("Failed to set config: %v", err) + } + + // Formula with only title var (no desc) + f := &formula.Formula{ + Formula: "mol-simple", + Description: "Static description that should be preserved", + Version: 1, + Type: formula.TypeWorkflow, + Vars: map[string]*formula.VarDef{ + "title": {Description: "Task title", Required: true}, + }, + Steps: []*formula.Step{ + {ID: "work", Title: "Do: {{title}}", Type: "task"}, + }, + } + + subgraph, err := cookFormulaToSubgraphWithVars(f, f.Formula, f.Vars) + if err != nil { + t.Fatalf("Failed to cook formula: %v", err) + } + + vars := map[string]string{"title": "Custom Title"} + result, err := spawnMolecule(ctx, s, subgraph, vars, "", "test", false, "mol") + if err != nil { + t.Fatalf("spawnMolecule failed: %v", err) + } + + spawnedRoot, err := s.GetIssue(ctx, result.NewEpicID) + if err != nil { + t.Fatalf("Failed to get spawned root: %v", err) + } + + // Title should be substituted + if !strings.Contains(spawnedRoot.Title, "Custom Title") { + t.Errorf("Root title should contain 'Custom Title', got: %q", spawnedRoot.Title) + } + + // Description should be the static formula description (no desc var) + if spawnedRoot.Description != "Static description that should be preserved" { + t.Errorf("Root description should be static formula desc, got: %q", spawnedRoot.Description) + } +} + +// TestPourRootNoVars verifies backward compatibility: no title/desc vars defined. +// Root should use formula name and formula description (original behavior). +func TestPourRootNoVars(t *testing.T) { + ctx := context.Background() + dbPath := t.TempDir() + "/test.db" + s, err := sqlite.New(ctx, dbPath) + if err != nil { + t.Fatalf("Failed to create store: %v", err) + } + defer s.Close() + if err := s.SetConfig(ctx, "issue_prefix", "mol"); err != nil { + t.Fatalf("Failed to set config: %v", err) + } + + // Formula with no title/desc vars (uses different var names) + f := &formula.Formula{ + Formula: "mol-release", + Description: "Release workflow for version bumps", + Version: 1, + Type: formula.TypeWorkflow, + Vars: map[string]*formula.VarDef{ + "version": {Description: "Version number", Required: true}, + }, + Steps: []*formula.Step{ + {ID: "bump", Title: "Bump to {{version}}", Type: "task"}, + {ID: "tag", Title: "Tag {{version}}", Type: "task", DependsOn: []string{"bump"}}, + }, + } + + subgraph, err := cookFormulaToSubgraphWithVars(f, f.Formula, f.Vars) + if err != nil { + t.Fatalf("Failed to cook formula: %v", err) + } + + vars := map[string]string{"version": "1.2.3"} + result, err := spawnMolecule(ctx, s, subgraph, vars, "", "test", false, "mol") + if err != nil { + t.Fatalf("spawnMolecule failed: %v", err) + } + + spawnedRoot, err := s.GetIssue(ctx, result.NewEpicID) + if err != nil { + t.Fatalf("Failed to get spawned root: %v", err) + } + + // Title should be formula name (no title var defined) + if spawnedRoot.Title != "mol-release" { + t.Errorf("Root title should be formula name 'mol-release', got: %q", spawnedRoot.Title) + } + + // Description should be formula description (no desc var defined) + if spawnedRoot.Description != "Release workflow for version bumps" { + t.Errorf("Root description should be formula desc, got: %q", spawnedRoot.Description) + } +}