Automated Token Audit Scripts for Design Systems
Part of Token Scaling, Validation & CI Pipelines. This page covers the programmatic quality gates that detect structural anomalies, orphaned references, and naming convention violations before they reach production CSS.
Automated token audit scripts serve as the foundational checkpoint within a scaling design system. By scanning token repositories at the AST level, engineering teams catch dangling aliases, deprecated variables still in use, and cross-platform inconsistencies before they propagate to downstream consumers. These scripts operate independently of framework-specific renderers, ensuring design system integrity is maintained across web, mobile, and native platforms simultaneously.
Problem Framing Link to this section
Design systems degrade silently. A --ds-color-feedback-error alias that pointed to a deprecated primitive three sprints ago continues to compile without error until someone screenshots a production screen and notices the wrong red. Audit scripts catch this class of failure at commit time, not in QA.
Three failure modes motivate this investment: (1) orphaned tokens — defined in the source dictionary but never referenced in any stylesheet or component; (2) dangling aliases — tokens whose {reference.path} points to a deleted or renamed target; (3) naming drift — tokens that violate the agreed taxonomy after a rushed redesign sprint. None of these failures surface as build errors without explicit audit tooling.
Three-Tier Architectural Trade-offs Link to this section
- Regex vs. AST parsing: Regex offers low overhead for flat token files but fails to resolve nested namespaces and circular aliases. AST-based traversal guarantees structural fidelity at the cost of higher initial parse latency — worth the trade-off for any token tree beyond a few hundred entries.
- Full scan vs. incremental diff: Full scans guarantee 100% coverage but scale poorly with monorepo token growth. Incremental diffing reduces CI time by roughly 70% but requires robust baseline caching and strict branch protection rules.
- Framework-coupled vs. framework-agnostic: Decoupling audit logic from React/Vue/Angular build steps validates tokens at the source of truth. Framework-coupled checks catch downstream consumption errors but miss upstream schema violations that occur before any framework processes the tokens.
- Synchronous blocking vs. async reporting: Schema violations must block PR merges immediately. CSS architecture checks can run in parallel with build jobs and only fail the pipeline if critical custom property mappings break — keeping developer feedback loops tight.
- SARIF vs. custom JSON output: SARIF integrates natively with GitHub Advanced Security and gives inline PR annotations. Custom JSON is easier to pipe into Slack or Datadog but requires bespoke tooling to surface violations inline in the review UI.
Build Pipeline Workflow Steps Link to this section
- Parse source dictionaries. Use
json5or standardJSON.parseto ingest raw token exports from Style Dictionary, Tokens Studio, or a bespoke YAML pipeline. - Construct a hierarchical AST. Map parent-child relationships and preserve metadata —
$type,$description,$deprecated— as node attributes. - Resolve alias references. Implement a directed acyclic graph (DAG) resolver to flatten
{alias.path}chains. Detect cycles by enforcing a recursion depth limit before throwing. - Build a usage manifest. Scan the consuming codebase — TypeScript components, CSS files, native templates — for every token reference. This manifest is the ground truth for orphan detection, an area explored in depth in detecting orphaned and unused tokens in CI.
- Run structural validators. Compare the resolved AST against the JSON schema contract, enforce naming conventions (
kebab-casefor CSS,camelCasefor JS exports), and flag any$deprecatedtokens still referenced. - Generate a machine-readable report. Output SARIF for GitHub, JSON for downstream pipeline consumption, or both. Include token path, violation type, severity, and a suggested fix.
- Gate the pipeline. Fail the CI job on any
error-severity finding. Emitwarning-severity findings as annotations without blocking merge when the team is in a grace period. - Cache the audit baseline. Persist the clean AST snapshot to the CI cache so the next incremental run only evaluates modified token branches.
// Core AST Traversal & Alias Resolution (Node.js)
const resolveAliases = (tokens, root = tokens, depth = 0) => {
if (depth > 10) throw new Error('Circular alias reference detected');
return Object.entries(tokens).reduce((acc, [key, value]) => {
if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
if ('value' in value && typeof value.value === 'string' && value.value.startsWith('{')) {
// Resolve alias: "{color.primitive.blue.500}" -> look up in root
const refPath = value.value.replace(/[{}]/g, '').split('.');
const resolved = refPath.reduce((obj, k) => obj?.[k], root);
acc[key] = resolved !== undefined ? resolved : value;
} else {
acc[key] = resolveAliases(value, root, depth + 1);
}
} else {
acc[key] = value;
}
return acc;
}, {});
};
The depth > 10 guard converts what would be an infinite stack overflow into a named, actionable CI failure. Why this works: JavaScript’s call stack is finite and its error message for stack overflows is opaque; throwing early with context gives engineers a navigable diagnostic.
Validation & Quality Gates Link to this section
Audit outputs feed directly into Stylelint Plugin Configuration, enabling automated blocking of invalid CSS custom properties at the stylesheet level. The pipeline also integrates with JSON Schema Validation for Tokens to enforce structural contracts before any compilation step runs.
Multi-Stage CI Configuration (GitHub Actions):
name: Token Audit Pipeline
on:
pull_request:
paths: ['tokens/**', 'config/schema.json']
push:
branches: [main]
jobs:
audit-tokens:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '22', cache: 'npm' }
- run: npm ci
- name: Restore audit baseline cache
uses: actions/cache@v4
with:
path: .token-audit-cache
key: token-audit-${{ hashFiles('tokens/**') }}
- name: Run Static Schema Validation
run: npm run audit:schema -- --format sarif --output audit-report.sarif
- name: Upload SARIF to GitHub Security
uses: github/codeql-action/upload-sarif@v3
if: always()
with: { sarif_file: 'audit-report.sarif' }
- name: Incremental CSS Architecture Verification
run: npm run audit:css -- --diff-base origin/main
- name: Save audit baseline cache
uses: actions/cache@v4
with:
path: .token-audit-cache
key: token-audit-${{ hashFiles('tokens/**') }}
Tool reference:
| Tool | Purpose | Integration Point |
|---|---|---|
| Style Dictionary | Compiles token JSON to platform outputs; exposes hooks for custom validators | Pre-compilation transform step |
ajv |
Fast JSON Schema validator; runs against the token dictionary before compilation | audit:schema npm script |
stylelint + stylelint-design-system-utils |
Lint CSS files for invalid custom property references | Post-compilation lint step |
| SARIF output adapter | Converts audit findings to GitHub-native security annotations | upload-sarif Actions step |
token-transformer |
Resolves Tokens Studio aliases to flat W3C DTCG format before audit | Ingestion pre-processor |
Cross-Domain Dependency Mapping Link to this section
Token audit scripts sit at the center of the validation and CI dependency graph. They consume upstream design exports and gate every downstream workflow.
| Parent Section | Sibling Area | Integration Point | Validation Strategy |
|---|---|---|---|
| Token Scaling & CI | Design-to-Code Sync Workflows | Audit runs after Figma export, before commit | Schema check on raw export payload |
| Token Scaling & CI | JSON Schema Validation for Tokens | Schema contract is the audit’s primary ruleset | ajv strict mode with additionalProperties: false |
| Token Scaling & CI | Stylelint Plugin Configuration | Audit report drives which custom properties Stylelint allows | Allowlist regenerated from clean audit artifact |
| Token Scaling & CI | Versioning & Semantic Release for Tokens | Clean audit is a prerequisite for a semantic release | Audit exit code gates the release:tokens script |
| Token Scaling & CI | Token Compiler Comparison | Compiler choice affects AST shape the audit must understand | Audit schema adapters per compiler output format |
/* tokens/semantic/color.css */
/* @depends: tokens/primitive/color.json — audit will fail if primitives change shape */
:root {
--ds-color-action-primary: var(--ds-primitive-blue-600);
--ds-color-feedback-error: var(--ds-primitive-red-500);
}
Production Code Reference Link to this section
The framework-agnostic gating script runs after every merge to main, sequencing schema validation before CSS verification so a broken schema never reaches the more expensive CSS analysis step.
#!/bin/bash
# scripts/post-merge-audit.sh
set -e
echo "Running post-merge token audit..."
# 1. Validate schema — fast, blocks immediately on structural errors
npm run audit:schema
# 2. Check CSS mapping accuracy — more expensive, runs only if schema is clean
npm run audit:css
echo "Audit passed. Triggering semantic release..."
npm run release:tokens
The set -e flag ensures any non-zero exit code from either audit step aborts the script before the release runs. Why this works: without set -e, a failed audit:schema silently exits and the release:tokens command still executes, publishing broken tokens.
Orphan detection snippet — cross-references the resolved AST against the usage manifest:
// scripts/audit-orphans.js
import { readTokenAST } from './lib/ast.js';
import { buildUsageManifest } from './lib/manifest.js';
const tokens = await readTokenAST('./tokens');
const used = await buildUsageManifest(['./src/**/*.{ts,tsx,css}']);
const orphans = Object.keys(tokens).filter(key => !used.has(key));
if (orphans.length > 0) {
console.error('Orphaned tokens detected:');
orphans.forEach(t => console.error(` - ${t}`));
process.exit(1);
}
console.log(`Audit passed. ${Object.keys(tokens).length} tokens, 0 orphans.`);
Turning an Audit Into a Deprecation Workflow Link to this section
Finding unused tokens is easy. Removing them safely is the part that needs process, because “unused” is a statement about the code you scanned, not about the code that exists.
The scan is blind to at least four consumers: repositories outside the search path, published packages that pin an older version, documentation and design files that reference token names as text, and runtime composition that builds names from fragments. A token that is genuinely unused in the monorepo may be the only thing holding a partner integration together.
The workflow that respects that uncertainty runs over three releases:
- Mark, do not delete. Add a
$deprecatedflag with a target removal version and a replacement name. The token continues to compile and consumers see no change. - Warn at the point of use. The lint rule reports any reference to a deprecated token as a warning with the replacement name in the message. This is the step that finds the consumers your scan missed, because it runs in their repository rather than in yours.
- Remove in a major release, with the removal listed in the release notes and the replacement named. By this point the warning has been visible for at least one full release cycle.
The flag is what makes the process auditable. A CI job can report how many deprecated tokens exist, how long each has been marked, and which are past their stated removal version — turning an open-ended cleanup into a burndown with dates.
What to do about permanently unused tokens Link to this section
Some tokens are never referenced and never will be: a full ramp published for completeness, a set of status colours the product has not needed yet, tokens that exist because a sibling platform consumes them. These will show up in every audit run forever.
Rather than allow-listing them individually — which quietly grows into an unreviewable list — mark them at the source with an explicit intent flag, so the audit can group them:
$audit: platform— consumed by iOS or Android builds, not by CSS.$audit: reserved— deliberately published ahead of use, with an owner and a reason.$audit: external— referenced by consumers outside this repository.
The report then reads as “12 unused, 40 accounted for” rather than “52 unused”, and the number that matters is the one that can actually be acted on. An audit whose output is mostly noise stops being read within two sprints, which is the real failure mode — not the unused tokens themselves.
Diagnostic Matrix Link to this section
| Diagnostic Step | Execution Detail |
|---|---|
| Confirm AST parses cleanly | Run npm run audit:schema -- --dry-run and inspect the JSON output for null nodes — these indicate malformed alias paths |
| Verify alias resolver depth limit | Introduce a deliberate circular alias in a test fixture; confirm the audit throws Circular alias reference detected at depth 10 |
| Check usage manifest coverage | Print manifest.size and compare to the known token count; a manifest far smaller than the token count indicates glob patterns are not matching component files |
| Confirm SARIF uploads to GitHub | Inspect the repository Security tab after a failing PR; missing annotations mean the upload-sarif step ran before the audit generated the file |
| Validate incremental diff accuracy | Delete a token that is known to be used, then run audit:css -- --diff-base HEAD~1; confirm it surfaces the deletion as an error, not a warning |
Root causes and resolutions:
| Symptom | Root Cause | Resolution |
|---|---|---|
| Orphan detection reports zero orphans on a clearly stale token file | Usage manifest glob does not cover all consumer file extensions | Add *.vue, *.svelte, or native template extensions to the manifest scanner config |
| Circular alias error on a token that has no self-reference | Two tokens reference each other indirectly through three or more hops | Flatten the alias chain in the source dictionary; use a primitive directly |
| SARIF file not appearing in GitHub Security tab | upload-sarif step runs before audit:schema generates the file |
Add if: always() to the upload step and confirm the file path matches the --output flag |
| Incremental diff misses a renamed token as a violation | Baseline cache is stale from before the rename | Delete .token-audit-cache and run a full scan to rebuild the baseline |
Naming convention check passes for a token that uses camelCase |
Regex pattern only checks top-level keys, not nested namespace segments | Update the convention validator to recurse into nested objects and validate every path segment |
FAQ Link to this section
Q: Should the audit script run before or after Style Dictionary compilation? Link to this section
Run the structural schema validation before compilation and the CSS mapping verification after. Schema checks are fast and catch the errors most likely to break compilation — running them first prevents wasting minutes on a compilation step that was doomed from the start. The CSS verification step requires compiled output, so it necessarily runs second.
Q: How do you handle tokens that are intentionally defined but not yet used? Link to this section
Add a $status: reserved metadata field to the W3C DTCG token definition and configure the orphan detector to skip tokens carrying that flag. This gives teams a way to pre-declare tokens in the dictionary before the consuming components are built, without triggering false-positive audit failures.
Q: What is the right threshold for blocking a PR versus warning? Link to this section
Block on any error-severity finding: dangling aliases, schema violations, and naming convention failures that would produce invalid CSS. Warn on warning-severity findings: orphaned tokens (they may be used in a platform the manifest scanner does not cover), deprecated tokens still in use (they need a migration window), and tokens with missing $description fields. Revisit warning thresholds quarterly as the system matures.
Report Trends, Not Snapshots Link to this section
A single audit run says little: every mature codebase has some orphans and many unused tokens. What matters is direction. Store each run’s counts with its commit and report the delta, so a pull request that adds three orphans is visible even when the absolute number was already large. A gate on the delta is enforceable from day one, where a gate on the absolute count would have to wait for a cleanup that may never be scheduled.
Running the Audit Across Repositories Link to this section
In a multi-repository organisation the audit has to run somewhere that can see every consumer, which is rarely the design system repository. Two patterns work. A scheduled job in a central place clones or checks out each consuming repository and runs the scan across all of them, producing one report. Alternatively each consumer runs the scan itself against the published token list and posts its counts to a shared endpoint.
The centralised version gives a complete picture and needs read access to every repository. The distributed version needs no special access and scales naturally, but it only sees repositories that have opted in — which means the number it reports is a lower bound on the real orphan count, and it should be labelled as such in whatever dashboard it feeds.
Related Link to this section
- Token Scaling, Validation & CI Pipelines — the parent reference covering the full validation and release architecture
- Detecting Orphaned and Unused Tokens in CI — deep implementation guide for the orphan-detection step covered briefly above
- JSON Schema Validation for Tokens — structural contract enforcement that feeds directly into the audit ruleset
- Stylelint Plugin Configuration — consumes audit reports to enforce token usage at the stylesheet level