forked from c-fu/arah2
285 lines
12 KiB
JavaScript
285 lines
12 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
|
|
* 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
|
|
*/
|
|
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>
|
|
`);
|
|
|
|
/* ————— 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)}`);
|
|
}
|
|
|
|
// 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');
|
|
}
|
|
|
|
/* ————— 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);
|