Product design skills

scripts/check-token-names.mjs

A supporting file of the token-naming skill, shown as source.

#!/usr/bin/env node
// Lints design-token NAMES (not values) across DTCG JSON files and CSS
// custom-property files. Zero dependencies.
//
// Usage:
//   node check-token-names.mjs <path...> [options]
//
// <path...>  Files or directories. Directories are walked recursively for
//            *.json (DTCG token sets) and *.css (custom-property sheets).
//
// Options:
//   --grammar <regex>              Full token name must match this regex.
//                                   Enables rule "grammar-mismatch".
//   --tiers primitive=<prefix>,semantic=<prefix>,component=<prefix>
//                                   Declares tier prefixes. Enables
//                                   "component-references-primitive" (needs
//                                   component+primitive), "numbered-semantic"
//                                   and "opinion-word-at-semantic-tier" (both
//                                   need semantic=).
//   --opinion-words <comma,list>   Words banned at the semantic tier. Enables
//                                   "opinion-word-at-semantic-tier" (also
//                                   needs --tiers semantic=).
//   --json                         Emit machine-readable JSON instead of text.
//
// Rules (always on unless noted):
//   unresolved-alias               A {alias} / var(--x) reference that does
//                                   not resolve to any declared token.
//   mixed-scale-siblings           Sibling leaf keys under one parent mix
//                                   naming scales (numeric / t-shirt / level
//                                   / word), which reads as an inconsistent
//                                   system. Fires when a parent has 3+ sibling
//                                   tokens and either two of {numeric, tshirt,
//                                   level} appear, or one of them appears
//                                   alongside a plain word. The 3-sibling
//                                   floor applies to every case: two keys
//                                   are too few to call a scale, and a
//                                   numbered pair at the semantic tier is
//                                   already reported by numbered-semantic.
//   raw-value-in-composite (JSON)  A composite token ($value is an object or
//                                   an array of objects — typography, shadow,
//                                   gradient, border, ...) has a part that is
//                                   a literal string/number instead of an
//                                   {alias}.
//   component-references-primitive Needs --tiers. A component-tier token
//                                   aliases a primitive-tier token directly,
//                                   skipping the semantic tier.
//   numbered-semantic              Needs --tiers semantic=. A semantic-tier
//                                   token whose leaf name is bare digits, or
//                                   ends in "-<digits>", unless it is a
//                                   declared level/step/elevation scale
//                                   ("level-2", "step-3").
//   grammar-mismatch               Needs --grammar. Full token name (dotted
//                                   path for JSON, "--name" for CSS) does not
//                                   match the regex.
//   opinion-word-at-semantic-tier  Needs --opinion-words and --tiers
//                                   semantic=. A semantic-tier token whose
//                                   name contains a banned word as a whole
//                                   "."/"-"-delimited segment.
//
// Exit codes: 0 = clean, 1 = findings reported, 2 = usage error (bad
// arguments, missing/unreadable path, invalid JSON, invalid --grammar regex).

import { readFileSync, readdirSync, statSync, existsSync } from 'node:fs';
import { join, extname } from 'node:path';
import { pathToFileURL } from 'node:url';

const ALIAS_RE = /^\{([^}]+)\}$/;
const NUMERIC_RE = /^\d+$/;
const ENDS_DASH_DIGIT_RE = /-\d+$/;
const LEVEL_RE = /^(level|step|elevation)-?\d+$/i;
const TSHIRT_RE = /^(x{0,3}s|s|sm|m|md|l|lg|x{0,3}l|xs|xl|xxl|xxxl|[2-9]xl|[2-9]xs)$/i;

function usageError(msg) {
  process.stderr.write(`${msg}\n`);
  process.exit(2);
}

function parseArgs(argv) {
  const paths = [];
  let grammar = null;
  let tiers = null;
  let opinionWords = null;
  let jsonOutput = false;
  for (let i = 0; i < argv.length; i++) {
    const a = argv[i];
    if (a === '--grammar') grammar = argv[++i];
    else if (a === '--tiers') tiers = argv[++i];
    else if (a === '--opinion-words') opinionWords = argv[++i];
    else if (a === '--json') jsonOutput = true;
    else if (a.startsWith('--')) usageError(`unknown option: ${a}`);
    else paths.push(a);
  }
  if (paths.length === 0) usageError('usage: check-token-names.mjs <path...> [--grammar <regex>] [--tiers primitive=p,semantic=s,component=c] [--opinion-words a,b] [--json]');

  let grammarRe = null;
  if (grammar != null) {
    try { grammarRe = new RegExp(grammar); }
    catch (e) { usageError(`invalid --grammar regex: ${e.message}`); }
  }

  let tierMap = null;
  if (tiers != null) {
    tierMap = {};
    for (const part of tiers.split(',')) {
      const eq = part.indexOf('=');
      if (eq === -1) usageError(`invalid --tiers entry (expected tier=prefix): ${part}`);
      tierMap[part.slice(0, eq).trim()] = part.slice(eq + 1).trim();
    }
  }

  const opinionList = opinionWords != null
    ? opinionWords.split(',').map((w) => w.trim().toLowerCase()).filter(Boolean)
    : null;

  return { paths, grammarRe, grammarSrc: grammar, tierMap, opinionList, jsonOutput };
}

