Two helpers made the bulk of it mechanical. `inkStroke` is a drop-in for
sigEdge — the same line at the identity's weight rather than the track's — and
`castForm` is a drop-in for sigForm, same signature so call sites do not change
shape. With those in place the substitution table is one-to-one:
sigForm( -> castForm( sigShape( -> castMain( sigEdge( -> inkStroke(
Forty-seven scenes went through that pass in one run: twenty-six take the cast
and the ink, twenty-one take the ink alone. Then the gate ran over all sixty-five
and found three the pass had broken, which is the entire reason it exists.
Spectrum Sculpture rendered pure black. It had been using sigShape as a RADIAL
METRIC rather than drawing it, and the cast carries notches and a hollow — an
annulus used as a radius turns a sculpture inside out. Reverted to sigShape and
dropped to ink only. The lesson generalises: a scene that reads a form as
geometry is not a scene that draws it, and the classifier cannot tell those
apart from the source.
Eclipse Field stopped honouring its `style` trait. It opts out of surface grain,
so sigEdge was its only style evidence, and the ink replaced it. The trait claim
is now dropped — and so is the lint change that had let inkMask count as style
evidence, which was wrong and was hiding exactly this. A trait is a property of
the track a scene may honour; an artifact is content it draws. Taking the ink
says nothing about whether a scene responds to u_sigLine.
Circuit Bloom went empty at the bottom of its `grown` range, where the pads were
carried by a hairline and the ink's stroke is thinner than the edge it replaced.
Now filled as well as stroked.
Also: the backtick check now covers every shader literal rather than only the
preamble, because a mechanical pass over sixty files reintroduced one
immediately. Three rounds lost to that typo is enough.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
360 lines
18 KiB
JavaScript
360 lines
18 KiB
JavaScript
#!/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',
|
|
'u_sigFrameScale', 'u_sigFrameShift',
|
|
// The identity artifacts. See look/Identity.js. A scene reads these through
|
|
// castMain/inkMask/stageNode rather than directly, but a migrated scene may
|
|
// legitimately branch on one — Prism Bloom takes its facet count from the
|
|
// cast's side count — so they belong in the contract set.
|
|
'u_castSides', 'u_castRound', 'u_castElong', 'u_castTilt',
|
|
'u_castNotchN', 'u_castNotchD', 'u_castHollow',
|
|
'u_chorusSides', 'u_chorusRound', 'u_chorusElong', 'u_chorusTilt',
|
|
'u_chorusNotchN', 'u_chorusNotchD', 'u_chorusHollow',
|
|
'u_inkWeight', 'u_inkEdge', 'u_inkFill', 'u_inkHatchAngle',
|
|
'u_inkHatchScale', 'u_inkOutline', 'u_inkPosterize',
|
|
'u_latKind', 'u_latJitter', 'u_latSpread',
|
|
'u_latScaleSpread', 'u_latScaleBias', 'u_latScale',
|
|
]);
|
|
|
|
/**
|
|
* 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.
|
|
*/
|
|
// A scene that draws the song's CAST is expressing `shape` more completely than
|
|
// sigShape ever did — the form is the subject rather than a hint applied to one
|
|
// — so castMain/castChorus count as evidence.
|
|
//
|
|
// The INK does not work the same way and must not be listed under `style`. A
|
|
// trait is a property of the track that a scene may honour; an artifact is
|
|
// content it draws. Taking the ink says nothing about whether the scene
|
|
// responds to u_sigLine, and treating it as evidence let Eclipse Field claim a
|
|
// trait it had stopped honouring — which the runtime gate then caught.
|
|
// The same idea as TRAIT_EVIDENCE, for the identity artifacts: a scene that
|
|
// declares it consumes the cast has to actually draw it. Without this,
|
|
// `consumes` is a comment, and the migration becomes unverifiable the moment it
|
|
// is more than a handful of files.
|
|
const ARTIFACT_EVIDENCE = {
|
|
cast: /\bcast(Main|Chorus|SDF|Form)\s*\(/,
|
|
ink: /\bink(Mask|Value|Pattern|Stroke)\s*\(/,
|
|
staging: /\b(stageNode|stageScale)\s*\(/,
|
|
};
|
|
|
|
const TRAIT_EVIDENCE = {
|
|
shape: /\b(sig(Shape|Form)|cast(Main|Chorus|SDF|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/,
|
|
};
|
|
|
|
// Every shader in this project lives inside a JS template literal, so a
|
|
// backtick anywhere in one silently closes it. Three times now that has cost a
|
|
// debugging round: twice in the preamble, where it produced a check page that
|
|
// hung on "starting…" with an empty console, and once in a scene, where at
|
|
// least the module failed to parse loudly. A GLSL comment is the natural place
|
|
// to reach for backticks when quoting a param name, which is exactly why this
|
|
// keeps happening.
|
|
//
|
|
// Checked across the contract AND every scene, since the scene case is the one
|
|
// a mechanical pass over sixty files will keep reintroducing.
|
|
console.log('\nshader literals');
|
|
{
|
|
const targets = [join(SRC, 'engine/shader-contract.js'), ...walk(join(SRC, 'scenes'))];
|
|
let clean = 0;
|
|
for (const file of targets) {
|
|
const src = readFileSync(file, 'utf8');
|
|
const rel = relative(SRC, file).replace(/\\/g, '/');
|
|
// Every template literal in the file, then the lines inside them that
|
|
// carry a backtick without being an interpolation.
|
|
const stray = [];
|
|
const rx = /`([\s\S]*?)`/g;
|
|
let m;
|
|
while ((m = rx.exec(src)) !== null) {
|
|
if (!/\bvec4 scene|precision highp|void main/.test(m[1])) continue;
|
|
for (const line of m[1].split('\n')) {
|
|
if (line.includes('`') && !line.includes('${')) stray.push(line);
|
|
}
|
|
}
|
|
// A shader body that swallowed a closing backtick shows up as an
|
|
// unbalanced count across the file.
|
|
const ticks = (src.match(/`/g) || []).length;
|
|
if (stray.length || ticks % 2 !== 0) {
|
|
fail(`${rel}: backtick inside a shader literal — it closes the string` +
|
|
(stray.length ? `:\n${stray.map((l) => ` ${l.trim()}`).join('\n')}` : ''));
|
|
} else clean++;
|
|
}
|
|
if (clean === targets.length) ok(`${clean} shader literals balanced, no stray backticks`);
|
|
}
|
|
|
|
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 || '<unnamed>';
|
|
|
|
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`);
|
|
}
|
|
}
|
|
|
|
for (const artifact of module.consumes || []) {
|
|
const evidence = ARTIFACT_EVIDENCE[artifact];
|
|
if (evidence && !evidence.test(src)) {
|
|
fail(`${id}: declares it consumes '${artifact}' but the shader never calls it — ` +
|
|
`an artifact that is declared and ignored is worse than one that is ` +
|
|
`not declared, because the casting code will believe it`);
|
|
}
|
|
}
|
|
// The reverse: using an artifact without declaring it hides the scene
|
|
// from the migration status report and from anything that selects on
|
|
// capability later.
|
|
for (const [artifact, evidence] of Object.entries(ARTIFACT_EVIDENCE)) {
|
|
if (evidence.test(src) && !(module.consumes || []).includes(artifact)) {
|
|
fail(`${id}: uses the '${artifact}' artifact but does not declare it in \`consumes\``);
|
|
}
|
|
}
|
|
|
|
// 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');
|