forked from c-fu/arah2
815 lines
32 KiB
JavaScript
815 lines
32 KiB
JavaScript
#!/usr/bin/env node
|
|
/*
|
|
* audit.mjs — automated off-token audit (D-13)
|
|
* ------------------------------------------------------------------
|
|
* "Zero off-token values" is evidence, not an eyeball claim: this
|
|
* script scans RENDERED CSS — every declaration the implementation
|
|
* actually wears — and flags any color / spacing / typography /
|
|
* radius / shadow / blur value that does not derive from
|
|
* theme/src/tokens.css (the single source of truth, D-07).
|
|
*
|
|
* Usage:
|
|
* node scripts/audit.mjs [--tokens <tokens.css>] [--pages <a.html,b.html>] [css...]
|
|
* --tokens token sheet to build the allowlist from
|
|
* (default: ./src/tokens.css)
|
|
* --pages implementation pages that define the used-class
|
|
* harvest (default: the IMPLEMENTATION_PAGES registry
|
|
* below — later UI phases APPEND their pages there)
|
|
* css... CSS inputs to audit (default: ./dist/rams.css)
|
|
*
|
|
* Exit codes (no silent passes — T-1-07):
|
|
* 0 clean — every checked declaration derives from tokens
|
|
* 1 violations — each printed as file:line: declaration
|
|
* 2 drift/error — token sheet degraded (see message)
|
|
*
|
|
* ——— Scope adjudications (documented, deliberate) ———
|
|
*
|
|
* 1. RENDERED, not compiled-vocabulary (D-06/D-13): dist/rams.css
|
|
* compiles the mockup screens' utility vocabulary 1:1 (wave-1
|
|
* @source decision) — including utilities the mockups wear but
|
|
* the Rams design doc REJECTS (shadow-*, blur, backdrop-blur,
|
|
* arbitrary bg-[#…] values). Mockup markup is layout reference
|
|
* only; the implementation never wears those classes. So within
|
|
* @layer utilities only rules whose selectors match a class
|
|
* actually used by an implementation page are audited. If a
|
|
* later phase lets shadow/backdrop-blur/bg-black into its
|
|
* markup, those classes enter the harvest and get flagged.
|
|
*
|
|
* 2. Framework layers are skipped: @layer properties / theme /
|
|
* base are Tailwind's runtime plumbing (—tw-* registrations,
|
|
* preflight normalization) plus the token emission itself (the
|
|
* allowlist). @layer components, unlayered CSS, and every other
|
|
* layer are implementation-owned and fully audited. --tw-*
|
|
* custom properties are plumbing in any layer and are skipped.
|
|
*
|
|
* 3. Token-hue + alpha is sanctioned: 8-digit hex whose first six
|
|
* digits are a token value (e.g. matte-black at reduced opacity)
|
|
* derives from the package; Tailwind's own black (#000000xx) is
|
|
* NOT a token and stays flagged.
|
|
*
|
|
* 4. rounded-full is a circle primitive, not a roundedness degree:
|
|
* the design doc's Shapes rule enumerates rect families (cards,
|
|
* buttons, banners, badges, containers) — all of which this audit
|
|
* enforces at absolute zero. The mockups' circular signets
|
|
* (position puck, speed medallion, status dots) are drawn
|
|
* circles: spec geometry, like the 18x18 checkbox square. Only
|
|
* the full-circle primitive (calc(infinity*1px) and its
|
|
* minified >=1e30px form) is sanctioned; every other non-zero
|
|
* radius — including 50% — is flagged.
|
|
*
|
|
* 5. rgb()/rgba()/hsl()/hsla() literals are checked too (hex is not
|
|
* the only escape hatch for off-token colors) — as are named color
|
|
* keywords (on color-bearing properties; currentcolor/inherit/
|
|
* transparent stay sanctioned) and modern color functions —
|
|
* oklch()/oklab()/lab()/lch()/hwb()/color()/color-mix(). var()
|
|
* references are always token paths and pass by construction.
|
|
*
|
|
* 6. Spacing numerics (WR-04) are audited on the spacing-scale
|
|
* properties — the padding/margin/gap families, logical variants
|
|
* included: literals must equal a --spacing-* token value, zero,
|
|
* or auto. Dimensional geometry — width/height, positioning
|
|
* offsets, border widths, the 18x18 checkbox square — is spec
|
|
* geometry per the components.css header, not spacing-scale
|
|
* consumption.
|
|
*
|
|
* 7. Typography numerics (WR-04) — font-size/font-weight/line-height/
|
|
* letter-spacing — are audited against the --type-* scale; the
|
|
* normal/bold keywords ≡ initial/≡ the 700 scale weight and stay
|
|
* sanctioned. @font-face blocks describe the self-hosted font
|
|
* binaries (D-16): their font-weight values are asset metadata,
|
|
* not rendered typography, and are exempt from the numeric scale
|
|
* (color and font-family checks still apply).
|
|
*
|
|
* Reuse (D-14 lineage): later UI phases extend IMPLEMENTATION_PAGES
|
|
* with their pages and keep running `npm run audit` in CI/local
|
|
* gates — the DSGN-01 evidence command for every phase.
|
|
*/
|
|
import { existsSync, readFileSync } from 'node:fs';
|
|
import { dirname, join, relative, resolve } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
const SCRIPTS = dirname(fileURLToPath(import.meta.url));
|
|
const THEME_ROOT = resolve(SCRIPTS, '..');
|
|
const REPO_ROOT = resolve(THEME_ROOT, '..');
|
|
const DEFAULT_TOKENS = join(THEME_ROOT, 'src', 'tokens.css');
|
|
const DEFAULT_INPUT = join(THEME_ROOT, 'dist', 'rams.css');
|
|
|
|
/* Drift guard floor (T-1-07): the Rams token scale has 55 colors;
|
|
* a parse far below that means tokens.css degraded silently. */
|
|
const COLOR_FLOOR = 40;
|
|
|
|
/* Framework-owned cascade layers (see adjudication 2). */
|
|
const FRAMEWORK_LAYERS = new Set(['properties', 'theme', 'base']);
|
|
const UTILITY_LAYER = 'utilities';
|
|
|
|
/* At-rules that wrap nested blocks rather than declare anything. */
|
|
const WRAPPER_AT = new Set([
|
|
'layer', 'media', 'supports', 'container', 'scope',
|
|
'keyframes', 'starting-style',
|
|
]);
|
|
|
|
/*
|
|
* Implementation pages (the rendered surface — adjudication 1).
|
|
* EVERY page that renders Rams components belongs here; later UI
|
|
* phases append theirs. Inline <style> blocks of these pages are
|
|
* audited as page-owned CSS, and their class attributes define
|
|
* the used-class harvest for @layer utilities.
|
|
*/
|
|
const IMPLEMENTATION_PAGES = [
|
|
'src/sheet.html', // plan 01-02 — component sheet (D-03)
|
|
'basemap/index.html', // plan 01-03 — basemap proof page (D-08/D-09)
|
|
'gallery.html', // plan 01-04 — DSGN-02 approval gallery (D-12)
|
|
];
|
|
|
|
/* ————— small helpers ————— */
|
|
function fail(code, ...msg) {
|
|
console.error(`AUDIT DRIFT — ${msg.join(' ')}`);
|
|
process.exit(code);
|
|
}
|
|
|
|
function displayPath(abs) {
|
|
const rel = relative(REPO_ROOT, abs);
|
|
return rel.startsWith('..') || rel === '' ? abs : rel;
|
|
}
|
|
|
|
function lineAt(text, offset) {
|
|
let line = 1;
|
|
for (let i = 0; i < offset && i < text.length; i++) {
|
|
if (text[i] === '\n') line++;
|
|
}
|
|
return line;
|
|
}
|
|
|
|
function normalizeHex(hashLiteral) {
|
|
let h = hashLiteral.slice(1);
|
|
if (h.length === 3 || h.length === 4) h = [...h].map((c) => c + c).join('');
|
|
return h.toLowerCase(); // 6 or 8 digits
|
|
}
|
|
|
|
function clamp255(n) {
|
|
return Math.max(0, Math.min(255, Math.round(n)));
|
|
}
|
|
|
|
function hslToHex(h, s, l) {
|
|
const hue = h % 360 < 0 ? h % 360 + 360 : h % 360;
|
|
const sat = Math.max(0, Math.min(1, s / 100));
|
|
const lig = Math.max(0, Math.min(1, l / 100));
|
|
const c = (1 - Math.abs(2 * lig - 1)) * sat;
|
|
const hp = hue / 60;
|
|
const x = c * (1 - Math.abs((hp % 2) - 1));
|
|
let r = 0;
|
|
let g = 0;
|
|
let b = 0;
|
|
if (hp <= 1) [r, g, b] = [c, x, 0];
|
|
else if (hp <= 2) [r, g, b] = [x, c, 0];
|
|
else if (hp <= 3) [r, g, b] = [0, c, x];
|
|
else if (hp <= 4) [r, g, b] = [0, x, c];
|
|
else if (hp <= 5) [r, g, b] = [x, 0, c];
|
|
else [r, g, b] = [c, 0, x];
|
|
const m = lig - c / 2;
|
|
const hex = (v) => clamp255((v + m) * 255).toString(16).padStart(2, '0');
|
|
return `${hex(r)}${hex(g)}${hex(b)}`;
|
|
}
|
|
|
|
/* ————— token sheet parsing (allowlist + drift guards) ————— */
|
|
|
|
function parseTokens(path) {
|
|
if (!existsSync(path)) fail(2, `token sheet not found: ${displayPath(path)}`);
|
|
const text = readFileSync(path, 'utf8');
|
|
|
|
const colors = new Map(); // normalized 6-digit hex -> first token name
|
|
let colorCount = 0;
|
|
for (const m of text.matchAll(/--color-([a-z0-9-]+)\s*:\s*(#[0-9a-fA-F]{3,8})\s*;/g)) {
|
|
colorCount++;
|
|
const norm = normalizeHex(m[2]).slice(0, 6);
|
|
if (!colors.has(norm)) colors.set(norm, m[1]);
|
|
}
|
|
if (colorCount < COLOR_FLOOR) {
|
|
fail(2, `token sheet parsed to ${colorCount} color tokens (floor is ${COLOR_FLOOR}) — ${displayPath(path)} degraded?`);
|
|
}
|
|
|
|
let radiusCount = 0;
|
|
/* (?:-[A-Za-z0-9-]*)? — also matches the deprecated bare --radius key
|
|
* that drives the bare `rounded` utility, so a future non-zero bare
|
|
* key override cannot escape the drift guard. */
|
|
for (const m of text.matchAll(/--radius(?:-[A-Za-z0-9-]*)?\s*:\s*([^;}]+?)\s*;/g)) {
|
|
radiusCount++;
|
|
if (!/^0*\.?0*(px|rem|em)?$/.test(m[1].trim())) {
|
|
fail(2, `radius token --radius-…: ${m[1].trim()} is not zero — Rams requires absolute zero roundedness (design doc, Shapes)`);
|
|
}
|
|
}
|
|
|
|
const fonts = new Set();
|
|
for (const m of text.matchAll(/--font-[a-z0-9-]+\s*:\s*([^;}]+?)\s*;/g)) {
|
|
for (const family of splitTopLevel(m[1], ',')) {
|
|
const f = stripFamily(family);
|
|
if (f) fonts.add(f.toLowerCase());
|
|
}
|
|
}
|
|
if (fonts.size === 0) fail(2, 'no --font-* stacks parsed from the token sheet');
|
|
|
|
/* WR-04: spacing values — literals on the spacing-scale properties
|
|
* must equal a --spacing-* token value (or be zero/auto). */
|
|
const spacings = new Set();
|
|
for (const m of text.matchAll(/--spacing-[a-z0-9-]+\s*:\s*([^;}]+?)\s*;/g)) {
|
|
for (const v of m[1].split(/[\s,]+/)) {
|
|
if (v) spacings.add(v.toLowerCase());
|
|
}
|
|
}
|
|
if (spacings.size === 0) fail(2, 'no --spacing-* values parsed from the token sheet');
|
|
|
|
/* WR-04: the --type scale — weight, size/line-height, and tracking
|
|
* values, parsed from the compound token grammar
|
|
* `weight size/line-height family…` plus the -tracking companions. */
|
|
const typeSizes = new Set();
|
|
const typeWeights = new Set();
|
|
const typeLineHeights = new Set();
|
|
const typeTrackings = new Set();
|
|
for (const m of text.matchAll(/--type-([a-z0-9-]+)\s*:\s*([^;}]+?)\s*;/g)) {
|
|
const value = m[2].trim();
|
|
if (m[1].endsWith('-tracking')) {
|
|
typeTrackings.add(value.toLowerCase());
|
|
continue;
|
|
}
|
|
const t = /^([0-9]{3})\s+([0-9]*\.?[0-9]+[a-z]+)\/([0-9]*\.?[0-9]+[a-z]+)\s/.exec(value);
|
|
if (t) {
|
|
typeWeights.add(t[1]);
|
|
typeSizes.add(t[2].toLowerCase());
|
|
typeLineHeights.add(t[3].toLowerCase());
|
|
}
|
|
}
|
|
if (typeSizes.size === 0) fail(2, 'no --type-* scale parsed from the token sheet');
|
|
|
|
return { colors, colorCount, radiusCount, fonts, spacings, typeSizes, typeWeights, typeLineHeights, typeTrackings };
|
|
}
|
|
|
|
function stripFamily(part) {
|
|
return part.trim().replace(/^["']|["']$/g, '').trim();
|
|
}
|
|
|
|
/* ————— generic CSS parsing ————— */
|
|
|
|
function stripComments(text) {
|
|
return text.replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, ' '));
|
|
}
|
|
|
|
function parseRules(text) {
|
|
const src = stripComments(text);
|
|
const rules = [];
|
|
const open = []; // { wrapper | rule }
|
|
let pending = '';
|
|
let i = 0;
|
|
const n = src.length;
|
|
while (i < n) {
|
|
const c = src[i];
|
|
if (c === '"' || c === "'") {
|
|
pending += c;
|
|
const q = c;
|
|
i++;
|
|
while (i < n && src[i] !== q) {
|
|
if (src[i] === '\\') pending += src[i++];
|
|
pending += src[i];
|
|
i++;
|
|
}
|
|
pending += q;
|
|
i++;
|
|
continue;
|
|
}
|
|
if (c === '{') {
|
|
const sel = pending.trim();
|
|
pending = '';
|
|
if (sel.startsWith('@')) {
|
|
const name = sel.slice(1).split(/[\s({]/)[0].toLowerCase();
|
|
if (WRAPPER_AT.has(name)) {
|
|
open.push({ wrapper: true, at: name, arg: sel.slice(1 + name.length).trim() });
|
|
} else {
|
|
open.push({ rule: true, selector: sel, bodyStart: i + 1 });
|
|
}
|
|
} else {
|
|
open.push({ rule: true, selector: sel, bodyStart: i + 1 });
|
|
}
|
|
} else if (c === '}') {
|
|
const blk = open.pop();
|
|
if (blk && blk.rule) {
|
|
let layer = null;
|
|
for (let k = open.length - 1; k >= 0; k--) {
|
|
if (open[k].wrapper && open[k].at === 'layer') {
|
|
layer = (open[k].arg || '').replace(/^["']|["']$/g, '').trim() || null;
|
|
break;
|
|
}
|
|
}
|
|
rules.push({ selector: blk.selector, body: src.slice(blk.bodyStart, i), bodyStart: blk.bodyStart, layer });
|
|
}
|
|
pending = '';
|
|
} else if (c === ';' && open.length === 0) {
|
|
pending = ''; // top-level statement (@import/@source/…) — build-time, skip
|
|
} else {
|
|
pending += c;
|
|
}
|
|
i++;
|
|
}
|
|
return rules;
|
|
}
|
|
|
|
/* Extract unescaped class-name tokens from a selector. */
|
|
function classTokens(selector) {
|
|
const tokens = [];
|
|
let i = 0;
|
|
const n = selector.length;
|
|
while (i < n) {
|
|
if (selector[i] === '.') {
|
|
let j = i + 1;
|
|
let name = '';
|
|
while (j < n) {
|
|
const cj = selector[j];
|
|
if (cj === '\\') {
|
|
name += selector[j + 1] ?? '';
|
|
j += 2;
|
|
continue;
|
|
}
|
|
if (!/[A-Za-z0-9_\-\u00a0-\uffff]/.test(cj)) break;
|
|
name += cj;
|
|
j++;
|
|
}
|
|
if (name) tokens.push(name);
|
|
i = j;
|
|
} else {
|
|
i++;
|
|
}
|
|
}
|
|
return tokens;
|
|
}
|
|
|
|
/* Split a value/selector on a top-level separator, parens+strings aware. */
|
|
function splitTopLevel(text, sep) {
|
|
const parts = [];
|
|
let depth = 0;
|
|
let quote = null;
|
|
let buf = '';
|
|
for (let i = 0; i < text.length; i++) {
|
|
const c = text[i];
|
|
if (quote) {
|
|
buf += c;
|
|
if (c === '\\') {
|
|
buf += text[++i] ?? '';
|
|
} else if (c === quote) quote = null;
|
|
continue;
|
|
}
|
|
if (c === '"' || c === "'") {
|
|
quote = c;
|
|
buf += c;
|
|
continue;
|
|
}
|
|
if (c === '(') depth++;
|
|
if (c === ')') depth = Math.max(0, depth - 1);
|
|
if (depth === 0 && c === sep) {
|
|
parts.push(buf);
|
|
buf = '';
|
|
continue;
|
|
}
|
|
buf += c;
|
|
}
|
|
parts.push(buf);
|
|
return parts;
|
|
}
|
|
|
|
/* Declarations with their offsets inside the body. */
|
|
function declarations(body) {
|
|
const out = [];
|
|
let start = 0;
|
|
let depth = 0;
|
|
let quote = null;
|
|
for (let i = 0; i < body.length; i++) {
|
|
const c = body[i];
|
|
if (quote) {
|
|
if (c === '\\') i++;
|
|
else if (c === quote) quote = null;
|
|
continue;
|
|
}
|
|
if (c === '"' || c === "'") {
|
|
quote = c;
|
|
continue;
|
|
}
|
|
if (c === '(') depth++;
|
|
else if (c === ')') depth = Math.max(0, depth - 1);
|
|
else if (c === ';' && depth === 0) {
|
|
out.push({ text: body.slice(start, i), offset: start });
|
|
start = i + 1;
|
|
}
|
|
}
|
|
out.push({ text: body.slice(start), offset: start });
|
|
return out.filter((d) => d.text.trim());
|
|
}
|
|
|
|
function splitDecl(text) {
|
|
const depthParts = splitTopLevel(text, ':');
|
|
if (depthParts.length < 2) return null;
|
|
const prop = depthParts[0].trim().toLowerCase();
|
|
const value = depthParts.slice(1).join(':').trim();
|
|
if (!prop || !value) return null;
|
|
return { prop, value };
|
|
}
|
|
|
|
/* ————— declaration checks ————— */
|
|
|
|
/* CSS named colors (the full CSS Color keyword list, WR-03): a named
|
|
* keyword on a color-bearing property is an off-token color — the
|
|
* palette lives in tokens.css (D-07). currentcolor/transparent and
|
|
* the css-wide keywords (inherit/initial/unset/revert) are not in
|
|
* the list and stay sanctioned. */
|
|
const NAMED_COLORS = new Set([
|
|
'aliceblue', 'antiquewhite', 'aqua', 'aquamarine', 'azure', 'beige',
|
|
'bisque', 'black', 'blanchedalmond', 'blue', 'blueviolet', 'brown',
|
|
'burlywood', 'cadetblue', 'chartreuse', 'chocolate', 'coral',
|
|
'cornflowerblue', 'cornsilk', 'crimson', 'cyan', 'darkblue', 'darkcyan',
|
|
'darkgoldenrod', 'darkgray', 'darkgreen', 'darkgrey', 'darkkhaki',
|
|
'darkmagenta', 'darkolivegreen', 'darkorange', 'darkorchid', 'darkred',
|
|
'darksalmon', 'darkseagreen', 'darkslateblue', 'darkslategray',
|
|
'darkslategrey', 'darkturquoise', 'darkviolet', 'deeppink', 'deepskyblue',
|
|
'dimgray', 'dimgrey', 'dodgerblue', 'firebrick', 'floralwhite',
|
|
'forestgreen', 'fuchsia', 'gainsboro', 'ghostwhite', 'gold', 'goldenrod',
|
|
'gray', 'green', 'greenyellow', 'grey', 'honeydew', 'hotpink', 'indianred',
|
|
'indigo', 'ivory', 'khaki', 'lavender', 'lavenderblush', 'lawngreen',
|
|
'lemonchiffon', 'lightblue', 'lightcoral', 'lightcyan',
|
|
'lightgoldenrodyellow', 'lightgray', 'lightgreen', 'lightgrey', 'lightpink',
|
|
'lightsalmon', 'lightseagreen', 'lightskyblue', 'lightslategray',
|
|
'lightslategrey', 'lightsteelblue', 'lightyellow', 'lime', 'limegreen',
|
|
'linen', 'magenta', 'maroon', 'mediumaquamarine', 'mediumblue',
|
|
'mediumorchid', 'mediumpurple', 'mediumseagreen', 'mediumslateblue',
|
|
'mediumspringgreen', 'mediumturquoise', 'mediumvioletred', 'midnightblue',
|
|
'mintcream', 'mistyrose', 'moccasin', 'navajowhite', 'navy', 'oldlace',
|
|
'olive', 'olivedrab', 'orange', 'orangered', 'orchid', 'palegoldenrod',
|
|
'palegreen', 'paleturquoise', 'palevioletred', 'papayawhip', 'peachpuff',
|
|
'peru', 'pink', 'plum', 'powderblue', 'purple', 'rebeccapurple', 'red',
|
|
'rosybrown', 'royalblue', 'saddlebrown', 'salmon', 'sandybrown',
|
|
'seagreen', 'seashell', 'sienna', 'silver', 'skyblue', 'slateblue',
|
|
'slategray', 'slategrey', 'snow', 'springgreen', 'steelblue', 'tan',
|
|
'teal', 'thistle', 'tomato', 'turquoise', 'violet', 'wheat', 'white',
|
|
'whitesmoke', 'yellow', 'yellowgreen',
|
|
]);
|
|
|
|
/* Color-bearing properties, shorthands included (WR-03): a bare named
|
|
* keyword in any of these values is a color choice. box-shadow and
|
|
* text-shadow are not listed — any non-none value is already forbidden
|
|
* outright by shadowViolations. */
|
|
const COLORISH_PROPS = new Set([
|
|
'color', 'background', 'background-color', 'background-image', 'fill',
|
|
'stroke', 'caret-color', 'outline', 'outline-color', 'border',
|
|
'border-top', 'border-right', 'border-bottom', 'border-left',
|
|
'border-x', 'border-y', 'border-block-start', 'border-block-end',
|
|
'border-inline-start', 'border-inline-end', 'border-color',
|
|
'border-top-color', 'border-right-color', 'border-bottom-color',
|
|
'border-left-color', 'border-block-color', 'border-block-start-color',
|
|
'border-block-end-color', 'border-inline-color',
|
|
'border-inline-start-color', 'border-inline-end-color',
|
|
'text-decoration', 'text-decoration-color', 'accent-color',
|
|
]);
|
|
|
|
function namedColorViolations(prop, value) {
|
|
if (!COLORISH_PROPS.has(prop)) return [];
|
|
const bad = [];
|
|
for (const part of splitTopLevel(value, ',')) {
|
|
for (const tok of splitTopLevel(part, ' ')) {
|
|
const t = tok.trim().toLowerCase().replace(/[(),]+$/g, '');
|
|
if (NAMED_COLORS.has(t)) bad.push(`named color keyword ${tok.trim()} — use var(--color-*) (Rams palette, D-07)`);
|
|
}
|
|
}
|
|
return bad;
|
|
}
|
|
|
|
/* Modern color functions (CSS Color 4, WR-03): unambiguous color
|
|
* literals on any property — the same surface the hex check covers.
|
|
* var()-bearing values pass by construction (Tailwind's own color-mix
|
|
* emissions are var-driven). */
|
|
function colorFunctionViolations(value) {
|
|
if (/var\(/i.test(value)) return [];
|
|
const bad = [];
|
|
for (const m of value.matchAll(/\b(?:oklch|oklab|lab|lch|hwb|color-mix|color)\(/gi)) {
|
|
bad.push(`off-token color function ${m[0].slice(0, -1)}(…) — color values derive from var(--color-*) (Rams palette, D-07)`);
|
|
}
|
|
return bad;
|
|
}
|
|
|
|
function literalHexViolations(value, tokens) {
|
|
const found = [];
|
|
const hexRe = /#([0-9a-fA-F]{8}|[0-9a-fA-F]{6}|[0-9a-fA-F]{4}|[0-9a-fA-F]{3})(?![0-9a-fA-F])/g;
|
|
for (const m of value.matchAll(hexRe)) {
|
|
const norm = normalizeHex(m[0]);
|
|
const allowed = norm.length === 6
|
|
? tokens.colors.has(norm)
|
|
: tokens.colors.has(norm.slice(0, 6));
|
|
if (!allowed) found.push(`off-token color literal ${m[0]}`);
|
|
}
|
|
for (const m of value.matchAll(/\b(?:rgba?|hsla?)\(([^()]*)\)/g)) {
|
|
const inner = m[1];
|
|
if (/var\(/i.test(inner)) continue; // token path or mixed — var refs pass by construction
|
|
const nums = inner.split(/[\s,/]+/).filter((p) => p.length > 0);
|
|
if (nums.length < 3) continue;
|
|
const pv = nums.map((p) => parseFloat(String(p).replace(/(deg|grad|rad|turn|%|px)$/i, '')));
|
|
if (pv.some((x) => Number.isNaN(x))) continue;
|
|
let hex;
|
|
if (/^hsl/i.test(m[0])) {
|
|
const unit = (nums[0].match(/(deg|grad|rad|turn)$/i) || [])[1];
|
|
let h = pv[0];
|
|
if (unit === 'grad') h = (h / 400) * 360;
|
|
else if (unit === 'rad') h = (h * 180) / Math.PI;
|
|
else if (unit === 'turn') h = h * 360;
|
|
hex = hslToHex(h, pv[1], pv[2]);
|
|
} else {
|
|
hex = [pv[0], pv[1], pv[2]]
|
|
.map((v) => clamp255(v).toString(16).padStart(2, '0'))
|
|
.join('');
|
|
}
|
|
if (!tokens.colors.has(hex)) found.push(`off-token color literal ${m[0].trim()}`);
|
|
}
|
|
return found;
|
|
}
|
|
|
|
function fontViolations(value, tokens) {
|
|
const bad = [];
|
|
for (const part of splitTopLevel(value, ',')) {
|
|
if (/^var\(/i.test(part.trim())) continue;
|
|
const family = stripFamily(part);
|
|
if (!family) continue;
|
|
if (!tokens.fonts.has(family.toLowerCase())) bad.push(`off-token font family "${family}"`);
|
|
}
|
|
return bad;
|
|
}
|
|
|
|
function radiusAllowed(value) {
|
|
const v = value.replace(/\s+/g, ' ').trim();
|
|
if (/^var\(/i.test(v)) return true; // --radius-* tokens are drift-guarded to zero
|
|
const compact = v.replace(/\s+/g, '');
|
|
if (/^calc\(infinity\*1px\)$/i.test(compact)) return true; // rounded-full circle primitive
|
|
return v.split(/\s+/).every((part) => {
|
|
if (/^0*\.?0*(px|rem|em|%)?$/i.test(part)) return true;
|
|
const sci = /^([\d.]+e[\d]+)px$/i.exec(part);
|
|
return !!sci && parseFloat(sci[1]) >= 1e30; // minified calc(infinity*1px)
|
|
});
|
|
}
|
|
|
|
function shadowViolations(prop, value) {
|
|
const v = value.trim().toLowerCase();
|
|
if (prop === 'box-shadow') return v === 'none' ? [] : ['box-shadow forbidden (Rams Shapes: no shadows)'];
|
|
if (prop === 'text-shadow') return v === 'none' ? [] : ['text-shadow forbidden (Rams Shapes: no shadows)'];
|
|
if (prop === 'backdrop-filter') return v === 'none' ? [] : ['backdrop-filter forbidden (Rams Shapes: no glass)'];
|
|
if (prop === 'filter') {
|
|
return /blur\(|drop-shadow\(|var\(--tw-blur|var\(--tw-drop-shadow/i.test(value)
|
|
? ['blur/drop-shadow filter forbidden (Rams Shapes: no blur)']
|
|
: [];
|
|
}
|
|
return [];
|
|
}
|
|
|
|
const RADIUS_PROPS = /^border(-(top|bottom)-(left|right))?-radius$/;
|
|
|
|
/* ————— WR-04: spacing + typography numeric scales ————— */
|
|
|
|
/* Spacing-scale properties: the padding/margin/gap families (logical
|
|
* variants included). Dimensional geometry — width/height, positioning
|
|
* offsets, border widths, the 18x18 checkbox square — is spec geometry
|
|
* per the components.css header, not spacing-scale consumption
|
|
* (adjudication 6). */
|
|
const SPACING_PROPS = new Set([
|
|
'padding', 'padding-top', 'padding-right', 'padding-bottom', 'padding-left',
|
|
'padding-block', 'padding-block-start', 'padding-block-end',
|
|
'padding-inline', 'padding-inline-start', 'padding-inline-end',
|
|
'margin', 'margin-top', 'margin-right', 'margin-bottom', 'margin-left',
|
|
'margin-block', 'margin-block-start', 'margin-block-end',
|
|
'margin-inline', 'margin-inline-start', 'margin-inline-end',
|
|
'gap', 'row-gap', 'column-gap',
|
|
]);
|
|
|
|
/* Typography numeric properties → the --type scale set that owns
|
|
* their values (adjudication 7). */
|
|
const TYPE_SCALE_PROPS = {
|
|
'font-size': 'typeSizes',
|
|
'font-weight': 'typeWeights',
|
|
'line-height': 'typeLineHeights',
|
|
'letter-spacing': 'typeTrackings',
|
|
};
|
|
|
|
const CSS_WIDE_KEYWORDS = new Set(['inherit', 'initial', 'unset', 'revert', 'revert-layer']);
|
|
|
|
function spacingViolations(value, tokens) {
|
|
const bad = [];
|
|
for (const part of splitTopLevel(value, ' ')) {
|
|
const v = part.trim().toLowerCase().replace(/,+$/, '');
|
|
if (!v) continue;
|
|
if (/^var\(/.test(v)) continue; // token path
|
|
if (v.startsWith('calc(') && /var\(/i.test(v)) continue; // token math (Tailwind's spacing utilities)
|
|
if (v === 'auto' || /^-?0(?:\.0+)?(?:px|rem|em|%)?$/.test(v)) continue; // zero / auto
|
|
if (tokens.spacings.has(v)) continue; // literal equal to a --spacing-* token value
|
|
bad.push(`off-token spacing literal ${part.trim()} — derive from theme/src/tokens.css --spacing-* values (D-07)`);
|
|
}
|
|
return bad;
|
|
}
|
|
|
|
function typeScaleViolations(prop, value, tokens) {
|
|
if (!(prop in TYPE_SCALE_PROPS)) return [];
|
|
const allowed = tokens[TYPE_SCALE_PROPS[prop]];
|
|
const bad = [];
|
|
for (const part of splitTopLevel(value, ',')) {
|
|
const v = part.trim().toLowerCase();
|
|
if (!v) continue;
|
|
if (/^var\(/.test(v)) continue; // token path
|
|
if (CSS_WIDE_KEYWORDS.has(v)) continue; // pass-through keywords
|
|
if (v === 'normal' || v === 'bold') continue; // initial / ≡ the 700 scale weight
|
|
if (allowed.has(v)) continue; // literal equal to a --type-* scale value
|
|
bad.push(`off-token ${prop} ${part.trim()} — derive from theme/src/tokens.css --type-* scale (D-07)`);
|
|
}
|
|
return bad;
|
|
}
|
|
|
|
function checkDeclaration(prop, value, tokens, selector = '') {
|
|
const violations = [];
|
|
if (prop.startsWith('--tw-')) return violations; // framework plumbing registration
|
|
violations.push(...literalHexViolations(value, tokens));
|
|
violations.push(...colorFunctionViolations(value));
|
|
if (prop.startsWith('--')) return violations; // custom property: color literals above are the checkable surface
|
|
violations.push(...namedColorViolations(prop, value));
|
|
violations.push(...shadowViolations(prop, value));
|
|
if (prop === 'font') {
|
|
/* WR-02: a literal font: shorthand embeds unvalidated weight/
|
|
size/line-height numerics — flag it outright (package convention:
|
|
font: var(--type-*)), naming the extracted families. */
|
|
if (!/var\(/i.test(value)) {
|
|
const families = familiesFromShorthand(value);
|
|
violations.push(
|
|
'literal font: shorthand — use font: var(--type-*) (Rams typography tokens)'
|
|
+ (families ? `; families: ${families}` : ''),
|
|
);
|
|
}
|
|
} else if (prop === 'font-family' && !/var\(/i.test(value)) {
|
|
violations.push(...fontViolations(value, tokens));
|
|
}
|
|
/* @font-face blocks describe the self-hosted font binaries (D-16):
|
|
* their font-weight values are asset metadata, not rendered
|
|
* typography — the numeric scales do not apply there (adjudication 7). */
|
|
if (!/^@font-face\b/i.test(selector)) {
|
|
if (SPACING_PROPS.has(prop)) violations.push(...spacingViolations(value, tokens));
|
|
violations.push(...typeScaleViolations(prop, value, tokens));
|
|
}
|
|
if (RADIUS_PROPS.test(prop) && !radiusAllowed(value)) {
|
|
violations.push('non-zero border-radius (Rams Shapes: absolute zero roundedness)');
|
|
}
|
|
return violations;
|
|
}
|
|
|
|
/* font: shorthand — families are the comma groups after the leading
|
|
style/variant/weight keyword run and the size[/line-height]
|
|
component: `700 14px/18px Arial, sans-serif` → `Arial, sans-serif`.
|
|
(WR-02: the old single-strip regex removed only the first token,
|
|
leaving "14px/18px Arial", which the numeric filter then dropped —
|
|
so no font: shorthand could ever be flagged.) */
|
|
function familiesFromShorthand(value) {
|
|
const families = [];
|
|
for (const group of splitTopLevel(value, ',')) {
|
|
let s = group.trim();
|
|
if (!s) continue;
|
|
/* strip the leading style/variant/weight run (any order per the
|
|
CSS grammar, incl. bare weights and an oblique angle) */
|
|
let m;
|
|
while ((m = /^(?:normal|italic|oblique(?:\s+[0-9.]+(?:deg|grad|rad|turn))?|small-caps|bold|bolder|lighter|[1-9][0-9]{0,2})\s+/i.exec(s))) {
|
|
s = s.slice(m[0].length);
|
|
}
|
|
/* strip the mandatory size (optionally /line-height) once */
|
|
s = s.replace(
|
|
/^(?:calc\([^()]*\)|[0-9]*\.?[0-9]+(?:[a-z%]+)?)\s*\/\s*(?:calc\([^()]*\)|[0-9]*\.?[0-9]+(?:[a-z%]+)?)\s+/i,
|
|
'',
|
|
).replace(/^(?:calc\([^()]*\)|[0-9]*\.?[0-9]+(?:[a-z%]+)?)\s+/i, '');
|
|
const family = stripFamily(s);
|
|
if (family) families.push(family);
|
|
}
|
|
return families.join(',');
|
|
}
|
|
|
|
/* ————— audit driver ————— */
|
|
|
|
function auditCss({ text, display, baseOffset, lineText }) {
|
|
const rules = parseRules(text);
|
|
const stats = { rulesAudited: 0, decls: 0, vocabularySkipped: 0 };
|
|
for (const rule of rules) {
|
|
const audited = (() => {
|
|
if (FRAMEWORK_LAYERS.has(rule.layer)) return false;
|
|
if (/^@property\s+--tw-/.test(rule.selector)) return false; // framework plumbing registration
|
|
if (rule.layer === UTILITY_LAYER) {
|
|
if (classTokens(rule.selector).some((t) => state.usedClasses.has(t))) return true;
|
|
stats.vocabularySkipped++;
|
|
return false;
|
|
}
|
|
return true;
|
|
})();
|
|
if (!audited) continue;
|
|
stats.rulesAudited++;
|
|
for (const d of declarations(rule.body)) {
|
|
const decl = splitDecl(d.text);
|
|
if (!decl) continue;
|
|
stats.decls++;
|
|
for (const reason of checkDeclaration(decl.prop, decl.value, state.tokens, rule.selector)) {
|
|
const offset = baseOffset + rule.bodyStart + d.offset;
|
|
state.violations.push(
|
|
`${display}:${lineAt(lineText, offset)}: ${rule.selector} { ${decl.prop}: ${decl.value} } — ${reason}`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
return stats;
|
|
}
|
|
|
|
const state = { tokens: null, violations: [], usedClasses: new Set(), totals: { rulesAudited: 0, decls: 0, vocabularySkipped: 0 }, inputs: 0 };
|
|
|
|
/* ————— CLI ————— */
|
|
|
|
const argv = process.argv.slice(2);
|
|
let tokensArg = DEFAULT_TOKENS;
|
|
let pagesArg = null;
|
|
const cssInputs = [];
|
|
for (let i = 0; i < argv.length; i++) {
|
|
const a = argv[i];
|
|
if (a === '--tokens') tokensArg = argv[++i] ?? fail(2, '--tokens requires a path');
|
|
else if (a === '--pages') pagesArg = argv[++i] ?? fail(2, '--pages requires a comma-separated list');
|
|
else if (a.startsWith('--')) fail(2, `unknown flag ${a}`);
|
|
else cssInputs.push(resolve(a));
|
|
}
|
|
if (cssInputs.length === 0) cssInputs.push(DEFAULT_INPUT);
|
|
|
|
state.tokens = parseTokens(tokensArg.startsWith('/') ? tokensArg : resolve(tokensArg));
|
|
|
|
/* Implementation pages: used-class harvest + inline style audit. */
|
|
const pages = (pagesArg !== null ? pagesArg.split(',') : IMPLEMENTATION_PAGES);
|
|
const pageStyleInputs = [];
|
|
for (const p of pages) {
|
|
const abs = p.startsWith('/') ? p : join(THEME_ROOT, p);
|
|
if (!existsSync(abs)) fail(2, `registered implementation page missing: ${p} (remove it or restore it — page registry is ${SCRIPTS}/audit.mjs)`);
|
|
const text = readFileSync(abs, 'utf8');
|
|
for (const m of text.matchAll(/class\s*=\s*"([^"]*)"|class\s*=\s*'([^']*)'/g)) {
|
|
for (const cls of (m[1] ?? m[2] ?? '').split(/\s+/)) {
|
|
if (cls) state.usedClasses.add(cls);
|
|
}
|
|
}
|
|
for (const m of text.matchAll(/<style\b[^>]*>([\s\S]*?)<\/style>/gi)) {
|
|
const contentOffset = m.index + m[0].indexOf('>') + 1;
|
|
pageStyleInputs.push({ text: m[1], display: `${displayPath(abs)} <style>`, baseOffset: contentOffset, lineText: text });
|
|
}
|
|
/* Inline style="…" attributes are rendered CSS too — audited so no
|
|
* declaration path around the <style>-block scan exists. */
|
|
for (const m of text.matchAll(/\bstyle\s*=\s*"([^"]*)"|\bstyle\s*=\s*'([^']*)'/g)) {
|
|
const attr = m[1] ?? m[2] ?? '';
|
|
const attrOffset = m.index + m[0].indexOf(attr);
|
|
for (const d of declarations(attr)) {
|
|
const decl = splitDecl(d.text);
|
|
if (!decl) continue;
|
|
state.totals.decls++;
|
|
for (const reason of checkDeclaration(decl.prop, decl.value, state.tokens)) {
|
|
state.violations.push(
|
|
`${displayPath(abs)}:${lineAt(text, attrOffset + d.offset)}: style attribute { ${decl.prop}: ${decl.value} } — ${reason}`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/* CSS file inputs. */
|
|
for (const input of cssInputs) {
|
|
if (!existsSync(input)) {
|
|
fail(2, `CSS input not found: ${displayPath(input)}${input === DEFAULT_INPUT ? ' — run `npm run build --workspace theme` first' : ''}`);
|
|
}
|
|
const text = readFileSync(input, 'utf8');
|
|
const s = auditCss({ text, display: displayPath(input), baseOffset: 0, lineText: text });
|
|
state.totals.rulesAudited += s.rulesAudited;
|
|
state.totals.decls += s.decls;
|
|
state.totals.vocabularySkipped += s.vocabularySkipped;
|
|
state.inputs++;
|
|
}
|
|
|
|
/* Page inline styles are implementation-owned CSS. */
|
|
for (const ps of pageStyleInputs) {
|
|
const s = auditCss(ps);
|
|
state.totals.rulesAudited += s.rulesAudited;
|
|
state.totals.decls += s.decls;
|
|
state.totals.vocabularySkipped += s.vocabularySkipped;
|
|
state.inputs++;
|
|
}
|
|
|
|
/* ————— report ————— */
|
|
|
|
if (state.violations.length > 0) {
|
|
for (const v of state.violations) console.log(v);
|
|
console.log(
|
|
`AUDIT FAILED — ${state.violations.length} off-token declaration(s) across ${state.inputs} input(s); ${state.totals.decls} declarations checked`,
|
|
);
|
|
process.exit(1);
|
|
}
|
|
|
|
console.log(
|
|
`AUDIT CLEAN — ${state.totals.decls} declarations checked across ${state.inputs} input(s) `
|
|
+ `(${state.totals.rulesAudited} rules; ${state.totals.vocabularySkipped} compiled-vocabulary utility rules out of scope, `
|
|
+ `${state.usedClasses.size} classes harvested from ${pages.length} implementation page(s)) — 0 off-token values.`,
|
|
);
|
|
console.log(
|
|
`token sheet: ${state.tokens.colorCount} colors / ${state.tokens.radiusCount} radius tokens (all zero) / ${state.tokens.fonts.size} sanctioned font families / `
|
|
+ `${state.tokens.spacings.size} spacing values / ${state.tokens.typeSizes.size} type sizes / ${state.tokens.typeTrackings.size} trackings`,
|
|
);
|
|
process.exit(0);
|