Formula cycle detection: show full extends chain in error message (gt-8tmz.15)

When bd cook encounters a circular extends chain (A extends B extends A),
the error message now shows the full chain: "cycle-a -> cycle-b -> cycle-a"
instead of just "circular extends detected: cycle-a".

This makes debugging circular dependencies much easier by showing exactly
which formulas are involved in the cycle.

🤖 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 02:33:22 -08:00
parent 84c124fc3d
commit 127a36dd5f
2 changed files with 32 additions and 9 deletions

View File

@@ -24,8 +24,11 @@ type Parser struct {
// cache stores loaded formulas by name. // cache stores loaded formulas by name.
cache map[string]*Formula cache map[string]*Formula
// resolving tracks formulas currently being resolved (for cycle detection). // resolvingSet tracks formulas currently being resolved (for cycle detection).
resolving map[string]bool resolvingSet map[string]bool
// resolvingChain tracks the order of formulas being resolved (for error messages).
resolvingChain []string
} }
// NewParser creates a new formula parser. // NewParser creates a new formula parser.
@@ -37,9 +40,10 @@ func NewParser(searchPaths ...string) *Parser {
paths = defaultSearchPaths() paths = defaultSearchPaths()
} }
return &Parser{ return &Parser{
searchPaths: paths, searchPaths: paths,
cache: make(map[string]*Formula), cache: make(map[string]*Formula),
resolving: make(map[string]bool), resolvingSet: make(map[string]bool),
resolvingChain: nil,
} }
} }
@@ -118,11 +122,17 @@ func (p *Parser) Parse(data []byte) (*Formula, error) {
// Returns a new formula with all inheritance applied. // Returns a new formula with all inheritance applied.
func (p *Parser) Resolve(formula *Formula) (*Formula, error) { func (p *Parser) Resolve(formula *Formula) (*Formula, error) {
// Check for cycles // Check for cycles
if p.resolving[formula.Formula] { if p.resolvingSet[formula.Formula] {
return nil, fmt.Errorf("circular extends detected: %s", formula.Formula) // Build the cycle chain for a clear error message
chain := append(p.resolvingChain, formula.Formula)
return nil, fmt.Errorf("circular extends detected: %s", strings.Join(chain, " -> "))
} }
p.resolving[formula.Formula] = true p.resolvingSet[formula.Formula] = true
defer delete(p.resolving, formula.Formula) p.resolvingChain = append(p.resolvingChain, formula.Formula)
defer func() {
delete(p.resolvingSet, formula.Formula)
p.resolvingChain = p.resolvingChain[:len(p.resolvingChain)-1]
}()
// If no extends, just validate and return // If no extends, just validate and return
if len(formula.Extends) == 0 { if len(formula.Extends) == 0 {

View File

@@ -3,6 +3,7 @@ package formula
import ( import (
"os" "os"
"path/filepath" "path/filepath"
"strings"
"testing" "testing"
) )
@@ -533,6 +534,18 @@ func TestResolve_CircularExtends(t *testing.T) {
if err == nil { if err == nil {
t.Error("Resolve should fail for circular extends") t.Error("Resolve should fail for circular extends")
} }
// Verify the error message shows the full cycle chain
errStr := err.Error()
if !strings.Contains(errStr, "cycle-a") {
t.Errorf("error should mention cycle-a: %v", err)
}
if !strings.Contains(errStr, "cycle-b") {
t.Errorf("error should mention cycle-b: %v", err)
}
if !strings.Contains(errStr, "->") {
t.Errorf("error should show cycle chain with '->': %v", err)
}
} }
func TestGetStepByID(t *testing.T) { func TestGetStepByID(t *testing.T) {