Detecting Orphaned and Unused Tokens in CI

Part of Automated Token Audit Scripts. This page walks through building a CI job that statically analyzes your token graph and source files to surface three failure classes: tokens that are defined but never referenced, variables that are referenced in CSS or components but have no definition, and alias chains where an intermediate token resolves to nothing.

Token graph showing orphaned and dangling nodes A directed graph of token nodes. Primitive tokens connect to semantic tokens, which connect to component tokens. Two nodes are highlighted: one orphaned token with no outgoing edges, and one dangling reference with no corresponding definition node. Primitive Semantic Component color.blue.500 color.blue.700 color.slate.100 color.teal.300 ORPHANED color.action .primary color.action .hover color.surface .subtle color.brand BROKEN ALIAS button.bg .default button.bg .hover input.border DANGLING REF valid orphaned broken / dangling
Token graph with three fault classes: a valid primitive-to-semantic-to-component chain (green), an orphaned primitive with no consumers (amber), and broken alias / dangling reference nodes (red).

Prerequisites

Before wiring the audit into CI, verify the following are in place:

  • Token source format. Tokens are stored as W3C Design Token Community Group (DTCG) JSON or Style Dictionary JSON, with $value / $type keys. The audit script reads these files directly — it does not need Style Dictionary to compile first.
  • Node.js ≥ 20. The script uses fs.glob (Node 22) or falls back to fast-glob for ≤ 20.
  • Source files accessible. CSS, Sass, JSX/TSX, and Vue SFC files must be checked out before the audit step. Shallow clones are fine as long as --depth captures the full working tree.
  • CI write access to artifacts. The report is emitted as a JSON file and echoed to stdout; the job uploads it as a build artifact for review.
  • Token file paths documented. Know where your token JSON lives relative to the repo root, e.g. design-tokens/src/**/*.json, because you will parameterize the script with that glob.

Step-by-Step Implementation

Step 1: Install the audit script’s dependencies

Add fast-glob and json5 to devDependencies. fast-glob handles path matching consistently across platforms; json5 tolerates comment-annotated token files that some design tools emit.

npm install --save-dev fast-glob json5

Why this works: keeping these as devDependencies means they are never bundled into a production artifact, and npm ci --omit=dev in a deploy step skips them entirely.

Step 2: Write the token graph builder

Create scripts/token-audit.mjs. This module walks all token JSON files, builds a flat map of every defined token path, and resolves alias chains to produce a directed graph.

// scripts/token-audit.mjs
import fs from "node:fs";
import path from "node:path";
import { globSync } from "fast-glob";
import JSON5 from "json5";

const TOKEN_GLOB = process.env.TOKEN_GLOB ?? "design-tokens/src/**/*.json";
const SOURCE_GLOB =
  process.env.SOURCE_GLOB ??
  "src/**/*.{css,scss,ts,tsx,jsx,vue,html}";

// ── 1. Collect all token definitions ────────────────────────────────────────

function flattenTokens(obj, prefix = "") {
  const result = {};
  for (const [key, val] of Object.entries(obj)) {
    const fullKey = prefix ? `${prefix}.${key}` : key;
    if (val && typeof val === "object" && ("$value" in val || "$type" in val)) {
      result[fullKey] = val;
    } else if (val && typeof val === "object" && !key.startsWith("$")) {
      Object.assign(result, flattenTokens(val, fullKey));
    }
  }
  return result;
}

const tokenFiles = globSync(TOKEN_GLOB);
let allTokens = {};

for (const file of tokenFiles) {
  const raw = fs.readFileSync(file, "utf8");
  const parsed = JSON5.parse(raw);
  Object.assign(allTokens, flattenTokens(parsed));
}

const definedKeys = new Set(Object.keys(allTokens));

// ── 2. Resolve alias chains ──────────────────────────────────────────────────

const ALIAS_RE = /^\{(.+)\}$/;

function resolveAlias(key, seen = new Set()) {
  if (seen.has(key)) return { resolved: null, broken: true, cycle: true };
  const token = allTokens[key];
  if (!token) return { resolved: null, broken: true };
  const rawValue = token.$value;
  const match = typeof rawValue === "string" && rawValue.match(ALIAS_RE);
  if (!match) return { resolved: key, broken: false };
  const target = match[1];
  if (!definedKeys.has(target)) return { resolved: null, broken: true, missingTarget: target };
  return resolveAlias(target, new Set([...seen, key]));
}

const brokenAliases = [];
for (const key of definedKeys) {
  const { broken, missingTarget, cycle } = resolveAlias(key);
  if (broken) brokenAliases.push({ token: key, missingTarget, cycle: !!cycle });
}

