#!/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', 'u_sigSides', 'u_sigRound', 'u_sigElong', 'u_sigTilt', 'u_sigDrift', 'u_sigSway', 'u_sigSwayRate', 'u_sigSpin', 'u_sigBreathe', 'u_sigHorizon', 'u_sigDepth', 'u_sigWash', 'u_sigLine', 'u_sigSoft', 'u_sigTexture', 'u_sigFold', ]); /** * What counts as honouring a personality trait, in shader source. * * The disqualification rule in look/Personality.js is only as good as these * declarations: a scene that claims `shape` and draws circles anyway will be * cast in the hexagon video and be the one shot that looks filmed elsewhere. * So the claim is machine-checked against the source rather than trusted. */ const TRAIT_EVIDENCE = { shape: /\bsig(Shape|Form)\s*\(/, camera: /\bsigCamera\s*\(/, space: /\b(sigHorizonY|sigAir)\s*\(|\bu_sig(Horizon|Depth|Wash)\b/, style: /\b(sigEdge|sigGrain|sigFolded)\s*\(|\bu_sig(Line|Soft|Texture|Fold)\b/, }; 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') { // 3D modules honour traits in JS, against the `personality` handed // to update(); there is no shader source to grep, so the evidence // check is just that they read it at all. if ((module.traits || []).length && !/personality/.test(String(module.update))) { fail(`${id}: declares traits but update() never reads \`personality\``); } continue; } const src = module.shader || ''; // Comment-free copy for rules that ask "does the code do X". A scene // that mentions prev() in a comment explaining why it does NOT rely on // prev() should not be failed for relying on prev(). const code = src.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/[^\n]*/g, ''); 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)`); } // Personality traits: declaring one is a promise to express it. for (const trait of module.traits || []) { const evidence = TRAIT_EVIDENCE[trait]; if (evidence && !evidence.test(src)) { fail(`${id}: declares trait '${trait}' but the shader never uses it — ` + `either express it or drop the claim, or the scene will be cast ` + `in tracks built on something it ignores`); } } // A dead camera: `p = sigCamera(p)` and then nothing reads p again. // This passed the evidence grep above, passed review, and shipped — the // Phase 9 render gate later measured the scene's response to the camera // at exactly zero. Cheaper to catch here than on a GPU. const cameraAssign = code.match(/(\w+)\s*=\s*sig(?:Camera|Folded)\s*\([^;]*\);/); if (cameraAssign) { const target = cameraAssign[1]; const after = code.slice(code.indexOf(cameraAssign[0]) + cameraAssign[0].length); const reads = new RegExp(`\\b${target}\\b`).test(after); if (!reads) { fail(`${id}: assigns sigCamera to '${target}' and never reads it again — ` + `the trait is declared but the image cannot change`); } } // A scene whose only content is the previous frame is black on its first // frames and different after a seek than after playback. if (/\bprev\s*\(/.test(code)) { const bodyBeforePrev = code.slice(0, code.search(/\bprev\s*\(/)); if (!/\b(pal|palRamp|fbm|vnoise|hash1[12])\s*\(/.test(bodyBeforePrev)) { fail(`${id}: reads prev() without generating a base image first — ` + `it will be black until feedback converges and will not survive a seek`); } } // Loop cost. GLSL needs a constant bound, so the pattern here is a // generous fixed bound plus an early break on the param that actually // decides the count — that break is what keeps the cost proportional to // what the look asked for. A big bound WITHOUT one runs every iteration // on every pixel at 4K, which the budget check will catch on a GPU and // this catches in a second. for (const loop of code.matchAll(/for\s*\(\s*int\s+\w+\s*=\s*0\s*;\s*\w+\s*<\s*(\d+)[^)]*\)/g)) { const bound = Number(loop[1]); const body = code.slice(code.indexOf(loop[0]) + loop[0].length, code.indexOf(loop[0]) + loop[0].length + 600); const breaksEarly = /\bbreak\s*;/.test(body); // Opt-out for a genuinely fixed-cost loop — sampling a curve at a // fixed resolution has nothing to break on. The author states it, // and the measured 4K budget check still governs. const at = src.indexOf(loop[0].replace(/\s+/g, ' ')) >= 0 ? src.indexOf(loop[0].replace(/\s+/g, ' ')) : src.search(new RegExp(`for\\s*\\(\\s*int\\s+\\w+\\s*=\\s*0\\s*;\\s*\\w+\\s*<\\s*${bound}\\b`)); const optOut = at >= 0 && /\/\/\s*lint:\s*fixed-cost/.test( src.slice(Math.max(0, at - 220), at)); if (optOut) continue; if (bound > 64) { fail(`${id}: fixed loop bound ${bound} is too large whatever it breaks on`); } else if (bound > 16 && !breaksEarly) { fail(`${id}: loop of ${bound} with no early break — bound it on the param ` + `(\`if (i >= u_count) break;\`) so the cost follows what the look asked for`); } } // 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');