gastownhall/beads · error
circular extends detected: %s
Error message
circular extends detected: %s
What it means
Resolve() detects that the formula currently being resolved is already in the active inheritance chain (p.resolvingSet), meaning formula A extends B which extends A (directly or transitively). The library refuses to recurse infinitely and reports the full cycle chain joined with ' -> ' so the offending loop is visible. Formula inheritance must form a DAG, not a cycle.
Source
Thrown at internal/formula/parser.go:205
// 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) {
// Check for cycles
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.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 {
if err := formula.Validate(); err != nil {
return nil, err
}
return formula, nil
}
// Build merged formula from parents
merged := &Formula{View on GitHub (pinned to 71377f2769)
Solutions
- Read the chain in the error message (A -> B -> A) and remove the last extends link that closes the loop in one of the named formulas.
- If both formulas genuinely need each other's vars, extract the shared vars into a third base formula and have both extend that.
- If a rename caused the loop, grep the formulas directory for the old name and fix stale extends entries.
Example fix
# before: staging.md extends: [deploy] # and deploy.md extends: [staging] # after: deploy.md holds the base vars; # staging.md extends: [deploy] # deploy.md no longer extends staging
Defensive patterns
Strategy: validation
Validate before calling
// Build a parent map and detect cycles before calling Resolve.
func hasCycle(name string, extends map[string][]string, seen map[string]bool, chain []string) bool {
if seen[name] {
fmt.Fprintf(os.Stderr, "cycle: %s -> %s\n", strings.Join(chain, " -> "), name)
return true
}
seen[name] = true
for _, parent := range extends[name] {
if hasCycle(parent, extends, seen, append(chain, name)) {
return true
}
}
delete(seen, name)
return false
} Try / catch
if _, err := p.Resolve(f); err != nil {
if strings.Contains(err.Error(), "circular extends detected") {
// surface the chain from the message
}
} Prevention
- Keep inheritance depth shallow and directed: base formulas should never extend their children.
- After renaming a formula, grep all extends entries for the old name.
- Add a CI check that runs Resolve over every formula to catch cycles at commit time.
When it happens
Trigger: Calling Parser.Resolve on a formula whose Extends chain loops back to an ancestor, e.g. formula 'deploy' has 'extends: [staging]' and 'staging' has 'extends: [deploy]'. Also triggered when a formula extends itself ('extends: [deploy]' inside deploy.md).
Common situations: Copy-pasting an existing formula and forgetting to update its extends list; renaming formulas so an old self-reference now points back; two teams each making the other formula their parent during a refactor; merging formula directories where both files claim the other as base.
Related errors
- extends %s: %w
- resolve parent %s: %w
- resolving formula: %w
- resolving formula %q: %w
- formula %q not accessible: %w
AI-assisted analysis of gastownhall/beads@71377f2769 (2026-08-30).
Data as JSON: /api/errors/9e01e2857a198a7c.
Report an issue: GitHub.