// ── 3. Scan source files for token references ────────────────────────────────

// Matches both CSS var(--ds-...) and JS/JSX token strings like 'color.action.primary'
const CSS_VAR_RE = /var\(--ds-([\w-]+)\)/g;
const TOKEN_PATH_RE = /[`'"]([a-z][a-z0-9]*(?:\.[a-z][a-z0-9-]*){1,6})[`'"]/g;

const referencedCssVars = new Set();
const referencedTokenPaths = new Set();

const sourceFiles = globSync(SOURCE_GLOB);
for (const file of sourceFiles) {
  const src = fs.readFileSync(file, "utf8");
  for (const [, name] of src.matchAll(CSS_VAR_RE)) {
    referencedCssVars.add(name);
  }
  for (const [, path] of src.matchAll(TOKEN_PATH_RE)) {
    referencedTokenPaths.add(path);
  }
}

// Convert CSS var names to token paths (--ds-color-action-primary → color.action.primary)
function cssVarToPath(varName) {
  return varName.replace(/-/g, ".");
}

const referencedPaths = new Set([
  ...referencedTokenPaths,
  ...[...referencedCssVars].map(cssVarToPath),
]);

// ── 4. Classify tokens ───────────────────────────────────────────────────────

const orphaned = [...definedKeys].filter((k) => !referencedPaths.has(k));
const dangling = [...referencedPaths].filter((k) => !definedKeys.has(k));

// ── 5. Emit report ───────────────────────────────────────────────────────────

const report = {
  summary: {
    defined: definedKeys.size,
    orphaned: orphaned.length,
    dangling: dangling.length,
    brokenAliases: brokenAliases.length,
  },
  orphaned,
  dangling,
  brokenAliases,
};

const reportPath = process.env.REPORT_PATH ?? "token-audit-report.json";
fs.writeFileSync(reportPath, JSON.stringify(report, null, 2));

console.log(JSON.stringify(report.summary, null, 2));

if (orphaned.length || dangling.length || brokenAliases.length) {
  if (orphaned.length) {
    console.error(`\n[FAIL] ${orphaned.length} orphaned token(s):`);
    orphaned.forEach((t) => console.error(`  · ${t}`));
  }
  if (dangling.length) {
    console.error(`\n[FAIL] ${dangling.length} dangling reference(s):`);
    dangling.forEach((t) => console.error(`  · ${t}`));
  }
  if (brokenAliases.length) {
    console.error(`\n[FAIL] ${brokenAliases.length} broken alias chain(s):`);
    brokenAliases.forEach(({ token, missingTarget, cycle }) =>
      console.error(`  · ${token}${cycle ? " (cycle)" : ` → missing ${missingTarget}`}`)
    );
  }
  process.exit(1);
}

console.log("\n[PASS] Token graph is clean.");

Why this works: the script builds a complete definition map before scanning source — so alias resolution and usage detection share the same key namespace. Exiting non-zero on any fault category makes the CI step fail the build without extra scripting in YAML.

Step 3: Add a package.json script entry

Wire it so contributors can run the audit locally with the same command CI uses:

{
  "scripts": {
    "token:audit": "node scripts/token-audit.mjs"
  }
}

Why this works: parity between local and CI invocations means developers can reproduce a CI failure before pushing. There is no build step — the script is plain ESM and runs directly.

Step 4: Configure GitHub Actions

Add the audit as a required check that runs after the token source is available but before the CSS compile step. This catches problems before they cascade into a broken build output.

# .github/workflows/token-audit.yml
name: Token Audit

on:
  push:
    paths:
      - "design-tokens/src/**"
      - "src/**/*.css"
      - "src/**/*.scss"
      - "src/**/*.tsx"
      - "src/**/*.jsx"
      - "src/**/*.vue"
  pull_request:
    types: [opened, synchronize, reopened]

jobs:
  audit:
    name: Detect orphaned and unused tokens
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: "20"
          cache: "npm"

      - run: npm ci

      - name: Run token audit
        env:
          TOKEN_GLOB: "design-tokens/src/**/*.json"
          SOURCE_GLOB: "src/**/*.{css,scss,ts,tsx,jsx,vue}"
          REPORT_PATH: "token-audit-report.json"
        run: npm run token:audit

      - name: Upload audit report
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: token-audit-report
          path: token-audit-report.json
          retention-days: 14

Why this works: the paths filter prevents the job from running on documentation-only or unrelated changes. if: always() on the artifact upload means you get the report even when the job fails — which is when you need it most.

Step 5: Enforce the check as a required status

In your repository settings under Branch protection rules, add Token Audit / Detect orphaned and unused tokens to the required status checks for the main branch. This prevents merging any PR that introduces a new orphan, dangling reference, or broken alias chain.

Why this works: required status checks are the enforcement mechanism — the audit script and workflow alone only report problems. Making it a branch requirement closes the loop so no engineer can accidentally merge broken token graph state.

Step 6: Integrate with the token validation CI pipeline

Call the audit step before your Style Dictionary compile step in the main pipeline so that a broken alias chain surfaces immediately rather than producing a cryptically empty CSS variable output:

# In your main pipeline (e.g. ci.yml), add before the compile step:
- name: Token graph audit
  run: npm run token:audit
  env:
    TOKEN_GLOB: "design-tokens/src/**/*.json"
    SOURCE_GLOB: "src/**/*.{css,scss,ts,tsx,jsx,vue}"

Why this works: Style Dictionary’s own error messages for broken references are often cryptic. Failing fast with the audit script gives engineers a clear list of exact token paths before they spend time debugging generated CSS.

Step 7: Suppress intentional exclusions with an allowlist

Some tokens are legitimately defined but consumed only at runtime (e.g., tokens injected via JavaScript for charts or third-party widgets). Add an allowlist file rather than hacking the script:

// design-tokens/audit-allowlist.json
{
  "orphanedAllowlist": [
    "color.chart.series-1",
    "color.chart.series-2",
    "color.chart.series-3"
  ],
  "danglingAllowlist": []
}

Load the allowlist in token-audit.mjs by reading design-tokens/audit-allowlist.json (if it exists) and filtering both the orphaned and dangling arrays before writing the report and exiting. Keep the allowlist small: if it grows beyond ten entries, that is a signal to re-examine your token consumption patterns.

Verification

Run the script locally against a known-orphaned token to confirm the exit behavior:

# Temporarily add a token that nothing references
echo '{"color":{"orphan-test":{"$value":"#ff0000","$type":"color"}}}' \
  > design-tokens/src/_orphan-test.json

npm run token:audit
# → [FAIL] 1 orphaned token(s):
# →   · color.orphan-test
# → Exit code 1

rm design-tokens/src/_orphan-test.json
npm run token:audit
# → [PASS] Token graph is clean.
# → Exit code 0

The generated token-audit-report.json for a clean run looks like:

{
  "summary": {
    "defined": 247,
    "orphaned": 0,
    "dangling": 0,
    "brokenAliases": 0
  },
  "orphaned": [],
  "dangling": [],
  "brokenAliases": []
}

On failure, the report lists each fault by full token path, giving engineers a direct path to the offending token file rather than a generic build error.

Troubleshooting

Symptom Likely Cause Fix
Dynamically constructed token names missed (e.g., color.${theme}.primary) Template literal token references are not matched by the static regex patterns Extract dynamic token names into a token-manifest.ts file that re-exports them as string literals; the audit can then grep that file
False positives from string concatenation in test files Test fixtures contain partial token path strings that match the TOKEN_PATH_RE pattern Add **/__tests__/** and **/*.test.* to an exclusion list in SOURCE_GLOB, or use a separate SOURCE_GLOB for test vs. production code
Monorepo path globs miss packages Workspace package source lives under packages/*/src/ rather than src/ Set SOURCE_GLOB="{packages,apps}/*/src/**/*.{css,scss,ts,tsx,jsx,vue}" in the workflow environment
CSS custom property name mismatches Your token compiler uses a prefix other than --ds- Change CSS_VAR_RE to match your actual prefix, e.g. /var\(--tokens-([\w-]+)\)/g
Audit passes locally but fails in CI Node version mismatch between local and CI Pin node-version in the workflow to the same major version in your .nvmrc or engines field

Migration Note

If you are introducing this audit into a codebase that already has many accumulated tokens, run the script once in report-only mode before adding the required status check. To do this, remove the process.exit(1) call temporarily, collect the token-audit-report.json, and triage the results:

  1. Immediate deletions: tokens defined in a single file with a clear replacement — delete and update references in one PR.
  2. Allowlist candidates: tokens used only by runtime-injected code — add to audit-allowlist.json with a comment explaining the consumer.
  3. Investigation backlog: tokens with ambiguous ownership — open tracking issues, then add them to the allowlist temporarily until the investigation is complete.

Remove the allowlist entries as you resolve each category. This phased approach lets you enable the required check on day one without blocking the team while you work through the backlog. It also complements writing custom Stylelint rules for token usage, which enforces correct token usage in CSS at lint time — the two checks operate at different layers and are stronger together than either alone.