Files
Arah2/theme/scripts/audit.mjs
T

701 lines
27 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 / 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.
*
* 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');
return { colors, colorCount, radiusCount, fonts };
}
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$/;
function checkDeclaration(prop, value, tokens) {
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));
}
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)) {
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`,
);
process.exit(0);