How to Handle Circular Token References in DESIGN.md: Detection and Resolution

Circular token references in DESIGN.md are treated as invalid by the linter, which reports them as broken-ref errors; you must break the cycle by ensuring every reference chain terminates at a primitive value.

Circular token references occur when a design token points back to itself through a chain of dependencies, preventing the system from computing concrete values. In the google-labs-code/design.md repository, the linter treats these references as errors and halts downstream exports until resolved. Understanding how to detect and eliminate these cycles is essential for maintaining valid design token systems.

What Are Circular Token References?

A circular token reference happens when a token resolves (directly or indirectly) to itself, creating an infinite resolution loop. According to the DESIGN.md specification, these references are invalid because they prevent the computation of concrete values needed for downstream exports like Tailwind CSS or JSON Design Token Group (DTG) formats.

When the resolver encounters a loop, it cannot determine a final primitive value (such as a hex color or pixel dimension), causing the export pipeline to stall. The linter explicitly forbids this pattern to ensure token integrity across all output targets.

How the Linter Detects Circular References

The Token Reference Syntax

References in DESIGN.md use the syntax {path.to.token} inside YAML frontmatter. As defined in docs/spec.md, every reference must resolve to a primitive value—a literal string, number, or color—not to another reference.

The Detection Algorithm

The linter’s detection logic resides in two key locations:

  • packages/cli/src/linter/model/handler.ts: The resolveReference function executes the resolution logic. When it encounters a circular chain, it returns null to prevent infinite recursion and signals that the reference is unresolvable.

  • packages/cli/src/linter/linter/rules/broken-ref.ts: This rule implements the validation logic. It walks the token tree and maintains a stack of visited tokens. If a reference chain revisits a token already on the stack, the resolver records a circular-ref error (displayed as "Broken/circular references").

The test suite in packages/cli/src/linter/linter/rules/broken-ref.test.ts demonstrates specific detection scenarios for circular reference chains.

Resolving Circular References

Step 1: Identify the Cycle

Run the linter to surface the exact location of the circular dependency:

npx @google/design.md lint path/to/DESIGN.md

The CLI output pinpoints the problematic chain:


error: circular reference detected at components.button-primary.backgroundColor -> colors.primary-60 -> components.button-primary.backgroundColor

Step 2: Break the Cycle

Restructure your tokens so that every reference points to a concrete primitive. Two effective strategies include:

  • Extract primitives: Move concrete values out of component sections into dedicated token groups like colors or spacing.
  • Duplicate values: If the same primitive is needed in multiple places, define it literally in the base token and reference it from components, never referencing back to a component token.

Step 3: Validate the Fix

Re-run the linter until no circular reference errors remain:

npx @google/design.md lint examples/totality-festival/DESIGN.md

A successful validation prints:


✔︎  design.md passes all linter checks

Code Examples

Problematic Definition

The following YAML creates a circular dependency between a component token and a color token:

---
components:
  badge:
    backgroundColor: "{colors.primary}"
colors:
  primary: "{components.badge.backgroundColor}"
---

Here, components.badge.backgroundColor references colors.primary, which immediately references back to components.badge.backgroundColor. The resolver detects this loop and reports a circular-ref error.

Corrected Definition

Break the cycle by assigning a primitive value to the base token:

---
components:
  badge:
    backgroundColor: "{colors.primary}"
colors:
  primary: "#2A9D8F"
---

Now colors.primary holds a literal color value, allowing the reference chain to terminate successfully.

Shared Primitive Pattern

Multiple components can safely reference the same primitive without creating cycles:

---
colors:
  primary: "#2A9D8F"
components:
  badge:
    backgroundColor: "{colors.primary}"
  button:
    backgroundColor: "{colors.primary}"
---

Both components consume the primitive value from colors.primary, maintaining a one-way dependency graph.

CLI Usage

Use the following command to lint your DESIGN.md files and detect circular references:


# Lint a specific file

npx @google/design.md lint examples/totality-festival/DESIGN.md

# Expected output for valid files

✔︎  design.md passes all linter checks

If a circular reference exists, the CLI outputs a concise error indicating the token path:


✖  broken-ref: circular reference detected at components.button-primary.backgroundColor -> colors.primary-60 -> components.button-primary.backgroundColor

Summary

  • Circular references are invalid according to the DESIGN.md specification and block downstream exports.
  • Detection occurs in packages/cli/src/linter/linter/rules/broken-ref.ts using a stack-based traversal that identifies revisiting tokens.
  • Resolution fails safely when resolveReference in packages/cli/src/linter/model/handler.ts returns null for circular chains.
  • Fix cycles by ensuring all reference chains terminate at primitive values in dedicated token groups.
  • Validation requires running npx @google/design.md lint until no circular-ref errors remain.

Frequently Asked Questions

What error code does the linter use for circular references?

The linter emits a broken-ref error with the specific classification circular-ref, displayed in the CLI as "Broken/circular references." This error appears alongside the file location and the exact token path that forms the cycle.

How does the resolver prevent infinite loops during token resolution?

The resolveReference function in packages/cli/src/linter/model/handler.ts tracks the resolution stack. When it detects that a reference chain has returned to a token already being processed, it immediately returns null and halts further resolution, preventing infinite recursion.

Can I reference a token that references another token?

Yes, chained references are supported as long as they eventually terminate at a primitive value. The DESIGN.md specification only prohibits cycles where a reference chain loops back to itself. You can safely reference colors.primary from components.button, even if colors.primary itself references colors.base, provided colors.base contains a concrete value.

Where is the broken-ref rule implemented?

The rule implementation resides in packages/cli/src/linter/linter/rules/broken-ref.ts. This file contains the logic for detecting both broken references (non-existent tokens) and circular references (tokens that reference themselves). The corresponding test suite in packages/cli/src/linter/linter/rules/broken-ref.test.ts provides examples of valid and invalid reference patterns.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →