- implementation pages' style="…" attributes are rendered CSS: every
declaration checked like <style>-block CSS, violations reported as
file:line: style attribute { prop: value }
- token-var references pass by construction; only off-token literals flag
- 16/16 audit tests green; real build still AUDIT CLEAN
583 lines
21 KiB
JavaScript
583 lines
21 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()/hsl() literals are checked too (hex is not
|
|
* the only escape hatch for off-token colors). 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)
|
|
];
|
|
|
|
/* ————— 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;
|
|
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 ————— */
|
|
|
|
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 === '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));
|
|
if (prop.startsWith('--')) return violations; // custom property: color literals above are the checkable surface
|
|
violations.push(...shadowViolations(prop, value));
|
|
if (prop === 'font-family' || prop === 'font') {
|
|
if (!/var\(/i.test(value)) violations.push(...fontViolations(prop === 'font' ? familiesFromShorthand(value) : 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 without sizes/weights. */
|
|
function familiesFromShorthand(value) {
|
|
return value
|
|
.split(',')
|
|
.map((g) => g.trim().replace(/^[0-9./]+\S*\s+/g, '').trim())
|
|
.filter((g) => !/^[0-9.]/.test(g) && g.length > 0)
|
|
.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);
|