function collectFiles(paths) {
  const files = [];
  const walk = (dir) => {
    for (const entry of readdirSync(dir, { withFileTypes: true })) {
      if (entry.name.startsWith('.')) continue;
      const full = join(dir, entry.name);
      if (entry.isDirectory()) walk(full);
      else if (/\.(json|css)$/i.test(entry.name)) files.push(full);
    }
  };
  for (const p of paths) {
    if (!existsSync(p)) usageError(`path not found: ${p}`);
    const st = statSync(p);
    if (st.isDirectory()) walk(p);
    else if (st.isFile()) {
      if (!/\.(json|css)$/i.test(p)) usageError(`unsupported file (expected .json or .css): ${p}`);
      files.push(p);
    }
  }
  return files.sort();
}

// ---- alias helpers ----
function isAlias(v) { return typeof v === 'string' && ALIAS_RE.test(v.trim()); }
function aliasTarget(v) { return v.trim().match(ALIAS_RE)[1].trim(); }

function collectAliasesDeep(value) {
  const refs = [];
  if (typeof value === 'string') {
    if (isAlias(value)) refs.push(aliasTarget(value));
  } else if (Array.isArray(value)) {
    for (const v of value) refs.push(...collectAliasesDeep(v));
  } else if (value && typeof value === 'object') {
    for (const v of Object.values(value)) refs.push(...collectAliasesDeep(v));
  }
  return refs;
}

function normalizeRef(ref) {
  let norm = ref.trim();
  while (norm.startsWith('./') || norm.startsWith('../')) {
    norm = norm.startsWith('../') ? norm.slice(3) : norm.slice(2);
  }
  return norm;
}

function resolveJsonAlias(ref, tokenNames) {
  const trimmed = ref.trim();
  if (tokenNames.has(trimmed)) return true;
  const norm = normalizeRef(trimmed);
  if (tokenNames.has(norm)) return true;
  const tail = norm.includes('.') ? norm.split('.').slice(1).join('.') : norm;
  if (tokenNames.has(tail)) return true;
  for (const k of tokenNames) {
    if (k.endsWith(norm) || k.endsWith(tail)) return true;
  }
  return false;
}

// ---- JSON (DTCG) parsing ----
function walkDtcg(obj, segments, file, tokens) {
  if (!obj || typeof obj !== 'object' || Array.isArray(obj)) return;
  if ('$value' in obj) {
    tokens.push({ kind: 'json', name: segments.join('.'), segments: segments.slice(), file, value: obj.$value });
    return;
  }
  for (const key of Object.keys(obj)) {
    if (key.startsWith('$')) continue;
    const v = obj[key];
    if (v && typeof v === 'object' && !Array.isArray(v)) walkDtcg(v, [...segments, key], file, tokens);
  }
}

function findRawParts(value) {
  const offending = [];
  const checkPart = (key, v) => {
    if (typeof v === 'string') { if (!isAlias(v)) offending.push(key); }
    else if (typeof v === 'number') offending.push(key);
  };
  if (Array.isArray(value)) {
    value.forEach((el, idx) => {
      if (el && typeof el === 'object' && !Array.isArray(el)) {
        for (const [k, v] of Object.entries(el)) checkPart(`[${idx}].${k}`, v);
      }
    });
  } else if (value && typeof value === 'object') {
    for (const [k, v] of Object.entries(value)) checkPart(k, v);
  }
  return offending;
}

