// The doc-guides' spoken-budget lint, read from the source so a PR meets it. // // DocGuide.step() and .text() throw when a caption cannot be narrated // (captionFault), but only under DOCS_GENERATE=1, and an engine-tier guide // regenerates only in the nightly docs run. So an over-long caption merged // green and failed the next night, one per night, since the lint stops at the // first (commentary, 2026-10-11/12). checks.yml runs this instead: // // bun node_modules/@norskvideo/ctl-dev-kit/conventions/caption-check.ts // // For each step()/text() call whose caption has literal text, it takes the // worst case (every optional clause, the longer arm of a ternary, one word per // substitution) and applies captionFault with the guide's budget: a literal // `captionWords` in the file, else captionBudget(). A caption with no literal // text (a wrapper passing variables through) cannot be judged here; the // runtime lint still holds it. import { readFileSync } from "node:fs"; import ts from "typescript"; import { captionBudget, captionFault } from "../doc-guide/doc-guide.js"; export interface CaptionFault { file: string; line: number; what: string; fault: string; } const count = (s: string) => s.split(/\s+/).filter(Boolean).length; /** The longest text an expression can narrate, or null when it has no literal * text to judge (a variable, a function, a call). */ function worstCase(e: ts.Expression): string | null { if (ts.isStringLiteral(e) || ts.isNoSubstitutionTemplateLiteral(e)) return e.text; if (ts.isTemplateExpression(e)) return e.head.text + e.templateSpans.map((s) => ` x ${s.literal.text}`).join(""); if (ts.isParenthesizedExpression(e)) return worstCase(e.expression); if (ts.isConditionalExpression(e)) { const [a, b] = [worstCase(e.whenTrue), worstCase(e.whenFalse)]; if (a === null && b === null) return null; return count(a ?? "x") >= count(b ?? "x") ? (a ?? "x") : (b ?? "x"); } if (ts.isBinaryExpression(e) && e.operatorToken.kind === ts.SyntaxKind.PlusToken) { const [l, r] = [worstCase(e.left), worstCase(e.right)]; return l === null && r === null ? null : `${l ?? " x "}${r ?? " x "}`; } if (ts.isBinaryExpression(e) && e.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandToken) return worstCase(e.right); return null; } /** Every caption in one source file that cannot be narrated within budget. */ export function captionFaultsIn(source: string, file: string, budget: number): CaptionFault[] { const sf = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, true); const own = /captionWords\s*:\s*(\d+)/.exec(source); const limit = own ? Number(own[1]) : budget; const out: CaptionFault[] = []; const visit = (n: ts.Node) => { if (ts.isCallExpression(n) && ts.isPropertyAccessExpression(n.expression)) { const method = n.expression.name.text; const [first, second] = n.arguments; let what: string | null = null; let narrated: string | null = null; if (method === "step" && first && (!second || worstCase(second) !== null)) { const heading = worstCase(first); const description = second ? worstCase(second) : ""; if (heading !== null) { what = `step "${heading}"`; narrated = description ? `${heading} ${description}` : heading; } } else if (method === "text" && first && n.arguments.length === 1) { const prose = worstCase(first); if (prose !== null) { what = `text "${prose.split(/\s+/).slice(0, 6).join(" ")} ..."`; narrated = prose; } } const fault = narrated === null ? null : captionFault(narrated, limit); if (what && fault) out.push({ file, line: sf.getLineAndCharacterOfPosition(n.getStart(sf)).line + 1, what, fault }); } ts.forEachChild(n, visit); }; visit(sf); return out; } /** The repo's guide sources: tracked .ts files that are guides or use DocGuide. */ function guideFiles(): string[] { const listed = Bun.spawnSync(["git", "ls-files", "*.ts"]).stdout.toString().split("\n").filter(Boolean); return listed.filter( (f) => !f.includes("node_modules/") && (f.endsWith(".guide.test.ts") || readFileSync(f, "utf8").includes("DocGuide")), ); } if (import.meta.main) { const files = guideFiles(); const faults = files.flatMap((f) => captionFaultsIn(readFileSync(f, "utf8"), f, captionBudget())); for (const f of faults) console.log(`::error file=${f.file},line=${f.line}::doc-guide ${f.what} cannot be narrated: ${f.fault}`); if (faults.length) { console.error( `caption-check: ${faults.length} caption(s) in ${files.length} guide file(s) cannot be narrated (worst case: every optional clause included).`, ); process.exit(1); } console.log(`caption-check: ${files.length} guide file(s), every caption within budget.`); }