Files
PIG-Demo/scripts/check-demos.mjs
T
karti-ai 408ce4a525 Capture harness, fixture verification, CI, and the public README
The site does no live inference. Rollouts are captured once against spark-1 and
replayed at their recorded wall-clock — a public demo with no auth cannot hold
an API key, and a recorded run can be scrubbed, permalinked, blind-compared and
verified in ways a live one cannot. What stops it being a video is that the
browser re-derives every number from the recorded moves.

verify_fixtures.py is the Python half of that: it replays every committed
fixture through the engine and reproduces its own rewards. All 16 land at
delta 0.0. A fixture that cannot be regenerated is a claim with no receipt.

First real measurement, thinking off, 8 seeds: solved 0/8. The model repeats
guesses it has already played, invents words (trape, slith, postt, boomy),
and contradicts its own feedback — consistency 0.09 to 0.17. That is the
published failure taxonomy showing up in our own data on the first run, and it
is why `consistency` is a reward component rather than a footnote.

A capture failure is recorded as a turn with a null reply, never dropped. A
capture that silently discarded failed turns would be reporting a better model
than the one that ran.

CI gates both halves and four things that fail silently in production: the word
lists must rebuild byte-identically, the prerendered routes must carry their own
baked og tags (crawlers do not run JS, so without them every shared link
previews as the homepage), no blob: URL may reach the bundle (the site's CSP has
no worker-src, so it falls back to default-src 'self' and a blob worker is
blocked with no error), and the conformance digest must match across languages.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019mt6sHQHEnEYrJZvoMCJSB
2026-08-28 15:47:31 -07:00

598 lines
24 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, and that the reward has
* something pulling against its objective.
*
* Thirteen 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,
isUnresolved,
loadManifest,
loadMeta,
parseStringUnion,
read,
rel,
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 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)}.`);
/**
* 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 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*/,
];
/* ------------------------------------------------------------- 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.`);
}
/* -- 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) => /\bsolve[_R]?ate|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);
}
/* ---------------------------------------- 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 */
/** 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 {
const run = spawnSync(process.execPath, [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 });
`;
}