342 lines
16 KiB
JavaScript
342 lines
16 KiB
JavaScript
#!/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 <repo-relative-path> — <reason>
|
|
* 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 <path> — <reason>" 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 = /(?<![a-zA-Z0-9_/])#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})\b/g;
|
|
|
|
// rgb()/rgba()/hsl()/hsla()/oklch() with a LITERAL first argument (a digit,
|
|
// a `.` decimal, or a `%` — i.e. not `var(` or `calc(` immediately inside).
|
|
// `hsl(var(--x)/0.16)` must NOT match (var() reference); `rgba(0,0,0,0.5)`
|
|
// MUST match (literal numeric channels).
|
|
const COLOR_FN_LITERAL_RE = /\b(?:rgb|rgba|hsl|hsla|oklch)\(\s*(?!var\()[0-9.%-]/g;
|
|
|
|
function findColorLiteralIssues(content) {
|
|
const issues = [];
|
|
for (const m of content.matchAll(HEX_COLOR_RE)) {
|
|
issues.push({ index: m.index, snippet: m[0] });
|
|
}
|
|
for (const m of content.matchAll(COLOR_FN_LITERAL_RE)) {
|
|
issues.push({ index: m.index, snippet: m[0] });
|
|
}
|
|
return issues;
|
|
}
|
|
|
|
// ── Gate 2: value-bearing arbitrary bracket utilities ───────────────────
|
|
// Matches `word-[content]` (optionally prefixed by `!`, and optionally
|
|
// preceded by a Tailwind variant chain like `sm:` / `dark:` / `hover:` /
|
|
// `data-[state=open]:` etc. — the regex only needs to find the utility's
|
|
// OWN bracket, not parse the whole variant chain, since VARIANT_KEYWORDS
|
|
// below excludes variant-shaped words directly at the match site).
|
|
//
|
|
// A bracket is a VARIANT (excluded by definition, see header) if:
|
|
// (a) the word immediately before `-[` is one of the known variant
|
|
// keywords (data, group-data, has, group-has-data, aria, supports,
|
|
// group-aria, peer-data, peer-aria, in, not), OR
|
|
// (b) the bracket is immediately followed by `:` (a breakpoint-style
|
|
// variant prefix, e.g. `max-[480px]:hidden` — the `:` right after
|
|
/// `]` is the structural signal that this bracket is a CONDITION,
|
|
// not a value).
|
|
const BRACKET_RE = /(!?)([a-zA-Z][a-zA-Z0-9-]*)-\[([^\[\]]*)\]/g;
|
|
|
|
const VARIANT_WORD_RE =
|
|
/(?:^|[\s"'`{])(?:group-|peer-)?(?:data|has|aria|supports|in|not)(?:-[a-zA-Z0-9]+)*$/;
|
|
|
|
// A bracket carries a VALUE (not just a keyword/selector fragment) if its
|
|
// content looks like: a number (optionally with a CSS unit or %), a CSS
|
|
// color literal (# hex or a color function), OR a known CSS value function
|
|
// call (calc/min/max/clamp/var/env/linear-gradient/radial-gradient/
|
|
// conic-gradient/cubic-bezier/rgba/rgb/hsl/hsla/oklch). Pure CSS KEYWORDS
|
|
// (e.g. `inherit`, `auto`, `pointer`) do NOT match and are not gated here
|
|
// (they're a separate, allowlisted concern — see `rounded-[inherit]`).
|
|
const VALUE_UNIT_RE = /^-?[0-9.]+(?:px|rem|em|vh|vw|dvh|dvw|svh|svw|ch|%|deg|s|ms|fr)?$/;
|
|
const VALUE_FUNC_RE =
|
|
/^(?:calc|min|max|clamp|var|env|linear-gradient|radial-gradient|conic-gradient|cubic-bezier|rgba?|hsla?|oklch|color-mix)\(/;
|
|
const HEX_ONLY_RE = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/;
|
|
|
|
function bracketCarriesValue(raw) {
|
|
const trimmed = raw.trim();
|
|
if (VALUE_UNIT_RE.test(trimmed)) return true;
|
|
if (HEX_ONLY_RE.test(trimmed)) return true;
|
|
if (VALUE_FUNC_RE.test(trimmed)) return true;
|
|
// A bracket containing an embedded CSS value function anywhere (e.g. a
|
|
// grid track list `56px_56px_24px_minmax(0,1fr)` that doesn't itself
|
|
// start with one of the above, or `translate-y-[-50%]`-style negative
|
|
// percentages already covered by VALUE_UNIT_RE) also counts.
|
|
if (/[0-9](?:px|rem|em|vh|vw|dvh|dvw|svh|svw|ch|%|deg|fr)\b/.test(trimmed)) return true;
|
|
if (/\b(?:calc|min|max|clamp|var|env|linear-gradient|radial-gradient|conic-gradient|cubic-bezier|rgba?|hsla?|oklch|color-mix)\(/.test(trimmed)) return true;
|
|
if (HEX_COLOR_RE.test(trimmed)) return true;
|
|
return false;
|
|
}
|
|
|
|
function findArbitraryBracketIssues(content) {
|
|
const issues = [];
|
|
for (const m of content.matchAll(BRACKET_RE)) {
|
|
const [full, , word, raw] = m;
|
|
const matchEnd = m.index + full.length;
|
|
const followedByColon = content[matchEnd] === ":";
|
|
if (followedByColon) continue; // breakpoint/arbitrary-variant prefix, not a utility value
|
|
|
|
// Reject if `word` itself IS (or ends in) a variant keyword shape, e.g.
|
|
// a match that accidentally captured "...data" as the utility name for
|
|
// some malformed/edge case. In practice BRACKET_RE's utility-name
|
|
// capture group only ever contains real utility names (data-[...] etc.
|
|
// are matched with `word` = "data", "group-data", "has", etc.).
|
|
const precedingContext = content.slice(Math.max(0, m.index - 1), m.index + word.length + 1);
|
|
if (VARIANT_WORD_RE.test(precedingContext)) continue;
|
|
if (/^(?:data|has|aria|supports|group-data|group-has-data|group-aria|peer-data|peer-aria|group-has-data-slot|in|not)$/.test(word)) {
|
|
continue;
|
|
}
|
|
|
|
if (!raw.includes("[") && bracketCarriesValue(raw)) {
|
|
issues.push({ index: m.index, snippet: `${word}-[${raw}]` });
|
|
}
|
|
}
|
|
return issues;
|
|
}
|
|
|
|
// ── Gate 3: raw font-size declarations ──────────────────────────────────
|
|
const FONT_SIZE_CLASS_RE = /\btext-\[(?:[0-9.]+(?:px|rem|em)|[0-9.]+\/[0-9.]+)\]/g;
|
|
// A raw literal font-size value: starts with a digit (px/rem/em number) —
|
|
// EXCLUDES `fontSize: "var(--text-micro)"`-style token references, which start
|
|
// with `var(` and are the desired post-extraction form, not a violation.
|
|
const FONT_SIZE_INLINE_RE = /\bfontSize\s*:\s*["'][0-9][^"']*["']/g;
|
|
const FONT_SIZE_CSS_PROP_RE = /(?<!-)\bfont-size\s*:\s*["'`][0-9][^"'`]*["'`]/g;
|
|
|
|
function findFontSizeIssues(content) {
|
|
const issues = [];
|
|
for (const m of content.matchAll(FONT_SIZE_CLASS_RE)) {
|
|
issues.push({ index: m.index, snippet: m[0] });
|
|
}
|
|
for (const m of content.matchAll(FONT_SIZE_INLINE_RE)) {
|
|
issues.push({ index: m.index, snippet: m[0] });
|
|
}
|
|
for (const m of content.matchAll(FONT_SIZE_CSS_PROP_RE)) {
|
|
issues.push({ index: m.index, snippet: m[0] });
|
|
}
|
|
return issues;
|
|
}
|
|
|
|
// Semantic color custom properties hold complete color values. Legacy
|
|
// Tailwind-v3-era hsl(var(--token) / alpha) composition is therefore invalid.
|
|
const LEGACY_HSL_VAR_WRAPPER_RE = /\bhsla?\(\s*var\(--[^)]+\)[^)]*\)/g;
|
|
|
|
function findLegacyHslVarWrapperIssues(content) {
|
|
return Array.from(content.matchAll(LEGACY_HSL_VAR_WRAPPER_RE), (match) => ({
|
|
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();
|