Docs/Skills and code/skill winning-video-teardown
.claude/skills/winning-video-teardown/scripts/validate.js
What the file says about itself
Validates one teardown analysis: structure, vocabulary, and above all that every quote is really in the transcript. node validate.js --file <analysis.json> --case <caseDir>
#!/usr/bin/env node
// Validates one teardown analysis: structure, vocabulary, and above all that every quote is really in the transcript.
// node validate.js --file <analysis.json> --case <caseDir>
const fs = require('fs');
const path = require('path');
const args = {};
for (let i = 2; i < process.argv.length; i += 2) args[process.argv[i].replace(/^--/, '')] = process.argv[i + 1];
if (!args.file || !args.case) { console.error('usage: node validate.js --file <analysis.json> --case <caseDir>'); process.exit(2); }
const problems = [];
const bad = (m) => problems.push(m);
let a;
try { a = JSON.parse(fs.readFileSync(args.file, 'utf8')); } catch (e) { console.error(`INVALID JSON: ${e.message}`); process.exit(1); }
const transcript = fs.readFileSync(path.join(args.case, 'transcript.txt'), 'utf8');
const normalise = (s) => String(s ?? '').normalize('NFC').toLowerCase().replace(/[^\p{L}\p{N}]+/gu, '');
const haystack = normalise(transcript.replace(/\[\d{1,2}(?::\d{2}){1,2}\]/g, ' '));
const toSec = (t) => { const p = String(t).replace(/[\[\]]/g, '').split(':').map(Number); return p.some(Number.isNaN) ? null : p.reduce((s, v) => s * 60 + v, 0); };
const lastStamp = [...transcript.matchAll(/\[(\d{1,2}(?::\d{2}){1,2})\]/g)].map((m) => toSec(m[1])).pop() ?? 0;
const taxonomy = fs.readFileSync(path.join(__dirname, '..', 'references', 'taxonomy.md'), 'utf8');
const validIds = new Set([...taxonomy.matchAll(/\*\*([SPT]-[A-Z0-9-]+)\*\*/g)].map((m) => m[1]));
let quotes = 0, found = 0;
const missing = [];
function checkEvidence(list, where) {
if (!Array.isArray(list) || !list.length) return bad(`${where}: needs at least one evidence item`);
for (const e of list) {
if (!e || typeof e !== 'object') { bad(`${where}: evidence item is not an object`); continue; }
if (!e.quote || !e.ts || !e.gloss) { bad(`${where}: evidence needs ts, quote and gloss`); continue; }
const sec = toSec(e.ts);
if (sec === null) bad(`${where}: bad timestamp "${e.ts}"`);
else if (sec > lastStamp + 60) bad(`${where}: timestamp ${e.ts} is beyond the end of the transcript`);
// an ellipsis lets the analyst skip words; every fragment must still be present
const fragments = String(e.quote).split(/\.{3}|…/).map(normalise).filter((f) => f.length >= 6);
quotes++;
if (fragments.length && fragments.every((f) => haystack.includes(f))) found++;
else missing.push(`${where} @${e.ts}: "${String(e.quote).slice(0, 70)}"`);
}
}
function checkIds(list, prefixes, where) {
if (!Array.isArray(list)) return bad(`${where}: must be a list`);
for (const m of list) {
if (!m.id || !validIds.has(m.id)) bad(`${where}: unknown mechanism id "${m.id}" (use taxonomy.md ids, or put it under new_mechanisms)`);
else if (!prefixes.some((p) => m.id.startsWith(p))) bad(`${where}: id ${m.id} does not belong here`);
}
}
for (const k of ['video_id', 'one_line', 'packaging', 'script', 'doing_well', 'weak_points', 'transferable_moves', 'confidence']) if (a[k] == null) bad(`missing top-level key "${k}"`);
const p = a.packaging || {}, s = a.script || {};
if (p.title) {
for (const k of ['promise', 'specificity', 'formula', 'mechanisms']) if (p.title[k] == null) bad(`packaging.title.${k} missing`);
checkIds(p.title.mechanisms, ['P-'], 'packaging.title.mechanisms');
if ((p.title.mechanisms || []).length < 2) bad('packaging.title.mechanisms: expected at least 2');
} else bad('packaging.title missing');
if (p.thumbnail) { if (p.thumbnail.seen) checkIds(p.thumbnail.mechanisms || [], ['T-'], 'packaging.thumbnail.mechanisms'); } else bad('packaging.thumbnail missing');
if (p.promise_delivery) {
for (const k of ['promise_restated', 'delivered_at', 'delivered', 'clickbait_risk', 'why']) if (p.promise_delivery[k] == null) bad(`packaging.promise_delivery.${k} missing`);
} else bad('packaging.promise_delivery missing');
if (s.hook) {
if (typeof s.hook.time_to_promise_sec !== 'number') bad('script.hook.time_to_promise_sec must be a number');
checkIds(s.hook.moves || [], ['S-'], 'script.hook.moves');
(s.hook.moves || []).forEach((m, i) => checkEvidence(m.evidence, `script.hook.moves[${i}]`));
} else bad('script.hook missing');
if (!Array.isArray(s.structure) || s.structure.length < 3) bad('script.structure: need at least 3 stretches');
if (!Array.isArray(s.loops)) bad('script.loops must be a list');
else if (!s.loops.length && !(a.weak_points || []).some((w) => /loop/i.test(JSON.stringify(w)))) bad('script.loops is empty: find the loops or explain their absence in weak_points');
if (!Array.isArray(s.mechanisms) || s.mechanisms.length < 5) bad('script.mechanisms: need at least 5');
else { checkIds(s.mechanisms, ['S-'], 'script.mechanisms'); s.mechanisms.forEach((m, i) => { if (!m.how_it_works || !m.effect_on_viewer) bad(`script.mechanisms[${i}]: needs how_it_works and effect_on_viewer`); checkEvidence(m.evidence, `script.mechanisms[${i}]`); }); }
if (!Array.isArray(a.doing_well) || a.doing_well.length < 3 || a.doing_well.length > 6) bad('doing_well: need 3 to 6 items');
else a.doing_well.forEach((d, i) => { if (!d.move || !d.how) bad(`doing_well[${i}]: needs move and how`); checkEvidence(d.evidence, `doing_well[${i}]`); });
if (!Array.isArray(a.transferable_moves) || a.transferable_moves.length < 3) bad('transferable_moves: need at least 3');
(a.new_mechanisms || []).forEach((m, i) => { if (!m.name || !m.definition) bad(`new_mechanisms[${i}]: needs name and definition`); if (m.evidence) checkEvidence(m.evidence, `new_mechanisms[${i}]`); });
// ---- fields added 2026-10-24 (stakes, flow, forced continuation): warnings unless --strict 1 ----
const warnings = [];
const soft = (m) => (args.strict === '1' ? bad(m) : warnings.push(m));
const LINKS = new Set(['THEREFORE', 'BUT', 'AND_THEN', 'NEW_TOPIC']);
if (!Array.isArray(s.junctions)) soft('script.junctions missing: list every boundary between the stretches with a link type (taxonomy.md section D) and a quoted hinge');
else {
const need = Math.max(3, (Array.isArray(s.structure) ? s.structure.length : 0) - 1);
if (s.junctions.length < need) soft(`script.junctions: ${s.junctions.length} listed, expected at least ${need} (one per boundary in script.structure)`);
s.junctions.forEach((j, i) => {
if (!LINKS.has(j.link)) soft(`script.junctions[${i}]: link must be one of ${[...LINKS].join(', ')}`);
if (!j.at || toSec(j.at) === null) soft(`script.junctions[${i}]: needs a timestamp "at"`);
checkEvidence(j.evidence, `script.junctions[${i}]`);
});
}
if (!s.numbers || typeof s.numbers !== 'object') soft('script.numbers missing: how the video frames and paces its figures');
else {
if (!Array.isArray(s.numbers.hero_figures)) soft('script.numbers.hero_figures must be a list');
else s.numbers.hero_figures.forEach((h, i) => {
if (!['prediction', 'pair', 'human_scale', 'person', 'question', 'none'].includes(h.anchored_by)) soft(`script.numbers.hero_figures[${i}]: anchored_by must be prediction, pair, human_scale, person, question or none`);
if (h.evidence) checkEvidence(h.evidence, `script.numbers.hero_figures[${i}]`);
});
if (!s.numbers.load_note) soft('script.numbers.load_note missing');
}
if (s.hook) for (const k of ['first_payoff_sec', 'figures_in_first_60s']) if (typeof s.hook[k] !== 'number') soft(`script.hook.${k} must be a number`);
if (s.hook && !s.hook.destination_hint) soft('script.hook.destination_hint missing');
else if (s.hook?.destination_hint?.evidence) checkEvidence(s.hook.destination_hint.evidence, 'script.hook.destination_hint');
if (s.hook && !Array.isArray(s.hook.extra_promises)) soft('script.hook.extra_promises must be a list (empty if the opening seeds nothing beyond the title)');
const rate = quotes ? found / quotes : 0;
if (quotes < 8) bad(`only ${quotes} quotes in total: the analysis is under-evidenced`);
if (rate < 0.85) bad(`only ${found}/${quotes} quotes (${(rate * 100).toFixed(0)}%) were found in the transcript; need 85%. Not found:\n ${missing.join('\n ')}`);
if (problems.length) { console.error('PROBLEMS:\n- ' + problems.join('\n- ')); process.exit(1); }
console.log(`OK (${found}/${quotes} quotes verified${missing.length ? '; unverified: ' + missing.length : ''})`);
if (warnings.length) console.log('WARNINGS (new fields, not enforced without --strict 1):\n- ' + warnings.join('\n- '));
Shown as it is in the repository at build time (2026-11-03); the home directory appears as <home>. If this page and the file differ, the file is right. Back to the catalogue.