// ---- CSS parsing ----
function parseCss(text, file, tokens, varRefs) {
  const stripped = text.replace(/\/\*[\s\S]*?\*\//g, '');
  const declRe = /--([a-zA-Z][a-zA-Z0-9_-]*)\s*:\s*([^;}]+)[;}]/g;
  let m;
  while ((m = declRe.exec(stripped))) {
    const name = m[1];
    const value = m[2].trim();
    const refs = [];
    const varRe = /var\(\s*--([a-zA-Z][a-zA-Z0-9_-]*)/g;
    let vm;
    while ((vm = varRe.exec(value))) refs.push(vm[1]);
    tokens.push({ kind: 'css', name, segments: name.split('-'), file, value, refs });
    for (const r of refs) varRefs.push({ from: name, to: r, file });
  }
}

// ---- shared classification ----
function classifyLeaf(leaf) {
  if (NUMERIC_RE.test(leaf)) return 'numeric';
  if (TSHIRT_RE.test(leaf)) return 'tshirt';
  if (LEVEL_RE.test(leaf)) return 'level';
  return 'word';
}

function hasPrefix(name, prefix, sep) {
  if (prefix == null) return false;
  const p = prefix.endsWith(sep) ? prefix : prefix + sep;
  return name === prefix || name.startsWith(p);
}

function segmentsOf(name) { return name.split(/[.-]/); }

