Docs/Skills and code/skill video-script-production

.claude/skills/video-script-production/scripts/selftest.js

Code126 lines17.0 KBSkill: video-script-production

What the file says about itself

Damage test for the linter (same idea as script-cohort-analysis/scripts/perturb.js): take a script that passes, break one property at a time, and check that the rule meant to catch it actually fires.

Mentioned on: page 07, page 09, page 12, page in

#!/usr/bin/env node
// Damage test for the linter (same idea as script-cohort-analysis/scripts/perturb.js): take a script that passes, break one
// property at a time, and check that the rule meant to catch it actually fires. A linter that cannot fail is not a linter.
//   node selftest.js --script <SCRIPT.md> --chain <chain.json>
// Prints one line per mutation; exits 1 if the control fails or any mutation is missed.

const fs = require('fs');
const os = require('os');
const path = require('path');
const { spawnSync } = require('child_process');

const args = {};
for (let i = 2; i < process.argv.length; i += 2) args[process.argv[i].replace(/^--/, '')] = process.argv[i + 1];
if (!args.script || !args.chain) { console.error('usage: node selftest.js --script <SCRIPT.md> --chain <chain.json>'); process.exit(2); }

const baseScript = fs.readFileSync(args.script, 'utf8');
const baseChain = JSON.parse(fs.readFileSync(args.chain, 'utf8'));
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'lint-selftest-'));
const lint = path.join(__dirname, 'lint.js');

function run(name, script, chain) {
  const sp = path.join(tmp, `${name}.md`), cp = path.join(tmp, `${name}.json`);
  fs.writeFileSync(sp, script, 'utf8'); fs.writeFileSync(cp, JSON.stringify(chain), 'utf8');
  const r = spawnSync(process.execPath, [lint, '--script', sp, '--chain', cp, '--label', name, '--out', tmp, ...(args.lang ? ['--lang', args.lang] : [])], { encoding: 'utf8' });
  const j = JSON.parse(fs.readFileSync(path.join(tmp, `lint-${name}.json`), 'utf8'));
  return j.results.filter((x) => x.status === 'FAIL' || x.status === 'WARN').map((x) => `${x.id}:${x.status}`);
}
// Mutations touch the spoken text only (lines that start **VO:**): a quote can also appear earlier in the notes sections of the file.
const voFirst = (s, a) => s.split('\n').findIndex((l) => /^\*\*VO:\*\*/.test(l) && l.includes(a));
const swap = (s, a, b) => {
  if (a.startsWith('**VO:**')) { if (!s.includes(a)) throw new Error('mutation target not found: ' + a.slice(0, 50)); return s.replace(a, b); }
  const k = voFirst(s, a);
  if (k < 0) throw new Error('mutation target not found in the voice-over: ' + a.slice(0, 50));
  const Ls = s.split('\n'); Ls[k] = Ls[k].replace(a, b); return Ls.join('\n');
};
const clone = (o) => JSON.parse(JSON.stringify(o));

