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

  1. 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.
  2. If both formulas genuinely need each other's vars, extract the shared vars into a third base formula and have both extend that.
  3. 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

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


AI-assisted analysis of gastownhall/beads@71377f2769 (2026-08-30). Data as JSON: /api/errors/9e01e2857a198a7c. Report an issue: GitHub.