// presentation skill — rule registry. // // Each rule is a small object with a `check` function that emits Findings. // Slide-scope rules run once per slide; deck-scope rules run once over the // whole deck. Adding a rule = appending to the array. The runner in // doctor.ts is layout-agnostic — `appliesTo` controls which slides a rule // runs on. import { resolve } from "node:path"; import { codeBlockLineCounts, countAllListItems, countAtxHeading, countTopLevelListItems, extractNotes, fileExists, findImageRefs, hasLayoutDirective, hasNestedChildren, listItems, stripCodeAndLinks, tableRowCount, wordCount, } from "./lint-helpers"; import type { Finding, Rule, SlideContext } from "./lint-types"; const BULLET_LAYOUTS = new Set(["content", "agenda", "comparison", "two-column"]); function isBulletLayout(ctx: SlideContext): boolean { return BULLET_LAYOUTS.has(ctx.layout); } function isExerciseSlide(ctx: SlideContext): boolean { // Detect by filename (what I see) OR by h2 prefix (what the room sees). if (/exercise/i.test(ctx.name)) return true; if (ctx.heads2.some((h) => /^Exercise\b/i.test(h))) return true; return false; } export const RULES: Rule[] = [ // ── Global rules (run on every slide) ─────────────────────────────────── { name: "no-layout", scope: "slide", check: (ctx) => { if (hasLayoutDirective(ctx.body)) return []; return [ { rule: "no-layout", severity: "W", msg: "no directive — defaults to 'content'", }, ]; }, }, { name: "long-title", scope: "slide", check: (ctx) => { const findings: Finding[] = []; for (const h of ctx.heads1) { if (h.length > 60) { findings.push({ rule: "long-title", severity: "W", msg: `h1 is ${h.length} chars (soft limit 60): "${h.slice(0, 50)}…"`, }); } } return findings; }, }, { name: "long-subtitle", scope: "slide", check: (ctx) => { const findings: Finding[] = []; for (const h of ctx.heads2) { if (h.length > 100) { findings.push({ rule: "long-subtitle", severity: "W", msg: `h2 is ${h.length} chars (soft limit 100)`, }); } } return findings; }, }, { name: "missing-asset", scope: "slide", check: async (ctx) => { const findings: Finding[] = []; for (const ref of findImageRefs(ctx.bodyNoNotes)) { // `../assets/X` is the natural relative path from a slides/*.md file — // resolve it from inside `slides/`. Plain `assets/X` (deck-root style) // resolves from the deck root. Both resolve to the same file on disk. const base = ref.startsWith("../") ? resolve(ctx.deckDir, "slides") : ctx.deckDir; const abs = resolve(base, ref); if (!(await fileExists(abs))) { findings.push({ rule: "missing-asset", severity: "E", msg: `image referenced but not found: ${ref}`, }); } } return findings; }, }, { name: "bullet-emdash-continuation", scope: "slide", check: (ctx) => { // Em-dash continuation in body bullets is disallowed by SKILL.md content // rules. Em-dash is reserved for title qualifiers in headings ("Block 4 // — Landscape") and Q&A format in notes (`"Question?" — short answer`). // Inside the slide body, a bullet of the form `- foo — bar` is prose // pretending to be a bullet. Convert to a sub-bullet instead. const findings: Finding[] = []; for (const line of ctx.bodyNoNotes.split("\n")) { const m = new RegExp(/^(\s*)(?:[-*]\s+|\d+\.\s+)(.*)$/).exec(line); if (!m) continue; const stripped = stripCodeAndLinks(m[2]); if (/\s—\s/.test(stripped)) { findings.push({ rule: "bullet-emdash-continuation", severity: "W", msg: `em-dash continuation in bullet: "${m[2].trim().slice(0, 60)}…" — convert to sub-bullet`, }); } } return findings; }, }, { name: "slide-line-budget", scope: "slide", appliesTo: isBulletLayout, check: (ctx) => { // 10 fits cleanly on a slide; 11+ overflows even when each line is short. const all = countAllListItems(ctx.bodyNoNotes); if (all <= 10) return []; return [ { rule: "slide-line-budget", severity: "W", msg: `${all} list lines (top-level + sub-bullets) — slide fits 10 cleanly`, }, ]; }, }, // ── Content-quality rules (Tier 1) ────────────────────────────────────── { // Top-level bullets should land 2–15 words. Below 2 = stub; above 15 = // prose pretending to be a bullet. SKILL.md's stricter target is 6–12; // the doctor uses looser bounds to flag only egregious cases. // // Two carve-outs: // 1. A bullet with nested children is a "label" — minimum doesn't apply. // "Locations" → sub-bullets is fine. // 2. Comparison layout has its own visual rhythm (column labels under // headers); skip the rule there entirely. name: "bullet-length-top-level", scope: "slide", appliesTo: (ctx) => isBulletLayout(ctx) && ctx.layout !== "comparison", check: (ctx) => { const findings: Finding[] = []; const items = listItems(ctx.bodyNoNotes); items.forEach((item, idx) => { if (item.indent !== 0) return; const wc = wordCount(item.content); const isLabel = hasNestedChildren(items, idx); const tooShort = wc < 2 && !isLabel; const tooLong = wc > 15; if (tooShort || tooLong) { findings.push({ rule: "bullet-length-top-level", severity: "W", msg: `top-level bullet has ${wc} words (target 2–15): "${item.content.trim().slice(0, 50)}…"`, }); } }); return findings; }, }, { // Sub-bullets should land 2–10 words. They are elaborations of the parent, // not new claims, so they stay short. name: "bullet-length-sub", scope: "slide", appliesTo: isBulletLayout, check: (ctx) => { const findings: Finding[] = []; for (const item of listItems(ctx.bodyNoNotes)) { if (item.indent === 0) continue; const wc = wordCount(item.content); if (wc < 2 || wc > 10) { findings.push({ rule: "bullet-length-sub", severity: "W", msg: `sub-bullet has ${wc} words (target 2–10): "${item.content.trim().slice(0, 50)}…"`, }); } } return findings; }, }, { // If notes contain source links, the FIRST bullet should be one (or a // "Sources" parent whose children are links). The rule is "if you cite, // cite first" — slides without any source link are exempt because the // content is analysis, not citation. name: "notes-link-first", scope: "slide", appliesTo: (ctx) => ctx.layout === "content" || ctx.layout === "big-stat" || ctx.layout === "comparison" || ctx.layout === "table", check: (ctx) => { const notes = extractNotes(ctx.body); if (!notes.trim()) return []; // No links anywhere = analysis-only notes; rule doesn't apply. if (!/\[[^\]]+\]\([^)]+\)/.test(notes)) return []; const items = listItems(notes); if (items.length === 0) return []; const first = items[0].content.trim(); if (/^\[[^\]]+\]\([^)]+\)/.test(first)) return []; // "Sources" / "Per-X sources" parent whose children are links — allowed. if (/^(per-\w+ )?sources?\b/i.test(first)) { const sub = items.slice(1).find((it) => it.indent > items[0].indent); if (sub && /^\[[^\]]+\]\([^)]+\)/.test(sub.content.trim())) return []; } return [ { rule: "notes-link-first", severity: "W", msg: `notes have a source link, but first bullet isn't one: "${first.slice(0, 50)}…"`, }, ]; }, }, { // Body should be bullets + headings + blockquotes + HTML wrappers + code. // A bare prose paragraph in body usually means "what happens in the room" // narration leaked onto the slide. Only enforced on content/agenda where // prose is least legitimate; two-column and image-text legitimately host // prose inside their wrappers. name: "prose-paragraph-in-body", scope: "slide", appliesTo: (ctx) => ctx.layout === "content" || ctx.layout === "agenda", check: (ctx) => { const findings: Finding[] = []; const lines = ctx.bodyNoNotes.split("\n"); let inFence = false; for (const line of lines) { if (/^```/.test(line)) { inFence = !inFence; continue; } if (inFence) continue; const trimmed = line.trim(); if (!trimmed) continue; if (/^#/.test(trimmed)) continue; // heading if (/^\s*(?:[-*]\s|\d+\.\s)/.test(line)) continue; // any-indent bullet if (/^>/.test(trimmed)) continue; // blockquote if (/^