From 127a36dd5ff2dc6e7f13d3e86baeb8e50f62367b Mon Sep 17 00:00:00 2001 From: Steve Yegge Date: Thu, 25 Dec 2025 02:33:22 -0800 Subject: [PATCH] Formula cycle detection: show full extends chain in error message (gt-8tmz.15) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- internal/formula/parser.go | 28 +++++++++++++++++++--------- internal/formula/parser_test.go | 13 +++++++++++++ 2 files changed, 32 insertions(+), 9 deletions(-) diff --git a/internal/formula/parser.go b/internal/formula/parser.go index 9e8b0155..3cd17f46 100644 --- a/internal/formula/parser.go +++ b/internal/formula/parser.go @@ -24,8 +24,11 @@ type Parser struct { // cache stores loaded formulas by name. cache map[string]*Formula - // resolving tracks formulas currently being resolved (for cycle detection). - resolving map[string]bool + // resolvingSet tracks formulas currently being resolved (for cycle detection). + resolvingSet map[string]bool + + // resolvingChain tracks the order of formulas being resolved (for error messages). + resolvingChain []string } // NewParser creates a new formula parser. @@ -37,9 +40,10 @@ func NewParser(searchPaths ...string) *Parser { paths = defaultSearchPaths() } return &Parser{ - searchPaths: paths, - cache: make(map[string]*Formula), - resolving: make(map[string]bool), + searchPaths: paths, + cache: make(map[string]*Formula), + 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. func (p *Parser) Resolve(formula *Formula) (*Formula, error) { // Check for cycles - if p.resolving[formula.Formula] { - return nil, fmt.Errorf("circular extends detected: %s", formula.Formula) + if p.resolvingSet[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 - defer delete(p.resolving, formula.Formula) + p.resolvingSet[formula.Formula] = true + 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 len(formula.Extends) == 0 { diff --git a/internal/formula/parser_test.go b/internal/formula/parser_test.go index 773b62e1..1e411344 100644 --- a/internal/formula/parser_test.go +++ b/internal/formula/parser_test.go @@ -3,6 +3,7 @@ package formula import ( "os" "path/filepath" + "strings" "testing" ) @@ -533,6 +534,18 @@ func TestResolve_CircularExtends(t *testing.T) { if err == nil { 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) {