#!/usr/bin/env node // Static gates that don't need a GPU: // // 1. Determinism grep — no wall-clock or unseeded randomness in engine/scene code. // 2. Scene schema lint — the params block and the shader source must agree, // in BOTH directions. A declared param that no shader reads is dead weight; // a uniform the shader reads that nothing declares is a silent zero, which // is the single most annoying way for a scene to look subtly wrong. // // Run: npm run lint:scenes import { readdirSync, readFileSync, statSync } from 'fs'; import { join, relative, dirname } from 'path'; import { fileURLToPath, pathToFileURL } from 'url'; const root = join(dirname(fileURLToPath(import.meta.url)), '..'); const SRC = join(root, 'src'); let failures = 0; const fail = (msg) => { console.error(` ✗ ${msg}`); failures++; }; const ok = (msg) => console.log(` ✓ ${msg}`); function walk(dir, out = []) { for (const entry of readdirSync(dir)) { const full = join(dir, entry); if (statSync(full).isDirectory()) walk(full, out); else if (entry.endsWith('.js')) out.push(full); } return out; } // ---------------------------------------------------------------- grep gate const FORBIDDEN = [ { pattern: /\bMath\.random\s*\(/, name: 'Math.random()', why: 'use Rng — unseeded randomness breaks reproducibility' }, { pattern: /\bperformance\.now\s*\(/, name: 'performance.now()', why: 'use Timeline — wall clock breaks preview/export parity' }, { pattern: /\bDate\.now\s*\(/, name: 'Date.now()', why: 'use Timeline' }, { pattern: /\bnew Date\s*\(/, name: 'new Date()', why: 'use Timeline' }, ]; // Directories whose output must be a pure function of (seed, params, frame). const DETERMINISTIC_DIRS = ['engine', 'scenes', 'look', 'audio', 'params']; // Files legitimately allowed a wall clock: perf measurement, not image content. const ALLOWED = new Set(['engine/perf.js']); console.log('\ndeterminism grep'); { let checked = 0; for (const dir of DETERMINISTIC_DIRS) { const full = join(SRC, dir); let files; try { files = walk(full); } catch { continue; } for (const file of files) { const rel = relative(SRC, file).replace(/\\/g, '/'); if (ALLOWED.has(rel)) continue; checked++; const source = readFileSync(file, 'utf8'); const lines = source.split('\n'); lines.forEach((line, i) => { if (/^\s*(\/\/|\*)/.test(line)) return; // comments may name them for (const f of FORBIDDEN) { if (f.pattern.test(line)) fail(`${rel}:${i + 1} uses ${f.name} — ${f.why}`); } }); } } if (!failures) ok(`${checked} files clean of wall-clock and unseeded randomness`); } // ------------------------------------------------------------- scene lint const CONTRACT_UNIFORMS = new Set([ 'u_resolution', 'u_aspect', 'u_pixelScale', 'u_time', 'u_frame', 'u_progress', 'u_seed', 'u_opacity', 'u_colors', 'u_colorCount', 'u_prev', 'u_hasPrev', 'u_loudness', 'u_rms', 'u_bandSub', 'u_bandLow', 'u_bandMid', 'u_bandHigh', 'u_bandAir', 'u_flux', 'u_centroid', 'u_flatness', 'u_width', 'u_beat', 'u_beatPhase', 'u_barPhase', 'u_phrasePhase', 'u_sectionProgress', 'u_sectionEnergy', 'u_buildSlope', ]); console.log('\nscene schema lint'); { const { scenes, FAMILIES } = await import(pathToFileURL(join(SRC, 'scenes/registry.js')).href); const { validateModule } = await import(pathToFileURL(join(SRC, 'params/schema.js')).href); if (!scenes.length) fail('no scenes registered'); const seenNames = new Set(); for (const module of scenes) { const id = module.name || ''; for (const err of validateModule(module)) fail(err); if (seenNames.has(id)) fail(`duplicate scene name '${id}'`); seenNames.add(id); if (module.family && !FAMILIES[module.family]) { fail(`${id}: unknown family '${module.family}'`); } if (module.kind !== 'fragment') continue; const src = module.shader || ''; const declared = new Map(); for (const [name, def] of Object.entries(module.params || {})) { if (def.uniform) declared.set(def.uniform, name); } // Collision with the shader contract. A param that reuses a contract // uniform name (u_width, u_time, u_seed...) is a GLSL redefinition error, // and the whole scene renders as a black frame with no other symptom. for (const [uniform, param] of declared) { if (CONTRACT_UNIFORMS.has(uniform)) { fail(`${id}: param '${param}' uses '${uniform}', which the shader ` + `contract already declares — pick another name`); } } // Direction 1: every declared uniform is actually read by the shader. for (const [uniform, param] of declared) { const used = new RegExp(`\\b${uniform}\\b`).test(src); if (!used) fail(`${id}: param '${param}' declares ${uniform}, but the shader never reads it`); } // Direction 2: every u_* the shader reads is declared somewhere. const referenced = new Set(src.match(/\bu_[A-Za-z0-9_]+\b/g) || []); for (const uniform of referenced) { if (CONTRACT_UNIFORMS.has(uniform)) continue; if (declared.has(uniform)) continue; fail(`${id}: shader reads ${uniform}, which no param declares (it will silently be 0)`); } // Rate params: anything the shader multiplies absolute time by must be // flagged `rate: true`, which stops reactivity and drift from touching it. // Modulating such a param jumps the phase by elapsed*delta — sixty seconds // in, a wobble of 0.05 throws the phase by three units between frames. // That measured as 6 flashes/second on Classic Wave, twice the WCAG 2.3.1 // ceiling, and it gets worse the longer the track runs. const timeProducts = [ ...src.matchAll(/u_time\s*\*\s*(u_[A-Za-z0-9_]+)/g), ...src.matchAll(/(u_[A-Za-z0-9_]+)\s*\*\s*u_time/g), ]; for (const match of timeProducts) { const uniform = match[1]; const paramName = declared.get(uniform); if (!paramName) continue; if (!module.params[paramName].rate) { fail(`${id}: '${paramName}' (${uniform}) multiplies u_time but is not marked ` + `\`rate: true\` — reactivity or drift on it will cause phase jumps`); } } // A scene with a palette param should actually use the palette helpers, // otherwise the look generator cannot recolour it. const hasPalette = Object.values(module.params || {}).some((d) => d.type === 'palette'); if (hasPalette && !/\b(pal|palRamp)\s*\(/.test(src)) { fail(`${id}: declares a palette but never calls pal()/palRamp()`); } // Hardcoded saturated colours are the thing that made the party-stage // shaders resist recolouring; flag the obvious cases. const literalColors = src.match(/vec3\s*\(\s*[01]\.\d+\s*,\s*[01]\.\d+\s*,\s*[01]\.\d+\s*\)/g) || []; const suspicious = literalColors.filter((c) => !/vec3\s*\(\s*0\.0*\s*,\s*0\.0*\s*,\s*0\.0*\s*\)/.test(c)); if (hasPalette && suspicious.length > 2) { fail(`${id}: ${suspicious.length} hardcoded vec3 colour literals — use pal()`); } } if (!failures) ok(`${scenes.length} scenes: schemas and shaders agree both ways`); else console.log(` (${scenes.length} scenes checked)`); } console.log(''); if (failures) { console.error(`FAILED — ${failures} problem${failures === 1 ? '' : 's'}\n`); process.exit(1); } console.log('all static gates passed\n');