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.
- package/README.md +215 -0
- package/package.json +64 -0
- package/public/fonts/Handjet-variable.woff2 +0 -0
- package/remotion.config.ts +14 -0
- package/scripts/check-legibility.ts +284 -0
- package/scripts/check-safe-area.ts +148 -0
- package/scripts/custom-fonts.ts +114 -0
- package/scripts/export.sh +31 -0
- package/scripts/field-images.ts +93 -0
- package/scripts/ig-probe.ts +83 -0
- package/scripts/ig-sync.ts +204 -0
- package/scripts/ingest.sh +34 -0
- package/scripts/library.ts +143 -0
- package/scripts/look-card.ts +107 -0
- package/scripts/look-store.ts +176 -0
- package/scripts/make-card.ts +237 -0
- package/scripts/merge-index.ts +74 -0
- package/scripts/new-episode.ts +149 -0
- package/scripts/overlay-worker.ts +1119 -0
- package/scripts/place-overlay.ts +359 -0
- package/scripts/prep-card.ts +73 -0
- package/scripts/quality.ts +0 -0
- package/scripts/render-overlay.ts +150 -0
- package/scripts/report.ts +127 -0
- package/scripts/rerender-cards.ts +116 -0
- package/scripts/series.ts +816 -0
- package/scripts/set-difficulty.ts +62 -0
- package/scripts/state-dir.ts +102 -0
- package/scripts/stock.ts +254 -0
- package/scripts/verify.ts +149 -0
- package/scripts/wp-restock.ts +281 -0
- package/src/Root.tsx +112 -0
- package/src/index.css +1 -0
- package/src/index.ts +4 -0
- package/src/lab/FontLab.tsx +50 -0
- package/src/lab/FontSheet.tsx +188 -0
- package/src/lab/PillLab.tsx +121 -0
- package/src/overlay/Composition.tsx +297 -0
- package/src/overlay/DifficultyMeter.tsx +86 -0
- package/src/overlay/PixelText.tsx +134 -0
- package/src/overlay/Title.tsx +75 -0
- package/src/overlay/brandFonts.ts +58 -0
- package/src/overlay/cardLayout.ts +94 -0
- package/src/overlay/fonts.ts +19 -0
- package/src/overlay/look.ts +155 -0
- package/src/overlay/safeArea.ts +89 -0
- package/src/overlay/types.ts +134 -0
- package/src/series/what-prints/CodeCard.tsx +107 -0
- package/src/series/what-prints/Composition.tsx +106 -0
- package/src/series/what-prints/codeCardTypes.ts +105 -0
- package/src/series/what-prints/types.ts +22 -0
- package/tsconfig.json +17 -0
- package/worker/cli.mjs +151 -0
- 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
|
+
}
|
package/scripts/stock.ts
ADDED
|
@@ -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();
|