Catch design token names that do not exist in the stylesheet, so the style silently does nothing. Also catch colours and sizes typed in by hand instead of taken from the tokens.
Add "Design token checks" from ntent to this repo.
Fetch https://ntent.app/r/f/check-tokens as plain text. Write it verbatim to scripts/check-tokens.mjs. Then make exactly these edits and no others: Point CONFIG.stylesheets at the generated stylesheet this repo actually ships, and list a family in CONFIG.ownedFamilies only once your tokens replace the framework’s scale for it. It needs a generated stylesheet and waivers.mjs. If this repo does not have them, stop and tell me.
Then check it: Write text-fg-nope next to a real text-fg-muted. The check names it, says it resolves to nothing, and lists the nearest real tokens. Delete every token in a family and it says that family is no longer checked rather than passing.Reads https://ntent.app/r/f/check-tokens
Read the code
check-tokens.mjsscripts/check-tokens.mjs
787 lines
#!/usr/bin/env node
/**
* Token gate: no raw values, and no class in a family your stylesheet does not
* define.
*
* THE RULE SET IS DERIVED FROM THE GENERATED STYLESHEET AT RUNTIME. That is the
* whole design. A checker that keeps its own list of valid token names is a
* second copy of the design system, and the copy is wrong within a month: a
* family gets added, no rule owns it, and every class in it passes silently
* from then on. Nobody notices, because a check that has gone blind and a check
* that is passing print the same thing. Here the families come out of the CSS
* you actually ship, so adding a token teaches the checker about it and the
* completeness assert below refuses to run against a namespace it cannot check.
*
* Three rules.
*
* 1. raw-color a hex or a colour function in .ts/.tsx/.css.
* 2. magic-length a number with a length unit, in the same files. Every
* unit, not just px: a rule that only catches the honest
* spelling of a violation trains people into the dishonest
* one, and max-w-[87rem] is the same value as
* max-w-[1392px].
* 3. unknown-class a utility in a family your stylesheet owns, naming a
* member it does not define. text-fg-nope where fg-muted
* and fg-subtle exist.
*
* RULE 3 IS THE ONE YOU CANNOT GET ANY OTHER WAY. Tailwind drops a class it
* does not recognise in silence. There is no build error, no console warning
* and no failing test: the element simply renders without that property, which
* on a colour looks like inheriting the parent and on a max-width looks like a
* page that is 60px too wide on one route. Every gate stays green. The only
* thing that catches it is asking the stylesheet whether the class resolves.
*
* WHY THIS IS NOT AN ESLINT RULE. Two reasons, and the second is the general
* one. ESLint does not parse stylesheets, so an ESLint rule cannot know which
* token names exist, which is the whole of rule 3, and it never sees a .css
* file, which is where raw values hide once they have been chased out of the
* components. More generally: when a rule could live in either checker, it
* belongs in the one with the wider view. Two checkers that both half-know
* about tokens is how you end up with two answers.
*
* EXIT CODES. 0 clean, 1 findings (with --check), 2 cannot check. The third one
* matters. A missing stylesheet, a stylesheet with no token block, or a
* namespace this checker has no rule for all exit 2, so "I could not check
* this" is never reported as "this is fine". For the same reason, a run that
* scanned zero files says so in plain words instead of printing a clean pass. A
* vacuous pass reads exactly like a real one.
*
* WHAT THIS DOES NOT PROVE. That the page looks right. Every finding here is
* about an input. A screen can use nothing but real tokens, pass this and every
* other gate, and still be laid out wrongly or be completely unstyled because
* the stylesheet never got imported. Look at the screen.
*
* Waivers are comments, collected and printed on every run by ./lib/waivers.mjs:
*
* /* project-check-ignore -- the vendor widget only takes a hex *\/
* // project-check-ignore-next-line -- ditto
* /* project-check-ignore-start *\/ ... /* project-check-ignore-end *\/
*
* A waiver you already know is temporary can carry its own expiry. Past the
* date the finding goes live again and the run says which waiver ran out:
*
* /* project-check-ignore until 2026-06-30 -- vendor ships a token in v4 *\/
*
* Usage:
* node scripts/check-tokens.mjs
* node scripts/check-tokens.mjs --check
* node scripts/check-tokens.mjs --css path/to/tokens.css src/components
*/
import { readFileSync, readdirSync, statSync, existsSync } from 'node:fs'
import { join, resolve, relative, extname } from 'node:path'
import { scanSource, collectWaivers, applyWaivers, reportWaivers } from './lib/waivers.mjs'
// ---------------------------------------------------------------- CONFIG ---
const CONFIG = {
// The generated stylesheet, in preference order. First one that exists wins,
// and --css overrides all of them. This is the file the browser gets, not the
// source the tokens are authored in: the point is to check against what
// actually shipped.
stylesheets: [
'packages/tokens/dist/tokens.css',
'src/styles/tokens.css',
'src/app/globals.css',
],
// Which block holds the definitions.
// 'theme' a Tailwind v4 `@theme { ... }` block, every one in the file
// 'root' a plain `:root { ... }` custom-property block
// 'auto' @theme if the file has one, otherwise :root
blockKind: 'auto',
// What every token name carries after the two dashes. '' for Tailwind v4,
// where the name is --color-fg-muted. A project that namespaces its custom
// properties as --app-color-fg-muted sets 'app-'.
varPrefix: '',
scanDirs: ['src', 'packages'],
extensions: ['.ts', '.tsx', '.css'],
ignoreDirs: [
'node_modules',
'dist',
'build',
'out',
'coverage',
'.git',
'.next',
'.turbo',
'.vercel',
'.cache',
'storybook-static',
],
// Files whose raw values are legitimate, as repo-relative paths or path
// fragments. The stylesheet itself is always skipped. Prefer this over a
// whole-file waiver comment: a file waiver cannot be checked for staleness,
// so it is the one exception that never gets reviewed again.
ignoreFiles: [],
// Families your stylesheet OWNS outright, meaning it replaces the framework's
// scale rather than adding to it. Start empty and add one the day that
// becomes true. It changes two things and nothing else.
//
// A single-word class in an unlisted family is left alone, because
// rounded-lg may still be resolving against Tailwind's own default theme,
// which is not in this file. List radius and rounded-mdd is caught too.
//
// A numeric step in an unlisted family is left alone, for the same reason:
// p-4 is the framework's until the spacing scale is yours.
//
// Named classes are checked either way. text-fg-nope is convicted the moment
// you define one fg-* token, and that is the case no ESLint rule can reach.
// Listing a family you do not actually own makes the checker cry wolf, and a
// checker that cries wolf gets waived in bulk.
ownedFamilies: [],
// Namespaces your stylesheet emits that this checker deliberately does not
// check. Anything emitted and NOT listed here, and not in a family below,
// exits 2 rather than passing silently. That assert is what stops the checker
// going blind when the design system grows a family.
uncheckedNamespaces: [
'breakpoint',
'leading',
'tracking',
'ease',
'animate',
'blur',
'perspective',
'aspect',
'default',
],
// Families that must be non-empty or the stylesheet is not a token build and
// there is nothing to check against.
requireFamilies: ['color'],
// null = work it out. Tailwind v4 ships a single `--spacing` multiplier and
// derives p-4 from it, so on a stylesheet that defines one, every numeric
// step is valid and only named steps are checked.
spacingIsMultiplier: null,
// Tailwind spellings that map onto a differently named step, e.g. Tailwind's
// p-0.5 against a scale that calls that step '05' after its Figma name.
aliases: {},
// Lengths that are structural rather than design decisions.
structuralLengths: [
'0px',
'0rem',
'0em',
'1px',
'100vh',
'100vw',
'100dvh',
'100dvw',
'100svh',
'100svw',
'100lvh',
'100lvw',
'100vmin',
'100vmax',
],
// Hyphenated CSS property names that a prefix rule would otherwise read as
// classes. `style={{ fontFamily }}` written as a string key is not a broken
// font-* utility, and convicting it is how a real rule gets a reputation for
// noise.
notClasses: [
/^font-(family|size|weight|style|stretch|variant|feature-settings|kerning)$/,
/^text-(transform|decoration|overflow|indent|rendering|orientation|emphasis)$/,
/^shadow-(root|dom)$/,
],
waiverToken: 'project-check-ignore',
// A run that found nothing to scan has not checked anything. Under --check
// that is a failure, not a pass.
failOnEmptyScan: true,
maxSuggestions: 8,
}
// ------------------------------------------------------------------ ARGS ---
const argv = process.argv.slice(2)
// Boolean and value flags are pulled out separately on purpose. One helper that
// guessed, by taking the next argument unless it began with a dash, ate the
// path in `check-tokens.mjs --check src`: the run then silently scanned
// CONFIG.scanDirs instead of what it was told to, and reported on the wrong
// files while looking entirely normal.
const takeFlag = (name) => {
const i = argv.indexOf(name)
if (i === -1) return false
argv.splice(i, 1)
return true
}
const takeValue = (name) => {
const i = argv.indexOf(name)
if (i === -1) return null
const value = argv[i + 1]
if (value === undefined || value.startsWith('--')) {
console.error(`check-tokens: ${name} needs a value.`)
process.exit(2)
}
argv.splice(i, 2)
return value
}
const cssFlag = takeValue('--css')
const quiet = takeFlag('--quiet')
const strict = takeFlag('--check')
const roots = argv.length ? argv.map((p) => resolve(p)) : CONFIG.scanDirs.map((d) => resolve(d))
const fail = (message) => {
console.error(`check-tokens: ${message}`)
process.exit(2)
}
// ------------------------------------------------------------ STYLESHEET ---
const cssPath = (() => {
const candidates = [cssFlag && resolve(String(cssFlag)), ...CONFIG.stylesheets.map((p) => resolve(p))].filter(
Boolean,
)
for (const c of candidates) if (existsSync(c)) return c
return fail(
'no generated stylesheet found. Build your tokens, or pass --css <path>.\n' +
` Looked for: ${CONFIG.stylesheets.join(', ')}`,
)
})()
const css = readFileSync(cssPath, 'utf8')
/** Every balanced block opened by `marker`, concatenated. A file can hold both
* an `@theme` and an `@theme inline`, and half the tokens are in the second. */
const blocks = (marker) => {
const out = []
let from = 0
for (;;) {
const start = css.indexOf(marker, from)
if (start === -1) break
const open = css.indexOf('{', start)
if (open === -1) break
let depth = 0
let i = open
for (; i < css.length; i++) {
if (css[i] === '{') depth++
else if (css[i] === '}' && --depth === 0) break
}
out.push(css.slice(open, i))
from = i + 1
}
return out
}
const block = (() => {
const wanted =
CONFIG.blockKind === 'auto' ? (css.includes('@theme') ? 'theme' : 'root') : CONFIG.blockKind
const found = blocks(wanted === 'theme' ? '@theme' : ':root')
if (!found.length) {
fail(
`${rel(cssPath)} has no ${wanted === 'theme' ? '@theme' : ':root'} block, so it is not a token build.\n` +
' Point CONFIG.stylesheets at the generated file, or set CONFIG.blockKind.',
)
}
return found.join('\n')
})()
// Namespaces spelled with two words. Without this list --font-weight-bold is
// read as a --font-* member called "weight-bold", which makes font-bold look
// undefined and font-weight-bold look fine. Exactly backwards.
const COMPOUND_NAMESPACES = ['font-weight', 'inset-shadow', 'drop-shadow', 'text-shadow']
// family -> the CSS namespaces it draws from. The four shadow namespaces are
// folded into one family on purpose: they are all shadow names, and conflating
// them can at worst miss a drop-shadow used as a box-shadow, which is a smaller
// failure than a class that resolves to nothing at all.
const FAMILY_NAMESPACES = {
color: ['color'],
spacing: ['spacing'],
radius: ['radius'],
shadow: ['shadow', 'inset-shadow', 'drop-shadow', 'text-shadow'],
text: ['text'],
font: ['font'],
fontWeight: ['font-weight'],
container: ['container'],
}
const FAMILY_OF = new Map()
for (const [family, namespaces] of Object.entries(FAMILY_NAMESPACES)) {
for (const ns of namespaces) FAMILY_OF.set(ns, family)
}
const namespaceOf = (key) => {
for (const c of COMPOUND_NAMESPACES) if (key === c || key.startsWith(`${c}-`)) return c
return key.split('-')[0]
}
const NAMES = Object.fromEntries(Object.keys(FAMILY_NAMESPACES).map((f) => [f, new Set()]))
const emittedNamespaces = new Set()
const namespaceRoots = new Set()
for (const m of block.matchAll(/^\s*--([a-z0-9][a-z0-9-]*)\s*:/gm)) {
let key = m[1]
// Modifier keys (--text-sm--line-height) belong to their base key, not to the
// namespace. Counting them adds a member called "sm--line-height" that no
// class can ever name.
if (key.includes('--')) continue
if (CONFIG.varPrefix) {
if (!key.startsWith(CONFIG.varPrefix)) continue
key = key.slice(CONFIG.varPrefix.length)
}
const ns = namespaceOf(key)
emittedNamespaces.add(ns)
const member = key.length > ns.length ? key.slice(ns.length + 1) : ''
if (member === '') {
namespaceRoots.add(ns)
continue
}
const family = FAMILY_OF.get(ns)
if (family) NAMES[family].add(member)
}
// COMPLETENESS. Every namespace the stylesheet emits has to be one this checker
// either checks or has been told to ignore. The failure this prevents is quiet:
// a layout family arrives, no rule owns it, and a typo of max-w-content that
// resolves to nothing and silently drops the page's max width reports clean
// forever. A domain nobody maintains goes blind, so it is derived and asserted
// rather than maintained.
{
const known = new Set([...FAMILY_OF.keys(), ...CONFIG.uncheckedNamespaces])
const unknown = [...emittedNamespaces].filter((n) => !known.has(n))
if (unknown.length) {
fail(
`${rel(cssPath)} emits ${unknown.length} namespace(s) this checker cannot check: ` +
`${unknown.map((n) => `--${n}-*`).join(', ')}.\n` +
' Give them a family in FAMILY_NAMESPACES and a prefix in UTILITY_PREFIXES, or list\n' +
' them in CONFIG.uncheckedNamespaces. Every class in those families passes silently\n' +
' until you do. Refusing to report clean on a domain I cannot see.',
)
}
}
for (const family of CONFIG.requireFamilies) {
if (!NAMES[family] || NAMES[family].size === 0) {
fail(
`parsed no ${family} tokens out of ${rel(cssPath)}. Refusing to pass vacuously.\n` +
' Check CONFIG.varPrefix and CONFIG.blockKind against the file.',
)
}
}
const SPACING_IS_MULTIPLIER = CONFIG.spacingIsMultiplier ?? namespaceRoots.has('spacing')
// The first segment of every member is a family root. A class is only ever
// convicted when its root is one you define: bg-red-500 is the framework's
// business, bg-surface-elevated is yours.
const ROOTS = Object.fromEntries(
Object.entries(NAMES).map(([k, v]) => [k, new Set([...v].map((n) => n.split('-')[0]))]),
)
const OWNED = new Set(CONFIG.ownedFamilies)
const defined = (family) => NAMES[family].size > 0 || (family === 'spacing' && SPACING_IS_MULTIPLIER)
// px value -> the custom property that already carries it. A conviction is
// worth more when it names the fix.
const DIMENSION_TOKENS = (() => {
const byPx = new Map()
for (const m of css.matchAll(/^\s*(--[a-z0-9-]+):\s*(-?[\d.]+)(px|rem)\s*;/gm)) {
const px = m[3] === 'rem' ? Number.parseFloat(m[2]) * 16 : Number.parseFloat(m[2])
if (!byPx.has(px)) byPx.set(px, m[1])
}
return byPx
})()
// ----------------------------------------------------------------- RULES ---
const HEX = /#([0-9a-fA-F]{3,8})\b/g
const COLOR_FN = /\b(rgba?|hsla?|oklch|oklab|lab|lch|color-mix)\s*\(/g
const LENGTH_UNITS =
'px|rem|em|ch|ex|cap|ic|lh|rlh|vh|vw|vmin|vmax|dvh|dvw|svh|svw|lvh|lvw|cm|mm|in|pt|pc|Q'
const LENGTH = new RegExp(`(?<![\\w.$-])(\\d*\\.?\\d+)(${LENGTH_UNITS})\\b`, 'g')
const STRUCTURAL = new Set(CONFIG.structuralLengths)
// Deliberately NOT covered: `%` and unitless numbers. Not an oversight. A
// dimension token is an absolute length, so a percentage or a ratio can never
// be the dishonest spelling of one: w-[50%] and leading-[1.5] are structural,
// and convicting them would make the rule cry wolf.
const toPx = (value, unit) => {
const n = Number.parseFloat(value)
if (unit === 'px') return n
if (unit === 'rem' || unit === 'em') return n * 16
return null
}
const literalFindings = (scan) => {
const found = []
const { code, lines, lineOf } = scan
// Comments are blanked out of `code`, so a hex inside one is structurally
// invisible and can never be convicted. String bodies are KEPT, because that
// is exactly where a raw '#ff4438' or '13px' hides in a .tsx.
for (const m of code.matchAll(HEX)) {
const n = m[1].length
if (n !== 3 && n !== 4 && n !== 6 && n !== 8) continue
// An id selector, a fragment href, or the tail of a longer token.
const pre = code.slice(Math.max(0, m.index - 6), m.index)
if (/url\($|href="$|[\w#]$/.test(pre)) continue
found.push({ line: lineOf(m.index), rule: 'raw-color', text: m[0], hint: 'use a colour token' })
}
for (const m of code.matchAll(COLOR_FN)) {
// color-mix() takes tokens as arguments, so it is a way of using the system
// rather than a way round it.
if (m[1] === 'color-mix') continue
found.push({
line: lineOf(m.index),
rule: 'raw-color',
text: `${m[1]}(...)`,
hint: 'use a colour token',
})
}
for (const m of code.matchAll(LENGTH)) {
if (STRUCTURAL.has(m[0])) continue
const line = lineOf(m.index)
// Breakpoints are almost never tokenised, so a media condition is the one
// place a raw length is the honest answer.
if (/@(media|container)/.test(lines.code[line])) continue
const px = toPx(m[1], m[2])
const token = px === null ? null : DIMENSION_TOKENS.get(px)
const sameAs = m[2] !== 'px' && px !== null ? ` (${px}px)` : ''
found.push({
line,
rule: 'magic-length',
text: m[0],
hint: token
? `use var(${token})${sameAs}`
: `no token carries this value${sameAs}. Add one at source, or waive it with a reason.`,
})
}
return found
}
// Each utility prefix is checked with the strategy that suits its scale.
//
// rooted the value is family-step (bg-brand-500). Convict only when the
// root is one you define, so the framework's own palette is left be.
// closed the value is a single size word (rounded-lg, shadow-xs). Only for
// a family you own, where anything outside the set is drift.
// numeric the value is a step on the spacing scale (p-4).
// size width and height, which draw on containers and on spacing.
//
// Longest prefix wins, so border-t beats border.
const UTILITY_PREFIXES = [
[
['bg', 'ring', 'ring-offset', 'outline', 'fill', 'stroke', 'caret', 'accent', 'decoration', 'placeholder', 'from', 'via', 'to'],
{ strategy: 'rooted', spaces: ['color'] },
],
[
['border', 'border-x', 'border-y', 'border-t', 'border-r', 'border-b', 'border-l', 'border-s', 'border-e', 'divide', 'divide-x', 'divide-y'],
{ strategy: 'rooted', spaces: ['color'] },
],
[['text'], { strategy: 'rooted', spaces: ['text', 'color'], keywords: 'text' }],
[
['rounded', 'rounded-t', 'rounded-r', 'rounded-b', 'rounded-l', 'rounded-tl', 'rounded-tr', 'rounded-br', 'rounded-bl', 'rounded-s', 'rounded-e', 'rounded-ss', 'rounded-se', 'rounded-ee', 'rounded-es'],
{ strategy: 'closed', spaces: ['radius'], keywords: 'radius' },
],
[
['shadow', 'inset-shadow', 'drop-shadow', 'text-shadow'],
{ strategy: 'closed', spaces: ['shadow', 'color'], keywords: 'shadow' },
],
[['font'], { strategy: 'closed', spaces: ['font', 'fontWeight'], keywords: 'font' }],
[['w', 'h', 'size', 'min-w', 'min-h', 'max-w', 'max-h'], { strategy: 'size', spaces: ['container', 'spacing'] }],
[
['p', 'px', 'py', 'pt', 'pr', 'pb', 'pl', 'ps', 'pe', 'm', 'mx', 'my', 'mt', 'mr', 'mb', 'ml', 'ms', 'me', 'gap', 'gap-x', 'gap-y', 'space-x', 'space-y', 'basis', 'indent', 'top', 'right', 'bottom', 'left', 'start', 'end', 'inset', 'inset-x', 'inset-y', 'translate-x', 'translate-y', 'scroll-m', 'scroll-mx', 'scroll-my', 'scroll-p', 'scroll-px', 'scroll-py'],
{ strategy: 'numeric', spaces: ['spacing'] },
],
]
// Structural values the framework ships that a design system has no opinion
// about, plus the overloaded non-scale meanings of text-.
const KEYWORDS = {
radius: new Set(['none', 'full', 'inherit', 'initial']),
shadow: new Set(['none', 'inner', 'inherit', 'initial', 'current', 'transparent']),
font: new Set(['sans', 'serif', 'mono', 'inherit', 'initial', 'stretch', 'condensed', 'expanded']),
text: new Set([
'left', 'center', 'right', 'justify', 'start', 'end', 'wrap', 'nowrap', 'balance', 'pretty',
'clip', 'ellipsis', 'current', 'transparent', 'inherit', 'initial', 'auto',
]),
}
const SPACING_KEYWORDS = new Set(['auto', 'full', 'screen', 'min', 'max', 'fit', 'reverse', 'inherit', 'initial', 'px'])
// Width and height keywords the framework ships, including its own container
// scale, which your names usually ADD to rather than replace. If the framework
// adds a keyword this list reports a false positive once, which is the right
// direction: a visible wrong answer beats a silent one.
const SIZE_KEYWORDS = new Set([
'3xs', '2xs', 'xs', 'sm', 'md', 'lg', 'xl', '2xl', '3xl', '4xl', '5xl', '6xl', '7xl',
'full', 'screen', 'min', 'max', 'fit', 'auto', 'px', 'none', 'prose',
'svw', 'lvw', 'dvw', 'svh', 'lvh', 'dvh',
'screen-sm', 'screen-md', 'screen-lg', 'screen-xl', 'screen-2xl', 'inherit', 'initial',
])
const PREFIX_MAP = new Map()
for (const [prefixes, rule] of UTILITY_PREFIXES) {
for (const prefix of prefixes) {
const existing = PREFIX_MAP.get(prefix)
if (!existing) PREFIX_MAP.set(prefix, { ...rule, spaces: [...rule.spaces] })
else existing.spaces.push(...rule.spaces)
}
}
const PREFIXES_BY_LENGTH = [...PREFIX_MAP.keys()].sort((a, b) => b.length - a.length)
/** Strip variants (hover:, md:, data-[open]:), the ! marker and a leading -. */
const bareUtility = (token) => {
let t = token.replace(/^-/, '').replace(/^!/, '').replace(/!$/, '')
for (let guard = 0; guard < 20; guard++) {
const m = /^([a-z0-9@_-]+|\[[^\]]*\]|[a-z0-9-]+-\[[^\]]*\]|group-[a-z-]+(?:\/[a-z0-9-]+)?|peer-[a-z-]+):(.*)$/.exec(t)
if (!m) break
t = m[2]
}
return t
}
const isNumeric = (value) => /^\d+(\.\d+)?$/.test(value)
const classFindings = (candidates) => {
const found = []
const seen = new Set()
for (const { line, token } of candidates) {
let util = bareUtility(token)
// Arbitrary values are the magic-length rule's business, and an uppercase
// letter means this word was a prop or an identifier, not a class.
if (!util || util.includes('[') || util.includes('(') || util.includes('\\')) continue
if (/[A-Z]/.test(util)) continue
if (CONFIG.notClasses.some((re) => re.test(util))) continue
const prefix = PREFIXES_BY_LENGTH.find((p) => util.startsWith(`${p}-`))
if (!prefix) continue
const rule = PREFIX_MAP.get(prefix)
// Only check a family the stylesheet actually defines. A family it does not
// is reported once at the end as unchecked, never convicted here.
const spaces = rule.spaces.filter(defined)
if (!spaces.length) continue
// The /50 opacity modifier only exists on colour utilities. On w-1/2 the
// slash is a fraction and stripping it would hide a real value.
if (spaces.includes('color')) util = util.replace(/\/(\[[^\]]*\]|\d+(?:\.\d+)?)$/, '')
const value = util.slice(prefix.length + 1)
if (!value || value.includes('/')) continue
if (rule.keywords && KEYWORDS[rule.keywords].has(value)) continue
if (spaces.some((space) => NAMES[space].has(value))) continue
const alias = CONFIG.aliases[value]
if (alias && spaces.some((space) => NAMES[space].has(alias))) continue
const owned = spaces.every((space) => OWNED.has(space))
// A numeric step is only ever convicted by a family that owns its scale.
// Everywhere else p-4 may still be resolving against the framework's own
// spacing, which is not in this stylesheet, and failing it would be the
// checker arguing with a default it cannot see.
if (isNumeric(value) && !(owned && !SPACING_IS_MULTIPLIER)) continue
// A closed family you have not claimed to own falls back to the rooted
// rule. rounded-lg is the framework's word until you say the radius scale
// is yours; text-fg-nope is yours the moment you define one fg-* token.
const strategy = rule.strategy === 'closed' && !owned ? 'rooted' : rule.strategy
const root = value.split('-')[0]
let convict = false
if (strategy === 'size') {
if (SIZE_KEYWORDS.has(value)) continue
convict = true
} else if (strategy === 'numeric') {
if (SPACING_KEYWORDS.has(value)) continue
// Numeric OR named: a spacing scale carries both, and the framework ships
// no named steps beyond the keywords above, so a name that is not one of
// yours is a class resolving to nothing.
convict = true
} else if (strategy === 'closed') {
// A dashed value on a shadow utility is a colour (shadow-brand-500), so
// it falls back to the colour family rather than the size scale.
convict = value.includes('-') ? spaces.some((space) => ROOTS[space].has(root)) : true
} else {
convict = spaces.some((space) => ROOTS[space].has(root))
}
if (!convict) continue
const key = `${line}:${token}`
if (seen.has(key)) continue
seen.add(key)
// Suggest members of the same root, or single words when the value is one.
// Numeric steps are never a suggestion for a named class: offering "1, 2, 3"
// as the nearest thing to max-w-contentt is noise that trains people to skip
// reading the hint.
const nearest = [...new Set(spaces.flatMap((space) => [...NAMES[space]]))]
.filter((n) => !isNumeric(n))
.filter((n) => (value.includes('-') ? n.split('-')[0] === root : !n.includes('-')))
.sort()
.slice(0, CONFIG.maxSuggestions)
found.push({
line,
rule: 'unknown-class',
text: token,
hint:
`"${value}" is in no ${spaces.join('/')} scale in ${rel(cssPath)}, so it resolves to ` +
'nothing and the property is silently dropped.' +
(nearest.length ? ` Nearest: ${nearest.join(', ')}` : ''),
})
}
return found
}
// ------------------------------------------------------------------ WALK ---
const files = []
const walk = (dir) => {
let entries
try {
entries = readdirSync(dir, { withFileTypes: true })
} catch {
return
}
for (const entry of entries) {
if (entry.name.startsWith('.')) continue
const full = join(dir, entry.name)
if (entry.isDirectory()) {
if (CONFIG.ignoreDirs.includes(entry.name)) continue
walk(full)
} else if (CONFIG.extensions.includes(extname(entry.name))) {
files.push(full)
}
}
}
for (const root of roots) {
const s = statSync(root, { throwIfNoEntry: false })
if (!s) continue
if (s.isDirectory()) walk(root)
else if (CONFIG.extensions.includes(extname(root))) files.push(root)
}
const scanned = files.filter((f) => {
if (resolve(f) === resolve(cssPath)) return false
const r = rel(f)
return !CONFIG.ignoreFiles.some((ignored) => r === ignored || r.startsWith(`${ignored}/`))
})
// ------------------------------------------------------------------ MAIN ---
const results = []
const waivedDetail = []
const staleWaivers = []
const expiredWaivers = []
for (const file of scanned) {
const source = readFileSync(file, 'utf8')
const isCss = extname(file) === '.css'
const scan = scanSource(source, { isCss })
const waivers = collectWaivers(scan, { token: CONFIG.waiverToken })
// Class candidates: every word inside a string literal, plus @apply in CSS.
// Crude on purpose. The prefix table and the rooted rule do the narrowing,
// and a candidate list built from JSX className attributes alone misses the
// clsx call, the variant map and the constant at the top of the file, which
// is where the interesting classes live.
const candidates = []
for (const s of scan.strings) {
for (const word of s.text.split(/[\s`${}]+/)) {
const token = word.replace(/[.,;]+$/, '')
if (token) candidates.push({ line: scan.lineOf(s.start), token })
}
}
if (isCss) {
for (const m of scan.masked.matchAll(/@apply\s+([^;{]+)/g)) {
for (const word of m[1].split(/\s+/)) {
if (word) candidates.push({ line: scan.lineOf(m.index), token: word })
}
}
}
const findings = [...literalFindings(scan), ...classFindings(candidates)]
const split = applyWaivers(findings, waivers, rel(file))
results.push(...split.live)
waivedDetail.push(...split.waived)
staleWaivers.push(...split.stale)
expiredWaivers.push(...split.expired)
}
// ---------------------------------------------------------------- REPORT ---
const label = `${scanned.length} file${scanned.length === 1 ? '' : 's'}`
const unchecked = Object.keys(NAMES).filter((f) => !defined(f))
if (scanned.length === 0) {
console.log(
`check-tokens: scanned 0 files under ${roots.map(rel).join(', ')}.\n` +
' Nothing was checked. This is not a pass, it is an empty run: point CONFIG.scanDirs\n' +
' at the code, or pass a path.',
)
process.exit(strict && CONFIG.failOnEmptyScan ? 2 : 0)
}
if (results.length === 0) {
if (!quiet) {
console.log(`check-tokens: ok. ${label} scanned against ${rel(cssPath)}.`)
console.log(
` families: ${Object.entries(NAMES)
.filter(([f]) => defined(f))
.map(([f, v]) => `${v.size} ${f}`)
.join(', ')}${SPACING_IS_MULTIPLIER ? ', spacing by multiplier' : ''}`,
)
// Blind spots are named, not skipped. A family the stylesheet does not
// define is a family every class in is passing untested.
if (unchecked.length) {
console.log(
` not checked, no tokens defined: ${unchecked.join(', ')}. Classes in those families are not verified.`,
)
}
reportWaivers({
waived: waivedDetail,
stale: staleWaivers,
expired: expiredWaivers,
token: CONFIG.waiverToken,
})
}
process.exit(0)
}
results.sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line)
console.log(`\ncheck-tokens: ${results.length} finding(s) in ${label}\n`)
let current = null
for (const r of results) {
if (r.file !== current) {
current = r.file
console.log(` ${r.file}`)
}
console.log(` ${String(r.line + 1).padStart(5)}: ${r.rule.padEnd(13)} ${r.text}`)
console.log(` ${' '.repeat(13)} ${r.hint}`)
}
if (unchecked.length) {
console.log(`\n not checked, no tokens defined: ${unchecked.join(', ')}.`)
}
reportWaivers({
waived: waivedDetail,
stale: staleWaivers,
expired: expiredWaivers,
token: CONFIG.waiverToken,
})
console.log(
`\n Waive a deliberate one with /* ${CONFIG.waiverToken} -- reason */ on or above the\n` +
' statement. The reason is the point: every run prints it back.\n',
)
process.exit(strict ? 1 : 0)
function rel(f) {
return relative(process.cwd(), f) || f
}
Success check: Write text-fg-nope next to a real text-fg-muted. The check names it, says it resolves to nothing, and lists the nearest real tokens. Delete every token in a family and it says that family is no longer checked rather than passing.