forked from c-fu/arah2
- T8: style="color: #00ff00" on an implementation page currently passes silently — an evasion path around the <style>-block scan (Rule 2 hole in the D-13 evidence: the gallery itself will use style attributes)
234 lines
9.2 KiB
JavaScript
234 lines
9.2 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)
|
|
*/
|
|
import { spawnSync } from 'node:child_process';
|
|
import { mkdirSync, readFileSync, 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;'));
|
|
|
|
/* 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)}`);
|
|
}
|
|
|
|
/* ————— 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);
|