// Mutations are derived from the chain itself, so the test keeps working as the script is edited.
const cut = (s, q) => swap(s, q, '');
const firstBut = (c) => c.beats.find((b) => b.link_to_next === 'BUT');
const lastHero = (c) => c.hero_numbers[c.hero_numbers.length - 1];
const mutations = [
  ['control (unmodified)', null, (s, c) => [s, c]],
  ['and-then opener at a join', ['C1'], (s, c) => { const b = c.beats.find((x) => x.next_opens_with); const t = c.beats.find((x) => x.id === b.id); const n = c.beats[c.beats.indexOf(t) + 1]; const q = t.next_opens_with; return [swap(s, q, 'Then ' + q.charAt(0).toLowerCase() + q.slice(1)), c]; }],
  ['stake line placed after the figure', ['A6'], (s, c) => { const k = clone(c); const h = lastHero(k); if (!h.landing_quote) throw new Error('SKIP no landing to move the stake onto'); h.stake_quote = h.landing_quote; return [s, k]; }],
  ['route term leaked into the hook', ['B2'], (s, c) => [swap(s, '**VO:** ', '**VO:** ' + c.cdl.withheld[0] + '. '), c]],
  ['greeting in the hook', ['B2'], (s, c) => [swap(s, '**VO:** ', '**VO:** Hello and welcome back to the channel. '), c]],
  ['figures stacked in the hook', ['A3'], (s, c) => [swap(s, '**VO:** ', '**VO:** 2,280 workers, 15 crore, 85 percent, 4,828 crore. '), c]],
  ['promise seed removed from the hook', ['B4'], (s, c) => [cut(s, c.ladder.find((r) => r.seed_quote).seed_quote), c]],
  ['jargon gloss removed', ['A7'], (s, c) => [cut(s, c.jargon[0].gloss_quote), c]],
  ['six figures in one breath', ['A2'], (s, c) => [swap(s, c.peak_reveal.quote, 'It lost 12 crore, then 45 crore, then 88 crore, then 130 crore, then 210 crore, then 340 crore. ' + c.peak_reveal.quote), c]],
  ['spine closed too early', ['D5'], (s, c) => { const k = clone(c); const l = k.loops.find((x) => x.kind === 'spine'); const o = k.loops.find((x) => x.kind === 'beat' && x.opened_in === 'B2'); l.closed_in = o.closed_in; l.close_quote = o.close_quote; return [s, k]; }],
  ['junction typed AND_THEN', ['C4'], (s, c) => { const k = clone(c); k.beats[2].link_to_next = 'AND_THEN'; return [s, k]; }],
  ['beat ends without its compel line', ['C4'], (s, c) => [cut(s, firstBut(c).compel_quote), c]],
  ['care line after the first figure', ['D2'], (s, c) => { const k = clone(c); const b = k.beats.find((x) => x.id === lastHero(k).beat); b.care_quote = b.compel_quote; return [s, k]; }],
  ['a BUT with no reason given', ['C4'], (s, c) => { const k = clone(c); firstBut(k).link_why = 'but'; return [s, k]; }],
  ['unannounced bonus leaked into the hook', ['B5'], (s, c) => [swap(s, '**VO:** ', '**VO:** ' + c.bonuses[0].quote + '. '), c]],
  // E and F rules: skipped for a chain with no pulls (E) so older scripts can still be damage-tested
  ['pull touches removed', ['E1'], (s, c) => { if (!(c.pulls || []).length) throw new Error('SKIP no pulls declared'); const k = clone(c); k.pulls[0].touches = []; return [s, k]; }],
  ['pull quote no longer in the text', ['E1'], (s, c) => { if (!(c.pulls || []).length) throw new Error('SKIP no pulls declared'); return [cut(s, c.pulls[0].touches[0].quote), c]; }],
  ['pulls reduced to one kind', ['E3'], (s, c) => { if (!(c.pulls || []).length) throw new Error('SKIP no pulls declared'); const k = clone(c); k.pulls.forEach((p) => { p.type = 'motif'; }); return [s, k]; }],
  ['pulls removed except one early signal', ['E2'], (s, c) => { if (!(c.pulls || []).length) throw new Error('SKIP no pulls declared'); const k = clone(c); k.pulls = [k.pulls[0]]; k.pulls[0].touches = k.pulls[0].touches.slice(0, 1); k.loops = k.loops.filter((l) => l.kind === 'spine'); return [s, k]; }],
  ['negation pivots stacked', ['F1'], (s, c) => [swap(s, '**VO:** ', '**VO:** It was not a crash. It was a slow leak. No warning, no noise, just a bill. This is not just a bill. It is a plan. I am not saying it was luck. '), c]],
  ['script-talk stacked', ['F2'], (s, c) => [swap(s, '**VO:** ', '**VO:** Here is what matters. Think about what that means. Notice what happens next. Remember the prediction. I promised you this. Let me be clear. '), c]],
  ['machine vocabulary', ['F3'], (s, c) => [swap(s, '**VO:** ', '**VO:** It was a pivotal and transformative journey through a vibrant landscape, a testament to a multifaceted realm. '), c]],
  ['fragment drumrolls', ['F4'], (s, c) => [swap(s, '**VO:** ', '**VO:** No. Not that. Not this. The plant ran for years after that and nobody in the building thought anything of it. Done. Over. Gone. '), c]],
  ['dashes in spoken text', ['F5'], (s, c) => [swap(s, '**VO:** ', '**VO:** One thing — two things — three things — four things — done. '), c]],
  // G, H, I, B8: skipped for a chain that does not declare the section (so older scripts can still be damage-tested)
  ['governing metaphor never returns', ['G3'], (s, c) => { need(c, 'figures'); const k = clone(c); k.figures.find((f) => f.kind === 'governing').touches = []; return [s, k]; }],
  ['metaphor with no stated limit', ['G3'], (s, c) => { need(c, 'figures'); const k = clone(c); delete k.figures.find((f) => f.kind === 'governing').breaks; return [s, k]; }],
  ['parable stuffed with figures', ['G3'], (s, c) => { need(c, 'figures'); const p = c.figures.find((f) => f.kind === 'parable'); const k = clone(c); return [swap(s, p.quote, p.quote + ', paying 40 rupees, then 55 rupees, then 90 rupees'), k]; }],
  ['figure flood', ['G4'], (s, c) => [swap(s, '**VO:** ', '**VO:** ' + Array.from({ length: 18 }, (_, i) => `It was like a ${['tab', 'lease', 'wall', 'river', 'coin', 'door', 'bell', 'cup', 'bag', 'box', 'hat', 'drum', 'map', 'rope', 'bus', 'pen', 'fan', 'lamp'][i]}.`).join(' ') + ' '), c]],
  ['stock figures and verdict words', ['G4'], (s, c) => [swap(s, '**VO:** ', '**VO:** It was a perfect storm, a house of cards, a ticking time bomb, a silver bullet, a game-changer and a recipe for disaster, run by greedy, reckless fools. '), c]],
  ['only one register of the voice', ['G1'], (s, c) => { need(c, 'persona'); const k = clone(c); k.persona.aloof = []; return [s, k]; }],
  ['contested call told one-sided', ['G2'], (s, c) => { need(c, 'persona'); const k = clone(c); k.persona.sides = []; return [s, k]; }],
  ['a wall of numbers', ['H1'], (s, c) => [prependVo(s, 3, Array.from({ length: 34 }, () => 'It lost 12 crore, then 45 crore, then 88 crore.').join(' ')), c]],
  ['no landing after a hero number', ['H4'], (s, c) => { need(c, 'humans'); const k = clone(c); delete k.hero_numbers[k.hero_numbers.length - 1].landing_quote; return [s, k]; }],
  ['cold close full of figures', ['H5'], (s, c) => [prependVo(s, voIdx(s).idx.length - 1, 'It cost 12 crore, 45 crore and 88 crore.'), c]],
  ['hook without the protagonist\'s name', ['I2'], (s, c) => { need(c, 'humans'); const p = c.humans.find((h) => h.role === 'protagonist'); const out = replaceIn(s, 0, regions(s).hookEnd, nameRe(p), 'the man'); if (out === s) throw new Error('SKIP the protagonist is not named in the hook'); return [out, c]; }],
  ['hinge line leans on a pronoun with no name near it', ['I3'], (s, c) => { need(c, 'humans'); if (!c.beats.some((x) => x.compel_quote && /\b(he|she|his|him|her)\b/i.test(x.compel_quote))) throw new Error('SKIP no hinge line uses a pronoun'); let out = s; for (const h of c.humans) out = replaceIn(out, 0, out.split('\n').length, nameRe(h), 'the person'); return [out, c]; }],
  ['two minutes with no person and no "you"', ['I4'], (s, c) => { need(c, 'humans'); return [prependVo(s, 3, Array.from({ length: 42 }, () => 'The industry kept adding capacity every quarter.').join(' ')), c]; }],
  ['no viewer stakes', ['I5'], (s, c) => { need(c, 'humans'); const k = clone(c); k.viewer_stakes = []; return [s, k]; }],
  ['witness never returns in the close', ['I6'], (s, c) => { need(c, 'humans'); const w = c.humans.find((h) => h.role === 'witness'); const out = replaceIn(s, regions(s).lastStart, s.split('\n').length, nameRe(w), 'a man'); if (out === s) throw new Error('SKIP the witness is not named in the last beat'); return [out, c]; }],
  ['story with no people in it', ['J1'], (s, c) => { need(c, 'beats.1.arc'); const k = clone(c); k.beats.forEach((b) => { b.arc = [].concat(b.arc || []).filter((r) => r !== 'people'); }); return [s, k]; }],
  ['story parts out of order', ['J2'], (s, c) => { need(c, 'beats.1.arc'); const k = clone(c); const a = k.beats.find((b) => [].concat(b.arc || []).includes('origin')), z = k.beats.find((b) => [].concat(b.arc || []).includes('fall')); [a.arc, z.arc] = [z.arc, a.arc]; return [s, k]; }],
  ['hook without the learning', ['B8'], (s, c) => { need(c, 'cdl.hook_elements'); return [cut(s, c.cdl.hook_elements.learning_quote), c]; }],
  // K, L, M (review round 7): skipped for a chain that does not declare the section
  ['person named with no tag', ['K1'], (s, c) => { need(c, 'humans.0.tags'); const k = clone(c); k.humans.forEach((h) => { h.tags = ['zzz-no-such-tag']; }); return [s, k]; }],
  ['person returns after a gap with no tag', ['K2'], (s, c) => { need(c, 'humans.0.tags'); const h = c.humans.find((x) => x.role !== 'protagonist' && (x.tags || []).length); const t = new RegExp(h.tags.map(escRe).join('|'), 'gi'); let n = 0; const L = s.split('\n').map((l) => (/^\*\*VO:\*\*/.test(l) ? l.replace(t, (m) => (n++ === 0 ? m : 'the man')) : l)); if (n < 3) throw new Error('SKIP fewer than three tag phrases in the voice-over'); return [L.join('\n'), c]; }],
  ['name that appears once with no reason', ['K4'], (s, c) => { need(c, 'one_off_names'); const k = clone(c); k.one_off_names = []; return [s, k]; }],
  ['a count with no items behind it', ['K5'], (s, c) => { need(c, 'counted_sets'); return [prependVo(s, 3, 'There were five reasons for this. '), c]; }],
  ['named idea never reused', ['K6'], (s, c) => { need(c, 'concepts'); const cn = c.concepts[0]; const re = new RegExp('\\b' + escRe(cn.term) + '\\w*', 'gi'); const d = s.indexOf(cn.define_quote); if (d < 0) throw new Error('SKIP define quote not in script'); const after = s.slice(d + cn.define_quote.length).replace(re, 'move'); return [s.slice(0, d + cn.define_quote.length) + after, c]; }],
  ['a term the audience knows gets a gloss', ['K7'], (s, c) => { need(c, 'audience.known_terms'); return [prependVo(s, 3, 'A ' + c.audience.known_terms[0] + ' is a unit most of us know. '), c]; }],
  ['a vague referent and a buried verb', ['K8'], (s, c) => [prependVo(s, 3, 'Whatever was left over was sold. The founder, the man many of his people had trusted for most of the early years, was running it. '), c]],
  ['hook turn does not open on a contrast', ['L1'], (s, c) => { need(c, 'cdl.hook_elements.turn_quote'); const t = c.cdl.hook_elements.turn_quote; if (!/^(But|Lekin|Par|Magar|Phir bhi|Fir bhi) /.test(t)) throw new Error('SKIP the turn quote does not open on a contrast word'); return [swap(s, t, t.replace(/^(But|Lekin|Par|Magar|Phir bhi|Fir bhi) /, 'So ')), c]; }],
  ['the witness cost stated flat', ['L2'], (s, c) => { if (!(c.humans || []).some((h) => h.cost_quote)) throw new Error('SKIP no cost_quote declared'); const k = clone(c); const w = k.humans.find((h) => h.role === 'witness'); if (!w || !w.cost_quote) throw new Error('SKIP no witness cost_quote'); const flat = w.cost_quote.replace(/\b(?:full|whole|entire|even|own|every|all|poore|poori|poora|apni|apne|apna|har|sabhi)\s+/gi, ''); if (flat === w.cost_quote) throw new Error('SKIP the cost quote has no intensifier to remove'); const out = s.replace(w.cost_quote, flat); w.cost_quote = flat; return [out, k]; }],
  ['a BUT join with no contrast word', ['L3'], (s, c) => { const j = c.beats.find((b) => b.link_to_next === 'BUT'); const k = clone(c); k.beats = k.beats.map((b) => (b.id === j.id ? Object.assign(b, { link_to_next: 'BUT' }) : b)); let out = s; const re = /\b(?:But|Yet|Still|However|Even so|Even though|Lekin|Par|Magar|Phir bhi|Fir bhi)\b[, ]?/g; out = out.replace(re, ''); return [out, c]; }],
  ['a neutrality disclaimer about a mechanism', ['L4'], (s, c) => [prependVo(s, 6, 'I don\'t have a favourite theory. '), c]],
  ['a dense beat with no headline', ['M1'], (s, c) => { if (!(c.beats || []).some((b) => b.headline_quote)) throw new Error('SKIP no headline_quote declared'); const k = clone(c); let n = 0; k.beats.forEach((b) => { if (b.headline_quote) { delete b.headline_quote; n++; } }); return [s, k]; }],
  ['a hook that ends in a menu, not a trailer', ['M2'], (s, c) => { need(c, 'cdl.hook_elements.tease_quote'); const k = clone(c); k.cdl.hook_elements.tease_quote += ', plus a whole run of other developments, side stories, twists and long detours that we will get to in due course, one after another'; return [s, k]; }],
  ['the narrator announces the test', ['M3'], (s, c) => [prependVo(s, 5, 'If that is right, you would expect sales to barely move. There should be a sign. '), c]],
];
function need(c, what) { const v = what.split('.').reduce((o, k) => (o ? o[k] : undefined), c); if (!v || (Array.isArray(v) && !v.length)) throw new Error('SKIP no ' + what + ' declared'); }

