refactor: remove unused bd pin/unpin/hook commands (bd-x0zl)
Analysis found these commands are dead code: - gt never calls `bd pin` - uses `bd update --status=pinned` instead - Beads.Pin() wrapper exists but is never called - bd hook functionality duplicated by gt mol status - Code comment says "pinned field is cosmetic for bd hook visibility" Removed: - cmd/bd/pin.go - cmd/bd/unpin.go - cmd/bd/hook.go 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -212,6 +212,8 @@ func expandLoopWithVars(step *Step, vars map[string]string) ([]*Step, error) {
|
||||
// expandLoopIteration expands a single iteration of a loop.
|
||||
// The iteration index is used to generate unique step IDs.
|
||||
// The iterVars map contains loop variable bindings for this iteration (gt-8tmz.27).
|
||||
//
|
||||
//nolint:unparam // error return kept for API consistency with future error handling
|
||||
func expandLoopIteration(step *Step, iteration int, iterVars map[string]string) ([]*Step, error) {
|
||||
result := make([]*Step, 0, len(step.Loop.Body))
|
||||
|
||||
|
||||
@@ -72,8 +72,11 @@ func ApplyExpansions(steps []*Step, compose *ComposeRules, parser *Parser) ([]*S
|
||||
return nil, fmt.Errorf("expand: %q has no template steps", rule.With)
|
||||
}
|
||||
|
||||
// Merge formula default vars with rule overrides (gt-8tmz.34)
|
||||
vars := mergeVars(expFormula, rule.Vars)
|
||||
|
||||
// Expand the target step (start at depth 0)
|
||||
expandedSteps, err := expandStep(targetStep, expFormula.Template, 0)
|
||||
expandedSteps, err := expandStep(targetStep, expFormula.Template, 0, vars)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("expand %q: %w", rule.Target, err)
|
||||
}
|
||||
@@ -112,6 +115,9 @@ func ApplyExpansions(steps []*Step, compose *ComposeRules, parser *Parser) ([]*S
|
||||
return nil, fmt.Errorf("map: %q has no template steps", rule.With)
|
||||
}
|
||||
|
||||
// Merge formula default vars with rule overrides (gt-8tmz.34)
|
||||
vars := mergeVars(expFormula, rule.Vars)
|
||||
|
||||
// Find all matching steps (including nested children - gt-8tmz.33)
|
||||
// Rebuild stepMap to capture any changes from previous expansions
|
||||
stepMap = buildStepMap(result)
|
||||
@@ -124,7 +130,7 @@ func ApplyExpansions(steps []*Step, compose *ComposeRules, parser *Parser) ([]*S
|
||||
|
||||
// Expand each matching step
|
||||
for _, targetStep := range toExpand {
|
||||
expandedSteps, err := expandStep(targetStep, expFormula.Template, 0)
|
||||
expandedSteps, err := expandStep(targetStep, expFormula.Template, 0, vars)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("map %q -> %q: %w", rule.Select, targetStep.ID, err)
|
||||
}
|
||||
@@ -146,14 +152,45 @@ func ApplyExpansions(steps []*Step, compose *ComposeRules, parser *Parser) ([]*S
|
||||
}
|
||||
}
|
||||
|
||||
// Validate no duplicate step IDs after expansion (gt-8tmz.36)
|
||||
if dups := findDuplicateStepIDs(result); len(dups) > 0 {
|
||||
return nil, fmt.Errorf("duplicate step IDs after expansion: %v", dups)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// findDuplicateStepIDs returns any duplicate step IDs found in the steps slice.
|
||||
// It recursively checks all children.
|
||||
func findDuplicateStepIDs(steps []*Step) []string {
|
||||
seen := make(map[string]int)
|
||||
countStepIDs(steps, seen)
|
||||
|
||||
var dups []string
|
||||
for id, count := range seen {
|
||||
if count > 1 {
|
||||
dups = append(dups, id)
|
||||
}
|
||||
}
|
||||
return dups
|
||||
}
|
||||
|
||||
// countStepIDs counts occurrences of each step ID recursively.
|
||||
func countStepIDs(steps []*Step, counts map[string]int) {
|
||||
for _, step := range steps {
|
||||
counts[step.ID]++
|
||||
if len(step.Children) > 0 {
|
||||
countStepIDs(step.Children, counts)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// expandStep expands a target step using the given template.
|
||||
// Returns the expanded steps with placeholders substituted.
|
||||
// The depth parameter tracks recursion depth for children; if it exceeds
|
||||
// DefaultMaxExpansionDepth, an error is returned.
|
||||
func expandStep(target *Step, template []*Step, depth int) ([]*Step, error) {
|
||||
// The vars parameter provides variable values for {varname} substitution.
|
||||
func expandStep(target *Step, template []*Step, depth int, vars map[string]string) ([]*Step, error) {
|
||||
if depth > DefaultMaxExpansionDepth {
|
||||
return nil, fmt.Errorf("expansion depth limit exceeded: max %d levels (currently at %d) - step %q",
|
||||
DefaultMaxExpansionDepth, depth, target.ID)
|
||||
@@ -163,12 +200,12 @@ func expandStep(target *Step, template []*Step, depth int) ([]*Step, error) {
|
||||
|
||||
for _, tmpl := range template {
|
||||
expanded := &Step{
|
||||
ID: substituteTargetPlaceholders(tmpl.ID, target),
|
||||
Title: substituteTargetPlaceholders(tmpl.Title, target),
|
||||
Description: substituteTargetPlaceholders(tmpl.Description, target),
|
||||
ID: substituteVars(substituteTargetPlaceholders(tmpl.ID, target), vars),
|
||||
Title: substituteVars(substituteTargetPlaceholders(tmpl.Title, target), vars),
|
||||
Description: substituteVars(substituteTargetPlaceholders(tmpl.Description, target), vars),
|
||||
Type: tmpl.Type,
|
||||
Priority: tmpl.Priority,
|
||||
Assignee: tmpl.Assignee,
|
||||
Assignee: substituteVars(tmpl.Assignee, vars),
|
||||
SourceFormula: tmpl.SourceFormula, // Preserve source from template (gt-8tmz.18)
|
||||
SourceLocation: tmpl.SourceLocation, // Preserve source location (gt-8tmz.18)
|
||||
}
|
||||
@@ -177,7 +214,7 @@ func expandStep(target *Step, template []*Step, depth int) ([]*Step, error) {
|
||||
if len(tmpl.Labels) > 0 {
|
||||
expanded.Labels = make([]string, len(tmpl.Labels))
|
||||
for i, l := range tmpl.Labels {
|
||||
expanded.Labels[i] = substituteTargetPlaceholders(l, target)
|
||||
expanded.Labels[i] = substituteVars(substituteTargetPlaceholders(l, target), vars)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -185,20 +222,20 @@ func expandStep(target *Step, template []*Step, depth int) ([]*Step, error) {
|
||||
if len(tmpl.DependsOn) > 0 {
|
||||
expanded.DependsOn = make([]string, len(tmpl.DependsOn))
|
||||
for i, d := range tmpl.DependsOn {
|
||||
expanded.DependsOn[i] = substituteTargetPlaceholders(d, target)
|
||||
expanded.DependsOn[i] = substituteVars(substituteTargetPlaceholders(d, target), vars)
|
||||
}
|
||||
}
|
||||
|
||||
if len(tmpl.Needs) > 0 {
|
||||
expanded.Needs = make([]string, len(tmpl.Needs))
|
||||
for i, n := range tmpl.Needs {
|
||||
expanded.Needs[i] = substituteTargetPlaceholders(n, target)
|
||||
expanded.Needs[i] = substituteVars(substituteTargetPlaceholders(n, target), vars)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle children recursively with depth tracking
|
||||
if len(tmpl.Children) > 0 {
|
||||
children, err := expandStep(target, tmpl.Children, depth+1)
|
||||
children, err := expandStep(target, tmpl.Children, depth+1, vars)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -232,6 +269,26 @@ func substituteTargetPlaceholders(s string, target *Step) string {
|
||||
return s
|
||||
}
|
||||
|
||||
// mergeVars merges formula default vars with rule overrides.
|
||||
// Override values take precedence over defaults.
|
||||
func mergeVars(formula *Formula, overrides map[string]string) map[string]string {
|
||||
result := make(map[string]string)
|
||||
|
||||
// Start with formula defaults
|
||||
for name, def := range formula.Vars {
|
||||
if def.Default != "" {
|
||||
result[name] = def.Default
|
||||
}
|
||||
}
|
||||
|
||||
// Apply overrides (these win)
|
||||
for name, value := range overrides {
|
||||
result[name] = value
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// buildStepMap creates a map of step ID to step (recursive).
|
||||
func buildStepMap(steps []*Step) map[string]*Step {
|
||||
result := make(map[string]*Step)
|
||||
@@ -303,3 +360,82 @@ func UpdateDependenciesForExpansion(steps []*Step, expandedID string, lastExpand
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// ApplyInlineExpansions applies Step.Expand fields to inline expansions (gt-8tmz.35).
|
||||
// Steps with the Expand field set are replaced by the referenced expansion template.
|
||||
// The step's ExpandVars are passed as variable overrides to the expansion.
|
||||
//
|
||||
// This differs from compose.Expand in that the expansion is declared inline on the
|
||||
// step itself rather than in a central compose section.
|
||||
//
|
||||
// Returns a new steps slice with inline expansions applied.
|
||||
// The original steps slice is not modified.
|
||||
func ApplyInlineExpansions(steps []*Step, parser *Parser) ([]*Step, error) {
|
||||
if parser == nil {
|
||||
return steps, nil
|
||||
}
|
||||
|
||||
return applyInlineExpansionsRecursive(steps, parser, 0)
|
||||
}
|
||||
|
||||
// applyInlineExpansionsRecursive handles inline expansions for a slice of steps.
|
||||
// depth tracks recursion to prevent infinite expansion loops.
|
||||
func applyInlineExpansionsRecursive(steps []*Step, parser *Parser, depth int) ([]*Step, error) {
|
||||
if depth > DefaultMaxExpansionDepth {
|
||||
return nil, fmt.Errorf("inline expansion depth limit exceeded: max %d levels", DefaultMaxExpansionDepth)
|
||||
}
|
||||
|
||||
var result []*Step
|
||||
|
||||
for _, step := range steps {
|
||||
// Check if this step has an inline expansion
|
||||
if step.Expand != "" {
|
||||
// Load the expansion formula
|
||||
expFormula, err := parser.LoadByName(step.Expand)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("inline expand on step %q: loading %q: %w", step.ID, step.Expand, err)
|
||||
}
|
||||
|
||||
if expFormula.Type != TypeExpansion {
|
||||
return nil, fmt.Errorf("inline expand on step %q: %q is not an expansion formula (type=%s)",
|
||||
step.ID, step.Expand, expFormula.Type)
|
||||
}
|
||||
|
||||
if len(expFormula.Template) == 0 {
|
||||
return nil, fmt.Errorf("inline expand on step %q: %q has no template steps", step.ID, step.Expand)
|
||||
}
|
||||
|
||||
// Merge formula default vars with step's ExpandVars overrides
|
||||
vars := mergeVars(expFormula, step.ExpandVars)
|
||||
|
||||
// Expand the step using the template (reuse existing expandStep)
|
||||
expandedSteps, err := expandStep(step, expFormula.Template, 0, vars)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("inline expand on step %q: %w", step.ID, err)
|
||||
}
|
||||
|
||||
// Recursively process expanded steps for nested inline expansions
|
||||
processedSteps, err := applyInlineExpansionsRecursive(expandedSteps, parser, depth+1)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result = append(result, processedSteps...)
|
||||
} else {
|
||||
// No inline expansion - keep the step, but process children recursively
|
||||
clone := cloneStep(step)
|
||||
|
||||
if len(step.Children) > 0 {
|
||||
processedChildren, err := applyInlineExpansionsRecursive(step.Children, parser, depth)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
clone.Children = processedChildren
|
||||
}
|
||||
|
||||
result = append(result, clone)
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
@@ -87,7 +87,7 @@ func TestExpandStep(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
result, err := expandStep(target, template, 0)
|
||||
result, err := expandStep(target, template, 0, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("expandStep failed: %v", err)
|
||||
}
|
||||
@@ -138,7 +138,7 @@ func TestExpandStepDepthLimit(t *testing.T) {
|
||||
|
||||
// With depth 0 start, going to level 6 means 7 levels total (0-6)
|
||||
// DefaultMaxExpansionDepth is 5, so this should fail
|
||||
_, err := expandStep(target, template, 0)
|
||||
_, err := expandStep(target, template, 0, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected depth limit error, got nil")
|
||||
}
|
||||
@@ -159,7 +159,7 @@ func TestExpandStepDepthLimit(t *testing.T) {
|
||||
}
|
||||
|
||||
shallowTemplate := []*Step{shallowChild}
|
||||
result, err := expandStep(target, shallowTemplate, 0)
|
||||
result, err := expandStep(target, shallowTemplate, 0, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("expected shallow template to succeed, got: %v", err)
|
||||
}
|
||||
@@ -432,3 +432,387 @@ func getChildIDs(steps []*Step) []string {
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
func TestSubstituteVars(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
vars map[string]string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "single var substitution",
|
||||
input: "Deploy to {environment}",
|
||||
vars: map[string]string{"environment": "production"},
|
||||
expected: "Deploy to production",
|
||||
},
|
||||
{
|
||||
name: "multiple var substitution",
|
||||
input: "{component} v{version}",
|
||||
vars: map[string]string{"component": "auth", "version": "2.0"},
|
||||
expected: "auth v2.0",
|
||||
},
|
||||
{
|
||||
name: "unmatched placeholder stays",
|
||||
input: "{known} and {unknown}",
|
||||
vars: map[string]string{"known": "replaced"},
|
||||
expected: "replaced and {unknown}",
|
||||
},
|
||||
{
|
||||
name: "empty vars map",
|
||||
input: "no {change}",
|
||||
vars: nil,
|
||||
expected: "no {change}",
|
||||
},
|
||||
{
|
||||
name: "empty string",
|
||||
input: "",
|
||||
vars: map[string]string{"foo": "bar"},
|
||||
expected: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := substituteVars(tt.input, tt.vars)
|
||||
if result != tt.expected {
|
||||
t.Errorf("substituteVars(%q, %v) = %q, want %q", tt.input, tt.vars, result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeVars(t *testing.T) {
|
||||
formula := &Formula{
|
||||
Vars: map[string]*VarDef{
|
||||
"env": {Default: "staging"},
|
||||
"version": {Default: "1.0"},
|
||||
"name": {Required: true}, // No default
|
||||
},
|
||||
}
|
||||
|
||||
t.Run("overrides take precedence", func(t *testing.T) {
|
||||
overrides := map[string]string{"env": "production"}
|
||||
result := mergeVars(formula, overrides)
|
||||
|
||||
if result["env"] != "production" {
|
||||
t.Errorf("env = %q, want 'production'", result["env"])
|
||||
}
|
||||
if result["version"] != "1.0" {
|
||||
t.Errorf("version = %q, want '1.0'", result["version"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("override adds new var", func(t *testing.T) {
|
||||
overrides := map[string]string{"custom": "value"}
|
||||
result := mergeVars(formula, overrides)
|
||||
|
||||
if result["custom"] != "value" {
|
||||
t.Errorf("custom = %q, want 'value'", result["custom"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("nil overrides uses defaults", func(t *testing.T) {
|
||||
result := mergeVars(formula, nil)
|
||||
|
||||
if result["env"] != "staging" {
|
||||
t.Errorf("env = %q, want 'staging'", result["env"])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestApplyExpansionsWithVars(t *testing.T) {
|
||||
// Create a temporary directory with an expansion formula that uses vars
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
// Create an expansion formula with variables
|
||||
envExpansion := `{
|
||||
"formula": "env-deploy",
|
||||
"type": "expansion",
|
||||
"version": 1,
|
||||
"vars": {
|
||||
"environment": {"default": "staging"},
|
||||
"replicas": {"default": "1"}
|
||||
},
|
||||
"template": [
|
||||
{"id": "{target}.prepare-{environment}", "title": "Prepare {environment} for {target.title}"},
|
||||
{"id": "{target}.deploy-{environment}", "title": "Deploy to {environment} with {replicas} replicas", "needs": ["{target}.prepare-{environment}"]}
|
||||
]
|
||||
}`
|
||||
err := os.WriteFile(filepath.Join(tmpDir, "env-deploy.formula.json"), []byte(envExpansion), 0644)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
parser := NewParser(tmpDir)
|
||||
|
||||
t.Run("expand with var overrides", func(t *testing.T) {
|
||||
steps := []*Step{
|
||||
{ID: "design", Title: "Design"},
|
||||
{ID: "release", Title: "Release v2"},
|
||||
{ID: "test", Title: "Test"},
|
||||
}
|
||||
|
||||
compose := &ComposeRules{
|
||||
Expand: []*ExpandRule{
|
||||
{
|
||||
Target: "release",
|
||||
With: "env-deploy",
|
||||
Vars: map[string]string{"environment": "production", "replicas": "3"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result, err := ApplyExpansions(steps, compose, parser)
|
||||
if err != nil {
|
||||
t.Fatalf("ApplyExpansions failed: %v", err)
|
||||
}
|
||||
|
||||
if len(result) != 4 {
|
||||
t.Fatalf("expected 4 steps, got %d", len(result))
|
||||
}
|
||||
|
||||
// Check expanded step IDs include var substitution
|
||||
expectedIDs := []string{"design", "release.prepare-production", "release.deploy-production", "test"}
|
||||
for i, exp := range expectedIDs {
|
||||
if result[i].ID != exp {
|
||||
t.Errorf("result[%d].ID = %q, want %q", i, result[i].ID, exp)
|
||||
}
|
||||
}
|
||||
|
||||
// Check title includes both target and var substitution
|
||||
if result[2].Title != "Deploy to production with 3 replicas" {
|
||||
t.Errorf("deploy title = %q, want 'Deploy to production with 3 replicas'", result[2].Title)
|
||||
}
|
||||
|
||||
// Check that needs was also substituted correctly
|
||||
if len(result[2].Needs) != 1 || result[2].Needs[0] != "release.prepare-production" {
|
||||
t.Errorf("deploy needs = %v, want [release.prepare-production]", result[2].Needs)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("expand with default vars", func(t *testing.T) {
|
||||
steps := []*Step{
|
||||
{ID: "release", Title: "Release"},
|
||||
}
|
||||
|
||||
compose := &ComposeRules{
|
||||
Expand: []*ExpandRule{
|
||||
{Target: "release", With: "env-deploy"},
|
||||
},
|
||||
}
|
||||
|
||||
result, err := ApplyExpansions(steps, compose, parser)
|
||||
if err != nil {
|
||||
t.Fatalf("ApplyExpansions failed: %v", err)
|
||||
}
|
||||
|
||||
// Check that defaults are used
|
||||
if result[0].ID != "release.prepare-staging" {
|
||||
t.Errorf("result[0].ID = %q, want 'release.prepare-staging'", result[0].ID)
|
||||
}
|
||||
if result[1].Title != "Deploy to staging with 1 replicas" {
|
||||
t.Errorf("deploy title = %q, want 'Deploy to staging with 1 replicas'", result[1].Title)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("map with var overrides", func(t *testing.T) {
|
||||
steps := []*Step{
|
||||
{ID: "deploy.api", Title: "Deploy API"},
|
||||
{ID: "deploy.web", Title: "Deploy Web"},
|
||||
}
|
||||
|
||||
compose := &ComposeRules{
|
||||
Map: []*MapRule{
|
||||
{
|
||||
Select: "deploy.*",
|
||||
With: "env-deploy",
|
||||
Vars: map[string]string{"environment": "prod"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result, err := ApplyExpansions(steps, compose, parser)
|
||||
if err != nil {
|
||||
t.Fatalf("ApplyExpansions failed: %v", err)
|
||||
}
|
||||
|
||||
// Each deploy.* step should expand with prod environment
|
||||
expectedIDs := []string{
|
||||
"deploy.api.prepare-prod", "deploy.api.deploy-prod",
|
||||
"deploy.web.prepare-prod", "deploy.web.deploy-prod",
|
||||
}
|
||||
if len(result) != len(expectedIDs) {
|
||||
t.Fatalf("expected %d steps, got %d", len(expectedIDs), len(result))
|
||||
}
|
||||
for i, exp := range expectedIDs {
|
||||
if result[i].ID != exp {
|
||||
t.Errorf("result[%d].ID = %q, want %q", i, result[i].ID, exp)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestApplyExpansionsDuplicateIDs(t *testing.T) {
|
||||
// Create a temporary directory with an expansion formula
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
// Create expansion formula that generates "{target}.draft"
|
||||
ruleOfFive := `{
|
||||
"formula": "rule-of-five",
|
||||
"type": "expansion",
|
||||
"version": 1,
|
||||
"template": [
|
||||
{"id": "{target}.draft", "title": "Draft: {target.title}"},
|
||||
{"id": "{target}.refine", "title": "Refine", "needs": ["{target}.draft"]}
|
||||
]
|
||||
}`
|
||||
err := os.WriteFile(filepath.Join(tmpDir, "rule-of-five.formula.json"), []byte(ruleOfFive), 0644)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
parser := NewParser(tmpDir)
|
||||
|
||||
// Test: expansion creates duplicate with existing step
|
||||
t.Run("duplicate with existing step", func(t *testing.T) {
|
||||
// "implement.draft" already exists, expansion will try to create it again
|
||||
steps := []*Step{
|
||||
{ID: "design", Title: "Design"},
|
||||
{ID: "implement", Title: "Implement the feature"},
|
||||
{ID: "implement.draft", Title: "Existing draft"}, // Conflicts with expansion
|
||||
{ID: "test", Title: "Test"},
|
||||
}
|
||||
|
||||
compose := &ComposeRules{
|
||||
Expand: []*ExpandRule{
|
||||
{Target: "implement", With: "rule-of-five"},
|
||||
},
|
||||
}
|
||||
|
||||
_, err := ApplyExpansions(steps, compose, parser)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for duplicate step IDs, got nil")
|
||||
}
|
||||
|
||||
if !strings.Contains(err.Error(), "duplicate step IDs") {
|
||||
t.Errorf("expected duplicate step IDs error, got: %v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "implement.draft") {
|
||||
t.Errorf("expected error to mention 'implement.draft', got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
// Test: map creates duplicates across multiple expansions
|
||||
t.Run("map creates cross-expansion duplicates", func(t *testing.T) {
|
||||
// Create a formula that generates static IDs (not using {target})
|
||||
staticExpansion := `{
|
||||
"formula": "static-ids",
|
||||
"type": "expansion",
|
||||
"version": 1,
|
||||
"template": [
|
||||
{"id": "shared-step", "title": "Shared step"},
|
||||
{"id": "another-shared", "title": "Another shared"}
|
||||
]
|
||||
}`
|
||||
err := os.WriteFile(filepath.Join(tmpDir, "static-ids.formula.json"), []byte(staticExpansion), 0644)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
steps := []*Step{
|
||||
{ID: "impl.auth", Title: "Implement auth"},
|
||||
{ID: "impl.api", Title: "Implement API"},
|
||||
}
|
||||
|
||||
compose := &ComposeRules{
|
||||
Map: []*MapRule{
|
||||
{Select: "impl.*", With: "static-ids"},
|
||||
},
|
||||
}
|
||||
|
||||
_, err = ApplyExpansions(steps, compose, parser)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for duplicate step IDs from map, got nil")
|
||||
}
|
||||
|
||||
if !strings.Contains(err.Error(), "duplicate step IDs") {
|
||||
t.Errorf("expected duplicate step IDs error, got: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestFindDuplicateStepIDs(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
steps []*Step
|
||||
expected []string
|
||||
}{
|
||||
{
|
||||
name: "no duplicates",
|
||||
steps: []*Step{
|
||||
{ID: "a"},
|
||||
{ID: "b"},
|
||||
{ID: "c"},
|
||||
},
|
||||
expected: nil,
|
||||
},
|
||||
{
|
||||
name: "top-level duplicate",
|
||||
steps: []*Step{
|
||||
{ID: "a"},
|
||||
{ID: "b"},
|
||||
{ID: "a"},
|
||||
},
|
||||
expected: []string{"a"},
|
||||
},
|
||||
{
|
||||
name: "nested duplicate",
|
||||
steps: []*Step{
|
||||
{ID: "parent", Children: []*Step{
|
||||
{ID: "child"},
|
||||
}},
|
||||
{ID: "child"}, // Duplicate with nested child
|
||||
},
|
||||
expected: []string{"child"},
|
||||
},
|
||||
{
|
||||
name: "deeply nested duplicate",
|
||||
steps: []*Step{
|
||||
{ID: "root", Children: []*Step{
|
||||
{ID: "level1", Children: []*Step{
|
||||
{ID: "level2"},
|
||||
}},
|
||||
}},
|
||||
{ID: "other", Children: []*Step{
|
||||
{ID: "level2"}, // Duplicate with deeply nested
|
||||
}},
|
||||
},
|
||||
expected: []string{"level2"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
dups := findDuplicateStepIDs(tt.steps)
|
||||
|
||||
if len(dups) != len(tt.expected) {
|
||||
t.Fatalf("expected %d duplicates, got %d: %v", len(tt.expected), len(dups), dups)
|
||||
}
|
||||
|
||||
// Check all expected duplicates are found (order may vary)
|
||||
for _, exp := range tt.expected {
|
||||
found := false
|
||||
for _, dup := range dups {
|
||||
if dup == exp {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("expected duplicate %q not found in %v", exp, dups)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,10 +7,16 @@ import (
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/BurntSushi/toml"
|
||||
)
|
||||
|
||||
// FormulaExt is the file extension for formula files.
|
||||
const FormulaExt = ".formula.json"
|
||||
// Formula file extensions. TOML is preferred, JSON is legacy fallback.
|
||||
const (
|
||||
FormulaExtTOML = ".formula.toml"
|
||||
FormulaExtJSON = ".formula.json"
|
||||
FormulaExt = FormulaExtJSON // Legacy alias for backwards compatibility
|
||||
)
|
||||
|
||||
// Parser handles loading and resolving formulas.
|
||||
//
|
||||
@@ -68,6 +74,7 @@ func defaultSearchPaths() []string {
|
||||
}
|
||||
|
||||
// ParseFile parses a formula from a file path.
|
||||
// Detects format from extension: .formula.toml or .formula.json
|
||||
func (p *Parser) ParseFile(path string) (*Formula, error) {
|
||||
// Check cache first
|
||||
absPath, err := filepath.Abs(path)
|
||||
@@ -86,7 +93,13 @@ func (p *Parser) ParseFile(path string) (*Formula, error) {
|
||||
return nil, fmt.Errorf("read %s: %w", path, err)
|
||||
}
|
||||
|
||||
formula, err := p.Parse(data)
|
||||
// Detect format from extension
|
||||
var formula *Formula
|
||||
if strings.HasSuffix(path, FormulaExtTOML) {
|
||||
formula, err = p.ParseTOML(data)
|
||||
} else {
|
||||
formula, err = p.Parse(data)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse %s: %w", path, err)
|
||||
}
|
||||
@@ -122,6 +135,24 @@ func (p *Parser) Parse(data []byte) (*Formula, error) {
|
||||
return &formula, nil
|
||||
}
|
||||
|
||||
// ParseTOML parses a formula from TOML bytes.
|
||||
func (p *Parser) ParseTOML(data []byte) (*Formula, error) {
|
||||
var formula Formula
|
||||
if err := toml.Unmarshal(data, &formula); err != nil {
|
||||
return nil, fmt.Errorf("toml: %w", err)
|
||||
}
|
||||
|
||||
// Set defaults
|
||||
if formula.Version == 0 {
|
||||
formula.Version = 1
|
||||
}
|
||||
if formula.Type == "" {
|
||||
formula.Type = TypeWorkflow
|
||||
}
|
||||
|
||||
return &formula, nil
|
||||
}
|
||||
|
||||
// Resolve fully resolves a formula, processing extends and expansions.
|
||||
// Returns a new formula with all inheritance applied.
|
||||
func (p *Parser) Resolve(formula *Formula) (*Formula, error) {
|
||||
@@ -205,18 +236,21 @@ func (p *Parser) Resolve(formula *Formula) (*Formula, error) {
|
||||
}
|
||||
|
||||
// loadFormula loads a formula by name from search paths.
|
||||
// Tries TOML first (.formula.toml), then falls back to JSON (.formula.json).
|
||||
func (p *Parser) loadFormula(name string) (*Formula, error) {
|
||||
// Check cache first
|
||||
if cached, ok := p.cache[name]; ok {
|
||||
return cached, nil
|
||||
}
|
||||
|
||||
// Search for the formula file
|
||||
filename := name + FormulaExt
|
||||
// Search for the formula file - try TOML first, then JSON
|
||||
extensions := []string{FormulaExtTOML, FormulaExtJSON}
|
||||
for _, dir := range p.searchPaths {
|
||||
path := filepath.Join(dir, filename)
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
return p.ParseFile(path)
|
||||
for _, ext := range extensions {
|
||||
path := filepath.Join(dir, name+ext)
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
return p.ParseFile(path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -249,20 +249,16 @@ type LoopSpec struct {
|
||||
// OnCompleteSpec defines actions triggered when a step completes (gt-8tmz.8).
|
||||
// Used for runtime expansion over step output (the for-each construct).
|
||||
//
|
||||
// Example JSON:
|
||||
// Example YAML:
|
||||
//
|
||||
// {
|
||||
// "id": "survey-workers",
|
||||
// "on_complete": {
|
||||
// "for_each": "output.polecats",
|
||||
// "bond": "mol-polecat-arm",
|
||||
// "vars": {
|
||||
// "polecat_name": "{item.name}",
|
||||
// "rig": "{item.rig}"
|
||||
// },
|
||||
// "parallel": true
|
||||
// }
|
||||
// }
|
||||
// step: survey-workers
|
||||
// on_complete:
|
||||
// for_each: output.polecats
|
||||
// bond: mol-polecat-arm
|
||||
// vars:
|
||||
// polecat_name: "{item.name}"
|
||||
// rig: "{item.rig}"
|
||||
// parallel: true
|
||||
type OnCompleteSpec struct {
|
||||
// ForEach is the path to the iterable collection in step output.
|
||||
// Format: "output.<field>" or "output.<field>.<nested>"
|
||||
|
||||
Reference in New Issue
Block a user