open-design/scripts/verify-media-models.mjs
Zakaria a46764fb1b
Some checks failed
ci / Validate workspace (push) Has been cancelled
landing-page-ci / Validate landing page (push) Has been cancelled
landing-page-deploy / Deploy landing page (push) Has been cancelled
github-metrics / Generate repository metrics SVG (push) Has been cancelled
first-commit
2026-05-04 14:58:14 -04:00

171 lines
5.5 KiB
JavaScript
Executable File

#!/usr/bin/env node
// Drift check between the TypeScript source-of-truth registry
// (apps/web/src/media/models.ts) and the TS mirror used by the Node daemon
// (apps/daemon/src/media-models.ts). The two are kept in sync by hand because the
// daemon avoids a TS toolchain at runtime; this script lets CI fail the
// build the moment they diverge.
//
// Usage:
// node scripts/verify-media-models.mjs
//
// Exit codes:
// 0 — registries match
// 1 — drift detected (diff printed to stderr)
// 2 — could not parse one of the registry files
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(__dirname, '..');
const TS_PATH = path.join(ROOT, 'apps', 'web', 'src', 'media', 'models.ts');
const JS_PATH = path.join(ROOT, 'apps', 'daemon', 'src', 'media-models.ts');
function fail(msg) {
process.stderr.write(`verify-media-models: ${msg}\n`);
process.exit(1);
}
function parseError(msg) {
process.stderr.write(`verify-media-models: ${msg}\n`);
process.exit(2);
}
// Pull a top-level array literal of `{ id: 'x', ... }` records out of the
// source. We deliberately avoid spinning up a TS compiler — we only need
// the IDs and the bucket shapes the two files agree on.
function extractIds(source, name) {
const re = new RegExp(`export const ${name}[^=]*=\\s*\\[([\\s\\S]*?)\\];`, 'm');
const m = source.match(re);
if (!m) return null;
const ids = [];
const idRe = /\bid:\s*['\"]([^'\"]+)['\"]/g;
let id;
while ((id = idRe.exec(m[1])) != null) ids.push(id[1]);
return ids;
}
function extractAudioIds(source) {
const re = /export const AUDIO_MODELS_BY_KIND[^=]*=\s*\{([\s\S]*?)\n\};/m;
const m = source.match(re);
if (!m) return null;
const body = m[1];
const out = {};
for (const kind of ['music', 'speech', 'sfx']) {
const kre = new RegExp(`${kind}\\s*:\\s*\\[([\\s\\S]*?)\\]`, 'm');
const km = body.match(kre);
if (!km) return null;
const ids = [];
const idRe = /\bid:\s*['\"]([^'\"]+)['\"]/g;
let id;
while ((id = idRe.exec(km[1])) != null) ids.push(id[1]);
out[kind] = ids;
}
return out;
}
function extractNumberArray(source, name) {
const re = new RegExp(`export const ${name}[^=]*=\\s*\\[([^\\]]*)\\]`, 'm');
const m = source.match(re);
if (!m) return null;
return m[1]
.split(',')
.map((s) => s.trim())
.filter(Boolean)
.map(Number)
.filter((n) => Number.isFinite(n));
}
function dedupCheck(label, ids) {
const seen = new Set();
for (const id of ids) {
if (seen.has(id)) fail(`duplicate id "${id}" in ${label}`);
seen.add(id);
}
if (ids.length === 0) fail(`${label} is empty`);
}
let ts;
let js;
try {
ts = readFileSync(TS_PATH, 'utf8');
} catch (err) {
parseError(`could not read ${TS_PATH}: ${err.message}`);
}
try {
js = readFileSync(JS_PATH, 'utf8');
} catch (err) {
parseError(`could not read ${JS_PATH}: ${err.message}`);
}
const tsImage = extractIds(ts, 'IMAGE_MODELS');
const tsVideo = extractIds(ts, 'VIDEO_MODELS');
const tsAudio = extractAudioIds(ts);
const tsLengths = extractNumberArray(ts, 'VIDEO_LENGTHS_SEC');
const tsDurations = extractNumberArray(ts, 'AUDIO_DURATIONS_SEC');
const jsImage = extractIds(js, 'IMAGE_MODELS');
const jsVideo = extractIds(js, 'VIDEO_MODELS');
const jsAudio = extractAudioIds(js);
const jsLengths = extractNumberArray(js, 'VIDEO_LENGTHS_SEC');
const jsDurations = extractNumberArray(js, 'AUDIO_DURATIONS_SEC');
if (!tsImage || !tsVideo || !tsAudio) parseError('failed to parse TS registry');
if (!jsImage || !jsVideo || !jsAudio) parseError('failed to parse JS registry');
dedupCheck('IMAGE_MODELS (ts)', tsImage);
dedupCheck('VIDEO_MODELS (ts)', tsVideo);
dedupCheck('IMAGE_MODELS (js)', jsImage);
dedupCheck('VIDEO_MODELS (js)', jsVideo);
for (const kind of ['music', 'speech', 'sfx']) {
dedupCheck(`AUDIO_MODELS_BY_KIND.${kind} (ts)`, tsAudio[kind]);
dedupCheck(`AUDIO_MODELS_BY_KIND.${kind} (js)`, jsAudio[kind]);
}
function diffArrays(label, a, b) {
const aSet = new Set(a);
const bSet = new Set(b);
const onlyA = [...aSet].filter((x) => !bSet.has(x));
const onlyB = [...bSet].filter((x) => !aSet.has(x));
if (onlyA.length === 0 && onlyB.length === 0) return null;
return `${label}: ts only=[${onlyA.join(', ')}], js only=[${onlyB.join(', ')}]`;
}
const diffs = [];
const dImage = diffArrays('IMAGE_MODELS', tsImage, jsImage);
if (dImage) diffs.push(dImage);
const dVideo = diffArrays('VIDEO_MODELS', tsVideo, jsVideo);
if (dVideo) diffs.push(dVideo);
for (const kind of ['music', 'speech', 'sfx']) {
const d = diffArrays(`AUDIO_MODELS_BY_KIND.${kind}`, tsAudio[kind], jsAudio[kind]);
if (d) diffs.push(d);
}
if (tsLengths && jsLengths && tsLengths.join(',') !== jsLengths.join(',')) {
diffs.push(
`VIDEO_LENGTHS_SEC: ts=[${tsLengths.join(', ')}] js=[${jsLengths.join(', ')}]`,
);
}
if (
tsDurations &&
jsDurations &&
tsDurations.join(',') !== jsDurations.join(',')
) {
diffs.push(
`AUDIO_DURATIONS_SEC: ts=[${tsDurations.join(', ')}] js=[${jsDurations.join(', ')}]`,
);
}
if (diffs.length > 0) {
process.stderr.write(
'verify-media-models: drift detected between apps/web/src/media/models.ts and apps/daemon/src/media-models.ts\n',
);
for (const d of diffs) process.stderr.write(` - ${d}\n`);
process.stderr.write(
'\nFix: update both files in lockstep, then re-run this script.\n',
);
process.exit(1);
}
process.stdout.write('verify-media-models: OK (TS + JS registries match)\n');