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.

Token Audit Pipeline Flow A flowchart showing a token audit pass: graph scan of the token tree leads to orphan and dangling-alias detection, which feeds into a structured report output. Graph Scan Parse JSON/YAML Build token DAG Detection Orphaned tokens Dangling aliases Naming violations Circular refs Report SARIF / JSON CI gate decision PASS FAIL Step 1 Step 2 Step 3 Token Audit Pass Graph scan → orphan/dangling detection → structured report
A token audit pass: the token graph is scanned and traversed, orphaned and dangling-alias tokens are detected, and a structured SARIF or JSON report drives the CI gate decision.

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.
The four token states an audit distinguishes A two-by-two grid of token states — defined and used, defined but unused, used but undefined, and defined twice — each with its cost and typical symptom. Four states a token can be in, and what each one costs defined and used the healthy state — nothing to do the audit exists to keep this the majority defined, never used ships bytes and choices nobody needs safe to delete after one deprecation cycle used, never defined silently resolves to the fallback or nothing the state that renders a broken component defined twice last declaration wins, order decides appears as a value that changes per build
Only one of the four states is healthy. An audit that reports just unused tokens misses the two that actually break rendering.

Build Pipeline Workflow Steps Link to this section

  1. Parse source dictionaries. Use json5 or standard JSON.parse to ingest raw token exports from Style Dictionary, Tokens Studio, or a bespoke YAML pipeline.
  2. Construct a hierarchical AST. Map parent-child relationships and preserve metadata — $type, $description, $deprecated — as node attributes.
  3. 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.
  4. 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.
  5. Run structural validators. Compare the resolved AST against the JSON schema contract, enforce naming conventions (kebab-case for CSS, camelCase for JS exports), and flag any $deprecated tokens still referenced.
  6. 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.
  7. Gate the pipeline. Fail the CI job on any error-severity finding. Emit warning-severity findings as annotations without blocking merge when the team is in a grace period.
  8. Cache the audit baseline. Persist the clean AST snapshot to the CI cache so the next incremental run only evaluates modified token branches.
Building the declared and referenced token sets, then differencing them Two sets are constructed — declared tokens from the source and referenced tokens from a codebase scan — and differenced in both directions, with a panel on false positives from runtime-composed names. declared set parse the token JSON or the compiled CSS referenced set grep var(--…) across css, ts, tsx, vue set difference both directions, reported separately Where naive greps produce false positives Tokens composed at runtime — var(--ds-color-, theme, -bg) or a template literal — never appear as a literal string. Treat any dynamic composition as a usage of the whole prefix, and record the exception in the audit config rather than allowing the report to cry wolf until people stop reading it.
The audit is a set difference. Its accuracy depends entirely on whether the reference scan understands dynamically composed names.
// 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:

  1. Mark, do not delete. Add a $deprecated flag with a target removal version and a replacement name. The token continues to compile and consumers see no change.
  2. 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.
  3. 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.

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.