5bbf913664
The environment page was a linear scroll of eight narrative beats. That is an essay, and it is the wrong shape for somebody who has just chosen an environment and wants to use it. It is now four tabs — Play, Watch, Reward, Evidence — opening on Play, with the board above the fold at 390x844 and the anatomy strip directly beneath it. The landing page leads with the picker instead of burying it under the thesis. The contract changed rather than layering tabs over beats. `Narrative.beats` is gone; `claims: Record<DemoTabId, string>` replaces it, one required sentence per tab. Writing the claim is how an author discovers whether a tab has anything to say — a tab whose claim is hard to write is usually a tab with nothing in it. Doing this now costs one migration; doing it after eleven more environments costs twelve. Tabs are derived, never declared: Play iff the demo ships an `interactive` mode, Watch iff it has recorded runs. A demo that could name its own tabs would mean environment seven inventing a fifth one and the site ceasing to be one product. One thing the browser caught that no gate would have. The header stat strip describes the RECORDED RUN, and on Play it sat above the visitor's own empty board reading "Outcome: failed" — which parses as your game having already failed before you touch a key. It now renders only on the tabs whose subject is that run, which also moved the board 54px up the page. The picker is honest about the shape of the lineup by construction: one built environment gets its own block and the demo's real board as its thumbnail, twelve written specifications render dimmed with a Spec badge, and every count on the page is derived from the data rather than typed. 206 contract checks pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019mt6sHQHEnEYrJZvoMCJSB
916 lines
36 KiB
JavaScript
916 lines
36 KiB
JavaScript
#!/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(`(?<![\\w-])${slug.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?![\\w-])`);
|
|
const hit = lines.findIndex((line) => 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 });
|
|
`;
|
|
}
|