Files

405 lines
18 KiB
JavaScript

#!/usr/bin/env node
/*
* audit.test.mjs — TDD harness for the off-token audit (D-13)
* ------------------------------------------------------------------
* RED phase: every test below fails while scripts/audit.mjs does
* not exist (spawn ENOENT); all must pass once it is implemented.
*
* Run: node scripts/audit.test.mjs (exit 0 = all green)
*
* Fixtures are (re)created under /tmp/opencode/audit-test/ —
* never inside the repo. Zero runtime dependencies.
*
* Contract under test (from plan 01-04 task 1):
* T1 stylesheet using ONLY token values → exit 0
* T2 stylesheet with off-token color / non-zero
* border-radius / off-token font / box-shadow /
* blur / backdrop-filter → exit 1, each
* offending declaration listed as file:line: declaration
* T3 tokens.css drifting below 40 colors → exit 2 (drift guard)
* T4 any --radius-* token other than 0 → exit 2 (drift guard)
* T5 real build (dist/rams.css) → exit 0, and the
* shipped vocabulary carries no bare shadow/blur
* utilities, no off-token radius default (WR-01)
* T6 off-token values inside @layer components → flagged (exit 1)
* T7 @layer utilities: only classes used by the
* implementation pages are audited (compiled
* mockup vocabulary is reference material per D-06,
* never worn by implementation markup)
* T8 inline style="…" attributes on implementation
* pages are audited too (no evasion path around
* the <style>-block scan) → flagged (exit 1)
* T9 CR-01: the deprecated bare --radius key drives
* bare `rounded` → var(--radius) = 0px (controlled
* build); non-zero bare key → exit 2; the inert
* v3-style DEFAULT sub-key must not reappear
* T10 literal font: shorthands flagged regardless of
* family (convention: font: var(--type-*)); var()
* shorthands pass; familiesFromShorthand extracts
* the family for the message → flagged (exit 1)
* T11 named color keywords, oklch()/oklab()/lab()/lch()/
* hwb()/color()/color-mix(), text-shadow → flagged
* (exit 1); currentcolor/inherit/transparent stay
* sanctioned
* T12 spacing + typography numerics audited against
* the --spacing and --type scale sets; token-value
* literals, var(), zero/auto pass; @font-face asset
* metadata exempt → flagged (exit 1)
*/
import { spawnSync } from 'node:child_process';
import { existsSync, mkdirSync, readFileSync, symlinkSync, writeFileSync } from 'node:fs';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const SCRIPTS = dirname(fileURLToPath(import.meta.url));
const THEME = resolve(SCRIPTS, '..');
const AUDIT = join(SCRIPTS, 'audit.mjs');
const FIX = '/tmp/opencode/audit-test';
const passes = [];
const failures = [];
function check(name, cond, detail = '') {
if (cond) passes.push(name);
else failures.push(detail ? `${name} — ${detail}` : name);
}
function runAudit(args, cwd = THEME) {
const r = spawnSync(process.execPath, [AUDIT, ...args], { encoding: 'utf8', cwd });
return {
status: r.status,
error: r.error ? String(r.error.code || r.error) : null,
out: `${r.stdout || ''}${r.stderr || ''}`,
};
}
/* ————— fixtures ————— */
mkdirSync(FIX, { recursive: true });
const clean = join(FIX, 'clean.css');
const dirty = join(FIX, 'dirty.css');
const fewTokens = join(FIX, 'tokens-few.css');
const badRadiusTokens = join(FIX, 'tokens-bad-radius.css');
const compLayerCss = join(FIX, 'components-layer.css');
const usedCss = join(FIX, 'used-utilities.css');
const usedPage = join(FIX, 'used-page.html');
const styleAttrPage = join(FIX, 'style-attr-page.html');
/* T1 — token values only: var() refs, verbatim token hex,
* token-hue+alpha 8-digit form, zero radius, sanctioned font
* families, box-shadow none, non-blur filter, token-valued
* custom property, non-literal color keywords. */
writeFileSync(clean, `/* clean fixture — token values only */
.ok-var { color: var(--color-signal-orange); }
.ok-hex { background: #EB8008; border-color: #1a1a1a99; }
.ok-radius { border-radius: 0; }
.ok-radius-var { border-radius: var(--radius-md); }
.ok-font { font-family: "Space Grotesk", Geist, ui-monospace, monospace; }
.ok-shadow { box-shadow: none; }
.ok-filter { filter: grayscale(1); }
.ok-slot { --shell-slot: var(--color-surface); }
.ok-keyword { color: currentColor; }
.ok-rgb-token { background: rgba(235, 128, 8, 0.5); }
`);
/* T2 — every declaration below must be flagged, each printed
* as file:line: declaration (the plan's example set plus the
* rgb()/hsl() literal-evasion guard). */
writeFileSync(dirty, `/* dirty fixture — every declaration must be flagged */
.a-off-token-hex { color: #00ff00; }
.b-non-zero-radius { border-radius: 12px; }
.c-off-token-font { font-family: Arial; }
.d-shadow { box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2); }
.e-blur { filter: blur(2px); }
.f-glass { backdrop-filter: blur(4px); }
.g-rgb-literal { background-color: rgb(255, 0, 0); }
.h-hsl-literal { fill: hsl(120, 100%, 50%); }
/* control: a verbatim token hex passes even in this file */
.i-control-token-hex { color: #d9d2c6; }
`);
/* T3 — token sheet that drifted below the 40-color floor. */
writeFileSync(fewTokens, `@theme static {
--color-surface: #fff8f0;
--color-signal-orange: #EB8008;
--color-matte-black: #1A1A1A;
--color-soft-beige: #D9D2C6;
--color-functional-red: #ED3F1C;
--spacing-space-md: 1rem;
--font-sans: "Geist", ui-sans-serif, system-ui, sans-serif;
--radius-xs: 0px;
--radius-md: 0px;
}
`);
/* T4 — real token sheet with one mutated radius token. */
const realTokens = readFileSync(join(THEME, 'src', 'tokens.css'), 'utf8');
if (!realTokens.includes('--radius-md: 0px;')) {
console.error('fixture setup: --radius-md: 0px; not found in src/tokens.css');
process.exit(1);
}
writeFileSync(badRadiusTokens, realTokens.replace('--radius-md: 0px;', '--radius-md: 4px;'));
/* T9 — CR-01: the deprecated bare --radius key (the key that actually
* drives the bare `rounded` utility in v4) must be present and zero;
* a non-zero mutation must trip the drift guard (exit 2). */
if (!realTokens.includes('--radius: 0px;')) {
console.error('fixture setup: bare --radius: 0px; (drives bare `rounded`) not found in src/tokens.css');
process.exit(1);
}
const bareRadiusTokens = join(FIX, 'tokens-bare-radius.css');
writeFileSync(bareRadiusTokens, realTokens.replace('--radius: 0px;', '--radius: 4px;'));
/* T6 — off-token value inside @layer components (implementation-
* owned layer) must never be skipped. */
writeFileSync(compLayerCss, `@layer components {
.comp-bad { background: #ff00ff; }
}
`);
/* T7 — utilities layer: used classes audited, unused
* compiled vocabulary skipped. */
writeFileSync(usedCss, `@layer utilities {
.used-bad { background: #00ff00; }
.unused-bad { background: #00ff00; }
}
`);
writeFileSync(usedPage, `<!DOCTYPE html>
<html><body>
<p class="used-bad text-label-sm">specimen</p>
</body></html>
`);
/* T8 — inline style attributes: token-var reference passes,
* off-token literal gets flagged. */
writeFileSync(styleAttrPage, `<!DOCTYPE html>
<html><body>
<p style="background: var(--color-signal-orange)">token reference</p>
<p style="color: #00ff00">off-token literal</p>
</body></html>
`);
/* T10 — literal font: shorthands (WR-02: the familiesFromShorthand
* strip bug left "14px/18px Arial" behind, which the numeric filter
* then dropped — no font: shorthand could ever be flagged). */
const fontShorthand = join(FIX, 'font-shorthand.css');
writeFileSync(fontShorthand, `/* literal font: shorthand — flagged regardless of family (package
convention: font: var(--type-*)); var() shorthands pass */
.a-font-shorthand { font: 700 14px/18px Arial; }
.b-font-shorthand { font: italic 700 12px/16px "Space Grotesk", sans-serif; }
.c-font-shorthand-var { font: var(--type-body-md); }
`);
/* T11 — color-evasion gaps (WR-03): named keywords, modern color
* functions, text-shadow — all previously passed the audit unflagged. */
const colorEvasions = join(FIX, 'color-evasions.css');
writeFileSync(colorEvasions, `/* every flagged declaration must be listed; sanctioned keywords pass */
.k-named { color: red; }
.l-oklch { background: oklch(0.6 0.2 30); }
.m-colorfn { fill: color(display-p3 1 0 0); }
.n-hwb { stroke: hwb(90 10% 0); }
.o-lab { border-color: lab(50% 40 60); }
.p-lch { background-color: lch(50% 40 60); }
.q-text-shadow { text-shadow: 0 1px 0 red; }
/* sanctioned: css-wide + currentcolor/transparent stay clean */
.r-ok { color: currentcolor; }
.s-ok { background: transparent; }
.t-ok { outline: 2px solid inherit; }
`);
/* T12 — D-13 spacing + typography numerics (WR-04): off-scale
* literals flagged; token-value literals, var(), zero/auto, and
* @font-face asset metadata pass. */
const numerics = join(FIX, 'numerics.css');
writeFileSync(numerics, `/* off-scale numerics — every flagged declaration must be listed */
.a-spacing { padding: 13px; }
.b-spacing { gap: 7px; }
.c-type { font-size: 17px; }
.d-type { font-weight: 650; }
.e-type { line-height: 1.7; }
.f-track { letter-spacing: 0.05em; }
/* on-token literal forms + sanctioned values pass */
.g-ok { padding: 1rem 0.25rem; gap: 3rem; margin: 0 auto; }
.h-ok { font-size: 14px; font-weight: 700; line-height: 20px; letter-spacing: -0.04em; }
.i-ok-var { padding: var(--spacing-space-md); margin: var(--spacing-margin); }
/* @font-face asset metadata (D-16) is not rendered typography */
@font-face { font-family: "Geist"; font-style: normal; font-weight: 500; src: url("Geist-Medium.woff2") format("woff2"); }
`);
/* ————— tests ————— */
// T1 — clean stylesheet exits 0
{
const r = runAudit([clean]);
check('T1 clean stylesheet → exit 0', r.status === 0,
`status=${r.status} error=${r.error} out=${r.out.slice(0, 300)}`);
}
// T2 — dirty stylesheet exits 1 and lists every offending declaration
{
const r = runAudit([dirty]);
const markers = ['#00ff00', 'border-radius: 12px', 'font-family: Arial',
'box-shadow', 'blur', 'backdrop-filter', 'rgb(255, 0, 0)', 'hsl(120'];
const missing = markers.filter((m) => !r.out.includes(m));
check('T2 dirty stylesheet → exit 1', r.status === 1,
`status=${r.status} error=${r.error}`);
check('T2 every violation listed', missing.length === 0,
`missing markers: ${missing.join(', ')}`);
check('T2 file:line: declaration format', /dirty\.css:\d+:/.test(r.out),
'no "file:line:" prefixed lines in output');
}
// T3 — color-token drift guard (below 40 colors) → exit 2
{
const r = runAudit(['--tokens', fewTokens, clean]);
check('T3 < 40 color tokens → exit 2', r.status === 2,
`status=${r.status} error=${r.error} out=${r.out.slice(0, 200)}`);
}
// T4 — radius-token drift guard (non-zero --radius-*) → exit 2
{
const r = runAudit(['--tokens', badRadiusTokens, clean]);
check('T4 non-zero --radius-* token → exit 2', r.status === 2,
`status=${r.status} error=${r.error} out=${r.out.slice(0, 200)}`);
}
// T5 — real build audits clean (dist/rams.css from the sheet build)
{
const b = spawnSync('npm', ['run', 'build'], { encoding: 'utf8', cwd: THEME });
check('T5 npm run build succeeds', b.status === 0,
`status=${b.status} ${(b.stderr || '').slice(0, 200)}`);
const r = runAudit([]);
check('T5 audit of real build → exit 0', r.status === 0,
`status=${r.status} error=${r.error} out=${r.out.slice(0, 400)}`);
/* WR-01: the shipped compiled vocabulary carries no prose-minted
* bare Rams-forbidden utilities and no off-token radius default. */
const dist = readFileSync(join(THEME, 'dist', 'rams.css'), 'utf8');
check('T5 no bare .shadow utility in shipped vocabulary', !/\.shadow\{/.test(dist),
'bare .shadow compiled into dist/rams.css — prose leakage or a re-enabled bare --shadow key');
check('T5 no bare .blur utility in shipped vocabulary', !/\.blur\{/.test(dist),
'bare .blur compiled into dist/rams.css — prose leakage or a re-enabled bare --blur key');
check('T5 no v3 .25rem radius default anywhere in dist', !/border-radius:\.25rem/.test(dist),
'border-radius:.25rem present in dist/rams.css — an off-token radius default escaped');
}
// T6 — @layer components is implementation-owned: fully audited
{
const r = runAudit([compLayerCss]);
check('T6 components-layer off-token → exit 1', r.status === 1,
`status=${r.status} error=${r.error}`);
check('T6 violation listed', r.out.includes('#ff00ff') && r.out.includes('comp-bad'),
`out=${r.out.slice(0, 300)}`);
}
// T7 — utilities layer: only classes used by implementation pages audited
{
const r = runAudit(['--pages', usedPage, usedCss]);
check('T7 used off-token utility → exit 1', r.status === 1,
`status=${r.status} error=${r.error}`);
check('T7 used class flagged', r.out.includes('used-bad') && r.out.includes('#00ff00'),
`out=${r.out.slice(0, 300)}`);
check('T7 unused vocabulary skipped', !r.out.includes('unused-bad'),
`out=${r.out.slice(0, 300)}`);
}
// T8 — inline style attributes are audited (no evasion path)
{
const r = runAudit(['--pages', styleAttrPage]);
check('T8 off-token style attribute → exit 1', r.status === 1,
`status=${r.status} error=${r.error} out=${r.out.slice(0, 300)}`);
check('T8 style-attr violation listed with file:line',
/style-attr-page\.html:\d+:.*#00ff00/.test(r.out), `out=${r.out.slice(0, 300)}`);
check('T8 token-var style attribute passes', !r.out.includes('var(--color-signal-orange)'),
`out=${r.out.slice(0, 300)}`);
}
// T9 — CR-01: bare `rounded` is driven by the deprecated bare --radius key
{
check('T9 tokens.css carries the bare --radius: 0px key (drives bare rounded)',
realTokens.includes('--radius: 0px;'),
'src/tokens.css lost the deprecated bare --radius key — bare rounded would fall back to the v3 .25rem default');
check('T9 inert v3-style --radius-DEFAULT sub-key absent',
!/--radius-DEFAULT/.test(realTokens),
'src/tokens.css re-added a v3-style DEFAULT sub-key — v4 ignores it; the bare --radius key is the one that drives bare rounded');
const r = runAudit(['--tokens', bareRadiusTokens, clean]);
check('T9 non-zero bare --radius token → exit 2 (drift guard)', r.status === 2,
`status=${r.status} error=${r.error} out=${r.out.slice(0, 200)}`);
/* controlled build (reviewer's evasion proof, locked in as a
* regression): the real token sheet must compile bare `rounded`
* to var(--radius) — never the v3 .25rem default. */
const entry = join(FIX, 'cr01-entry.css');
const out = join(FIX, 'cr01-out.css');
writeFileSync(entry, `@import "tailwindcss";\n${realTokens}\n@source inline("rounded");\n`);
const nm = join(FIX, 'node_modules');
if (!existsSync(nm)) {
symlinkSync(resolve(THEME, '..', 'node_modules'), nm); // fixture-side resolution for @import "tailwindcss"
}
const bin = resolve(THEME, '..', 'node_modules', '.bin', 'tailwindcss');
const b = spawnSync(bin, ['-i', entry, '-o', out, '--minify'], { encoding: 'utf8' });
check('T9 controlled build (bare rounded candidate) succeeds', b.status === 0,
`status=${b.status} ${(b.stderr || '').slice(0, 300)}`);
const built = existsSync(out) ? readFileSync(out, 'utf8') : '';
check('T9 bare rounded compiles to var(--radius)',
/\.rounded\{[^}]*border-radius:var\(--radius\)[^}]*\}/.test(built),
`rounded rule: ${(built.match(/\.rounded\{[^}]*\}/) || ['(none)'])[0]}`);
check('T9 --radius: 0px emitted by @theme static',
/--radius:\s*0px/.test(built), 'no --radius:0px custom property in the controlled build output');
check('T9 v3 .25rem default no longer drives bare rounded',
!/\.rounded\{[^}]*border-radius:\.25rem/.test(built),
'bare rounded still compiles to the v3 .25rem default');
}
// T10 — literal font: shorthands are flagged; var() shorthands pass
{
const r = runAudit([fontShorthand]);
check('T10 literal font: shorthand → exit 1', r.status === 1,
`status=${r.status} error=${r.error} out=${r.out.slice(0, 300)}`);
check('T10 off-token shorthand family extracted (Arial)',
/font: 700 14px\/18px Arial/.test(r.out) && /Arial/.test(r.out),
`out=${r.out.slice(0, 300)}`);
check('T10 sanctioned-family shorthand still flagged (convention: font: var(--type-*))',
/font: italic 700 12px\/16px "Space Grotesk", sans-serif/.test(r.out),
`out=${r.out.slice(0, 300)}`);
check('T10 var() shorthand passes', !/var\(--type-body-md\)/.test(r.out),
`out=${r.out.slice(0, 300)}`);
}
// T11 — named colors, modern color functions, text-shadow flagged
{
const r = runAudit([colorEvasions]);
const markers = ['color: red', 'oklch(0.6 0.2 30)', 'color(display-p3 1 0 0)',
'hwb(90 10% 0)', 'lab(50% 40 60)', 'lch(50% 40 60)', 'text-shadow: 0 1px 0 red'];
const missing = markers.filter((m) => !r.out.includes(m));
check('T11 color evasions → exit 1', r.status === 1,
`status=${r.status} error=${r.error}`);
check('T11 every evasion listed', missing.length === 0,
`missing markers: ${missing.join(', ')}`);
check('T11 sanctioned keywords pass (currentcolor/transparent/inherit)',
!/currentcolor/.test(r.out) && !/transparent/.test(r.out) && !/outline: 2px solid inherit/.test(r.out),
`out=${r.out.slice(0, 300)}`);
}
// T12 — spacing + typography numerics audited against the token scales
{
const r = runAudit([numerics]);
const markers = ['padding: 13px', 'gap: 7px', 'font-size: 17px', 'font-weight: 650',
'line-height: 1.7', 'letter-spacing: 0.05em'];
const missing = markers.filter((m) => !r.out.includes(m));
check('T12 off-scale numerics → exit 1', r.status === 1,
`status=${r.status} error=${r.error}`);
check('T12 every off-scale numeric listed', missing.length === 0,
`missing markers: ${missing.join(', ')}`);
check('T12 token-value literals pass (1rem/0.25rem/14px/700/20px/-0.04em)',
!/1rem 0\.25rem/.test(r.out) && !/font-size: 14px/.test(r.out) && !/letter-spacing: -0\.04em/.test(r.out),
`out=${r.out.slice(0, 400)}`);
check('T12 zero/auto margin passes', !/margin: 0 auto/.test(r.out),
`out=${r.out.slice(0, 400)}`);
check('T12 @font-face asset metadata (font-weight 500) passes', !/font-weight: 500/.test(r.out),
`out=${r.out.slice(0, 400)}`);
}
/* ————— report ————— */
console.log(`audit.test: ${passes.length} passed, ${failures.length} failed`);
passes.forEach((p) => console.log(` PASS ${p}`));
failures.forEach((f) => console.error(` FAIL ${f}`));
process.exit(failures.length ? 1 : 0);