// Targets are found from the script and the chain, never from fixed wording, so the damage test runs on any topic.
function voIdx(s) { const L = s.split('\n'); const idx = []; L.forEach((l, i) => { if (/^\*\*VO:\*\*/.test(l)) idx.push(i); }); return { L, idx }; }
function prependVo(s, k, text) { const { L, idx } = voIdx(s); const i = idx[Math.min(k, idx.length - 1)]; L[i] = L[i].replace('**VO:** ', '**VO:** ' + text + ' '); return L.join('\n'); }
function regions(s) { const L = s.split('\n'); const h = []; L.forEach((l, i) => { if (/^###\s*\[B\d+\]/.test(l)) h.push(i); }); return { hookEnd: h[1] === undefined ? L.length : h[1], lastStart: h[h.length - 1] }; }
const escRe = (x) => x.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
function nameRe(p) { return new RegExp('\\b(?:' + [p.name, ...(p.aliases || [])].filter((x) => x && !x.startsWith('re:') && !/^(the |its )?(founder|workers?|union'?s? president|workers' union president)$/i.test(x)).map(escRe).join('|') + ')\\b', 'g'); }
function replaceIn(s, from, to, re, rep) { const L = s.split('\n'); for (let i = from; i < to && i < L.length; i++) if (/^\*\*VO:\*\*/.test(L[i])) L[i] = L[i].replace(re, rep); return L.join('\n'); }

let bad = 0, skipped = 0;
for (const [name, expect, fn] of mutations) {
  let out;
  try { const [s, c] = fn(baseScript, baseChain); out = run(name.replace(/\W+/g, '_'), s, c); }
  catch (e) { if (/^SKIP/.test(e.message)) { console.log(`skip   ${name}: ${e.message.slice(5)}`); skipped++; continue; } console.log(`ERROR  ${name}: ${e.message}`); bad++; continue; }
  const fails = out.filter((x) => x.endsWith(':FAIL')), ids = out.map((x) => x.split(':')[0]);
  let ok;
  if (!expect) ok = fails.length === 0; else ok = expect.every((id) => ids.includes(id));
  if (!ok) bad++;
  console.log(`${ok ? 'ok   ' : 'MISS '} ${name}${expect ? `  expected ${expect.join(',')}  got ${out.join(' ') || 'nothing'}` : `  ${fails.length ? 'FAILS: ' + fails.join(' ') : 'no FAIL'}`}`);
}
console.log(bad ? `\n${bad} problem(s)` : `\nall ${mutations.length - skipped} checks behaved${skipped ? ` (${skipped} skipped: the chain does not declare that section)` : ''}`);
process.exit(bad ? 1 : 0);

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.