overlay-factory-worker 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/README.md +215 -0
  2. package/package.json +64 -0
  3. package/public/fonts/Handjet-variable.woff2 +0 -0
  4. package/remotion.config.ts +14 -0
  5. package/scripts/check-legibility.ts +284 -0
  6. package/scripts/check-safe-area.ts +148 -0
  7. package/scripts/custom-fonts.ts +114 -0
  8. package/scripts/export.sh +31 -0
  9. package/scripts/field-images.ts +93 -0
  10. package/scripts/ig-probe.ts +83 -0
  11. package/scripts/ig-sync.ts +204 -0
  12. package/scripts/ingest.sh +34 -0
  13. package/scripts/library.ts +143 -0
  14. package/scripts/look-card.ts +107 -0
  15. package/scripts/look-store.ts +176 -0
  16. package/scripts/make-card.ts +237 -0
  17. package/scripts/merge-index.ts +74 -0
  18. package/scripts/new-episode.ts +149 -0
  19. package/scripts/overlay-worker.ts +1119 -0
  20. package/scripts/place-overlay.ts +359 -0
  21. package/scripts/prep-card.ts +73 -0
  22. package/scripts/quality.ts +0 -0
  23. package/scripts/render-overlay.ts +150 -0
  24. package/scripts/report.ts +127 -0
  25. package/scripts/rerender-cards.ts +116 -0
  26. package/scripts/series.ts +816 -0
  27. package/scripts/set-difficulty.ts +62 -0
  28. package/scripts/state-dir.ts +102 -0
  29. package/scripts/stock.ts +254 -0
  30. package/scripts/verify.ts +149 -0
  31. package/scripts/wp-restock.ts +281 -0
  32. package/src/Root.tsx +112 -0
  33. package/src/index.css +1 -0
  34. package/src/index.ts +4 -0
  35. package/src/lab/FontLab.tsx +50 -0
  36. package/src/lab/FontSheet.tsx +188 -0
  37. package/src/lab/PillLab.tsx +121 -0
  38. package/src/overlay/Composition.tsx +297 -0
  39. package/src/overlay/DifficultyMeter.tsx +86 -0
  40. package/src/overlay/PixelText.tsx +134 -0
  41. package/src/overlay/Title.tsx +75 -0
  42. package/src/overlay/brandFonts.ts +58 -0
  43. package/src/overlay/cardLayout.ts +94 -0
  44. package/src/overlay/fonts.ts +19 -0
  45. package/src/overlay/look.ts +155 -0
  46. package/src/overlay/safeArea.ts +89 -0
  47. package/src/overlay/types.ts +134 -0
  48. package/src/series/what-prints/CodeCard.tsx +107 -0
  49. package/src/series/what-prints/Composition.tsx +106 -0
  50. package/src/series/what-prints/codeCardTypes.ts +105 -0
  51. package/src/series/what-prints/types.ts +22 -0
  52. package/tsconfig.json +17 -0
  53. package/worker/cli.mjs +151 -0
  54. package/worker/service.mjs +404 -0
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Stamp a difficulty onto each episode in index.json.
3
+ *
4
+ * Ratings reflect how many people get the answer wrong, not how obscure the
5
+ * feature is: "easy" = most devs get it, "medium" = trips juniors, "hard" =
6
+ * trips seniors or has no single right answer.
7
+ *
8
+ * Edit the map and re-run to adjust; new-episode.ts reads these values.
9
+ */
10
+ import { readFileSync, writeFileSync } from "node:fs";
11
+ import { join } from "node:path";
12
+
13
+ const INDEX = join(import.meta.dirname, "../episodes/index.json");
14
+
15
+ const DIFFICULTY: Record<string, "easy" | "medium" | "hard"> = {
16
+ "Donkey Kong Loop Chaos": "hard",
17
+ "Bowser's Type Trap": "easy",
18
+ "Mario's Inventory Bug": "medium",
19
+ "Toad's Lambda Factory": "hard",
20
+ "Luigi's Async Coin Collector": "medium",
21
+ "Boo's Equality Test": "medium",
22
+ "Mario's Copy Spell": "medium",
23
+ "Donkey Kong's Barrel Cleanup": "hard",
24
+ "Peach's Castle Registry": "easy",
25
+ "Star Power Scope": "medium",
26
+ "Warp Pipe Index": "easy",
27
+ "Koopa API Question": "medium",
28
+ "The Copy That Wasn't": "easy",
29
+ "The Thread Safety Debate": "hard",
30
+ "The Async Lie": "hard",
31
+ "Identity Crisis": "medium",
32
+ "The Sequel": "medium",
33
+ "The Finally Clause": "hard",
34
+ "The Floating Point War": "easy",
35
+ "Copy Machine": "medium",
36
+ "Equality vs Identity": "easy",
37
+ "The GIL Myth": "hard",
38
+ "NaN Logic": "medium",
39
+ "Async Doesn't Mean Async": "hard",
40
+ "The Timezone Trap": "hard",
41
+ "The API Retry Debate": "hard",
42
+ "The Survivor": "hard",
43
+ };
44
+
45
+ type Entry = { name: string; difficulty?: string };
46
+ const index = JSON.parse(readFileSync(INDEX, "utf8")) as Entry[];
47
+
48
+ let stamped = 0;
49
+ const missing: string[] = [];
50
+ for (const e of index) {
51
+ const d = DIFFICULTY[e.name];
52
+ if (d) {
53
+ e.difficulty = d;
54
+ stamped++;
55
+ } else {
56
+ missing.push(e.name);
57
+ }
58
+ }
59
+
60
+ writeFileSync(INDEX, JSON.stringify(index, null, 2) + "\n");
61
+ console.log(`Stamped difficulty on ${stamped}/${index.length} episodes.`);
62
+ if (missing.length) console.log(` no rating for: ${missing.join(", ")}`);
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Where this machine's Overlay Factory state lives.
3
+ *
4
+ * In a git checkout — the way this repo has always run — state stays in the
5
+ * repo: episodes/, puzzles/, series/, public/assets/, public/fonts/custom/.
6
+ * All of those are already gitignored, because they were always per-machine.
7
+ *
8
+ * Installed from npm, the code lives under ~/.goosetools/overlay-app/
9
+ * node_modules/, which `update` deletes and reinstalls. Anything written there
10
+ * is lost on the next release — the learned looks, the puzzle library, the
11
+ * uploaded fonts, the record of which cards have been used. So state moves to
12
+ * ~/.goosetools/overlay/ and the package directory gets symlinks pointing at
13
+ * it.
14
+ *
15
+ * Symlinks rather than a path refactor on purpose: `staticFile("assets/...")`
16
+ * in the compositions, and every join(ROOT, ...) in these scripts, resolve to
17
+ * exactly what they always did. Remotion bundles from the package directory
18
+ * and sees a normal public/ folder. Nothing downstream needs to know which
19
+ * mode it's in.
20
+ */
21
+ import {
22
+ existsSync,
23
+ lstatSync,
24
+ mkdirSync,
25
+ readdirSync,
26
+ realpathSync,
27
+ rmSync,
28
+ symlinkSync,
29
+ } from "node:fs";
30
+ import { homedir } from "node:os";
31
+ import { dirname, join } from "node:path";
32
+
33
+ const ROOT = join(__dirname, "..");
34
+
35
+ /** A checkout has .git; an npm install doesn't (npm never packs it). */
36
+ export const isCheckout = existsSync(join(ROOT, ".git"));
37
+
38
+ export const STATE_ROOT = isCheckout
39
+ ? ROOT
40
+ : join(homedir(), ".goosetools", "overlay");
41
+
42
+ /** [path inside the package, path inside the state dir] */
43
+ const LINKS: [string, string][] = [
44
+ ["episodes", "episodes"],
45
+ ["puzzles", "puzzles"],
46
+ ["series", "series"],
47
+ [join("public", "assets"), "assets"],
48
+ [join("public", "fonts", "custom"), join("fonts", "custom")],
49
+ ];
50
+
51
+ /**
52
+ * Point the package's writable directories at the state dir. Idempotent, and
53
+ * a no-op in a checkout. Safe to call on every worker start — which is what
54
+ * happens, because `update` replaces the package directory and takes the
55
+ * previous run's symlinks with it.
56
+ */
57
+ export function ensureStateLinks(): void {
58
+ if (isCheckout) return;
59
+ for (const [rel, sub] of LINKS) {
60
+ const target = join(STATE_ROOT, sub);
61
+ const link = join(ROOT, rel);
62
+ mkdirSync(target, { recursive: true });
63
+ mkdirSync(dirname(link), { recursive: true });
64
+
65
+ if (existsSync(link) || isDanglingLink(link)) {
66
+ const stat = lstatSync(link);
67
+ if (stat.isSymbolicLink()) {
68
+ // Already ours? Leave it. Pointing somewhere else (an older layout)?
69
+ // Replace it — a stale link is how state silently splits in two.
70
+ if (safeRealpath(link) === target) continue;
71
+ rmSync(link);
72
+ } else if (readdirSync(link).length > 0) {
73
+ // A non-empty real directory shipped in the tarball. Linking over it
74
+ // would hide files the render needs, so leave it and say so once.
75
+ console.warn(
76
+ `[state] ${rel} is a real directory in the package — not linking it to ${target}.`,
77
+ );
78
+ continue;
79
+ } else {
80
+ rmSync(link, { recursive: true });
81
+ }
82
+ }
83
+ symlinkSync(target, link, "dir");
84
+ }
85
+ }
86
+
87
+ function isDanglingLink(p: string): boolean {
88
+ try {
89
+ lstatSync(p);
90
+ return true;
91
+ } catch {
92
+ return false;
93
+ }
94
+ }
95
+
96
+ function safeRealpath(p: string): string | null {
97
+ try {
98
+ return realpathSync(p);
99
+ } catch {
100
+ return null;
101
+ }
102
+ }
@@ -0,0 +1,254 @@
1
+ /**
2
+ * Episode stock: the queue of things a series still has to say.
3
+ *
4
+ * A series defines two separate things, and conflating them is why only What
5
+ * Prints? could ever restock itself:
6
+ *
7
+ * how it LOOKS series.json "layers" + the HTML templates
8
+ * what it SAYS series.json "content" + episodes.json
9
+ *
10
+ * "content" is a brief — what one episode is, where to research it, what makes
11
+ * a bad one. episodes.json is the queue that brief produced. An overlay job
12
+ * with no inputs pops the next unused entry, exactly as What Prints? takes the
13
+ * next unused card from episodes/index.json.
14
+ *
15
+ * Restocking runs Claude on the user's own machine with WebSearch, the same
16
+ * arrangement the Brand Outreach worker already uses to research brands. The
17
+ * research is the point: a series like "Interviewer:" is only worth watching if
18
+ * the questions are ones people actually got asked, and that means going and
19
+ * finding them rather than inventing plausible-sounding ones.
20
+ */
21
+ import { execFile } from "node:child_process";
22
+ import {
23
+ existsSync,
24
+ mkdirSync,
25
+ readFileSync,
26
+ writeFileSync,
27
+ } from "node:fs";
28
+ import { join } from "node:path";
29
+ import { promisify } from "node:util";
30
+ import type { SeriesField } from "./series";
31
+
32
+ const execFileAsync = promisify(execFile);
33
+ const ROOT = join(__dirname, "..");
34
+ const SERIES_DIR = join(ROOT, "series");
35
+
36
+ /** How a series sources what it says. Absent = the series can't restock. */
37
+ export type ContentBrief = {
38
+ /** One sentence: what a single episode of this series IS. */
39
+ what: string;
40
+ /** Where to look, and what makes a good find. Drives the research pass. */
41
+ research?: string[];
42
+ /** Hard rules — what would make an episode wrong, not merely weak. */
43
+ rules?: string[];
44
+ };
45
+
46
+ export type Episode = {
47
+ /** Stable id, so "used" survives reordering and re-writes. */
48
+ id: string;
49
+ /** Field values, keyed by the series' own field keys. */
50
+ values: Record<string, string>;
51
+ /** Where this came from — a URL, a forum, or "written". Never invented. */
52
+ source?: string;
53
+ usedBy?: string;
54
+ };
55
+
56
+ type Store = { episodes: Episode[] };
57
+
58
+ const storePath = (slug: string) => join(SERIES_DIR, slug, "episodes.json");
59
+
60
+ const readStore = (slug: string): Store => {
61
+ const p = storePath(slug);
62
+ if (!existsSync(p)) return { episodes: [] };
63
+ try {
64
+ const parsed = JSON.parse(readFileSync(p, "utf8"));
65
+ return Array.isArray(parsed?.episodes) ? parsed : { episodes: [] };
66
+ } catch {
67
+ return { episodes: [] };
68
+ }
69
+ };
70
+
71
+ const writeStore = (slug: string, store: Store) => {
72
+ mkdirSync(join(SERIES_DIR, slug), { recursive: true });
73
+ writeFileSync(storePath(slug), JSON.stringify(store, null, 2) + "\n");
74
+ };
75
+
76
+ export const stockCount = (slug: string): number =>
77
+ readStore(slug).episodes.filter((e) => !e.usedBy).length;
78
+
79
+ /** The next unused episode, or null when the series is dry. */
80
+ export const nextEpisode = (slug: string): Episode | null =>
81
+ readStore(slug).episodes.find((e) => !e.usedBy) ?? null;
82
+
83
+ export const markEpisodeUsed = (slug: string, id: string, label: string) => {
84
+ const store = readStore(slug);
85
+ const found = store.episodes.find((e) => e.id === id);
86
+ if (!found) return;
87
+ found.usedBy = label;
88
+ writeStore(slug, store);
89
+ };
90
+
91
+ /**
92
+ * Research and write new episodes for a series.
93
+ *
94
+ * Two things are enforced rather than trusted, because both are the difference
95
+ * between a series worth watching and one that quietly makes things up:
96
+ *
97
+ * - every required field must be present, or the episode can't render
98
+ * - anything the brief says is sourced must carry a real URL. A model asked
99
+ * for "questions people were actually asked" will happily produce plausible
100
+ * inventions; requiring the link is what makes the claim checkable.
101
+ */
102
+ export const restock = async (opts: {
103
+ slug: string;
104
+ seriesName: string;
105
+ fields: SeriesField[];
106
+ content: ContentBrief;
107
+ count: number;
108
+ }): Promise<Episode[]> => {
109
+ const existing = readStore(opts.slug).episodes;
110
+ const seen = existing.map((e) => Object.values(e.values).join(" · "));
111
+
112
+ const wantsSources = Boolean(opts.content.research?.length);
113
+
114
+ const prompt = `You are writing new episodes for a short-form video series.
115
+
116
+ ## The series
117
+ ${opts.seriesName}
118
+
119
+ ## What one episode is
120
+ ${opts.content.what}
121
+
122
+ ## The fields an episode must fill
123
+ ${opts.fields
124
+ .map(
125
+ (f) =>
126
+ `- "${f.key}" (${f.type}${f.required ? ", REQUIRED" : ""}): ${f.label}` +
127
+ (f.options ? ` — must be one of: ${f.options.join(", ")}` : "") +
128
+ (f.placeholder ? ` — e.g. "${f.placeholder}"` : ""),
129
+ )
130
+ .join("\n")}
131
+
132
+ ${
133
+ wantsSources
134
+ ? `## Research — do this before writing anything
135
+ ${opts.content.research!.map((r) => `- ${r}`).join("\n")}
136
+
137
+ Use WebSearch and WebFetch. Every episode you return MUST carry a "source" URL
138
+ you actually visited and that actually contains it. Do NOT invent a source, do
139
+ NOT reuse a search-results page as a source, and do NOT attribute something to a
140
+ company unless the page you found says so. If you can only find ${opts.count > 3 ? "a few" : "one or two"} real ones,
141
+ return fewer episodes — a short honest list beats a padded invented one.`
142
+ : `## No research needed
143
+ Write these from your own knowledge. Set "source" to "written".`
144
+ }
145
+
146
+ ${
147
+ opts.content.rules?.length
148
+ ? `## Rules (breaking one makes the episode wrong, not just weak)
149
+ ${opts.content.rules.map((r) => `- ${r}`).join("\n")}`
150
+ : ""
151
+ }
152
+
153
+ ${
154
+ seen.length
155
+ ? `## Already in the library — do NOT repeat these or near-duplicates
156
+ ${seen.slice(-40).map((s) => `- ${s}`).join("\n")}`
157
+ : ""
158
+ }
159
+
160
+ ## Also remember
161
+ This is burned onto a vertical video and read on a phone at arm's length.
162
+ Keep every value short and punchy — a value that runs long gets shrunk to fit
163
+ and stops being readable. No markdown, no quotes around the whole value.
164
+
165
+ ## Output — a JSON array and NOTHING else, no commentary, no code fence
166
+ [
167
+ { "values": { ${opts.fields.map((f) => `"${f.key}": "..."`).join(", ")} }, "source": "https://…" }
168
+ ]
169
+
170
+ Return at most ${opts.count}.`;
171
+
172
+ const { stdout } = await execFileAsync(
173
+ "claude",
174
+ [
175
+ "-p",
176
+ prompt,
177
+ "--output-format",
178
+ "text",
179
+ "--allowedTools",
180
+ wantsSources ? "WebSearch,WebFetch,Read" : "Read",
181
+ ],
182
+ { maxBuffer: 20 * 1024 * 1024, timeout: 20 * 60 * 1000 },
183
+ );
184
+
185
+ // Models like to wrap JSON in prose or a fence however firmly asked not to.
186
+ const text = stdout.trim();
187
+ const start = text.indexOf("[");
188
+ const end = text.lastIndexOf("]");
189
+ if (start === -1 || end === -1) {
190
+ throw new Error("No JSON array in the research output");
191
+ }
192
+
193
+ let raw: { values?: Record<string, string>; source?: string }[];
194
+ try {
195
+ raw = JSON.parse(text.slice(start, end + 1));
196
+ } catch (e) {
197
+ throw new Error(`Research output was not valid JSON: ${(e as Error).message}`);
198
+ }
199
+
200
+ const required = opts.fields.filter((f) => f.required).map((f) => f.key);
201
+ const seenSet = new Set(seen);
202
+ const accepted: Episode[] = [];
203
+
204
+ for (const [i, item] of raw.entries()) {
205
+ const values = item?.values;
206
+ if (!values || typeof values !== "object") continue;
207
+
208
+ const missing = required.filter((k) => !String(values[k] ?? "").trim());
209
+ if (missing.length) {
210
+ console.warn(` ! episode ${i + 1} dropped: missing ${missing.join(", ")}`);
211
+ continue;
212
+ }
213
+
214
+ // A select field with an off-list value renders as nothing.
215
+ let badOption = false;
216
+ for (const f of opts.fields) {
217
+ if (f.options && values[f.key] && !f.options.includes(values[f.key])) {
218
+ console.warn(
219
+ ` ! episode ${i + 1} dropped: ${f.key}="${values[f.key]}" is not one of ${f.options.join(", ")}`,
220
+ );
221
+ badOption = true;
222
+ }
223
+ }
224
+ if (badOption) continue;
225
+
226
+ if (wantsSources && !/^https?:\/\//.test(item.source ?? "")) {
227
+ console.warn(` ! episode ${i + 1} dropped: no real source URL`);
228
+ continue;
229
+ }
230
+
231
+ const key = Object.values(values).join(" · ");
232
+ if (seenSet.has(key)) {
233
+ console.warn(` ! episode ${i + 1} dropped: duplicate`);
234
+ continue;
235
+ }
236
+ seenSet.add(key);
237
+
238
+ accepted.push({
239
+ // Deterministic id: no clock or randomness, and re-running can't collide.
240
+ id: `${opts.slug}-${existing.length + accepted.length + 1}`,
241
+ values: Object.fromEntries(
242
+ Object.entries(values).map(([k, v]) => [k, String(v).trim()]),
243
+ ),
244
+ source: item.source ?? "written",
245
+ });
246
+ }
247
+
248
+ if (accepted.length === 0) {
249
+ throw new Error("Nothing usable came back — every episode failed a check");
250
+ }
251
+
252
+ writeStore(opts.slug, { episodes: [...existing, ...accepted] });
253
+ return accepted;
254
+ };
@@ -0,0 +1,149 @@
1
+ /**
2
+ * Run a snippet for real and compare its output to the puzzle's stated answer.
3
+ *
4
+ * A "What Prints?" card whose answer is wrong is the worst thing this pipeline
5
+ * can ship — it's wrong in public, under a caption explaining why. So every card
6
+ * is executed before it renders, and a mismatch is a hard failure.
7
+ *
8
+ * Standalone audit of everything already in the library:
9
+ * npx tsx scripts/verify.ts
10
+ */
11
+ import { execFileSync } from "node:child_process";
12
+ import { readFileSync } from "node:fs";
13
+ import { join } from "node:path";
14
+
15
+ const ROOT = join(import.meta.dirname, "..");
16
+
17
+ type Runner = { cmd: string; args: (code: string) => string[] };
18
+
19
+ /** Languages we can actually execute. Anything else can't be auto-verified. */
20
+ const RUNNERS: Record<string, Runner> = {
21
+ python: { cmd: "python3", args: (c) => ["-c", c] },
22
+ javascript: { cmd: "node", args: (c) => ["-e", c] },
23
+ node: { cmd: "node", args: (c) => ["-e", c] },
24
+ };
25
+
26
+ export type VerifyResult =
27
+ | { status: "ok"; output: string }
28
+ | { status: "loose"; output: string; expected: string }
29
+ | { status: "mismatch"; output: string; expected: string }
30
+ | { status: "error"; output: string }
31
+ | { status: "skipped"; reason: string };
32
+
33
+ /** Trailing whitespace and blank lines shouldn't count as a difference. */
34
+ const normalize = (s: string) =>
35
+ s
36
+ .replace(/\r\n/g, "\n")
37
+ .split("\n")
38
+ .map((l) => l.trimEnd())
39
+ .join("\n")
40
+ .trim();
41
+
42
+ export const runSnippet = (code: string, lang: string) => {
43
+ const runner = RUNNERS[lang.toLowerCase()];
44
+ if (!runner) return null;
45
+ try {
46
+ const out = execFileSync(runner.cmd, runner.args(code), {
47
+ encoding: "utf8",
48
+ timeout: 10_000,
49
+ stdio: ["ignore", "pipe", "pipe"],
50
+ });
51
+ return { output: out, threw: false };
52
+ } catch (e) {
53
+ const err = e as { stdout?: string; stderr?: string };
54
+ // A snippet whose answer IS the traceback still "prints" something.
55
+ return { output: (err.stdout ?? "") + (err.stderr ?? ""), threw: true };
56
+ }
57
+ };
58
+
59
+ /**
60
+ * The written answers use display conventions the interpreter doesn't: object
61
+ * ids are elided, long reprs get pretty-printed onto several lines, and a
62
+ * traceback is quoted as just its exception line under a "Raises:" label.
63
+ * Fold all of that away before deciding something is actually wrong.
64
+ */
65
+ const loosen = (s: string) =>
66
+ s
67
+ .replace(/0x[0-9a-f]+/gi, "0x") // memory addresses vary per run
68
+ .replace(/<(\w+) object[^>]*>/g, "<$1 object>") // <coroutine object f at 0x..>
69
+ .replace(/^raises:?\s*/i, "")
70
+ .replace(/\s+/g, " ") // pretty-printing vs one line
71
+ .replace(/([[({])\s+|\s+([\])}])/g, "$1$2") // ...and its padding
72
+ .trim();
73
+
74
+ export const verify = (
75
+ code: string,
76
+ lang: string,
77
+ answer: string | null | undefined,
78
+ ): VerifyResult => {
79
+ const run = runSnippet(code, lang);
80
+ if (!run) return { status: "skipped", reason: `no runner for ${lang}` };
81
+ if (!answer) return { status: "skipped", reason: "no answer stated" };
82
+
83
+ const actual = normalize(run.output);
84
+ const expected = normalize(answer);
85
+ if (actual === expected) return { status: "ok", output: actual };
86
+
87
+ const [a, e] = [loosen(actual), loosen(expected)];
88
+ // A thrown snippet passes if the stated exception line shows up in stderr.
89
+ if (a === e || (run.threw && e && a.includes(e)))
90
+ return { status: "loose", output: actual, expected };
91
+
92
+ if (run.threw && !expected) return { status: "error", output: actual };
93
+ return { status: "mismatch", output: actual, expected };
94
+ };
95
+
96
+ /** Audit mode: check every entry in episodes/index.json. */
97
+ const main = () => {
98
+ const index = JSON.parse(
99
+ readFileSync(join(ROOT, "episodes/index.json"), "utf8"),
100
+ ) as {
101
+ card: string;
102
+ name: string;
103
+ code: string;
104
+ answer: string | null;
105
+ language?: string;
106
+ format?: string; // "debate" cards pose an opinion, not an output
107
+ }[];
108
+ const cards = JSON.parse(
109
+ readFileSync(join(ROOT, "episodes/cards.json"), "utf8"),
110
+ ) as { card: string; language: string }[];
111
+ const langOf = (card: string) =>
112
+ cards.find((c) => c.card === card)?.language ?? "python";
113
+
114
+ let ok = 0,
115
+ loose = 0,
116
+ bad = 0,
117
+ skipped = 0;
118
+ for (const e of index) {
119
+ if (e.format === "debate") {
120
+ skipped++;
121
+ console.log(`SKIP ${e.name} — debate card, no printed output`);
122
+ continue;
123
+ }
124
+ const r = verify(e.code, langOf(e.card), e.answer);
125
+ if (r.status === "ok") {
126
+ ok++;
127
+ continue;
128
+ }
129
+ if (r.status === "loose") {
130
+ loose++;
131
+ console.log(`~ok ${e.name} — matches after normalizing formatting`);
132
+ continue;
133
+ }
134
+ if (r.status === "skipped") {
135
+ skipped++;
136
+ console.log(`SKIP ${e.name} — ${r.reason}`);
137
+ continue;
138
+ }
139
+ bad++;
140
+ console.log(`\nBAD ${e.name} (${e.card})`);
141
+ console.log(` stated: ${JSON.stringify(e.answer)}`);
142
+ console.log(` actual: ${JSON.stringify(r.output)}`);
143
+ }
144
+ console.log(
145
+ `\n${ok} exact, ${loose} match after normalizing, ${bad} wrong, ${skipped} skipped`,
146
+ );
147
+ };
148
+
149
+ if (process.argv[1]?.endsWith("verify.ts")) main();