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.
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/$typekeys. 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 tofast-globfor ≤ 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
--depthcaptures 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:
- Immediate deletions: tokens defined in a single file with a clear replacement — delete and update references in one PR.
- Allowlist candidates: tokens used only by runtime-injected code — add to
audit-allowlist.jsonwith a comment explaining the consumer. - 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.
Related
- Automated Token Audit Scripts — parent section covering the full audit toolchain
- Token Scaling, Validation & CI Pipelines — the broader CI and validation context for design token systems
- Writing Custom Stylelint Rules for Token Usage — enforces correct token usage in CSS at lint time, complementing the graph audit