#!/usr/bin/env node /** * check-token-gates.mjs * * Phase 2 (extraction) DONE-WHEN gate check for the design-token-extraction * run (branch design/token-extraction; see DESIGN.md, GOAL-PROMPT.md, * TOKEN-AUDIT.md). Scans `ui/src/components/**` and `ui/src/pages/**` * (excluding `ui/src/lib|context|plugins`, which are explicitly out of * scope for this run per TOKEN-AUDIT.md's Batch 4 log) for three gates: * * Gate 1 — zero hardcoded COLOR LITERALS: hex colors (#fff, #ffffff, * #ffffffff) and rgb()/rgba()/hsl()/hsla()/oklch() value literals * (i.e. NOT a var() reference, and not merely referencing a CSS * variable inside one of those functions, e.g. hsl(var(--primary)) is * fine — only a literal numeric color argument fails the gate). * * Gate 2 — zero VALUE-BEARING arbitrary Tailwind bracket utilities: * bracket contents (`utility-[...]`) that carry a rendered CSS value * (digits with CSS units, bare numbers, color literals, or CSS value * functions like calc()/min()/max()/clamp()/var()/linear-gradient()/ * cubic-bezier()/rgba()/env()). This is checked on the UTILITY * position, i.e. `word-[...]` where `word` is not itself a selector/ * variant keyword. * * SELECTOR/VARIANT BRACKETS ARE EXCLUDED BY DEFINITION, not by * omission: `data-[...]`, `group-data-[...]`, `has-[...]`, * `group-has-data-[...]`, `aria-[...]`, `supports-[...]`, and * `max-[...]`/`min-[...]` used as a BREAKPOINT VARIANT PREFIX (i.e. * immediately followed by `:`, such as `max-[480px]:hidden`) are CSS * SELECTOR CONDITIONS or responsive variant prefixes, not visual * values applied to a property — they describe WHEN a rule applies, * not WHAT value it sets. A variant's bracket cannot reference a CSS * custom property (Tailwind resolves variants at build time, before * any `var()` could be evaluated), so there is nothing to tokenize; * tokenizing would require changing Tailwind's own variant syntax, * which is out of scope. These are recognized structurally: a * bracket immediately followed by `:` (not part of a class string's * trailing utility) is a variant, not a utility value. * * True exceptions that DO carry a value but cannot be tokenized are * ALLOWLISTED, not silently excluded (see ALLOWLIST parsing below): * `max-[480px]`/`min-[420px]` breakpoint variants (variant position * cannot reference a var), and `rounded-[inherit]` (a CSS-wide * keyword, not a literal value, cannot come from a custom property). * * Gate 3 — zero raw FONT-SIZE declarations: `text-[Npx]`/`text-[N.Nrem]` * Tailwind arbitrary font-size utilities (a subset of gate 2, checked * explicitly since font-size is its own DESIGN.md-named category) and * `fontSize: "..."` / `font-size:` string-literal declarations in * inline styles or css-in-js. * * Gate 4 — zero legacy hsl(var(--token)) wrappers in the token layer. * Semantic colors are complete color values (currently OKLCH), not bare * HSL channels. Wrapping one in hsl() creates an invalid declaration and * can void an entire composed box-shadow. * * The ALLOWLIST is parsed from the machine-readable block in * ui/src/index.css (search for "── ALLOWLIST" below it), one entry per * line in the form: * * allow * A violation at a path is suppressed if the path CONTAINS (substring * match) any allowlisted path. This intentionally allowlists the whole * file for simplicity/reviewability, matching how Batches 1-3 allowlisted * entire sites' surrounding functional code rather than individual * characters. * * Exit code: 0 if all three gates are clean (prints a per-gate summary). * Exit code: 1 if any gate has violations (lists them, grouped by gate). * * Usage: node scripts/check-token-gates.mjs */ import { readFileSync, readdirSync } from "node:fs"; import { resolve, dirname, join, relative } from "node:path"; import { fileURLToPath } from "node:url"; const __dirname = dirname(fileURLToPath(import.meta.url)); const REPO_ROOT = resolve(__dirname, ".."); const UI_SRC = resolve(REPO_ROOT, "ui/src"); const SCAN_DIRS = ["components", "pages"]; const CSS_PATH = resolve(UI_SRC, "index.css"); // ── Allowlist parsing ──────────────────────────────────────────────────── // Reads the machine-readable "* allow " lines from the // ALLOWLIST block in ui/src/index.css. Tolerant of either em-dash (—) or // a plain hyphen-minus as the path/reason separator, and of the historical // per-batch prose blocks NOT being in this format (they are not parsed; // only lines starting with "* allow " are). function loadAllowlist(cssPath) { const css = readFileSync(cssPath, "utf8"); const entries = []; const lineRe = /^\s*\*\s*allow\s+(\S+)\s+(?:—|-{1,2})\s*(.*)$/; for (const rawLine of css.split("\n")) { const m = rawLine.match(lineRe); if (m) { entries.push({ path: m[1], reason: m[2].trim() }); } } return entries; } function isAllowlisted(relPath, allowlist) { return allowlist.some((entry) => relPath.includes(entry.path)); } // ── File walking ───────────────────────────────────────────────────────── function walk(dir, out) { for (const entry of readdirSync(dir, { withFileTypes: true })) { const p = join(dir, entry.name); if (entry.isDirectory()) walk(p, out); else if (/\.(tsx?|jsx?)$/.test(entry.name)) out.push(p); } } function listFiles() { const files = []; for (const dir of SCAN_DIRS) walk(resolve(UI_SRC, dir), files); files.sort(); return files; } // ── Gate 1: color literals ─────────────────────────────────────────────── // Hex colors: #abc, #aabbcc, #aabbccdd — word-boundary guarded so it // doesn't match inside identifiers, and NOT preceded by another hex digit // (avoids over-matching truncated substrings of longer non-color tokens, // though `#` itself is a strong enough anchor in practice). // A genuine CSS hex color is never glued directly to an identifier // character (letter/digit/underscore) or `/` immediately before the `#` — // that shape is an issue/PR reference like "acme/web#241" or "acme/web#12" // (Batch 1's codemod header documented this exact false-positive risk for // its own hex-literal sweep; the same guard applies here). A real color // literal is preceded by a delimiter (quote, colon, paren, comma, // whitespace, backtick, template `${`) or sits at the start of the string. const HEX_COLOR_RE = /(? ({ index: match.index, snippet: match[0], })); } function lineNumberAt(content, index) { return content.slice(0, index).split("\n").length; } function main() { const allowlist = loadAllowlist(CSS_PATH); const files = listFiles(); const violations = { gate1: [], gate2: [], gate3: [], gate4: [] }; let allowlistedSkips = 0; for (const filePath of files) { const content = readFileSync(filePath, "utf8"); const relPathPosix = relPathToPosix(filePath); const allowed = isAllowlisted(relPathPosix, allowlist); const g1 = findColorLiteralIssues(content); const g2 = findArbitraryBracketIssues(content); const g3 = findFontSizeIssues(content); if (allowed) { allowlistedSkips += g1.length + g2.length + g3.length; continue; } for (const issue of g1) { violations.gate1.push({ file: relPathPosix, line: lineNumberAt(content, issue.index), snippet: issue.snippet }); } for (const issue of g2) { violations.gate2.push({ file: relPathPosix, line: lineNumberAt(content, issue.index), snippet: issue.snippet }); } for (const issue of g3) { violations.gate3.push({ file: relPathPosix, line: lineNumberAt(content, issue.index), snippet: issue.snippet }); } } const tokenLayer = readFileSync(CSS_PATH, "utf8"); for (const issue of findLegacyHslVarWrapperIssues(tokenLayer)) { violations.gate4.push({ file: relPathToPosix(CSS_PATH), line: lineNumberAt(tokenLayer, issue.index), snippet: issue.snippet, }); } const totalViolations = Object.values(violations).reduce((total, gate) => total + gate.length, 0); console.log("check-token-gates summary"); console.log(` Files scanned: ${files.length}`); console.log(` Allowlist entries loaded: ${allowlist.length}`); console.log(` Allowlisted issues skipped: ${allowlistedSkips}`); console.log(""); console.log(` Gate 1 (color literals): ${violations.gate1.length === 0 ? "CLEAN" : `${violations.gate1.length} violation(s)`}`); console.log(` Gate 2 (arbitrary bracket vals): ${violations.gate2.length === 0 ? "CLEAN" : `${violations.gate2.length} violation(s)`}`); console.log(` Gate 3 (raw font-size): ${violations.gate3.length === 0 ? "CLEAN" : `${violations.gate3.length} violation(s)`}`); console.log(` Gate 4 (legacy hsl(var())): ${violations.gate4.length === 0 ? "CLEAN" : `${violations.gate4.length} violation(s)`}`); if (totalViolations > 0) { console.log("\nViolations:\n"); for (const [gateName, list] of Object.entries(violations)) { if (list.length === 0) continue; console.log(`── ${gateName} ──`); for (const v of list) { console.log(` ${v.file}:${v.line} ${v.snippet}`); } console.log(""); } process.exitCode = 1; return; } console.log("\nAll gates clean."); process.exitCode = 0; } // Windows path separators never appear in this repo's CI, but keep relative // paths POSIX-style for allowlist substring matching regardless of platform. function relPathToPosix(filePath) { return ("ui/src/" + relative(UI_SRC, filePath)).split("\\").join("/"); } main();