#!/usr/bin/env node /** * The demo contract validator. * * `src/lib/demo-kit/types.ts` says what a demo is. TypeScript enforces the * shapes; this enforces everything a type cannot: that the directory name * matches the slug, that a `spec` demo is a real published specification rather * than a coming-soon card, that a `live` demo's traces exist on disk, that the * shared shell has no idea any particular demo exists, that every tab on the * page asserts something a reader can check, and that the reward has something * pulling against its objective. * * Fourteen numbered rules, each reported with the file to open. Run it with * `pnpm check`. * * Most rules are checked by READING the TypeScript, not by running it — the * demos import React, `?raw` Python and the Vite `@/` alias, none of which * survive a bare `node` import. Rule 13 is the exception: it tries the adapter * for real under tsx first, and says so in the output when it had to settle for * reading the source. */ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { spawnSync } from 'node:child_process'; import { DEMOS_DIR, Report, abs, allDemoDirs, demoFiles, demoSlugs, die, exists, findInDemo, evalLiteral, isUnresolved, literalAfter, loadManifest, loadMeta, parseStringUnion, read, rel, segment, traceFile, walk, } from './_lib.mjs'; const report = new Report('check-demos'); /* ------------------------------------------------ the contract, read once */ const TYPES_FILE = abs('src', 'lib', 'demo-kit', 'types.ts'); if (!exists(TYPES_FILE)) die(`${rel(TYPES_FILE)} is missing. It is the contract; there is nothing to check against.`); const typesSrc = read(TYPES_FILE); // Parsed out of types.ts rather than restated here. A second copy of this list // would drift, and it would drift silently in the direction of passing. const VERTICALS = parseStringUnion(typesSrc, 'Vertical'); const STATUSES = parseStringUnion(typesSrc, 'DemoStatus'); const TAB_IDS = parseStringUnion(typesSrc, 'DemoTabId'); const ROLES = ['objective', 'counterweight', 'gate']; if (!VERTICALS) die(`could not parse the \`Vertical\` union out of ${rel(TYPES_FILE)}.`); if (!STATUSES) die(`could not parse the \`DemoStatus\` union out of ${rel(TYPES_FILE)}.`); if (!TAB_IDS) { die( `could not parse the \`DemoTabId\` union out of ${rel(TYPES_FILE)}. ` + 'Rule 3 requires one claim per tab, and the list of tabs lives there.', ); } /** * `DEMO_TABS` is the runtime half of `DemoTabId`, and the shell orders the tab * bar from it. If the two ever disagree, rule 3 would happily pass a demo that * is missing a claim for a tab the page actually renders — so they are compared * here, once, before any demo is looked at. */ (() => { let text = null; try { text = literalAfter(typesSrc, /(?:export\s+)?const\s+DEMO_TABS\s*(?::\s*[^=]+)?=\s*/, '['); } catch { text = null; } if (text === null) { report.fail( rel(TYPES_FILE), 'rule 3 (one claim per tab)', 'no `DEMO_TABS` array literal found. The union says what a tab id is; the array says what order the ' + 'tabs come in, and the shell reads the array. Both have to exist.', ); return; } const parsed = evalLiteral(text, 'DEMO_TABS'); const listed = Array.isArray(parsed.value) ? parsed.value : null; report.check( listed !== null && listed.length === TAB_IDS.length && listed.every((id, i) => id === TAB_IDS[i]), rel(TYPES_FILE), 'rule 3 (one claim per tab)', `DEMO_TABS is ${JSON.stringify(listed)} but the DemoTabId union is ${JSON.stringify(TAB_IDS)}. ` + 'They are the same list written twice; a demo cannot be checked against a contract that disagrees ' + 'with itself.', ); })(); /** * Every icon name lucide-react actually exports, from its own type * declarations. "Plausible PascalCase" would accept `Grid3X3`, which compiles * to `undefined` and renders as a hole in the header. */ const LUCIDE_ICONS = (() => { const dts = abs('node_modules', 'lucide-react', 'dist', 'lucide-react.d.ts'); if (!exists(dts)) return null; const names = new Set(); for (const m of read(dts).matchAll(/declare const ([A-Za-z][A-Za-z0-9_]*)\s*:/g)) names.add(m[1]); return names.size > 100 ? names : null; })(); const nonEmptyString = (v) => typeof v === 'string' && v.trim().length > 0; /** field -> validator returning `true` or the reason it is wrong. */ const META_FIELDS = { slug: (v) => (/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(String(v)) ? true : 'must be a non-empty kebab-case string'), title: (v) => (nonEmptyString(v) ? true : 'must be a non-empty string'), tagline: (v) => (nonEmptyString(v) ? true : 'must be a non-empty string'), vertical: (v) => (VERTICALS.includes(v) ? true : `must be one of the Vertical union: ${VERTICALS.join(', ')}`), status: (v) => (STATUSES.includes(v) ? true : `must be one of the DemoStatus union: ${STATUSES.join(', ')}`), order: (v) => (typeof v === 'number' && Number.isFinite(v) ? true : 'must be a finite number'), icon: (v) => { if (!nonEmptyString(v)) return 'must be a non-empty string'; if (!/^[A-Z][A-Za-z0-9]*$/.test(v)) return `"${v}" is not PascalCase, so it is not a lucide export name`; if (LUCIDE_ICONS && !LUCIDE_ICONS.has(v)) return `lucide-react does not export "${v}" (check the exact casing at lucide.dev)`; return true; }, persona: (v) => (nonEmptyString(v) ? true : 'must be a non-empty string'), rewardLine: (v) => (nonEmptyString(v) ? true : 'must be a non-empty string'), ogImage: (v) => (nonEmptyString(v) && v.startsWith('/') ? true : "must be a site-absolute path, e.g. '/og/wordle.png'"), }; /* --------------------------------------------------------- literal anchors */ const COMPONENT_ANCHORS = [/\bcomponents\s*:\s*/]; const NARRATIVE_ANCHORS = [ /(?:export\s+)?const\s+narrative\s*(?::\s*[^=]+)?=\s*/, /(?:export\s+)?const\s+[A-Za-z_$][\w$]*Narrative\s*(?::\s*[^=]+)?=\s*/, /\bnarrative\s*:\s*/, ]; const ANATOMY_ANCHORS = [/(?:export\s+)?const\s+anatomy\s*(?::\s*[^=]+)?=\s*/, /\banatomy\s*:\s*/]; const PROVENANCE_ANCHORS = [ /(?:export\s+)?const\s+provenance\s*(?::\s*[^=]+)?=\s*/, /\bprovenance\s*:\s*/, ]; /* --------------------------------------------------------- rule 3 helpers */ /** * The shortest thing that can still be a claim rather than a heading. * * Calibrated against the shortest real one on the site — "Move one slider and * the winner changes." at 42 characters — with a wide margin, because the point * is to catch `play: 'Play'`, not to police brevity. */ const MIN_CLAIM_CHARS = 20; const MIN_CLAIM_WORDS = 4; /** Lower-cased, punctuation stripped, whitespace collapsed. */ const normalise = (text) => text .toLowerCase() .replace(/[^a-z0-9]+/g, ' ') .trim(); /** * Why `claims[tab]` is not a claim, or null if it is one. * * A claim is a sentence the tab then has to demonstrate. The three ways one * arrives broken are: absent, a heading pasted in as prose, and the tab's own * name — all of which type-check, and all of which render as a page that * asserts nothing. */ function claimProblem(tab, value) { const label = `narrative.claims.${tab}`; if (isUnresolved(value)) { return `${label} is the imported identifier \`${Object.values(value)[0] ?? '?'}\`, not a written-out ` + 'string. The claims are read on the page beside the surface they describe, so they are written here.'; } if (typeof value !== 'string' || value.trim().length === 0) { return `${label} is ${value === undefined ? 'missing' : JSON.stringify(value)}. Every tab needs one, ` + 'including a tab this demo may not render — writing the claim is how you find out whether the tab has ' + 'anything in it.'; } const text = value.trim(); const norm = normalise(text); // Stripped of the articles and the word "tab", is it just the tab's name? const bare = norm.replace(/^(?:the|a|an)\s+/, '').replace(/\s+tab$/, ''); if (bare === tab) { return `${label} is ${JSON.stringify(text)}, which is the tab's own name. The tab bar already says ` + 'that. This is the sentence the tab has to earn.'; } if (text.length < MIN_CLAIM_CHARS) { return `${label} is ${text.length} characters (${JSON.stringify(text)}). Under ${MIN_CLAIM_CHARS} it is a ` + 'heading, not a claim — say what the reader will be able to see for themselves on this tab.'; } if (norm.split(' ').filter(Boolean).length < MIN_CLAIM_WORDS) { return `${label} is ${JSON.stringify(text)}. A claim is a sentence; ${MIN_CLAIM_WORDS} words is the floor.`; } return null; } /* ------------------------------------------------------------- the checks */ const slugs = demoSlugs(); if (slugs.length === 0) die(`no demos found under ${rel(DEMOS_DIR)}. A repo with no demos has nothing to validate.`); const manifest = loadManifest(); for (const slug of slugs) { const dir = path.join(DEMOS_DIR, slug); const found = loadMeta(slug); if ('error' in found) { report.fail(found.file, 'rule 2 (DemoMeta present)', found.error); continue; } const { meta, file: metaFile } = found; /* -- 1. directory name is the slug ------------------------------------- */ report.check( meta.slug === slug, metaFile, 'rule 1 (slug is the directory name)', `meta.slug is ${JSON.stringify(meta.slug)} but the directory is ${JSON.stringify(slug)}. ` + 'The registry, the route and the OG card are all keyed on this; rename one of them.', ); /* -- 2. every required field, correctly typed ---------------------------- */ for (const [field, validate] of Object.entries(META_FIELDS)) { if (!(field in meta)) { report.fail(metaFile, 'rule 2 (DemoMeta complete)', `\`${field}\` is missing.`); continue; } const verdict = validate(meta[field]); report.check(verdict === true, metaFile, 'rule 2 (DemoMeta complete)', `\`${field}\`: ${verdict}`); } if (!LUCIDE_ICONS) { report.staticOnly(`${slug}: lucide-react is not installed, so \`icon\` was only checked for PascalCase shape.`); } /* -- 3. the narrative: a thesis, an anxiety, four claims, a limit ------- */ checkNarrative(slug, report); /* -- 4. the social card exists ------------------------------------------ */ if (nonEmptyString(meta.ogImage)) { const card = abs('public', String(meta.ogImage).replace(/^\/+/, '')); report.check( exists(card), metaFile, 'rule 4 (ogImage exists)', `meta.ogImage points at ${meta.ogImage}, which is ${rel(card)} on disk, and that file does not exist. ` + 'Generate it with `node scripts/og.mjs` (x86 only — it needs Playwright chromium).', ); } /* -- 5. demo.tsx default-exports via defineDemo ------------------------- */ const demoFile = path.join(dir, 'demo.tsx'); if (!exists(demoFile)) { report.fail(rel(demoFile), 'rule 5 (defineDemo default export)', 'demo.tsx does not exist.'); } else { const src = read(demoFile); report.check( /export\s+default\s+defineDemo\s*(?:<[^>]*>)?\s*\(/.test(src), rel(demoFile), 'rule 5 (defineDemo default export)', 'no `export default defineDemo(...)` found. The registry loads demos through defineDemo; ' + 'a bare object default export skips whatever the kit validates.', ); report.check( /\bdefineDemo\b[\s\S]*?from\s*'@\/lib\/demo-kit'/.test(src) || /from\s*'@\/lib\/demo-kit'[\s\S]*?\bdefineDemo\b/.test(src), rel(demoFile), 'rule 5 (defineDemo default export)', "defineDemo must be imported from '@/lib/demo-kit'.", ); } /* -- 6/10. the specification and the reward ----------------------------- */ const anatomy = findInDemo(slug, ANATOMY_ANCHORS, '{', 'anatomy', { lenient: true }); const provenance = findInDemo(slug, PROVENANCE_ANCHORS, '{', 'provenance', { lenient: true }); const components = findInDemo(slug, COMPONENT_ANCHORS, '[', 'reward.components', { lenient: true }); const specRule = 'rule 6 (complete specification)'; if (anatomy.error) { report.fail(anatomy.file ?? rel(dir), specRule, anatomy.error); } else { for (const field of ['task', 'actions', 'grader', 'score']) { report.check( nonEmptyString(anatomy.value?.[field]), anatomy.file, specRule, `anatomy.${field} is empty. A ${meta.status} demo publishes the four boxes in full; ` + 'an empty one reads as a placeholder and undoes the page it sits on.', ); } } if (provenance.error) { report.fail(provenance.file ?? rel(dir), specRule, provenance.error); } else { report.check( nonEmptyString(provenance.value?.command), provenance.file, specRule, 'provenance.command is empty. The eval command is the reader\'s way to disprove the page; ' + 'it is the one field that must never be aspirational.', ); } const weightRule = 'rule 10 (reward weights)'; if (components.error) { report.fail(components.file ?? rel(dir), weightRule, components.error); } else if (!Array.isArray(components.value) || components.value.length === 0) { report.fail(components.file, weightRule, 'reward.components is not a non-empty array.'); } else { const list = components.value; let sum = 0; let readable = true; list.forEach((component, i) => { const where = `reward.components[${i}]${component?.key ? ` (${component.key})` : ''}`; for (const field of ['key', 'label', 'description']) { report.check( nonEmptyString(component?.[field]), components.file, weightRule, `${where}: \`${field}\` is empty.`, ); } if (typeof component?.weight !== 'number' || !Number.isFinite(component.weight)) { readable = false; report.fail( components.file, weightRule, `${where}: \`weight\` is ${isUnresolved(component?.weight) ? 'an imported constant' : JSON.stringify(component?.weight)}, ` + 'not a literal number. Weights are read off the page beside the code, so they are written out here.', ); } else { sum += component.weight; } report.check( ROLES.includes(component?.role), components.file, weightRule, `${where}: \`role\` must be one of ${ROLES.join(', ')}.`, ); }); if (readable) { report.check( Math.abs(sum - 1) <= 1e-9, components.file, weightRule, `the weights sum to ${sum} and must sum to 1.0 (within 1e-9). ` + 'A reward whose weights do not sum to one is not the reward the page shows you tuning.', ); } report.check( list.some((c) => c?.role === 'counterweight'), components.file, 'rule 10 (counterweight required)', 'no component has role "counterweight". An objective with nothing pulling against it is a metric to ' + 'game, and the demo exists to show the opposite. If the second term is one every good policy also ' + 'scores 1.0 on, it is a gate — and the demo still needs a real counterweight.', ); const counterweight = list.find((c) => c?.role === 'counterweight'); if (counterweight) { report.check( nonEmptyString(counterweight.description), components.file, specRule, `the counterweight (${counterweight.key}) has no description. It is the half of the reward a reader ` + 'does not expect, so it is the half that must be spelled out.', ); } } /* -- 7/11/12. recorded runs --------------------------------------------- */ if (meta.status === 'live') { const runRule = 'rule 7 (live demo has runs)'; if (manifest.error) { report.fail(rel(abs('public', 'traces', 'manifest.json')), runRule, manifest.error); } else { const entry = manifest.byDemo.get(slug); const runs = entry?.runs ?? []; if (runs.length === 0) { report.fail( rel(abs('public', 'traces', 'manifest.json')), runRule, `demo "${slug}" is status "live" but has no runs in the manifest. ` + 'A live demo asserts recorded evidence; without a run there is nothing to replay.', ); } else { const episodes = []; for (const run of runs) { const label = `run ${run?.id ?? '(no id)'}`; if (!nonEmptyString(run?.path)) { report.fail(rel(abs('public', 'traces', 'manifest.json')), runRule, `${label}: \`path\` is empty.`); continue; } const file = traceFile(run.path); if (!report.check(exists(file), rel(file), runRule, `${label} points at ${run.path}, which does not exist on disk.`)) { continue; } try { const raw = JSON.parse(read(file)); for (const ep of Array.isArray(raw) ? raw : Array.isArray(raw?.episodes) ? raw.episodes : [raw]) { episodes.push({ run, file, episode: ep }); } } catch (error) { report.fail(rel(file), runRule, `not valid JSON: ${error.message}`); } /* -- 12. an intervened run must say what was done to it ---------- */ if (run?.kind === 'intervened') { report.check( nonEmptyString(run.intervention), rel(abs('public', 'traces', 'manifest.json')), 'rule 12 (intervened runs declare the intervention)', `${label} has kind "intervened" but no \`intervention\`. Without it a prompt change reads as a ` + 'training result by omission, which is the single most misleading thing this page could do.', ); } } /* -- 11. a failure, or a solve rate the page renders -------------- */ const failedRun = episodes.find((e) => e.episode?.outcome === 'failed'); if (!failedRun && episodes.length) { const rateSources = [entry?.extras ?? {}, ...runs]; const rate = rateSources .map((s) => s?.solveRate ?? s?.solve_rate) .find((v) => typeof v === 'number' && Number.isFinite(v)); const rendered = walk(abs('src'), (f) => /\.tsx?$/.test(f)).some((f) => /solveRate|solve_rate/.test(read(f))); report.check( typeof rate === 'number' && rendered, rel(abs('public', 'traces', 'manifest.json')), 'rule 11 (a clean sweep is reported, not hidden)', `demo "${slug}" ships no run with outcome "failed". That is allowed only if the page states the ` + 'solve rate: add a numeric `solveRate` beside the runs in the manifest AND render it. ' + `Right now solveRate is ${typeof rate === 'number' ? rate : 'absent'} and a renderer for it was ` + `${rendered ? 'found' : 'not found'} under src/. Hunting for a losing seed to make the demo look ` + 'honest is the failure mode this rule exists to block.', ); } } } } /* -- 13. every step announces itself ------------------------------------ */ checkAnnounce(slug, report, manifest); /* -- 14. the page still renders without the interactive half ------------ */ checkEagerHalfStandsAlone(slug, report, metaFile); } /* ---------------------------------------- 8. the shell knows no demo names */ const SHELL_DIR = abs('src', 'components', 'demo'); for (const file of walk(SHELL_DIR, (f) => /\.(tsx?|css)$/.test(f))) { const lines = read(file).split('\n'); for (const slug of allDemoDirs()) { if (slug.startsWith('_')) continue; // Word-boundary on both sides so a slug like `wordle` is not found inside // an unrelated identifier, and so `wordle-five` matches as one token. const needle = new RegExp(`(? needle.test(line)); if (hit !== -1) { report.fail( `${rel(file)}:${hit + 1}`, 'rule 8 (the shell is generic)', `mentions the demo slug "${slug}". Nothing under src/components/demo/ may know which demo it is ` + 'rendering. If the shell needs to special-case something, that is a missing slot on DemoModule or a ' + 'missing capability in demo-kit — fix it there and every future demo gets it too.', ); } else { report.passed += 1; } } } /* --------------------------------- 9. demos reach only for the kit's barrel */ const FORBIDDEN_IMPORTS = [ { test: (spec) => /^@\/lib\/demo-kit\/.+/.test(spec), why: "imports a file INSIDE demo-kit. Import '@/lib/demo-kit' — the barrel is the contract, and anything " + 'you can only get by reaching past it is either private or belongs in the barrel.', }, { test: (spec) => /^@\/components\/demo(\/|$)/.test(spec), why: 'imports the shared shell. The shell renders demos; demos never render the shell. This is the ' + 'dependency that, once it exists, makes the shell impossible to change without opening every demo.', }, ]; for (const slug of allDemoDirs()) { for (const file of demoFiles(slug)) { for (const spec of importSpecifiers(read(file))) { const normalised = normaliseSpecifier(spec, file); for (const rule of FORBIDDEN_IMPORTS) { if (rule.test(normalised)) { report.fail( rel(file), 'rule 9 (demos import only the demo-kit barrel)', `\`${spec}\`${normalised === spec ? '' : ` (resolves to ${normalised})`} ${rule.why}`, ); } else { report.passed += 1; } } } } } report.finish(); /* ------------------------------------------------------------- helpers */ /** * Rule 3: the narrative asserts something, tab by tab. * * The page is four tabs, not an essay, so the narrative is no longer a list of * beats with their own surfaces — the shell decides which tabs exist and in * what order. What the demo owes is the one sentence each tab has to earn, and * a claim is the field most likely to be left as a heading, because a heading * type-checks. */ function checkNarrative(slug, report) { const rule = 'rule 3 (one claim per tab)'; const dir = path.join(DEMOS_DIR, slug); // Lenient, because a narrative may legitimately interpolate a shared // constant; `claimProblem` then reports the stand-in by name rather than // letting the whole literal fail to evaluate. const found = findInDemo(slug, NARRATIVE_ANCHORS, '{', 'narrative', { lenient: true }); if (found.error) { report.fail(found.file ?? rel(dir), rule, found.error); return; } const narrative = found.value; const file = found.file; if (narrative === null || typeof narrative !== 'object') { report.fail(file, rule, 'the narrative is not an object literal.'); return; } report.check( nonEmptyString(narrative.thesis), file, rule, 'narrative.thesis is empty. It is the paragraph that says why this environment is worth the reader\'s ' + 'next five minutes, and no tab supplies it.', ); report.check( nonEmptyString(narrative.anxiety), file, rule, 'narrative.anxiety is empty. It is the question already in the reader\'s head when they land; the page ' + 'is built to answer it and cannot if it is not written down.', ); /* The claims. */ if (Array.isArray(narrative.beats) || 'beats' in narrative) { report.fail( file, rule, 'narrative still has `beats`. The page is four tabs now, and which tabs exist is the shell\'s decision, ' + 'not the demo\'s. Replace `beats` with `claims`: one sentence per tab id.', ); } const claims = narrative.claims; if (claims === null || typeof claims !== 'object' || Array.isArray(claims)) { report.fail( file, rule, `narrative.claims is ${claims === undefined ? 'missing' : JSON.stringify(claims)}. It must be an object ` + `keyed by tab id: ${TAB_IDS.join(', ')}.`, ); return; } for (const tab of TAB_IDS) { const problem = claimProblem(tab, claims[tab]); report.check(problem === null, file, rule, problem ?? ''); } for (const key of Object.keys(claims)) { report.check( TAB_IDS.includes(key), file, rule, `narrative.claims has a key "${key}", which is not a tab. The tabs are ${TAB_IDS.join(', ')}, and they ` + 'are fixed by the contract — a demo that needs a fifth surface adds it through `tabs`, not here.', ); } /* The limits. */ const limits = narrative.limits; if (!Array.isArray(limits) || limits.length === 0) { report.fail( file, rule, 'narrative.limits is empty. Every environment teaches something narrower than the thing it is standing ' + 'in for, and the page that will not say what is the one a reader is right to distrust.', ); return; } limits.forEach((limit, i) => { report.check( nonEmptyString(limit?.text), file, rule, `narrative.limits[${i}].text is empty.`, ); }); } /** * Rule 14: the eager half of a demo does not depend on the interactive half. * * Play is a tab now, and it is the tab a demo may not have: a demo with no * `interactive` opens on Watch, and one with no runs has no Watch tab at all. * That only works if `meta`, `narrative` and `reward` can be rendered on their * own. `meta.ts` is where it goes wrong first, because it is loaded EAGERLY for * every demo on every page — an import of `./demo` there drags one demo's board, * its controls and its word list into the entry chunk for all of them, and does * it silently. * * Only imports that stay inside the demo's own directory are followed. Reaching * out of it is rule 9's business, and following `@/lib/demo-kit` would walk into * the shell's own React and report a failure that belongs to nobody. */ function checkEagerHalfStandsAlone(slug, report, metaFile) { const rule = 'rule 14 (the eager half stands alone)'; const dir = path.join(DEMOS_DIR, slug); const entry = abs(metaFile); if (!exists(entry)) return; // Where `interactive` is declared. In practice demo.tsx, but the contract is // about the value, not the filename. const interactiveFiles = demoFiles(slug).filter((f) => /\binteractive\s*:/.test(codeOnly(read(f)))); const closure = localClosure(entry, dir); for (const file of closure) { if (interactiveFiles.includes(file)) { report.fail( rel(entry), rule, `reaches ${rel(file)}, which declares \`interactive\`. meta must be renderable with the interactive ` + 'mode absent — it is what the gallery card, the header and the router are built from, and Play is ' + 'the one tab a demo is allowed not to have. Move whatever meta needs into a plain data module.', ); } else if (/\.tsx$/.test(file)) { report.fail( rel(entry), rule, `imports ${rel(file)}, a component module. meta is eagerly loaded for EVERY demo on every page, so it ` + 'stays plain serialisable data: no React, no board, no controls. That is also what keeps Play ' + 'optional rather than load-bearing.', ); } else { report.passed += 1; } } if (closure.length === 0) report.passed += 1; // `interactive` present but nothing to play with is the other half of the // same contract: the shell would render a Play tab over an empty board. for (const file of interactiveFiles) { const code = codeOnly(read(file)); report.check( /\binit\s*:/.test(code) && /\bControls\s*:/.test(code), rel(file), rule, 'declares `interactive` without both `init` and `Controls`. The shell shows the Play tab because ' + '`interactive` exists; a half-declared one is a default tab with nothing in it.', ); } } /** Source with comments and string literals blanked out. */ function codeOnly(src) { return segment(src) .map((span) => (span.code ? span.text : ' ')) .join(''); } /** * Every file inside `dir` that `entry` imports, transitively. Excludes `entry`. */ function localClosure(entry, dir) { const seen = new Set([entry]); const queue = [entry]; while (queue.length > 0) { const file = queue.pop(); for (const spec of importSpecifiers(read(file))) { const resolved = resolveWithin(spec, file, dir); if (resolved && !seen.has(resolved)) { seen.add(resolved); queue.push(resolved); } } } seen.delete(entry); return [...seen].sort(); } /** Resolve a specifier to a file under `dir`, or null if it leaves it. */ function resolveWithin(spec, fromFile, dir) { const bare = String(spec).split('?')[0]; let base; if (bare.startsWith('.')) base = path.resolve(path.dirname(fromFile), bare); else if (bare.startsWith('@/')) base = abs('src', bare.slice(2)); else return null; if (path.relative(dir, base).startsWith('..')) return null; for (const ext of ['', '.ts', '.tsx', '/index.ts', '/index.tsx']) { const candidate = base + ext; if (exists(candidate) && fs.statSync(candidate).isFile()) return candidate; } return null; } /** Every module specifier a file imports, static, dynamic or side-effect. */ function importSpecifiers(src) { const out = new Set(); for (const m of src.matchAll(/\bfrom\s*['"]([^'"]+)['"]/g)) out.add(m[1]); for (const m of src.matchAll(/\bimport\s*\(\s*['"]([^'"]+)['"]\s*\)/g)) out.add(m[1]); for (const m of src.matchAll(/^\s*import\s+['"]([^'"]+)['"]/gm)) out.add(m[1]); return [...out]; } /** * Rewrites a relative specifier into its `@/` form. * * Without this, `../../components/demo/Player` walks straight past a lint that * only looks at the alias — and it is the form an editor's auto-import * produces, so it is the form the violation actually arrives in. */ function normaliseSpecifier(spec, fromFile) { if (!spec.startsWith('.')) return spec; const resolved = path.resolve(path.dirname(fromFile), spec); const inSrc = path.relative(abs('src'), resolved); if (inSrc.startsWith('..')) return spec; return `@/${inSrc.split(path.sep).join('/')}`.replace(/\.(tsx?|jsx?)$/, ''); } /** * Rule 13: every `DemoStep` an adapter produces sets a non-empty `announce`. * * Reduced motion clamps the tile flip to nothing, so for a screen-reader user * the announcement IS the result. A missing one is not a degraded experience, * it is a blank page that claims to be showing you something. * * Tried dynamically first, under tsx, against the demo's own recorded traces — * a static check cannot see through a helper that builds the step object. When * the module will not load in bare Node (a `?raw` import, a Vite-only alias, * JSX pulling in something browser-shaped) it falls back to reading the source * and SAYS SO in the report rather than quietly downgrading. */ function checkAnnounce(slug, report, manifest) { const rule = 'rule 13 (every step announces itself)'; const dir = path.join(DEMOS_DIR, slug); const candidates = ['adapt.ts', 'adapter.ts', 'adapters.ts', 'adapt.tsx', 'demo.tsx'] .map((n) => path.join(dir, n)) .filter((p) => exists(p) && /\badapt\b\s*[:=(]|function\s+adapt\b/.test(read(p))); if (candidates.length === 0) { report.fail( rel(dir), rule, 'no `adapt` implementation found. DemoModule.adapt is required; it is what turns a recorded episode ' + 'into the steps the player renders.', ); return; } const traces = (manifest.byDemo.get(slug)?.runs ?? []) .map((run) => (run?.path ? traceFile(run.path) : null)) .filter((p) => p && exists(p)); if (traces.length > 0) { for (const module of candidates) { const result = probeAdapter(module, traces); if (result.loaded) { if (result.ok) { report.passed += 1; } else { for (const problem of result.problems) report.fail(rel(module), rule, problem); } return; } } } // Static fallback. It cannot prove `announce` is set on every step, only that // the adapter sets it at all and never sets it to nothing. const module = candidates[0]; const src = read(module); const assigns = /\bannounce\s*:/.test(src); report.check( assigns, rel(module), rule, 'the adapter never assigns `announce`. Every DemoStep must carry one — with motion reduced, it is the ' + 'only thing that reports the result.', ); if (assigns) { const empty = /\bannounce\s*:\s*(?:''|""|``|null|undefined)\s*[,}\n]/.test(src); report.check(!empty, rel(module), rule, 'the adapter assigns an empty `announce` somewhere. An empty announcement is a missing one.'); } report.staticOnly( `${slug}: rule 13 was checked by reading ${rel(module)}, not by running the adapter` + `${traces.length === 0 ? ' (no recorded traces to feed it)' : ' (the module would not load under tsx in bare Node)'}.`, ); } /** Runs one adapter over real episodes under tsx. Never throws. */ function probeAdapter(modulePath, traces) { const tsx = abs('node_modules', '.bin', 'tsx'); if (!exists(tsx)) return { loaded: false, reason: 'tsx is not installed' }; const probe = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'pig-adapt-')), 'probe.mts'); fs.writeFileSync(probe, adapterProbeSource(), 'utf8'); try { // The tsx bin is a POSIX shell shim, not a JS file: run it directly. const run = spawnSync(tsx, [probe, modulePath, ...traces], { cwd: abs('.'), encoding: 'utf8', timeout: 30_000, // tsx resolves the `@/` alias from a tsconfig, and the probe lives in // a temp dir where it would never find ours. env: { ...process.env, TSX_TSCONFIG_PATH: abs('tsconfig.json') }, }); if (run.status !== 0 || !run.stdout) return { loaded: false, reason: (run.stderr || '').trim().split('\n').slice(-1)[0] }; const line = run.stdout.trim().split('\n').pop(); const parsed = JSON.parse(line); return parsed.loaded === false ? { loaded: false, reason: parsed.reason } : { loaded: true, ...parsed }; } catch { return { loaded: false, reason: 'the probe did not produce parseable output' }; } finally { fs.rmSync(path.dirname(probe), { recursive: true, force: true }); } } /** * The probe, written to a temp file and executed by tsx. * * It lives here as a string rather than as a checked-in `.mts` because it is an * implementation detail of this script, and a stray TypeScript file in * `scripts/` would get swept into the typecheck it is deliberately outside of. */ function adapterProbeSource() { return ` import { readFileSync } from 'node:fs'; const [modulePath, ...traces] = process.argv.slice(2); const say = (o: unknown) => console.log(JSON.stringify(o)); let mod: any; try { mod = await import(modulePath); } catch (error: any) { say({ loaded: false, reason: String(error?.message ?? error).split('\\n')[0] }); process.exit(0); } const adapt = typeof mod.adapt === 'function' ? mod.adapt : typeof mod.default?.adapt === 'function' ? mod.default.adapt : typeof mod.default === 'function' ? mod.default : null; if (!adapt) { say({ loaded: false, reason: 'no callable \\\`adapt\\\` export' }); process.exit(0); } const problems: string[] = []; let steps = 0; for (const trace of traces) { const raw = JSON.parse(readFileSync(trace, 'utf8')); const episodes = Array.isArray(raw) ? raw : Array.isArray(raw?.episodes) ? raw.episodes : [raw]; for (const episode of episodes) { let produced: any; try { produced = adapt(episode); } catch (error: any) { problems.push(trace + ': adapt() threw ' + String(error?.message ?? error).split('\\n')[0]); continue; } if (!Array.isArray(produced)) { problems.push(trace + ': adapt() returned ' + typeof produced + ', not an array of DemoStep'); continue; } produced.forEach((step: any, i: number) => { steps += 1; if (typeof step?.announce !== 'string' || step.announce.trim() === '') { problems.push(trace + ': step ' + i + ' has an empty \\\`announce\\\`'); } }); } } say({ loaded: true, ok: problems.length === 0, problems: problems.slice(0, 10), steps }); `; }