music-video-gen/flow-state/tools/lint-scenes.js
Dejvino 2d3ed8eb02 Finish the migration: all 65 scenes on the identity artifacts
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>
2026-08-18 06:39:19 +02:00

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');