function main(argv) {
  const { paths, grammarRe, grammarSrc, tierMap, opinionList, jsonOutput } = parseArgs(argv);
  const files = collectFiles(paths);

  const jsonTokens = [];
  const cssTokens = [];
  const cssVarRefs = [];

  for (const file of files) {
    if (extname(file).toLowerCase() === '.json') {
      let raw;
      try { raw = readFileSync(file, 'utf8'); } catch (e) { usageError(`cannot read ${file}: ${e.message}`); }
      let data;
      try { data = JSON.parse(raw); } catch (e) { usageError(`invalid JSON in ${file}: ${e.message}`); }
      walkDtcg(data, [], file, jsonTokens);
    } else {
      let raw;
      try { raw = readFileSync(file, 'utf8'); } catch (e) { usageError(`cannot read ${file}: ${e.message}`); }
      parseCss(raw, file, cssTokens, cssVarRefs);
    }
  }

  const jsonTokenNames = new Set(jsonTokens.map((t) => t.name));
  const cssTokenNames = new Set(cssTokens.map((t) => t.name));

  const findings = [];
  const add = (rule, name, file, message) => findings.push({ rule, name, file, message });

  // Rule: unresolved-alias (JSON)
  for (const t of jsonTokens) {
    for (const ref of collectAliasesDeep(t.value)) {
      if (!resolveJsonAlias(ref, jsonTokenNames)) {
        add('unresolved-alias', t.name, t.file, `alias {${ref}} does not resolve to any known token`);
      }
    }
  }
  // Rule: unresolved-alias (CSS)
  for (const { from, to, file } of cssVarRefs) {
    if (!cssTokenNames.has(to)) {
      add('unresolved-alias', `--${from}`, file, `var(--${to}) does not resolve to any declared custom property`);
    }
  }

  // Rule: mixed-scale-siblings (JSON + CSS, grouped by parent)
  const jsonGroups = new Map();
  for (const t of jsonTokens) {
    const parent = t.segments.slice(0, -1).join('.');
    const leaf = t.segments[t.segments.length - 1];
    if (!jsonGroups.has(parent)) jsonGroups.set(parent, []);
    jsonGroups.get(parent).push({ leaf, file: t.file });
  }
  const cssGroups = new Map();
  for (const t of cssTokens) {
    const parent = t.segments.slice(0, -1).join('-');
    const leaf = t.segments[t.segments.length - 1];
    if (!cssGroups.has(parent)) cssGroups.set(parent, []);
    cssGroups.get(parent).push({ leaf, file: t.file });
  }
  const checkMixedScales = (groups) => {
    for (const [parent, siblings] of groups) {
      if (!parent || siblings.length < 3) continue;
      const classes = siblings.map((s) => classifyLeaf(s.leaf));
      const scaleClasses = new Set(classes.filter((c) => c !== 'word'));
      const hasWord = classes.includes('word');
      const fires = scaleClasses.size >= 2 || (scaleClasses.size === 1 && hasWord);
      if (fires) {
        const detail = siblings.map((s, i) => `${s.leaf} (${classes[i]})`).join(', ');
        add('mixed-scale-siblings', parent, siblings[0].file, `siblings mix naming scales: ${detail}`);
      }
    }
  };
  checkMixedScales(jsonGroups);
  checkMixedScales(cssGroups);

  // Rule: raw-value-in-composite (JSON only)
  for (const t of jsonTokens) {
    if (t.value && typeof t.value === 'object') {
      const offending = findRawParts(t.value);
      if (offending.length > 0) {
        add('raw-value-in-composite', t.name, t.file, `raw (non-alias) part(s): ${offending.join(', ')}`);
      }
    }
  }

  const skipped = [];

  // Rule: component-references-primitive (needs --tiers)
  if (tierMap) {
    const { component, primitive } = tierMap;
    if (component && primitive) {
      for (const t of jsonTokens) {
        if (!hasPrefix(t.name, component, '.')) continue;
        for (const ref of collectAliasesDeep(t.value)) {
          const norm = normalizeRef(ref);
          if (hasPrefix(norm, primitive, '.')) {
            add('component-references-primitive', t.name, t.file, `aliases primitive-tier token {${ref}} directly; alias through a semantic token instead`);
          }
        }
      }
      for (const t of cssTokens) {
        if (!hasPrefix(t.name, component, '-')) continue;
        for (const ref of t.refs) {
          if (hasPrefix(ref, primitive, '-')) {
            add('component-references-primitive', `--${t.name}`, t.file, `aliases primitive-tier custom property var(--${ref}) directly; alias through a semantic token instead`);
          }
        }
      }
    }
  } else {
    skipped.push('component-references-primitive');
  }

  // Rule: numbered-semantic (needs --tiers semantic=)
  if (tierMap && tierMap.semantic) {
    const semantic = tierMap.semantic;
    const checkNumbered = (name, leaf, file, displayName) => {
      const numbered = NUMERIC_RE.test(leaf) || ENDS_DASH_DIGIT_RE.test(leaf);
      if (numbered && classifyLeaf(leaf) !== 'level') {
        add('numbered-semantic', displayName, file, `leaf "${leaf}" is purely numeric naming; use a role word or a declared level/step scale`);
      }
    };
    for (const t of jsonTokens) {
      if (!hasPrefix(t.name, semantic, '.')) continue;
      checkNumbered(t.name, t.segments[t.segments.length - 1], t.file, t.name);
    }
    for (const t of cssTokens) {
      if (!hasPrefix(t.name, semantic, '-')) continue;
      checkNumbered(t.name, t.segments[t.segments.length - 1], t.file, `--${t.name}`);
    }
  } else {
    skipped.push('numbered-semantic');
  }

  // Rule: grammar-mismatch (needs --grammar)
  if (grammarRe) {
    for (const t of jsonTokens) {
      if (!grammarRe.test(t.name)) add('grammar-mismatch', t.name, t.file, `does not match grammar ${grammarRe}`);
    }
    for (const t of cssTokens) {
      if (!grammarRe.test(`--${t.name}`)) add('grammar-mismatch', `--${t.name}`, t.file, `does not match grammar ${grammarRe}`);
    }
  } else {
    skipped.push('grammar-mismatch');
  }

  // Rule: opinion-word-at-semantic-tier (needs --opinion-words and --tiers semantic=)
  if (opinionList && tierMap && tierMap.semantic) {
    const semantic = tierMap.semantic;
    const checkOpinion = (name, file, displayName) => {
      const segs = segmentsOf(name).map((s) => s.toLowerCase());
      for (const word of opinionList) {
        if (segs.includes(word)) {
          add('opinion-word-at-semantic-tier', displayName, file, `contains opinion word "${word}" at the semantic tier`);
        }
      }
    };
    for (const t of jsonTokens) {
      if (hasPrefix(t.name, semantic, '.')) checkOpinion(t.name, t.file, t.name);
    }
    for (const t of cssTokens) {
      if (hasPrefix(t.name, semantic, '-')) checkOpinion(t.name, t.file, `--${t.name}`);
    }
  } else {
    skipped.push('opinion-word-at-semantic-tier');
  }

  findings.sort((a, b) => (a.file === b.file ? a.name.localeCompare(b.name) : a.file.localeCompare(b.file)));

  const totalTokens = jsonTokens.length + cssTokens.length;
  const totalFiles = files.length;

  if (jsonOutput) {
    process.stdout.write(JSON.stringify({ findings, skipped, tokens: totalTokens, files: totalFiles }, null, 2) + '\n');
  } else {
    for (const f of findings) {
      process.stdout.write(`${f.rule}  ${f.name}  (${f.file})  ${f.message}\n`);
    }
    process.stdout.write(`\n${findings.length} finding(s) across ${totalTokens} token(s) in ${totalFiles} file(s)\n`);
  }

  return findings.length > 0 ? 1 : 0;
}

if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
  process.exitCode = main(process.argv.slice(2));
}