How Token Reference Resolution Works in DESIGN.md Components
Token reference resolution in DESIGN.md uses a three-phase pipeline within the ModelHandler class to transform {path.to.token} syntax into concrete design values, enforcing cycle detection and configurable depth limits.
The google-labs-code/design.md CLI linter resolves token references through a sophisticated symbol table mechanism. This process converts curly-brace syntax like {colors.primary} into resolved color objects, dimensions, or typography tokens. Understanding this resolution pipeline is essential for debugging complex design systems and avoiding circular dependency errors.
The Three-Phase Resolution Pipeline
Token reference resolution operates sequentially across three distinct phases, all orchestrated by the ModelHandler class in packages/cli/src/linter/model/handler.ts.
Phase 1: Building the Symbol Table
The resolver first constructs a symbol table by iterating over top-level token groups including colors, typography, rounded, and spacing. During this phase (lines 54–124 in handler.ts), the handler distinguishes between primitive values and reference strings.
- Primitive values (e.g.,
#1A1C1E,16px) are parsed using type-specific functions likeparseColororparseDimensionand stored as resolved objects. - Reference strings (e.g.,
{colors.primary}) are inserted into the symbol table as raw strings for later processing.
This initial pass ensures that all potential target tokens exist in the lookup table before resolution begins.
Phase 2: Resolving Chained References
Once the symbol table is populated, the handler invokes resolveReference(symbolTable, path, visited, depth) for every entry still containing a reference string (lines 73–96 in handler.ts). This recursive function implements the core resolution logic:
- Depth checking: The function immediately returns
nullifdepthexceedsMAX_REFERENCE_DEPTH(default 10). - Cycle detection: A
visitedSet tracks all paths encountered during the current recursion chain. If a path appears twice, the function detects a circular reference and aborts. - Path lookup: The dot-separated path (e.g.,
colors.primary) is queried against the symbol table. - Recursive descent: If the looked-up value is another reference string, the function recurses with the new path.
- Resolution: Upon reaching a primitive value, the function returns the resolved object (e.g.,
ResolvedColor,ResolvedDimension).
Successfully resolved entries update both the symbol table and their respective token maps (lines 128–176 in handler.ts), ensuring subsequent lookups return concrete values rather than reference strings.
Phase 3: Component Property Resolution
Component definitions receive special handling during the final phase (lines 78–112 in handler.ts). For each property in a component definition:
- Numbers and booleans pass through unchanged.
- Reference strings trigger a call to
resolveReferenceagainst the fully populated symbol table. - Successful resolutions replace the raw reference with the resolved token object.
- Failed resolutions are collected in the
unresolvedRefsarray for error reporting.
This phase ensures that components receive fully materialized values ready for downstream generators like Tailwind configuration exporters.
Configuration Limits and Safety Guards
The resolution engine enforces two critical safety limits defined in packages/cli/src/linter/spec-config.ts (lines 24–27) and loaded from spec-config.yaml:
MAX_REFERENCE_DEPTH(default 10): Maximum hops allowed in a reference chain. Prevents runaway recursion when tokens reference deeply nested aliases.MAX_TOKEN_NESTING_DEPTH(default 20): Maximum depth of nested objects in the YAML tree. Enforced byforEachLeafto maintain sanity in token structures.
When either limit is exceeded, the resolver returns null and generates a finding with severity error.
Practical Examples
Resolving Simple Color References
Consider a DESIGN.md file containing:
colors:
primary: "#1A1C1E"
accent: "{colors.primary}"
The resolution process executes as follows:
// Phase 1 – Build symbol table
symbolTable.set('colors.primary', new ResolvedColor('#1A1C1E'));
symbolTable.set('colors.accent', '{colors.primary}');
// Phase 2 – Resolve chained references
const resolved = resolveReference(symbolTable, 'colors.primary', new Set(), 0);
// Returns ResolvedColor object
symbolTable.set('colors.accent', resolved);
Both primary and accent now point to identical ResolvedColor instances.
Detecting Circular References
Circular dependencies trigger explicit error detection:
colors:
a: "{colors.b}"
b: "{colors.c}"
c: "{colors.a}"
The resolver walks the chain until it detects colors.a in the visited set, generating a finding:
{
"severity": "error",
"path": "colors.a",
"message": "Reference could not be resolved (missing or circular)."
}
Resolving Component Properties
Component definitions reference tokens using the same syntax:
components:
card:
backgroundColor: "{colors.primary}"
padding: "{spacing.md}"
During component handling:
if (isTokenReference(rawValue)) {
const refPath = rawValue.slice(1, -1); // Strips braces
const resolved = resolveReference(symbolTable, refPath, new Set(), 0);
if (resolved !== null) {
properties.set(propName, resolved);
} else {
unresolvedRefs.push(rawValue);
}
}
The resulting ComponentDef contains fully resolved objects rather than reference strings.
Summary
- Token reference resolution operates through a three-phase pipeline: symbol table construction, chained reference resolution, and component property materialization.
- The
resolveReferencefunction inpackages/cli/src/linter/model/handler.tsimplements recursive lookup with cycle detection via avisitedSet and depth limiting viaMAX_REFERENCE_DEPTH. - Configuration limits defined in
spec-config.tsprevent infinite recursion and excessively nested token structures. - Unresolved references, circular dependencies, and depth limit violations are captured as findings in the model result for CLI reporting.
Frequently Asked Questions
What happens when a token reference cannot be resolved?
The resolveReference function returns null and the handler records the failure in the findings array with an error severity. For component properties, the raw reference string is added to unresolvedRefs and reported after processing completes.
How does the resolver prevent infinite loops in circular references?
The resolver tracks visited paths using a Set passed through recursive calls. If a reference path appears in the Set during resolution, the function immediately returns null and the model generates a circular reference error. This detection works alongside the MAX_REFERENCE_DEPTH limit to prevent stack overflow.
Can I reference composite tokens inside component properties?
According to the specification in docs/spec.md, composite values may only be referenced within the components section. The resolver expects reference paths to point at primitive tokens (colors, dimensions, typography) when resolving component properties, though the symbol table itself can contain nested structures during intermediate phases.
Where are the depth limits configured?
Depth limits are defined in spec-config.yaml and exposed as constants in packages/cli/src/linter/spec-config.ts (lines 24–27). The MAX_REFERENCE_DEPTH constant controls reference chain length, while MAX_TOKEN_NESTING_DEPTH controls YAML object nesting. Both values are loaded via getSpecConfig() at the start of the linting process.
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:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →