Docs/Skills and code/skill video-script-production
.claude/skills/video-script-production/scripts/lint.js
What the file says about itself
Script linter and scorer. Enforces lessons A to D (references/principles.md) on a script and its declared chain. node lint.js --script <SCRIPT.md> [--chain <chain.json>] [--rules <rules.json>] [--label v4] [--out <dir>] Spoken text = every…
Mentioned on: page 07, page 08, page 09, page 12
#!/usr/bin/env node
// Script linter and scorer. Enforces lessons A to D (references/principles.md) on a script and its declared chain.
// node lint.js --script <SCRIPT.md> [--chain <chain.json>] [--rules <rules.json>] [--label v4] [--out <dir>]
// Spoken text = every line starting "**VO:**". Beats = "### [Bn] ..." headings (v3-style "### Heading" files fall back to
// one beat per heading, text-only). Exit code 1 when any rule FAILs. Heuristic rules WARN, never FAIL.
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.script) { console.error('usage: node lint.js --script <SCRIPT.md> [--chain chain.json] [--rules rules.json] [--label v4] [--out dir]'); process.exit(2); }
const rules = JSON.parse(fs.readFileSync(args.rules || path.join(__dirname, 'rules.json'), 'utf8'));
const chain = args.chain && fs.existsSync(args.chain) ? JSON.parse(fs.readFileSync(args.chain, 'utf8')) : null;
const label = args.label || path.basename(args.script).replace(/\.md$/i, '');
const WPM = rules.wpm;
// --lang hi: the English word lists and patterns below are replaced by lang-hi.json (Hinglish). Without it nothing changes.
const LH = args.lang === 'hi' ? JSON.parse(fs.readFileSync(path.join(__dirname, 'lang-hi.json'), 'utf8')) : null;
// ---------- parse ----------
const clean = (s) => s
.replace(/\*\*\[[^\]]*\]\*\*/g, ' ').replace(/\[(?:V|CHECK)[^\]]*\]/gi, ' ')
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1').replace(/\*+/g, '').replace(/\s+/g, ' ').trim();
const norm = (s) => String(s ?? '').normalize('NFC').toLowerCase().replace(/[“”‘’"']/g, '').replace(/[^\p{L}\p{N}₹$%]+/gu, ' ').trim();
const lines = fs.readFileSync(args.script, 'utf8').split(/\r?\n/);
let beats = [];
let sawBeatHeadings = lines.some((l) => /^###\s*\[B\d+\]/.test(l));
let cur = null;
for (const l of lines) {
const h = l.match(/^###\s*(?:\[B(\d+)\]\s*)?(.*)$/);
if (h && (sawBeatHeadings ? /^###\s*\[B\d+\]/.test(l) : true)) {
cur = { id: sawBeatHeadings ? `B${h[1]}` : `B${beats.length}`, heading: h[2].trim(), vo: [] };
beats.push(cur);
continue;
}
if (/^#{1,2}\s/.test(l) && !/^###/.test(l)) { if (sawBeatHeadings) cur = null; continue; }
const m = l.match(/^\*\*VO:\*\*\s*(.*)$/);
if (m && cur && clean(m[1])) cur.vo.push(clean(m[1])); // an empty **VO:** line (a blank scaffold) is not spoken text
}
beats = beats.filter((b) => b.vo.length);
if (!beats.length) { console.error('no spoken text found (lines starting **VO:**)'); process.exit(2); }
const splitSentences = (t) => t.split(/(?<=[.?!])["”')]*\s+(?=["“'(]*[A-Z0-9₹$])/).map((s) => s.trim()).filter(Boolean);
let cursor = 0;
for (const b of beats) {
b.text = b.vo.join(' ');
b.sentences = splitSentences(b.text);
b.norm = norm(b.text);
b.words = (b.text.match(/\S+/g) || []).length;
b.start = cursor; cursor += b.words; b.end = cursor;
}
const totalWords = cursor;
const secOf = (w) => (w / WPM) * 60;
const fmt = (sec) => { const t = Math.round(sec); return `${Math.floor(t / 60)}:${String(t % 60).padStart(2, '0')}`; }; // round first: 59.6 s is 1:00, not 0:60
const beatById = Object.fromEntries(beats.map((b) => [b.id, b]));
const beatIdx = Object.fromEntries(beats.map((b, i) => [b.id, i]));
// index (in words, global) where a quote begins inside a beat, or -1
function locate(beat, quote) {
if (!beat || !quote) return -1;
const q = norm(quote);
if (!q) return -1;
const i = beat.norm.indexOf(q);
if (i < 0) return -1;
return beat.start + (beat.norm.slice(0, i).match(/\S+/g) || []).length;
}
const locateAny = (quote) => { for (const b of beats) { const i = locate(b, quote); if (i >= 0) return { beat: b, idx: i }; } return null; };
// ---------- quantities ----------
const MONTHS = 'january|february|march|april|may|june|july|august|september|october|november|december|jan|feb|mar|apr|jun|jul|aug|sep|sept|oct|nov|dec';
const ORD = 'first|second|third|fourth|fifth|sixth|seventh|eighth|ninth|tenth|eleventh|twelfth|thirteenth|fourteenth|fifteenth|sixteenth|seventeenth|eighteenth|nineteenth|twentieth|twenty-\\w+|thirtieth|thirty-first';
const mask = (m) => '_'.repeat(m.length);
function maskDates(s) {
return s
.replace(new RegExp(`\\b(?:the\\s+)?(?:${ORD})\\s+of\\s+(?:${MONTHS})\\b(?:,?\\s+\\d{4})?`, 'gi'), mask)
.replace(new RegExp(`\\b\\d{1,2}(?:st|nd|rd|th)?\\s+(?:of\\s+)?(?:${MONTHS})\\b(?:,?\\s+\\d{4})?`, 'gi'), mask)
.replace(new RegExp(`\\b(?:${MONTHS})\\s+(?:of\\s+)?\\d{4}`, 'gi'), mask)
.replace(new RegExp(`\\b(?:${MONTHS})\\s+\\d{1,2}(?:st|nd|rd|th)?(?:,?\\s+\\d{4})?`, 'gi'), mask)
.replace(/\b(?:19|20)\d{2}\b(?!\s*(?:crore|million|billion|lakh|%|percent|workers|people|employees))/g, mask)
.replace(/\bFY\d{2}\b/gi, mask);
}
const NUMW = new Set(LH ? LH.num_words : 'onetwo three four five six seven eight nine ten eleven twelve thirteen fourteen fifteen sixteen seventeen eighteen nineteen twenty thirty forty fifty sixty seventy eighty ninety hundred thousand lakh lakhs crore crores million billion trillion'.split(' '));
const SCALE = new Set(LH ? LH.scale : 'hundredthousand lakh lakhs crore crores million billion trillion'.split(' '));
const UNIT = new Set(LH ? LH.units : 'percentpaise rupees rupee dollars dollar days months years workers people employees times'.split(' '));
function quantitiesIn(text, baseIdx) {
const out = [];
const masked = maskDates(text);
// digits
for (const m of masked.matchAll(/(?<![A-Za-z\d])[₹$]?\d[\d,]*(?:\.\d+)?(?:\s?%)?/g)) {
const w = (masked.slice(0, m.index).match(/\S+/g) || []).length;
out.push({ idx: baseIdx + w, text: m[0].trim(), kind: 'digit' });
}
// spelled
const toks = masked.split(/\s+/);
const bare = (t) => t.toLowerCase().replace(/[^a-z-]/g, '');
for (let i = 0; i < toks.length;) {
const parts = bare(toks[i]).split('-').filter(Boolean);
if (parts.length && parts.every((p) => NUMW.has(p))) {
let j = i, run = [];
while (j < toks.length) {
const b = bare(toks[j]); const ps = b.split('-').filter(Boolean);
if (ps.length && ps.every((p) => NUMW.has(p))) { run.push(...ps); j++; }
else if (b === 'and' && j + 1 < toks.length && bare(toks[j + 1]).split('-').filter(Boolean).every((p) => NUMW.has(p)) && bare(toks[j + 1])) { run.push('and'); j++; }
else break;
}
const next = bare(toks[j] || '');
const hasScale = run.some((r) => SCALE.has(r));
const yearLike = /^(?:nineteen hundred|two thousand)(?: and)?(?: (?:one|two|three|four|five|six|seven|eight|nine|ten|eleven|twelve|thirteen|fourteen|fifteen|sixteen|seventeen|eighteen|nineteen|twenty|thirty|forty|fifty|sixty|seventy|eighty|ninety))?$/.test(run.join(' ')) && run.length <= 4;
const afterDigit = i > 0 && /\d/.test(toks[i - 1]) && run.every((r) => SCALE.has(r)); // "₹354 crore" is one quantity, already counted
if ((hasScale || UNIT.has(next)) && !yearLike && !afterDigit) out.push({ idx: baseIdx + i, text: toks.slice(i, j).join(' '), kind: 'spelled' });
i = Math.max(j, i + 1);
} else i++;
}
return out.sort((a, b) => a.idx - b.idx);
}
const quantities = [];
for (const b of beats) quantities.push(...quantitiesIn(b.text, b.start).map((q) => ({ ...q, beat: b.id })));
const minutes = totalWords / WPM;
let maxIn30 = 0, maxIn30At = 0;
const win = Math.round((30 / 60) * WPM);
for (const q of quantities) {
const n = quantities.filter((r) => r.idx >= q.idx && r.idx < q.idx + win).length;
if (n > maxIn30) { maxIn30 = n; maxIn30At = q.idx; }
}
const hookBeat = beats[0];
const hookQuantities = quantities.filter((q) => q.beat === hookBeat.id);
// naked-number heuristic (WARN only)
const mergeRe = (en, lh) => (lh ? new RegExp(`(?:${en.source})|(?:${lh})`, 'i') : en); // Hinglish alternates with the English list (Hinglish text still uses English words)
const COMPARATOR = mergeRe(/\b(from|to|than|versus|vs|against|down|fell|rose|dropped|per|every|each|compared|erased|worth|less than|more than|a quarter|a third|half|times|swing|instead|but)\b/i, LH && LH.comparator);
const HUMAN = mergeRe(/\b(workers?|people|family|families|salary|salaries|lenders?|customers?|you|your|investors?|employees?|staff|bondholders?|analyst)\b/i, LH && LH.human);
const STAKE_PREV = mergeRe(/(\?|\bif\b|\bshould\b|\bexpect|\bcheck\b|\bsign\b|\bpredict|\bwatch\b|\bnumber\b|let's)/i, LH && LH.stake_prev);
const naked = [];
for (const b of beats) b.sentences.forEach((s, i) => {
const qn = quantitiesIn(s, 0);
if (!qn.length) return;
const prev = b.sentences[i - 1] || '', next = b.sentences[i + 1] || '';
const paired = quantitiesIn(prev, 0).length > 0 || quantitiesIn(next, 0).length > 0; // a contrast pair split across adjacent sentences
const isHero = chain && (chain.hero_numbers || []).some((h) => norm(s).includes(norm(h.value))); // hero numbers get the strict stake-line check (A6)
const anchored = isHero || qn.length >= 2 || paired || COMPARATOR.test(s) || HUMAN.test(s) || STAKE_PREV.test(prev);
if (!anchored) naked.push({ beat: b.id, sentence: s.slice(0, 140) });
});
// ---------- connectives ----------
const openerRe = new RegExp(LH ? `(?:${rules.additive_openers})|(?:${LH.additive_openers})` : rules.additive_openers, 'i');
const inlineRe = new RegExp(LH ? `(?:${rules.additive_inline})|(?:${LH.additive_inline})` : rules.additive_inline, 'gi');
const additiveOpeners = [];
beats.forEach((b, i) => { if (i > 0 && openerRe.test(b.sentences[0])) additiveOpeners.push({ beat: b.id, sentence: b.sentences[0].slice(0, 100) }); });
let additiveInline = 0; const additiveHits = [];
for (const b of beats) for (const m of b.text.matchAll(inlineRe)) { additiveInline++; additiveHits.push(`${b.id}: "${m[0].trim()}"`); }
// a beat that opens on a bare date is usually a new topic, not a consequence; with a declared chain the junction rules decide, without one it is only a warning
const DATELINE = new RegExp(`^(?:in |by |on |during |from |after )?(?:the )?(?:early |late |mid-?)?(?:\\d{1,2}(?:st|nd|rd|th)? )?(?:${MONTHS})\\b|^(?:in |by |on )?(?:19|20)\\d{2}\\b`, 'i');
const datelineOpeners = beats.filter((x, k) => k > 0 && DATELINE.test(x.sentences[0]) && !/\b(because|so|but|yet|therefore|which|since|despite|still|instead)\b/i.test(x.sentences[0])).map((x) => `${x.id}: "${x.sentences[0].slice(0, 80)}"`);
const STOP = new Set(LH ? LH.stop : 'thatthis with from have were their there which would about been they them then than what when will into your only also just more most some such over after before because while where these those still even like make made does done being here very much many each every other another'.split(' '));
const stems = (s) => new Set((norm(s).match(/[a-z₹$]{4,}/g) || []).filter((w) => !STOP.has(w)).map((w) => w.replace(/(?:ing|ed|es|s)$/, '')));
const overlap = (a, b) => { const A = stems(a), B = stems(b); return [...A].filter((x) => B.has(x)); };
// ---------- results ----------
const results = [];
const add = (lesson, id, status, title, detail) => results.push({ lesson, id, status, title, detail });
const PASS = 'PASS', WARN = 'WARN', FAIL = 'FAIL';
// Structure declared?
if (!chain) {
add('D', 'D0', FAIL, 'Structure declared in a chain file', 'No chain.json: the beats cannot be checked against the four-step contract, the loop ledger or the promise ladder.');
}
// ----- A: stakes and load -----
add('A', 'A1', quantities.length / minutes <= rules.load.max_quantities_per_min ? PASS : WARN, `Quantities per minute <= ${rules.load.max_quantities_per_min}`, `${quantities.length} spoken quantities in ${minutes.toFixed(1)} min = ${(quantities.length / minutes).toFixed(1)}/min`);
add('A', 'A2', maxIn30 <= rules.load.max_quantities_in_30s ? PASS : FAIL, `No 30-second window holds more than ${rules.load.max_quantities_in_30s} quantities`, `max ${maxIn30} in 30 s, starting at ${fmt(secOf(maxIn30At))}`);
add('A', 'A3', hookQuantities.length <= rules.hook.max_quantities ? PASS : FAIL, `Hook has at most ${rules.hook.max_quantities} figure`, `${hookQuantities.length} in the hook${hookQuantities.length ? ': ' + hookQuantities.map((q) => q.text).join(', ') : ''}`);
add('A', 'A4', naked.length === 0 ? PASS : WARN, 'No naked numbers (heuristic: each figure has a pair, comparator, person or a question before it)', naked.length ? naked.slice(0, 8).map((n) => `${n.beat}: ${n.sentence}`).join('\n') : 'none flagged');
const jargonSeen = [];
{
const all = beats.map((b) => b.text).join(' ').toLowerCase();
for (const j of rules.jargon_watch) if (new RegExp(`\\b${j}\\b`, 'i').test(all)) jargonSeen.push(j);
}
if (chain) {
const heroes = chain.hero_numbers || [];
add('A', 'A5', heroes.length <= rules.load.max_hero_numbers ? PASS : FAIL, `At most ${rules.load.max_hero_numbers} hero numbers`, `${heroes.length} declared`);
const bad = [];
for (const h of heroes) {
const b = beatById[h.beat];
if (!b) { bad.push(`${h.id}: unknown beat ${h.beat}`); continue; }
const v = locate(b, h.value), s = locate(b, h.stake_quote);
if (v < 0) bad.push(`${h.id}: value "${h.value}" not found in ${h.beat}`);
else if (s < 0) bad.push(`${h.id}: stake quote not found in ${h.beat}`);
else if (s >= v) bad.push(`${h.id}: stake line comes AFTER the value in ${h.beat}`);
if (h.comparator_quote && locate(b, h.comparator_quote) < 0) bad.push(`${h.id}: comparator quote not found`);
}
add('A', 'A6', bad.length ? FAIL : PASS, 'Every hero number has a stake line before it (same beat) and a findable comparator', bad.length ? bad.join('\n') : `${heroes.length} hero numbers checked`);
const undeclared = jargonSeen.filter((j) => !(chain.jargon || []).some((d) => norm(d.term).includes(j)));
const jbad = [];
for (const d of chain.jargon || []) {
const all = beats.map((b) => b.text).join(' ');
const first = all.toLowerCase().indexOf(d.term.toLowerCase());
const g = norm(all).indexOf(norm(d.gloss_quote));
const firstN = norm(all).indexOf(norm(d.term));
if (first < 0) { jbad.push(`${d.term}: term never used (remove from chain)`); continue; }
if (g < 0) { jbad.push(`${d.term}: gloss quote not found`); continue; }
if (g > firstN + 250 || g < firstN - 400) jbad.push(`${d.term}: gloss is not at the first use`);
}
add('A', 'A7', jbad.length ? FAIL : PASS, 'Declared jargon is glossed at first use', jbad.length ? jbad.join('\n') : `${(chain.jargon || []).length} terms checked`);
add('A', 'A8', undeclared.length ? WARN : PASS, 'No undeclared jargon (watch list)', undeclared.length ? `used but not declared: ${undeclared.join(', ')}` : 'none');
} else {
add('A', 'A5', WARN, 'Hero numbers declared', 'no chain');
add('A', 'A8', jargonSeen.length ? WARN : PASS, 'Jargon on the watch list', jargonSeen.length ? `used: ${jargonSeen.join(', ')} (no glosses declared)` : 'none');
}
// ----- C: causal chain (text-level) -----
add('C', 'C1', additiveOpeners.length ? FAIL : PASS, 'No beat opens with an additive marker (and then / next / meanwhile / later ...)', additiveOpeners.length ? additiveOpeners.map((o) => `${o.beat}: "${o.sentence}"`).join('\n') : 'none');
const addRate = (additiveInline / totalWords) * 100;
if (!chain) add('C', 'C6', datelineOpeners.length ? WARN : PASS, 'No beat opens on a bare date with no causal link (text-only check)', datelineOpeners.length ? datelineOpeners.join('\n') : 'none');
add('C', 'C2', addRate <= rules.chain.max_additive_elsewhere_per_100w ? PASS : WARN, 'Few additive connectives inside beats', `${additiveInline} hit(s), ${addRate.toFixed(2)} per 100 words${additiveHits.length ? ': ' + additiveHits.slice(0, 6).join('; ') : ''}`);
const junctions = [];
beats.forEach((b, i) => {
if (i === beats.length - 1) return;
const n = beats[i + 1];
const lastTwo = b.sentences.slice(-2).join(' ');
const shared = overlap(lastTwo, n.sentences[0]);
junctions.push({ from: b.id, to: n.id, shared });
});
const noShare = junctions.filter((j) => !j.shared.length);
add('C', 'C3', noShare.length === 0 ? PASS : WARN, 'Dependency test: the next beat picks up a word the last two sentences created (heuristic)', noShare.length ? noShare.map((j) => `${j.from} -> ${j.to}: no shared content word`).join('\n') : junctions.map((j) => `${j.from}->${j.to}: ${j.shared.slice(0, 3).join('/')}`).join('; '));
// ----- chain-driven checks -----
if (chain) {
const cb = chain.beats || [];
// structure
const need = ['understand', 'care', 'care_type', 'care_quote', 'change'];
const sbad = [];
for (const b of beats) {
const d = cb.find((x) => x.id === b.id);
if (!d) { sbad.push(`${b.id}: no declaration`); continue; }
const isHook = b.id === beats[0].id, isLast = b.id === beats[beats.length - 1].id;
for (const k of need) if (!isHook && !d[k]) sbad.push(`${b.id}: missing ${k}`);
if (!isLast) for (const k of ['compel', 'compel_quote', 'link_to_next', 'link_why', 'next_opens_with']) if (!d[k]) sbad.push(`${b.id}: missing ${k}`);
if (isLast && !d.closes_spine) sbad.push(`${b.id}: last beat must set closes_spine`);
if (d.understand && d.understand.split(/\s+/).length > rules.load.max_understand_words) sbad.push(`${b.id}: "understand" is over ${rules.load.max_understand_words} words (split the beat)`);
if (d.care_type && !rules.care.types.includes(d.care_type)) sbad.push(`${b.id}: care_type "${d.care_type}"`);
}
add('D', 'D1', sbad.length ? FAIL : PASS, 'Every beat declares understand / care / change / compel with a link and a reason', sbad.length ? sbad.join('\n') : `${cb.length} beats declared`);
// care quote present and before the beat's hero numbers
const cbad = [];
for (const b of beats.slice(1)) {
const d = cb.find((x) => x.id === b.id); if (!d || !d.care_quote) continue;
const c = locate(b, d.care_quote);
if (c < 0) { cbad.push(`${b.id}: care quote not found`); continue; }
const firstQ = quantities.find((q) => q.beat === b.id && q.kind === 'digit' && !/^(?:19|20)\d{2}$/.test(q.text));
if (firstQ && c > firstQ.idx) cbad.push(`${b.id}: first figure ("${firstQ.text}") comes before the care line`);
}
add('D', 'D2', cbad.length ? FAIL : PASS, 'The care line sits in the beat and comes before its first figure', cbad.length ? cbad.join('\n') : 'ok');
const cares = cb.filter((d) => d.care_type);
const vp = cares.filter((d) => ['viewer', 'person'].includes(d.care_type)).length;
add('D', 'D3', cares.length && vp / cares.length >= rules.care.min_viewer_or_person_share ? PASS : WARN, `At least ${rules.care.min_viewer_or_person_share * 100}% of beats care via the viewer or a person`, `${vp}/${cares.length}`);
// junctions
const jbad = [], types = {};
cb.forEach((d, i) => {
if (i === cb.length - 1) return;
const b = beatById[d.id], n = beatById[cb[i + 1].id];
types[d.link_to_next] = (types[d.link_to_next] || 0) + 1;
if (!rules.chain.allowed_links.includes(d.link_to_next)) jbad.push(`${d.id}: link "${d.link_to_next}" is not ${rules.chain.allowed_links.join('/')}`);
if (b && d.compel_quote) {
const lastTwo = norm(b.sentences.slice(-2).join(' '));
if (!lastTwo.includes(norm(d.compel_quote))) jbad.push(`${d.id}: compel quote is not in the beat's last two sentences`);
}
if (n && d.next_opens_with) {
const first = norm(n.sentences.slice(0, 2).join(' '));
if (!first.startsWith(norm(d.next_opens_with)) && !first.includes(norm(d.next_opens_with))) jbad.push(`${d.id}: "${d.next_opens_with}" is not how ${n.id} opens`);
}
if (d.link_to_next === 'BUT' && d.link_why && d.link_why.split(/\s+/).length < 6) jbad.push(`${d.id}: a BUT needs link_why naming the expectation it reverses`);
});
add('C', 'C4', jbad.length ? FAIL : PASS, 'Every junction is BUT/THEREFORE/BECAUSE, the hinge quotes are in the text, and the beat ends on its compel line', jbad.length ? jbad.join('\n') : `${cb.length - 1} junctions: ${Object.entries(types).map(([k, v]) => k + ' ' + v).join(', ')}`);
const nJ = Math.max(1, cb.length - 1), butShare = (types.BUT || 0) / nJ;
add('C', 'C5', butShare >= rules.chain.min_but_share && butShare <= rules.chain.max_but_share ? PASS : WARN, `BUT share of junctions between ${rules.chain.min_but_share} and ${rules.chain.max_but_share}`, `${(butShare * 100).toFixed(0)}% BUT`);
// D: beat length
const lbad = beats.filter((b, i) => i > 0 && i < beats.length - 1 && (b.words < rules.chain.beat_words[0] || b.words > rules.chain.beat_words[1])).map((b) => `${b.id}: ${b.words} words (${fmt(secOf(b.words))})`);
add('D', 'D4', lbad.length ? WARN : PASS, `Middle beats run ${rules.chain.beat_words[0]} to ${rules.chain.beat_words[1]} words`, lbad.length ? lbad.join('\n') : 'ok');
// ----- loops -----
const L = chain.loops || [];
const lbad2 = [];
const iv = [];
for (const l of L) {
const ob = beatById[l.opened_in], cbt = beatById[l.closed_in];
const o = locate(ob, l.open_quote), c = locate(cbt, l.close_quote);
if (o < 0) lbad2.push(`${l.id}: open quote not found in ${l.opened_in}`);
if (c < 0) lbad2.push(`${l.id}: close quote not found in ${l.closed_in}`);
if (o >= 0 && c >= 0) {
if (c <= o) lbad2.push(`${l.id}: closes before it opens`);
iv.push({ ...l, o, c });
if (l.kind === 'beat' && beatIdx[l.closed_in] - beatIdx[l.opened_in] > rules.loops.beat_loop_max_gap) lbad2.push(`${l.id}: beat loop stays open ${beatIdx[l.closed_in] - beatIdx[l.opened_in]} beats`);
}
}
const spine = iv.find((l) => l.kind === 'spine');
if (!spine) lbad2.push('no spine loop declared');
else {
if (spine.opened_in !== beats[0].id) lbad2.push('spine must open in the hook');
if (spine.c / totalWords < rules.loops.spine_closes_after) lbad2.push(`spine closes at ${(spine.c / totalWords * 100).toFixed(0)}% of runtime (needs >= ${rules.loops.spine_closes_after * 100}%)`);
}
add('D', 'D5', lbad2.length ? FAIL : PASS, 'Loop ledger: quotes are in the text, spine opens in the hook and stays open into the last quarter, beat loops close within two beats', lbad2.length ? lbad2.join('\n') : `${iv.length} loops checked`);
// live concurrency of major loops (spine + beat loops), sampled at each opening
let maxLive = 0, maxLiveAt = 0;
for (const l of iv) { const n = iv.filter((m) => m.o <= l.o && m.c > l.o).length; if (n > maxLive) { maxLive = n; maxLiveAt = l.o; } }
add('D', 'D6', maxLive <= rules.loops.max_live_major ? PASS : FAIL, `At most ${rules.loops.max_live_major} loops live at once`, `max ${maxLive} at ${fmt(secOf(maxLiveAt))}`);
// coverage by non-spine loops
const nonSpine = iv.filter((l) => l.kind !== 'spine').sort((a, b) => a.o - b.o);
let reach = beats[0].end, maxGap = 0, gapAt = 0; // the hook is covered by the spine and the ladder seeds
for (const l of nonSpine) { if (l.o > reach && l.o - reach > maxGap) { maxGap = l.o - reach; gapAt = reach; } reach = Math.max(reach, l.c); }
if (totalWords - reach > maxGap) { maxGap = totalWords - reach; gapAt = reach; }
add('D', 'D7', secOf(maxGap) <= rules.loops.max_uncovered_sec ? PASS : WARN, `No stretch over ${rules.loops.max_uncovered_sec}s without a pending beat-level question`, `longest uncovered stretch ${Math.round(secOf(maxGap))}s from ${fmt(secOf(gapAt))}`);
// forced continuation: each non-final beat's compel is a question or open consequence (declared) and next beat answers it: covered by C4/D5
const lastBeat = beats[beats.length - 1];
const openers = beats.slice(0, -1).filter((b) => iv.some((l) => l.kind === 'beat' && l.opened_in === b.id));
add('D', 'D8', openers.length >= beats.length - 2 ? PASS : WARN, 'Nearly every beat opens a beat-level loop', `${openers.length}/${beats.length - 1} beats (excluding the close)`);
// ----- E: sustained pull (implied loops) -----
const PU = chain.pulls || [];
const pullNote = 'Pulls are implied open loops: a date that is coming, a person at risk, something the viewer knows that the people in the story do not. Declare them in chain.pulls (references/chain-schema.md).';
if (!PU.length) {
for (const [id, t] of [['E1', 'Pulls declared and each touched in the text'], ['E2', `No stretch over ${rules.pulls.max_uncovered_sec}s without an explicit loop or a touched pull`], ['E3', 'Several kinds of pull in use'], ['E4', 'Every beat carries a pull or a beat loop']]) add('E', id, WARN, t, 'no pulls declared. ' + pullNote);
} else {
const pbad = [], events = [], kinds = new Set();
const warm = Math.round((rules.pulls.warm_sec / 60) * WPM);
for (const p of PU) {
if (!rules.pulls.types.includes(p.type)) pbad.push(`${p.id}: type "${p.type}" is not one of ${rules.pulls.types.join('/')}`);
kinds.add(p.type);
const s = locate(beatById[p.beat], p.signal_quote);
if (s < 0) { pbad.push(`${p.id}: signal quote not found in ${p.beat}`); continue; }
const pts = [{ at: s, beat: p.beat }];
let last = s;
for (const t of p.touches || []) {
const i = locate(beatById[t.beat], t.quote);
if (i < 0) { pbad.push(`${p.id}: touch not found in ${t.beat}: "${String(t.quote || '').slice(0, 40)}"`); continue; }
if (i <= last) pbad.push(`${p.id}: touch in ${t.beat} comes before the previous one`);
pts.push({ at: i, beat: t.beat }); last = Math.max(last, i);
}
if ((p.touches || []).length < rules.pulls.min_touches) pbad.push(`${p.id}: ${(p.touches || []).length} touches (needs ${rules.pulls.min_touches}); a pull that is never touched again is not pulling`);
let end = Infinity;
if (p.resolve) {
const r = locate(beatById[p.resolve.beat], p.resolve.quote);
if (r < 0) pbad.push(`${p.id}: resolve quote not found in ${p.resolve.beat}`);
else { if (r <= last) pbad.push(`${p.id}: resolves before its last touch`); end = r; pts.push({ at: r, beat: p.resolve.beat }); }
} else if (p.left_open) {
const r = locate(beatById[p.left_open.beat], p.left_open.quote);
if (r < 0) pbad.push(`${p.id}: the line that admits the gap is not in ${p.left_open.beat}`);
else pts.push({ at: r, beat: p.left_open.beat });
} else pbad.push(`${p.id}: neither resolved nor admitted as left open`);
for (const x of pts) events.push({ id: p.id, beat: x.beat, from: x.at, to: Math.min(end, x.at + warm) });
}
if (PU.length < rules.pulls.min_pulls) pbad.push(`${PU.length} pulls declared (needs ${rules.pulls.min_pulls})`);
add('E', 'E1', pbad.length ? FAIL : PASS, 'Pulls declared, typed, touched in the text and resolved or honestly left open', pbad.length ? pbad.join('\n') : `${PU.length} pulls, ${events.length} touches/signals`);
const cover = [...iv.filter((l) => l.kind !== 'spine').map((l) => [l.o, l.c]), ...events.map((e) => [e.from, e.to])].sort((a, b) => a[0] - b[0]);
let reachE = beats[0].end, gapE = 0, gapEAt = 0;
for (const [a, b] of cover) { if (a > reachE && a - reachE > gapE) { gapE = a - reachE; gapEAt = reachE; } reachE = Math.max(reachE, b); }
const endW = totalWords - rules.pulls.tail_grace_words;
if (endW - reachE > gapE) { gapE = endW - reachE; gapEAt = reachE; }
const gapSec = secOf(gapE);
add('E', 'E2', gapSec <= rules.pulls.max_uncovered_sec ? PASS : gapSec <= rules.pulls.fail_uncovered_sec ? WARN : FAIL, `No stretch over ${rules.pulls.max_uncovered_sec}s without an explicit loop or a touched pull`, `longest stretch with nothing pending: ${Math.round(gapSec)}s from ${fmt(secOf(gapEAt))}`);
add('E', 'E3', kinds.size >= rules.pulls.min_types ? PASS : WARN, `At least ${rules.pulls.min_types} kinds of pull in use`, `${[...kinds].join(', ')}`);
const carrying = new Set([...events.map((e) => e.beat), ...iv.filter((l) => l.kind === 'beat').flatMap((l) => [l.opened_in])]);
const bare = beats.slice(1, -1).filter((b) => !carrying.has(b.id)).map((b) => b.id);
add('E', 'E4', bare.length ? WARN : PASS, 'Every middle beat carries a pull signal, a touch or a beat loop opening', bare.length ? `no pull in: ${bare.join(', ')}` : 'ok');
}
// ----- B: opening -----
const cdl = chain.cdl || {};
const hook = beats[0];
const bbad = [];
for (const k of ['packaging_question', 'question', 'viewer_prior', 'destination_hint', 'destination_quote', 'question_quote', 'first_payoff_quote', 'withheld']) if (!cdl[k] || (Array.isArray(cdl[k]) && !cdl[k].length)) bbad.push(`cdl.${k} missing`);
if ((cdl.destination_hint || '').split(/\s+/).length > 20) bbad.push('destination_hint is over 20 words');
if (cdl.destination_quote && locate(hook, cdl.destination_quote) < 0) bbad.push('destination_quote not in the hook');
if (cdl.question_quote && locate(hook, cdl.question_quote) < 0) bbad.push('question_quote not in the hook');
add('B', 'B1', bbad.length ? FAIL : PASS, 'A core driving loop is declared with a viewer prior and a destination hint, and both are in the hook', bbad.length ? bbad.join('\n') : 'ok');
const hbad = [];
const hookSec = secOf(hook.words);
if (hookSec > rules.hook.max_sec) hbad.push(`hook runs ${Math.round(hookSec)}s (max ${rules.hook.max_sec}s)`);
if (new RegExp(LH ? `(?:${rules.greeting})|(?:${LH.greeting})` : rules.greeting, 'i').test(hook.text.split(/\s+/).slice(0, rules.hook.greeting_scan_words).join(' '))) hbad.push('greeting / channel intro / subscribe phrase in the opening words');
const leaks = (cdl.withheld || []).filter((t) => new RegExp(`\\b${t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`, 'i').test(hook.text));
if (leaks.length) hbad.push(`withheld route terms leaked into the hook: ${leaks.join(', ')}`);
add('B', 'B2', hbad.length ? FAIL : PASS, 'Hook: within length, no greeting, destination without direction (no withheld term appears)', hbad.length ? hbad.join('\n') : `hook ${Math.round(hookSec)}s, ${(cdl.withheld || []).length} terms withheld`);
const fp = locate(hook, cdl.first_payoff_quote);
add('B', 'B3', fp >= 0 && fp - hook.start <= rules.hook.first_payoff_max_words ? PASS : FAIL, 'The click is confirmed with a concrete payoff in the first ~30 seconds', fp < 0 ? 'first_payoff_quote not found in the hook' : `payoff begins at word ${fp - hook.start} (about ${Math.round(secOf(fp - hook.start))}s)`);
const lad = chain.ladder || [];
const labad = [];
if (lad.length < rules.ladder.min_rungs) labad.push(`${lad.length} rungs (need ${rules.ladder.min_rungs})`);
let seeds = 0, prevIdx = -1;
for (const r of lad) {
const s = r.rung === 1 ? 1 : (locate(hook, r.seed_quote) >= 0 ? 1 : 0);
if (r.rung > 1 && !s) labad.push(`rung ${r.rung}: seed quote not in the hook`);
if (r.rung > 1 && s) seeds++;
const pb = beatById[r.payoff_beat]; const p = locate(pb, r.payoff_quote);
if (p < 0) { labad.push(`rung ${r.rung}: payoff quote not found in ${r.payoff_beat}`); continue; }
if (p <= prevIdx) labad.push(`rung ${r.rung}: payoff comes before the previous rung's`);
prevIdx = p;
const f = p / totalWords;
if (r.rung === 1 && f > rules.ladder.rung1_by) labad.push(`rung 1 pays at ${(f * 100).toFixed(0)}% (needs by ${rules.ladder.rung1_by * 100}%)`);
if (r.rung === 2 && (f < rules.ladder.rung2_between[0] || f > rules.ladder.rung2_between[1])) labad.push(`rung 2 pays at ${(f * 100).toFixed(0)}% (needs ${rules.ladder.rung2_between.map((x) => x * 100).join('-')}%)`);
if (r.rung === 3 && f < rules.ladder.rung3_after) labad.push(`rung 3 pays at ${(f * 100).toFixed(0)}% (needs after ${rules.ladder.rung3_after * 100}%)`);
}
if (seeds < rules.hook.min_ladder_seeds) labad.push(`only ${seeds} extra promises seeded in the hook (need ${rules.hook.min_ladder_seeds})`);
add('B', 'B4', labad.length ? FAIL : PASS, 'Promise ladder: three rungs, extra promises seeded in the hook, paid in ascending order and in the time bands', labad.length ? labad.join('\n') : lad.map((r) => `rung ${r.rung} -> ${r.payoff_beat}`).join(', '));
const bon = chain.bonuses || [];
const bonOk = bon.filter((b) => locate(beatById[b.beat], b.quote) >= 0);
const bonLeak = bon.filter((b) => locate(hook, b.quote) >= 0);
add('B', 'B5', bonOk.length >= rules.ladder.min_bonuses && !bonLeak.length ? PASS : FAIL, 'At least one unannounced bonus, present in the text and absent from the hook', `${bonOk.length}/${bon.length} found${bonLeak.length ? '; leaked into hook' : ''}`);
const cands = cdl.candidates || [];
add('B', 'B6', cands.length >= 3 && cands.filter((c) => c.chosen).length === 1 ? PASS : WARN, 'CDL was chosen from at least three scored candidates', `${cands.length} candidates, ${cands.filter((c) => c.chosen).length} chosen`);
// peak reveal after the midpoint
const peak = chain.peak_reveal;
if (peak) {
const p = locate(beatById[peak.beat], peak.quote);
add('B', 'B7', p >= 0 && p / totalWords > 0.5 ? PASS : FAIL, 'The biggest reveal comes after the midpoint', p < 0 ? 'peak_reveal quote not found' : `at ${(p / totalWords * 100).toFixed(0)}% of runtime`);
} else add('B', 'B7', WARN, 'The biggest reveal comes after the midpoint', 'peak_reveal not declared');
} else {
add('B', 'B1', FAIL, 'A core driving loop is declared', 'no chain');
}
// ----- G, H, I: personality and figures, rhythm, the human element (references/texture.md) -----
const VPX = JSON.parse(fs.readFileSync(path.join(__dirname, 'voice-patterns.json'), 'utf8'));
// Hinglish voice patterns are ADDED to the English ones (a Hinglish script still uses English business words): lists are joined, regex strings are alternated.
const mergeVoice = (target, over) => { for (const [k, v] of Object.entries(over)) { if (k.startsWith('_')) continue; if (Array.isArray(v) && (target[k] === undefined || Array.isArray(target[k]))) target[k] = (target[k] || []).concat(v); else if (typeof v === 'string' && typeof target[k] === 'string') target[k] = `(?:${target[k]})|(?:${v})`; else target[k] = Array.isArray(target[k]) || Array.isArray(v) ? [].concat(target[k] || [], v) : v; } return target; };
if (LH) mergeVoice(VPX, LH.voice_patterns);
const RH = rules.rhythm, FG = rules.figures, PE = rules.persona, HU = rules.human;
const escRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const wordAt = (b, ci) => b.start + (b.text.slice(0, ci).match(/\S+/g) || []).length;
const textAll = beats.map((b) => b.text).join('\n');
const matchesAt = (re) => beats.flatMap((b) => [...b.text.matchAll(re)].map((m) => ({ idx: wordAt(b, m.index), beat: b.id, text: m[0] })));
const gapOf = (events, from, to) => { // longest stretch (in words) between events, and where it starts
const pts = [from, ...events.map((e) => e.idx).filter((i) => i >= from && i <= to), to].sort((a, b) => a - b);
let g = 0, at = from; for (let i = 1; i < pts.length; i++) if (pts[i] - pts[i - 1] > g) { g = pts[i] - pts[i - 1]; at = pts[i - 1]; }
return { words: g, at };
};
// H (text level): hard windows are 30-second stretches holding two or more spoken quantities
const nWin = Math.max(1, Math.round(totalWords / ((RH.window_sec / 60) * WPM))), winLen = totalWords / nWin;
const wq = Array.from({ length: nWin }, (_, i) => quantities.filter((q) => q.idx >= i * winLen && q.idx < (i + 1) * winLen).length);
const hardW = wq.map((n) => n >= RH.hard_quantities), softW = wq.map((n) => n === 0);
let hardRun = 0, hardRunMax = 0, swaps = 0;
hardW.forEach((h, i) => { hardRun = h ? hardRun + 1 : 0; hardRunMax = Math.max(hardRunMax, hardRun); if (i && h !== hardW[i - 1]) swaps++; });
const hardShare = hardW.filter(Boolean).length / nWin, softShare = softW.filter(Boolean).length / nWin;
const swapsPer10 = swaps / (minutes / 10);
add('H', 'H1', hardRunMax > RH.max_hard_run_fail ? FAIL : hardRunMax > RH.max_hard_run_warn ? WARN : PASS, `No wall of numbers: at most ${RH.max_hard_run_warn} hard 30-second windows in a row`, `longest run ${hardRunMax} (${hardW.filter(Boolean).length} hard of ${nWin} windows)`);
add('H', 'H2', hardShare <= RH.max_hard_share && softShare >= RH.min_soft_share ? PASS : WARN, `Hard windows at most ${RH.max_hard_share * 100}% and quiet windows (no figure) at least ${RH.min_soft_share * 100}%`, `hard ${(hardShare * 100).toFixed(0)}%, quiet ${(softShare * 100).toFixed(0)}%, ${swapsPer10.toFixed(1)} swaps per 10 min`);
const dense = beats.slice(1, -1).map((b) => ({ id: b.id, qpm: quantities.filter((q) => q.beat === b.id).length / Math.max(0.3, secOf(b.words) / 60) }));
const denseRuns = dense.filter((d, i) => i && d.qpm >= RH.dense_beat_qpm && dense[i - 1].qpm >= RH.dense_beat_qpm).map((d) => d.id);
add('H', 'H3', denseRuns.length ? WARN : PASS, `No two consecutive beats both carry ${RH.dense_beat_qpm}+ quantities per minute`, denseRuns.length ? `dense back to back at: ${denseRuns.join(', ')}` : 'ok');
// the words of a figure: its quote, or for a parable everything from its first to its last line
const figSpan = (f) => {
const b = beatById[f.beat]; if (!b) return f.quote || '';
const lo = b.text.toLowerCase(), q = String(f.quote || '').toLowerCase().slice(0, 30), e = String(f.end_quote || '').toLowerCase();
const a = lo.indexOf(q), z = e ? lo.indexOf(e.slice(0, 30)) : -1;
return a >= 0 && z > a ? b.text.slice(a, z + e.length) : f.quote || '';
};
const hum = chain && chain.humans ? chain.humans : null;
const aliasSrc = (a) => (a.startsWith('re:') ? a.slice(3) : escRe(a)); // 're:' aliases are raw regexes, for example 're:(?<!Ratul )Puri'
const aliasRe = (h) => new RegExp(`\\b(?:${[h.name, ...(h.aliases || [])].filter(Boolean).map(aliasSrc).join('|')})\\b`, 'gi');
const viewerRe = new RegExp(VPX.viewer_address, 'gi'), viewerOne = new RegExp(VPX.viewer_address, 'i');
const personEvents = [...(hum || []).flatMap((h) => matchesAt(aliasRe(h))), ...matchesAt(viewerRe)].sort((a, b) => a.idx - b.idx);
let pg = null;
if (chain) {
// H4: a landing after every hero number
const heroes = chain.hero_numbers || [];
if (!heroes.some((h) => h.landing_quote)) add('H', 'H4', WARN, 'Each hero number is followed by a landing: a person, the viewer or a picture, with no figure in it', 'no landing_quote declared on the hero numbers. A landing turns the number into something felt (references/texture.md).');
else {
const lbad = [];
for (const h of heroes) {
const b = beatById[h.beat]; if (!b) continue;
const v = locate(b, h.value), l = locate(b, h.landing_quote);
if (!h.landing_quote) { lbad.push(`${h.id}: no landing_quote`); continue; }
if (l < 0) { lbad.push(`${h.id}: landing quote not found in ${h.beat}`); continue; }
if (v >= 0 && l <= v) lbad.push(`${h.id}: landing comes before the figure`);
else if (v >= 0 && l - v > RH.landing_max_words) lbad.push(`${h.id}: landing is ${l - v} words after the figure (limit ${RH.landing_max_words})`);
if (quantitiesIn(h.landing_quote, 0).length) lbad.push(`${h.id}: the landing holds a figure`);
const warm = viewerOne.test(h.landing_quote) || (hum || []).some((x) => aliasRe(x).test(h.landing_quote)) || VPX.figure_markers.some((m) => new RegExp(m, 'i').test(h.landing_quote)) || HUMAN.test(h.landing_quote) || (chain.figures || []).some((f) => overlap(h.landing_quote, figSpan(f)).length >= 2);
if (!warm) lbad.push(`${h.id}: the landing names no person, no viewer and no picture`);
}
add('H', 'H4', lbad.length ? FAIL : PASS, 'Each hero number is followed by a landing: a person, the viewer or a picture, with no figure in it', lbad.length ? lbad.join('\n') : `${heroes.length} landings checked`);
}
// H5: the last beat is quiet and human (peak-end)
const lastB = beats[beats.length - 1];
const lq = quantities.filter((q) => q.beat === lastB.id).length;
const lastHuman = personEvents.some((e) => e.beat === lastB.id);
add('H', 'H5', lq <= RH.closing_max_quantities && lastHuman ? PASS : WARN, `The close is quiet and human: at most ${RH.closing_max_quantities} figures and a person or the viewer in it`, `${lq} figures in ${lastB.id}; person present: ${lastHuman}`);
} else add('H', 'H4', WARN, 'Landing after hero numbers', 'no chain');
// I: the human element
if (!hum || !hum.length) {
for (const [id, t] of [['I1', 'People declared: a protagonist with a want and a witness who pays'], ['I2', 'Protagonist named in the hook with a want, before any pronoun stands for them'], ['I3', 'No hinge line leans on a pronoun whose person has not been named in the beat'], ['I4', 'A person or the viewer is present throughout'], ['I5', 'Viewer stakes: you are in the hook, the middle and the close'], ['I6', 'People recur']]) add('I', id, WARN, t, 'no humans declared (references/chain-schema.md)');
} else {
const ibad = [];
const roles = hum.map((h) => h.role);
if (hum.length < HU.min_humans) ibad.push(`${hum.length} people declared (needs ${HU.min_humans})`);
if (!roles.includes('protagonist')) ibad.push('no protagonist');
if (!roles.includes('witness')) ibad.push('no witness (someone who pays for the decisions)');
for (const h of hum) {
for (const k of ['name', 'want_quote', 'detail_quote', 'stake_quote']) if (!h[k]) ibad.push(`${h.id}: missing ${k}`);
for (const k of ['want_quote', 'detail_quote', 'stake_quote']) if (h[k] && !locateAny(h[k])) ibad.push(`${h.id}: ${k} not found in the text`);
if (!aliasRe(h).test(textAll)) ibad.push(`${h.id}: ${h.name} never appears in the text`);
}
add('I', 'I1', ibad.length ? FAIL : PASS, 'People declared: a protagonist with a want and a witness who pays, each with a concrete detail and a stake', ibad.length ? ibad.join('\n') : `${hum.map((h) => `${h.name} (${h.role})`).join(', ')}`);
// a person is NAMED by name, not by a role phrase ("its founder", "the union's president")
const roleOnly = /^(?:the |its |their )?(?:founder|workers?|union'?s? president|workers' union president|union president)$/i;
const nameRe = (h) => new RegExp(`\\b(?:${[h.name, ...(h.aliases || [])].filter((x) => x && !roleOnly.test(x)).map(aliasSrc).join('|')})\\b`, 'gi');
const prot = hum.find((h) => h.role === 'protagonist');
const hookB = beats[0];
const jbad2 = [];
if (prot) {
const m = nameRe(prot).exec(textAll);
if (!m) jbad2.push('protagonist never named');
else {
const wi = (textAll.slice(0, m.index).match(/\S+/g) || []).length;
if (wi > HU.protagonist_by_words) jbad2.push(`protagonist first named at word ${wi} (limit ${HU.protagonist_by_words})`);
if (!nameRe(prot).test(hookB.text)) jbad2.push('protagonist is not named in the hook');
const w = locateAny(prot.want_quote);
if (w && w.beat.id !== hookB.id && beatIdx[w.beat.id] > 1) jbad2.push(`want is stated in ${w.beat.id}; it belongs in the hook or the first story beat`);
}
}
add('I', 'I2', jbad2.length ? FAIL : PASS, 'The protagonist is named in the hook and gets a want in the hook or the first story beat', jbad2.length ? jbad2.join('\n') : `${prot ? prot.name : ''} named early`);
const pron = LH ? new RegExp(LH.pronoun, 'i') : /\b(he|she|his|him|her|hers)\b/i;
const abad = [];
for (const cd of chain.beats || []) {
const b = beatById[cd.id]; if (!b || !cd.compel_quote || !pron.test(cd.compel_quote)) continue;
const k = b.sentences.findIndex((x) => norm(x).includes(norm(cd.compel_quote)));
const near = b.sentences.slice(Math.max(0, k - 1), k + 1).join(' ');
if (k < 0 || !hum.some((h) => nameRe(h).test(near))) abad.push(`${cd.id}: "${cd.compel_quote}" leans on a pronoun but no person is named in that sentence or the one before it`);
}
add('I', 'I3', abad.length ? FAIL : PASS, 'No hinge line leans on a pronoun unless the person is named in that sentence or the one before', abad.length ? abad.join('\n') : 'ok');
const g = gapOf(personEvents, 0, totalWords), gs = secOf(g.words);
pg = Math.round(gs);
add('I', 'I4', gs > HU.max_person_gap_sec_fail ? FAIL : gs > HU.max_person_gap_sec_warn ? WARN : PASS, `A person or the viewer is present: no stretch over ${HU.max_person_gap_sec_warn}s without one`, `longest stretch with no person and no "you": ${Math.round(gs)}s from ${fmt(secOf(g.at))}`);
const vs = chain.viewer_stakes || [];
const vbad = [], vpos = [];
for (const s of vs) {
const i = locate(beatById[s.beat], s.quote);
if (i < 0) { vbad.push(`viewer stake not found in ${s.beat}: "${String(s.quote || '').slice(0, 40)}"`); continue; }
if (!new RegExp(VPX.viewer_address, 'i').test(s.quote)) vbad.push(`"${s.quote.slice(0, 40)}" does not speak to the viewer (no "you")`);
vpos.push(i / totalWords);
}
if (vs.length < HU.min_viewer_stakes) vbad.push(`${vs.length} viewer stakes (needs ${HU.min_viewer_stakes})`);
if (!vs.some((s) => s.beat === hookB.id)) vbad.push('no viewer stake in the hook');
if (!vpos.some((p) => p >= 0.3 && p <= 0.7)) vbad.push('no viewer stake in the middle of the script');
if (!vpos.some((p) => p >= 0.8)) vbad.push('no viewer stake in the last fifth');
add('I', 'I5', vbad.length ? FAIL : PASS, 'Viewer stakes: the script speaks to you in the hook, in the middle and near the end', vbad.length ? vbad.join('\n') : `${vs.length} viewer stakes`);
const rbad2 = [];
for (const h of hum) {
const n = beats.filter((b) => aliasRe(h).test(b.text)).length;
const need = h.role === 'protagonist' ? HU.min_protagonist_beats : h.role === 'witness' ? HU.min_witness_beats : 2;
if (n < need) rbad2.push(`${h.name} appears in ${n} beats (needs ${need})`);
if (h.role === 'witness' && !aliasRe(h).test(hookB.text) && !aliasRe(h).test(beats[1].text)) rbad2.push(`${h.name} does not appear until after beat 1; a witness is introduced early`);
if (h.role === 'witness' && !aliasRe(h).test(beats[beats.length - 1].text)) rbad2.push(`${h.name} does not return in the close`);
}
add('I', 'I6', rbad2.length ? FAIL : PASS, 'People recur: the protagonist across the story, the witness early and again in the close', rbad2.length ? rbad2.join('\n') : 'ok');
}
// G: personality and figures
const mk = VPX.figure_markers.map((m) => new RegExp(m, 'gi'));
const markerHits = mk.flatMap((re) => matchesAt(re)).sort((a, b) => a.idx - b.idx);
const clicheHits = VPX.cliches.flatMap((c) => matchesAt(new RegExp(`\\b${c}\\b`, 'gi')));
const verdictHits = VPX.verdict_words.flatMap((c) => matchesAt(new RegExp(c, 'gi')));
let maxIn60 = 0; for (const h of markerHits) maxIn60 = Math.max(maxIn60, markerHits.filter((x) => x.idx >= h.idx && x.idx < h.idx + WPM).length);
const gbad = [];
if (markerHits.length < FG.min_markers) gbad.push(`${markerHits.length} figurative markers (floor ${FG.min_markers}): the script explains with no comparisons`);
if (markerHits.length > FG.max_markers) gbad.push(`${markerHits.length} figurative markers (limit ${FG.max_markers}): a comparison every few lines stops being heard`);
if (maxIn60 > FG.max_per_60s) gbad.push(`${maxIn60} comparisons inside one minute (limit ${FG.max_per_60s})`);
if (clicheHits.length > FG.max_cliches_warn) gbad.push(`${clicheHits.length} stock figures: ${clicheHits.slice(0, 5).map((h) => `"${h.text}"`).join(', ')}`);
if (verdictHits.length > FG.max_verdict_warn) gbad.push(`${verdictHits.length} verdict words: ${verdictHits.slice(0, 5).map((h) => `"${h.text}"`).join(', ')}`);
const g4fail = clicheHits.length > FG.max_cliches_fail || verdictHits.length > FG.max_verdict_fail;
add('G', 'G4', g4fail ? FAIL : gbad.length ? WARN : PASS, `Figure budget: ${FG.min_markers} to ${FG.max_markers} comparisons, at most ${FG.max_per_60s} a minute, few stock figures, no verdict words`, gbad.length ? gbad.join('\n') : `${markerHits.length} markers, ${maxIn60} max per minute, ${clicheHits.length} stock figures, ${verdictHits.length} verdict words`);
if (!chain || !chain.figures || !chain.figures.length) {
add('G', 'G3', WARN, 'One governing metaphor (early, returning, with its limit stated), glosses by analogy, a parable', 'no chain.figures declared (references/chain-schema.md)');
} else {
const F = chain.figures, fbad = [];
const at = (f, q) => locate(beatById[f.beat], q);
const gov = F.filter((f) => f.kind === 'governing');
if (gov.length !== 1) fbad.push(`${gov.length} governing metaphors (needs exactly 1)`);
for (const f of F) { if (!['governing', 'gloss', 'parable'].includes(f.kind)) fbad.push(`${f.id}: kind "${f.kind}"`); if (at(f, f.quote) < 0) fbad.push(`${f.id}: quote not found in ${f.beat}`); }
const g = gov[0];
if (g) {
const s = at(g, g.quote);
if (s >= 0 && s / totalWords > FG.governing_by) fbad.push(`governing metaphor arrives at ${(s / totalWords * 100).toFixed(0)}% of runtime (needs by ${FG.governing_by * 100}%)`);
let last = s, found = 0;
for (const t of g.touches || []) { const i = locate(beatById[t.beat], t.quote); if (i < 0) fbad.push(`${g.id}: touch not found in ${t.beat}: "${String(t.quote || '').slice(0, 40)}"`); else { if (i <= last) fbad.push(`${g.id}: touch in ${t.beat} comes before the previous one`); last = Math.max(last, i); found++; } }
if (found < FG.min_governing_touches) fbad.push(`governing metaphor is touched ${found} times (needs ${FG.min_governing_touches}); a comparison used once is a decoration`);
if (!g.breaks) fbad.push('governing metaphor declares no breaking point (where the picture lies)');
else { const bi = locate(beatById[g.breaks.beat], g.breaks.quote); if (bi < 0) fbad.push('the line stating where the metaphor breaks is not in the text'); else if (bi <= s) fbad.push('the breaking point comes before the metaphor is introduced'); }
if (!g.maps || !g.source || !g.target) fbad.push('governing metaphor must name source, target and what maps');
}
const gl = F.filter((f) => f.kind === 'gloss');
if (gl.length < FG.min_gloss) fbad.push(`${gl.length} glosses by analogy (needs ${FG.min_gloss})`);
for (const f of gl) {
const j = (chain.jargon || []).find((d) => norm(d.term) === norm(f.term || ''));
if (!j) { fbad.push(`${f.id}: gloss must name a term from chain.jargon`); continue; }
const jb = locateAny(j.gloss_quote), fi = at(f, f.quote);
if (jb && fi >= 0 && Math.abs(fi - jb.idx) > 70) fbad.push(`${f.id}: the analogy for "${f.term}" is not at its gloss`);
}
const pa = F.filter((f) => f.kind === 'parable');
if (pa.length < FG.min_parables) fbad.push(`${pa.length} parables (needs ${FG.min_parables}): one short hypothetical that lets the viewer feel the mechanism`);
for (const f of pa) {
const b = beatById[f.beat]; const s = at(f, f.quote), e = f.end_quote ? at(f, f.end_quote) : -1;
if (!b || s < 0 || e < 0 || e < s) { fbad.push(`${f.id}: parable needs quote and end_quote in order in ${f.beat}`); continue; }
const len = e - s + (f.end_quote.match(/\S+/g) || []).length;
if (len < FG.parable_words[0] || len > FG.parable_words[1]) fbad.push(`${f.id}: parable runs ${len} words (${FG.parable_words.join(' to ')})`);
if (!VPX.hypothetical_markers.some((m) => new RegExp(m, 'i').test(f.quote))) fbad.push(`${f.id}: a parable opens as a hypothetical ("imagine", "say you", "suppose")`);
if (quantitiesIn(figSpan(f), 0).length) fbad.push(`${f.id}: no figures inside a parable`);
}
const perBeat = {};
for (const f of F) { (perBeat[f.beat] ||= new Set()).add(f.source || f.id); }
for (const [bid, set] of Object.entries(perBeat)) if (set.size > FG.max_domains_per_beat) fbad.push(`${bid}: ${set.size} different picture-worlds in one beat (${[...set].join(', ')}); mixed figures`);
add('G', 'G3', fbad.length ? FAIL : PASS, 'One governing metaphor (early, returning, with its limit stated), glosses by analogy, a parable', fbad.length ? fbad.join('\n') : `${F.length} figures: ${gov.length} governing, ${gl.length} gloss, ${pa.length} parable`);
}
if (!chain || !chain.persona) {
add('G', 'G1', WARN, 'Both registers of the voice are present: earnest about facts and people, aloof about verdicts', 'no chain.persona declared');
add('G', 'G2', WARN, 'Two-sided treatment of the contested calls', 'no chain.persona.sides declared');
} else {
const P = chain.persona, pbad2 = [];
const reg = (key) => {
const qs = P[key] || [], pos = [], bs = new Set();
for (const q of qs) { const i = locate(beatById[q.beat], q.quote); if (i < 0) pbad2.push(`${key}: not found in ${q.beat}: "${String(q.quote || '').slice(0, 44)}"`); else { pos.push(i / totalWords); bs.add(q.beat); } }
if (qs.length < PE.min_quotes_each) pbad2.push(`${key}: ${qs.length} lines (needs ${PE.min_quotes_each})`);
if (bs.size < PE.min_beats_each) pbad2.push(`${key}: lines sit in ${bs.size} beats (needs ${PE.min_beats_each})`);
if (pos.length && Math.min(...pos) > PE.first_by) pbad2.push(`${key}: first appears at ${(Math.min(...pos) * 100).toFixed(0)}% of runtime (needs by ${PE.first_by * 100}%)`);
if (pos.length && Math.max(...pos) < PE.last_after) pbad2.push(`${key}: last appears at ${(Math.max(...pos) * 100).toFixed(0)}% (needs after ${PE.last_after * 100}%); the voice must last`);
};
reg('earnest'); reg('aloof');
add('G', 'G1', pbad2.length ? FAIL : PASS, 'Both registers of the voice are present from early on to the end: earnest about facts and people, aloof about verdicts and heroes', pbad2.length ? pbad2.join('\n') : `${(P.earnest || []).length} earnest and ${(P.aloof || []).length} aloof lines`);
const sd = P.sides || [], sbad2 = [];
for (const s of sd) { const b = beatById[s.beat]; if (locate(b, s.for_quote) < 0 || locate(b, s.against_quote) < 0) sbad2.push(`${s.subject}: for/against line not both found in ${s.beat}`); }
if (sd.length < PE.min_sides) sbad2.push(`${sd.length} two-sided treatments (needs ${PE.min_sides})`);
add('G', 'G2', sbad2.length ? FAIL : PASS, 'The contested calls get both sides in the same beat (the case for and the case against)', sbad2.length ? sbad2.join('\n') : `${sd.map((s) => s.subject).join('; ')}`);
}
// J: the whole story. v3 and v4 cut the founder, the city, the partner and the rise; v5 put them back by hand. Each beat declares the
// part of the story it tells (chain.beats[].arc) and the script has to cover all the required parts, in a sensible order.
const AR = rules.arc;
if (!chain || !(chain.beats || []).some((b) => b.arc)) {
add('J', 'J1', WARN, 'The whole story is told: origin, rise, turn, response, fall, people, blame and lesson all have a beat', 'no beats[].arc declared (references/chain-schema.md)');
add('J', 'J2', WARN, 'The story parts come in a sensible order', 'no beats[].arc declared');
} else {
const req = chain.arc_required || AR.required, cb2 = chain.beats;
const firstAt = {}, lastAt = {}, jbad3 = [];
cb2.forEach((b, i) => { for (const r of [].concat(b.arc || [])) { if (!AR.roles.includes(r)) jbad3.push(`${b.id}: unknown story part "${r}"`); if (!(r in firstAt)) firstAt[r] = i; lastAt[r] = i; } });
const missing = req.filter((r) => !(r in firstAt));
if (missing.length) jbad3.push(`no beat tells: ${missing.join(', ')}`);
if (req.includes('origin') && 'origin' in firstAt && firstAt.origin > AR.origin_in_first) jbad3.push(`the origin first appears in beat ${firstAt.origin} (needs one of the first ${AR.origin_in_first} beats after the hook)`);
if (req.includes('lesson') && 'lesson' in lastAt && lastAt.lesson < cb2.length - AR.lesson_in_last) jbad3.push(`the lesson is last told in beat ${lastAt.lesson}; it belongs in the last ${AR.lesson_in_last} beats`);
add('J', 'J1', jbad3.length ? FAIL : PASS, 'The whole story is told: origin, rise, turn, response, fall, people, blame and lesson all have a beat', jbad3.length ? jbad3.join('\n') : `${req.length} story parts covered: ${req.join(', ')}`);
const order = AR.order.filter((r) => r in firstAt), obad = [];
for (let i = 1; i < order.length; i++) if (firstAt[order[i]] < firstAt[order[i - 1]]) obad.push(`"${order[i]}" first appears (beat ${firstAt[order[i]]}) before "${order[i - 1]}" (beat ${firstAt[order[i - 1]]})`);
add('J', 'J2', obad.length ? WARN : PASS, 'The story parts come in a sensible order (origin, rise, turn, response, fall)', obad.length ? obad.join('\n') : order.map((r) => `${r}@${firstAt[r]}`).join(' '));
}
// B8: a hook that carries weight
if (chain && chain.cdl) {
const he = chain.cdl.hook_elements, hb = beats[0];
if (!he) add('B', 'B8', WARN, 'The hook carries a named person, a viewer stake, the weight of the rise and of the fall, forward moments and the learning', 'no cdl.hook_elements declared');
else {
const ebad = [];
for (const k of ['person_quote', 'rise_quote', 'fall_quote', 'viewer_quote', 'learning_quote']) { if (!he[k]) ebad.push(`${k} missing`); else if (locate(hb, he[k]) < 0) ebad.push(`${k} not in the hook`); }
const fw = he.forward_quotes || [];
if (fw.length < 2) ebad.push(`${fw.length} forward moments (needs 2: things to look forward to that give away no route term)`);
for (const q of fw) if (locate(hb, q) < 0) ebad.push(`forward moment not in the hook: "${String(q).slice(0, 40)}"`);
if (he.viewer_quote && !new RegExp(VPX.viewer_address, 'i').test(he.viewer_quote)) ebad.push('viewer_quote does not speak to the viewer');
add('B', 'B8', ebad.length ? FAIL : PASS, 'The hook carries a named person, a viewer stake, the weight of the rise and of the fall, forward moments and the learning', ebad.length ? ebad.join('\n') : `${5 + fw.length} elements found`);
}
}
const texture = { figure_markers: markerHits.length, hard_share: +hardShare.toFixed(2), quiet_share: +softShare.toFixed(2), longest_hard_run: hardRunMax, swaps_per_10min: +swapsPer10.toFixed(1), person_gap_sec: pg, stock_figures: clicheHits.length, verdict_words: verdictHits.length };
// ----- F: plain spoken voice (budgets for the tells of machine-written prose; see references/voice.md) -----
const VP = JSON.parse(fs.readFileSync(path.join(__dirname, 'voice-patterns.json'), 'utf8'));
if (LH) mergeVoice(VP, LH.voice_patterns);
const V = rules.voice;
const allText = beats.map((b) => b.text).join('\n');
const hitsOf = (list) => (Array.isArray(list) ? list : [list]).flatMap((s) => [...allText.matchAll(new RegExp(s, 'gi'))].map((m) => m[0].replace(/\s+/g, ' ').slice(0, 64)));
const lvl = (n, warnAt, failAt) => (n > failAt ? FAIL : n > warnAt ? WARN : PASS);
const wlen = (s) => (s.match(/\S+/g) || []).length;
const sentLens = beats.flatMap((b) => b.sentences.map(wlen));
const avgLen = sentLens.reduce((a, c) => a + c, 0) / sentLens.length;
const cv = Math.sqrt(sentLens.reduce((a, c) => a + (c - avgLen) ** 2, 0) / sentLens.length) / avgLen;
let fragRuns = 0, openRuns = 0;
for (const b of beats) {
let run = 0, same = 1;
b.sentences.forEach((s, i) => {
if (wlen(s) <= 5) { run++; if (run === 3) fragRuns++; } else run = 0;
const w = (s.match(/[A-Za-z']+/) || [''])[0].toLowerCase(), pw = i ? (b.sentences[i - 1].match(/[A-Za-z']+/) || [''])[0].toLowerCase() : '';
if (i && w && w === pw) { same++; if (same === 3) openRuns++; } else same = 1;
});
}
const triads = (allText.match(/\b[\w']+(?: [\w']+){0,2}, [\w']+(?: [\w']+){0,2},? (?:and|or) [\w']+(?: [\w']+){0,2}[.?!]/g) || []).length;
const mids = beats.slice(1, -1);
const shortClosers = mids.filter((b) => wlen(b.sentences[b.sentences.length - 1]) <= 6).length;
const closerShare = mids.length ? shortClosers / mids.length : 0;
const neg = hitsOf(VP.negation_pivot), runUp = hitsOf(VP.run_up), meta = hitsOf(VP.meta);
const stock = [...hitsOf(VP.stock_depth), ...hitsOf(VP.ai_words), ...hitsOf(VP.intensifiers)];
const extremesRate = (hitsOf(VP.extremes).length / totalWords) * 1000;
const contrPer100 = (hitsOf(VP.contractions).length / totalWords) * 100;
const dashes = (allText.match(/—|–/g) || []).length;
const lst = (a) => a.slice(0, 8).map((x) => `"${x}"`).join(', ');
add('F', 'F1', lvl(neg.length, V.max_negation_pivots_warn, V.max_negation_pivots_fail), `Negation pivots ("not X, it's Y", "no X, no Y, just Z") at most ${V.max_negation_pivots_warn}; state the Y`, neg.length ? `${neg.length}: ${lst(neg)}` : 'none');
add('F', 'F2', lvl(runUp.length + meta.length, V.max_runups_warn, V.max_runups_fail), `Staged run-ups and script-talk ("here is what...", "notice that", "I promised", "I call that") at most ${V.max_runups_warn}`, runUp.length + meta.length ? `${runUp.length + meta.length}: ${lst([...runUp, ...meta])}` : 'none');
const stockBad = stock.length + (extremesRate > V.max_extremes_per_1000 ? 1 : 0);
add('F', 'F3', lvl(stockBad, V.max_stock_warn, V.max_stock_fail), `Stock phrases, machine vocabulary and empty intensifiers at most ${V.max_stock_warn}; lazy extremes under ${V.max_extremes_per_1000} per 1000 words`, stockBad ? `${stock.length} stock/vocabulary: ${lst(stock)}; extremes ${extremesRate.toFixed(1)}/1000 words` : 'none');
const rbad = [];
if (fragRuns > V.max_fragment_runs_warn) rbad.push(`${fragRuns} runs of three or more fragments in a row`);
if (triads > V.max_triads) rbad.push(`${triads} three-item lists ending a sentence (limit ${V.max_triads})`);
if (closerShare > V.max_short_closer_share) rbad.push(`${Math.round(closerShare * 100)}% of middle beats end on a six-word-or-shorter punch line (limit ${V.max_short_closer_share * 100}%)`);
if (cv < V.min_sentence_cv) rbad.push(`sentence lengths too even (variation ${cv.toFixed(2)}, floor ${V.min_sentence_cv})`);
if (openRuns > V.max_repeat_openers) rbad.push(`${openRuns} runs of three sentences starting with the same word`);
add('F', 'F4', fragRuns > V.max_fragment_runs_fail ? FAIL : rbad.length ? WARN : PASS, 'Rhythm: no fragment drumrolls, few triads, beats do not all end on a punch line, sentence lengths vary', rbad.length ? rbad.join('\n') : `variation ${cv.toFixed(2)}, ${triads} triads, ${Math.round(closerShare * 100)}% short closers`);
const sbad = [];
if (!(LH && LH.no_contractions) && contrPer100 < V.min_contractions_per_100) sbad.push(`${contrPer100.toFixed(2)} contractions per 100 words (floor ${V.min_contractions_per_100}); read it aloud, it is written English`);
if (dashes > V.max_dashes) sbad.push(`${dashes} dashes in spoken text`);
if (avgLen > V.max_avg_sentence_words) sbad.push(`average sentence ${avgLen.toFixed(1)} words (limit ${V.max_avg_sentence_words})`);
add('F', 'F5', sbad.length ? WARN : PASS, 'Speakable: contractions where people use them, no dashes, sentences short enough for one breath', sbad.length ? sbad.join('\n') : `${contrPer100.toFixed(2)} contractions per 100 words, average sentence ${avgLen.toFixed(1)} words`);
const voice = { negation_pivots: neg.length, run_ups: runUp.length + meta.length, stock: stock.length, contractions_per_100: +contrPer100.toFixed(2), sentence_cv: +cv.toFixed(2), avg_sentence_words: +avgLen.toFixed(1), fragment_runs: fragRuns, triads, short_closer_share: +closerShare.toFixed(2) };
// ----- K, L, M: clarity, stakes at the turns, density (references/review-round-7.md) -----
const CL = rules.clarity, TU = rules.turns, DE = rules.density;
const MONTHSET = new Set(MONTHS.split('|'));
const STARTERS = new Set(LH ? LH.starters : 'for in on at by from after before when while if but and so then now many most some even soon almost across between through during because since although though as with without what why how where who which that this these those there here one two three still yet instead however maybe perhaps say think call imagine suppose back start ask go it he she they we you its his her their our the a an'.split(' '));
const SENTS = [];
for (const b of beats) { let off = b.start; b.sentences.forEach((s, k) => { SENTS.push({ beat: b.id, k, text: s, start: off }); off += wlen(s); }); }
const contrastRe = new RegExp(VPX.contrast_words, 'i');
const ROLE_ONLY = /^(?:the |its |their )?(?:founder|workers?|union'?s? president|workers' union president|union president)$/i;
const pName = (h) => new RegExp(`\\b(?:${[h.name, ...(h.aliases || [])].filter((x) => x && !ROLE_ONLY.test(x)).map(aliasSrc).join('|')})\\b`, 'i');
const sentOf = (quote) => SENTS.findIndex((s) => norm(s.text).includes(norm(quote)));
// K1, K2: a person arrives with a tag (role or relation) and is tagged again when the viewer may have lost them
if (!hum || !hum.length) {
add('K', 'K1', WARN, 'Every person is tagged (role or relation) the first time they are named', 'no humans declared');
add('K', 'K2', WARN, 'A person is tagged again when they re-enter after a gap', 'no humans declared');
} else {
const k1 = [], k2 = [];
for (const h of hum) {
const tags = h.tags || [];
if (!tags.length) { k1.push(`${h.name}: no tags declared (the role or relation that tells a new viewer who this is)`); continue; }
const tagRe = new RegExp(tags.map(escRe).join('|'), 'i'), re = pName(h);
const at = SENTS.map((s, i) => (re.test(s.text) ? i : -1)).filter((i) => i >= 0);
const tagged = (i) => tagRe.test([SENTS[i - 1] && SENTS[i - 1].beat === SENTS[i].beat ? SENTS[i - 1].text : '', SENTS[i].text, SENTS[i + 1] ? SENTS[i + 1].text : ''].join(' '));
if (!at.length) continue;
if (!tagged(at[0])) k1.push(`${h.name}: first named in ${SENTS[at[0]].beat} with no tag (${tags.slice(0, 2).join(' / ')}) in that sentence or the next`);
if (h.role !== 'protagonist') {
for (let j = 1; j < at.length; j++) {
const gap = SENTS[at[j]].start - SENTS[at[j - 1]].start;
if (gap >= CL.retag_after_words && !tagged(at[j])) k2.push(`${h.name} returns in ${SENTS[at[j]].beat} after ${gap} words with no tag: "${SENTS[at[j]].text.slice(0, 70)}"`);
}
}
}
const legacyK = !hum.some((h) => (h.tags || []).length); // a chain written before review round 7 declares no tags: warn, do not fail
add('K', 'K1', k1.length ? (legacyK ? WARN : FAIL) : PASS, 'Every person is tagged (role or relation) the first time they are named', k1.length ? k1.join('\n') : `${hum.length} people tagged at first mention`);
add('K', 'K2', k2.length ? (legacyK ? WARN : FAIL) : PASS, `A person is tagged again when they re-enter after ${CL.retag_after_words} words or more`, k2.length ? k2.join('\n') : 'ok');
}
// K3, K4: how many new names arrive in a beat, and whether a name that appears once earns its place
let NEWBY = {};
{
const notCast = [...(VPX.places_common || []), ...((chain && chain.not_cast) || [])].map((x) => x.toLowerCase());
const isNotCast = (t) => { const l = t.toLowerCase(); return notCast.some((n) => l === n || l.startsWith(n + ' ')); };
const capOk = (w) => /^[A-Z][a-z][A-Za-z'’-]*$/.test(w) && !/^I(?:['’]|$)/.test(w);
const strip = (t) => t.replace(/^[("“'‘]+/, '').replace(/[.,;:!?)"”'’]+$/g, '').replace(/['’]s$/, '');
const capSeqs = (text) => {
const toks = text.split(/\s+/), out = [];
let i = 0;
while (i < toks.length) {
const c0 = strip(toks[i]);
if (!capOk(c0)) { i++; continue; }
const parts = [c0]; let j = i, stop = /[.,;:!?)]$/.test(toks[i]);
while (!stop && j + 1 < toks.length) {
const nc = strip(toks[j + 1]);
if (capOk(nc)) { parts.push(nc); j++; stop = /[.,;:!?)]$/.test(toks[j]); }
else if (nc === 'of' && j + 2 < toks.length && capOk(strip(toks[j + 2]))) { parts.push(nc); j++; }
else break;
}
while (parts.length && (MONTHSET.has(parts[0].toLowerCase()) || (i === 0 && STARTERS.has(parts[0].toLowerCase())))) parts.shift();
if (parts.length && !(i === 0 && parts.length === 1)) out.push(parts.join(' '));
i = j + 1;
}
return out;
};
const keyOf = (t) => { const h = (hum || []).find((x) => pName(x).test(t)); return h ? 'H:' + h.id : t.split(' ')[0].toLowerCase(); };
const seen = new Map(), newBy = {};
for (const b of beats) for (const s of b.sentences) for (const t of capSeqs(s)) {
if (isNotCast(t)) continue;
const k = keyOf(t), e = seen.get(k);
if (e) e.count++; else { seen.set(k, { name: t, count: 1, beat: b.id }); (newBy[b.id] ||= []).push(t); }
}
NEWBY = newBy;
const busiest = Object.entries(newBy).sort((a, b) => b[1].length - a[1].length)[0];
const k3 = Object.entries(newBy).filter(([, v]) => v.length > CL.max_new_names_per_beat).map(([id, v]) => `${id}: ${v.length} new names (${v.join(', ')}); limit ${CL.max_new_names_per_beat}`);
add('K', 'K3', k3.length ? WARN : PASS, `At most ${CL.max_new_names_per_beat} new people or organisations arrive in one beat`, k3.length ? k3.join('\n') : `busiest beat ${busiest ? busiest[0] + ' with ' + busiest[1].length : 'none'}`);
const declared = ((chain && chain.one_off_names) || []);
const k4 = [];
for (const [, e] of seen) {
if (e.count !== 1) continue;
const d = declared.find((x) => norm(e.name).includes(norm(x.name)) || norm(x.name).includes(norm(e.name)));
if (!d) k4.push(`${e.name} (${e.beat}) is named once with no declared reason; tie it to the story or cut it`);
else if (String(d.why || '').length < CL.min_oneoff_reason_chars) k4.push(`${e.name}: the reason is too thin`);
}
add('K', 'K4', k4.length ? WARN : PASS, 'A name that appears once earns its place (chain.one_off_names gives the reason) or is cut', k4.length ? k4.join('\n') : `${declared.length} one-off names declared and all used`);
Object.assign(texture, { new_names_busiest_beat: busiest ? busiest[1].length : 0 });
}
// K5: a count the viewer is promised or told is backed by items they were given, in order
{
const sets = (chain && chain.counted_sets) || [];
const NUMV = { two: 2, three: 3, four: 4, five: 5, six: 6 };
const k5 = [];
for (const cs of sets) {
const c = locateAny(cs.claim_quote);
if (!c) { k5.push(`claim not found: "${cs.claim_quote}"`); continue; }
const pos = (cs.items || []).map((q) => { const f = locateAny(q); if (!f) k5.push(`item not found: "${q.slice(0, 50)}"`); return f ? f.idx : -1; });
if (pos.includes(-1)) continue;
const m = cs.claim_quote.toLowerCase().match(/\b(two|three|four|five|six)\b/);
if (m && NUMV[m[1]] !== pos.length) k5.push(`"${cs.claim_quote}" says ${NUMV[m[1]]} but ${pos.length} items are declared`);
if (pos.some((p, i) => i && p <= pos[i - 1])) k5.push(`items of "${cs.claim_quote}" are out of order`);
if (cs.kind === 'forward' ? pos.some((p) => p <= c.idx) : pos.some((p) => p >= c.idx)) k5.push(`"${cs.claim_quote}" is a ${cs.kind === 'forward' ? 'promise: its items must come after it' : 'tally: its items must come before it'}`);
}
const undeclared = [...allText.matchAll(/\b(?:two|three|four|five) (?:escapes|pivots|questions|reasons|causes|ways|steps|mistakes|moves|things|signs|problems|exits|rules|lessons)\b/gi)].map((m) => m[0]).filter((t) => !sets.some((s) => norm(s.claim_quote).includes(norm(t))));
if (undeclared.length) k5.push(`counts used but not declared in chain.counted_sets: ${[...new Set(undeclared)].join(', ')}`);
add('K', 'K5', k5.length ? (chain && chain.counted_sets === undefined ? WARN : FAIL) : PASS, 'Every spoken count (three questions, three pivots) is backed by items the viewer was given, in order', k5.length ? k5.join('\n') : `${sets.length} counted sets checked`);
}
// K6: a named idea is defined once, in plain words, where it first appears, and then used
{
const cons = (chain && chain.concepts) || [], k6 = [];
for (const cn of cons) {
const d = locateAny(cn.define_quote);
if (!d) { k6.push(`${cn.term}: define_quote not found`); continue; }
const hits = matchesAt(new RegExp(`\\b${escRe(cn.term)}\\w*`, 'gi'));
const dEnd = d.idx + wlen(cn.define_quote);
if (!hits.length || hits[0].idx < d.idx || hits[0].idx > dEnd) k6.push(`${cn.term}: first used at ${hits.length ? fmt(secOf(hits[0].idx)) : 'never'}, before or away from its definition`);
const uses = hits.filter((h) => h.idx > dEnd).length;
if (uses < CL.concept_min_uses) k6.push(`${cn.term}: used ${uses} times after it is defined (needs ${CL.concept_min_uses}); a named idea used once is a label`);
const defs = (norm(allText).match(new RegExp(escRe(norm(cn.define_quote)), 'g')) || []).length;
if (defs > 1) k6.push(`${cn.term}: defined ${defs} times`);
}
add('K', 'K6', k6.length ? FAIL : PASS, 'A named idea is defined once, at its first use, in plain words, and then reused', k6.length ? k6.join('\n') : (cons.length ? cons.map((x) => x.term).join(', ') + ' defined once and reused' : 'no concept declared (none needed)'));
}
// K7: do not gloss what the audience already knows
{
const known = ((chain && chain.audience && chain.audience.known_terms) || []).map((x) => x.toLowerCase());
const k7 = [];
for (const j of (chain && chain.jargon) || []) if (known.includes(String(j.term).toLowerCase())) k7.push(`"${j.term}" is glossed but the audience knows it`);
for (const t of known) if (new RegExp(`\\b${escRe(t)}\\b (?:means|equals|stands for)\\b|\\b(?:a|an) ${escRe(t)} (?:is|means)\\b`, 'i').test(allText)) k7.push(`the text explains "${t}"`);
add('K', 'K7', k7.length ? WARN : PASS, 'No gloss for a term the audience already knows (chain.audience.known_terms)', k7.length ? k7.join('\n') : `${known.length} known terms, none glossed`);
}
// K8: nothing the viewer has to decode: vague referents and a verb buried behind a long aside
{
const vague = VPX.vague_referents.flatMap((p) => [...allText.matchAll(new RegExp(p, 'gi'))].map((m) => m[0]));
const buried = [];
for (const s of SENTS) {
if (wlen(s.text) < CL.buried_verb.min_sentence_words || STARTERS.has(s.text.split(/\s+/)[0].toLowerCase().replace(/[^a-z]/g, ''))) continue;
const m = s.text.match(/^([^,]{1,40}),\s+([^,]+),\s+\S/);
if (m && wlen(m[2]) > CL.buried_verb.max_aside_words) buried.push(`${s.beat}: "${s.text.slice(0, 90)}"`);
}
const k8 = [];
if (vague.length > CL.max_vague_referents) k8.push(`${vague.length} vague referents: ${vague.slice(0, 5).map((v) => `"${v}"`).join(', ')} (name the thing)`);
if (buried.length > CL.buried_verb.warn_over) k8.push(`${buried.length} sentences bury the verb behind an aside of more than ${CL.buried_verb.max_aside_words} words (split it):\n${buried.join('\n')}`);
add('K', 'K8', k8.length ? WARN : PASS, 'Nothing the viewer has to decode: no vague referents, no verb buried behind a long aside', k8.length ? k8.join('\n') : 'ok');
}
// L1: the hook shows the prize in the audience's terms, then turns on a contrast word
if (chain && chain.cdl && chain.cdl.hook_elements && chain.cdl.hook_elements.turn_quote) {
const he = chain.cdl.hook_elements, l1 = [];
const t = sentOf(he.turn_quote), r = sentOf(he.rise_quote);
if (t < 0) l1.push('turn_quote not found'); else {
if (!(LH ? new RegExp(LH.turn_opener, 'i') : /^(?:but|yet|and yet|still|until)\b/i).test(SENTS[t].text)) l1.push(`the turn sentence does not open on a contrast word: "${SENTS[t].text.slice(0, 60)}"`);
if (SENTS[t].beat !== beats[0].id) l1.push('the turn is not in the hook');
}
if (r < 0) l1.push('rise_quote not found'); else {
if (!new RegExp(VPX.prize_words, 'i').test(SENTS[r].text)) l1.push('the rise sentence names no prize in the audience\'s terms (money, a fortune, the biggest ...)');
if (t >= 0 && (t - r > TU.hook_turn_gap_sentences || t <= r)) l1.push(`the turn comes ${t - r} sentences after the rise (needs 1 to ${TU.hook_turn_gap_sentences})`);
}
add('L', 'L1', l1.length ? FAIL : PASS, 'The hook states the prize in the audience\'s terms and then turns on a contrast word', l1.length ? l1.join('\n') : 'rise, then a contrast');
} else add('L', 'L1', WARN, 'The hook states the prize in the audience\'s terms and then turns on a contrast word', 'no cdl.hook_elements.turn_quote declared');
// L2: where a person pays, the line carries an intensifier of the cost
{
const w = (hum || []).find((h) => h.role === 'witness'), l2 = [];
if (!w) add('L', 'L2', WARN, 'The line where the witness pays carries an intensifier of the cost (a full 17 years, their own pay)', 'no witness declared');
else {
if (!w.cost_quote) l2.push(`${w.name}: no cost_quote declared`);
else if (!locateAny(w.cost_quote)) l2.push(`${w.name}: cost_quote not found`);
else if (!new RegExp(VPX.cost_intensifiers, 'i').test(w.cost_quote)) l2.push(`${w.name}: "${w.cost_quote}" states the cost flat; add what it cost (a full, the whole, even, their own)`);
add('L', 'L2', l2.length ? (w.cost_quote ? FAIL : WARN) : PASS, 'The line where the witness pays carries an intensifier of the cost (a full 17 years, their own pay)', l2.length ? l2.join('\n') : 'ok');
}
}
// L3: a BUT is heard: a contrast word within two sentences either side of the join
if (chain && (chain.beats || []).length) {
const l3 = [];
chain.beats.forEach((d, i) => {
if (d.link_to_next !== 'BUT') return;
const b = beatById[d.id], n = beatById[(chain.beats[i + 1] || {}).id];
if (!b || !n) return;
const win = [...b.sentences.slice(-TU.but_join_scan_sentences), ...n.sentences.slice(0, TU.but_join_scan_sentences)].join(' ');
if (!contrastRe.test(win)) l3.push(`${b.id} -> ${n.id} is declared BUT but no contrast word is spoken near the join`);
});
add('L', 'L3', l3.length ? WARN : PASS, 'Every BUT junction is heard: a contrast word within two sentences either side of the join', l3.length ? l3.join('\n') : 'ok');
}
// L4: no neutrality disclaimer about a mechanism the record supports
{
const hs = VPX.hedge_disclaimers.flatMap((p) => [...allText.matchAll(new RegExp(p, 'gi'))].map((m) => m[0]));
add('L', 'L4', hs.length ? WARN : PASS, 'No neutrality disclaimer where the record supports a conclusion (aloof about people, plain about mechanisms)', hs.length ? hs.map((x) => `"${x}"`).join(', ') : 'none');
}
// M1: a dense beat opens with a headline
if (chain && (chain.beats || []).length) {
const m1 = [];
for (const b of beats.slice(1, -1)) {
const qn = quantities.filter((q) => q.beat === b.id).length;
const d = chain.beats.find((x) => x.id === b.id) || {};
const dense = qn >= DE.dense_quantities || (NEWBY[b.id] || []).length >= DE.dense_new_names;
if (dense && !d.headline_quote) { m1.push(`${b.id} is dense (${qn} figures, ${(NEWBY[b.id] || []).length} new names) and opens with no headline_quote`); continue; }
if (d.headline_quote) {
const k = b.sentences.findIndex((x) => norm(x).includes(norm(d.headline_quote)));
if (k < 0) m1.push(`${b.id}: headline not found`);
else {
if (k >= DE.headline_within_sentences) m1.push(`${b.id}: the headline is sentence ${k + 1}; it opens the beat (within the first ${DE.headline_within_sentences})`);
if (wlen(d.headline_quote) > DE.headline_max_words) m1.push(`${b.id}: the headline runs ${wlen(d.headline_quote)} words (limit ${DE.headline_max_words})`);
if (quantitiesIn(d.headline_quote, 0).length) m1.push(`${b.id}: the headline holds a figure; the figures come after it`);
}
}
}
const dn = beats.slice(1, -1).filter((b) => quantities.filter((q) => q.beat === b.id).length >= DE.dense_quantities || (NEWBY[b.id] || []).length >= DE.dense_new_names).map((b) => b.id);
add('M', 'M1', m1.length ? (chain.beats.some((x) => x.headline_quote) ? FAIL : WARN) : PASS, `A dense beat (${DE.dense_quantities}+ figures) opens with a one-line headline, no figure in it, then the details`, m1.length ? m1.join('\n') : `dense beats: ${dn.join(', ') || 'none'}; headlines declared: ${chain.beats.filter((x) => x.headline_quote).map((x) => x.id).join(', ') || 'none'}`);
}
// M2: the hook ends in a trailer: a short tease and a one-sentence takeaway
if (chain && chain.cdl && chain.cdl.hook_elements && chain.cdl.hook_elements.tease_quote) {
const he = chain.cdl.hook_elements, m2 = [];
for (const [k, max] of [['tease_quote', DE.tease_max_words], ['takeaway_quote', DE.takeaway_max_words]]) {
if (!he[k]) { m2.push(`${k} missing`); continue; }
if (locate(beats[0], he[k]) < 0) m2.push(`${k} not in the hook`);
if (wlen(he[k]) > max) m2.push(`${k} runs ${wlen(he[k])} words (limit ${max}): cut it to a trailer line`);
}
add('M', 'M2', m2.length ? FAIL : PASS, `The hook ends in a trailer: the tease at most ${DE.tease_max_words} words, the takeaway at most ${DE.takeaway_max_words}`, m2.length ? m2.join('\n') : `tease ${wlen(he.tease_quote)} words, takeaway ${wlen(he.takeaway_quote)}`);
} else add('M', 'M2', WARN, 'The hook ends in a trailer: a short tease and a one-sentence takeaway', 'no cdl.hook_elements.tease_quote declared');
// M3: the narrator does not reason out loud about a prediction; the story carries the logic
{
const lab = VPX.lab_voice.flatMap((p) => [...allText.matchAll(new RegExp(p, 'gi'))].map((m) => m[0].slice(0, 60)));
add('M', 'M3', lab.length > DE.max_lab_voice ? WARN : PASS, 'No lab voice ("if that is right, you would expect ..."): show the signs, do not announce the test', lab.length ? lab.map((x) => `"${x}"`).join(', ') : 'none');
}
// ---------- report ----------
const score =(set) => { const t = set.length; if (!t) return null; return (set.filter((r) => r.status === PASS).length + 0.5 * set.filter((r) => r.status === WARN).length) / t; };
const byLesson = {};
for (const r of results) (byLesson[r.lesson] ||= []).push(r);
const lessonNames = { J: 'The whole story: origin, rise, turn, response, fall, people, blame, lesson', G: 'Personality and figures: earnest and aloof, one governing metaphor', H: 'Rhythm: hard moments and easy moments alternate, numbers land on a person', I: 'The human element: a protagonist, a witness, and you', A: 'Stakes before data; load budget', K: 'Clarity: nothing the viewer has to decode', L: 'Stakes at the turns: the prize, the contrast, the cost', M: 'Density: headline first, a trailer hook', B: 'Core driving loop; destination without direction; promise ladder', C: 'Causal chain (BUT / THEREFORE)', D: 'Understand-Care-Change-Compel; forced continuation', E: 'Sustained pull: implied loops between the questions', F: 'Plain spoken voice: no machine tells' };
const summary = {
label, words: totalWords, minutes: +minutes.toFixed(1), beats: beats.length,
quantities: quantities.length, quantities_per_min: +(quantities.length / minutes).toFixed(1), max_quantities_in_30s: maxIn30, hook_quantities: hookQuantities.length,
additive_openers: additiveOpeners.length, additive_inline: additiveInline, voice, texture,
fails: results.filter((r) => r.status === FAIL).length, warns: results.filter((r) => r.status === WARN).length, passes: results.filter((r) => r.status === PASS).length,
score: +score(results).toFixed(3),
lesson_scores: Object.fromEntries(Object.entries(byLesson).map(([k, v]) => [k, +score(v).toFixed(2)])),
};
const md = [`# Lint: ${label}`, '',
`${summary.words} spoken words, about ${summary.minutes} min at ${WPM} wpm, ${summary.beats} beats. **Score ${(summary.score * 100).toFixed(0)}/100** (pass = 1, warn = 0.5, fail = 0). ${summary.fails} FAIL, ${summary.warns} WARN, ${summary.passes} PASS.${chain ? '' : ' (no chain file: structure rules could not run)'}`, '',
'| Lesson | Score |', '|---|---|', ...Object.entries(summary.lesson_scores).map(([k, v]) => `| ${k}: ${lessonNames[k]} | ${(v * 100).toFixed(0)} |`), '',
'## Measures', '',
`- Spoken quantities: ${summary.quantities} (${summary.quantities_per_min}/min); max in any 30 s: ${maxIn30}; in the hook: ${hookQuantities.length}`,
`- Additive openers at beat joins: ${additiveOpeners.length}; additive connectives inside beats: ${additiveInline}`,
`- Voice: ${voice.negation_pivots} negation pivots, ${voice.run_ups} run-ups/script-talk, ${voice.contractions_per_100} contractions per 100 words, sentence variation ${voice.sentence_cv}`,
`- Texture: ${texture.figure_markers} comparisons, ${texture.stock_figures} stock figures, hard windows ${(texture.hard_share * 100).toFixed(0)}% / quiet ${(texture.quiet_share * 100).toFixed(0)}%, longest hard run ${texture.longest_hard_run}, ${texture.swaps_per_10min} swaps per 10 min, longest stretch with no person ${texture.person_gap_sec ?? 'n/a'} s`,
`- Beat lengths (s): ${beats.map((b) => `${b.id} ${Math.round(secOf(b.words))}`).join(', ')}`, '',
'## Rules', '', '| | Rule | Status |', '|---|---|---|',
...results.map((r) => `| ${r.lesson} ${r.id} | ${r.title} | **${r.status}** |`), '', '## Detail', '',
...results.filter((r) => r.status !== PASS).map((r) => `### ${r.id} ${r.status}: ${r.title}\n\n${r.detail.split('\n').map((x) => '- ' + x).join('\n')}\n`)].join('\n');
console.log(md.split('\n').slice(0, 14).join('\n'));
console.log(`\n${summary.fails} FAIL, ${summary.warns} WARN, ${summary.passes} PASS`);
for (const r of results.filter((x) => x.status === FAIL)) console.log(`FAIL ${r.id}: ${r.title}\n ${r.detail.split('\n').join('\n ')}`);
if (args.out) {
fs.mkdirSync(args.out, { recursive: true });
fs.writeFileSync(path.join(args.out, `lint-${label}.json`), JSON.stringify({ summary, results, quantities, naked }, null, 2), 'utf8');
fs.writeFileSync(path.join(args.out, `lint-${label}.md`), md, 'utf8');
}
process.exit(summary.fails ? 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.