Files
Lucas WintherandClaude Opus 5.5 638549161f
CI / Tool tests and sync-check (push) Successful in 13s
CI / Verify references (all modules, Rust and TS) (push) Successful in 1m32s
CI / Verify Expansion Drop solutions (all modules, Rust and TS) (push) Successful in 1m35s
CI / Coupling audit on references (push) Successful in 13s
CI / Site build (push) Successful in 25s
CI / Naive engine tests (Module 0) (push) Successful in 32s
feat(ci): verify Expansion Drop solutions; README quick start, disclaimer link; Rust reference lockfiles
- verify-references --expansion overlays expansion/solution on each reference and runs the
  cumulative tests plus the drop test; refuses a solution that touches src/engine/
- CI job verify-expansions
- README: workspace-template + git init step, tool list, repo disclaimer link
- Cargo.lock for every Rust reference (staged with the reference, pins serde for rustc 1.85.1)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 19:13:11 +01:00

122 lines
5.4 KiB
JavaScript

#!/usr/bin/env node
// For each module <= N with a reference/: copy reference + tests of modules 01..module into a temp dir, run tests.
// The card data (loaded by the references from Module 5) is staged as <dir>/cards: course/cards
// from Module 5 on (Modules 1-4 keep their cards in code), plus the cards/ of each module up to
// the one verified. Each tested module's levels/ (the puzzle levels its tests load, from
// Module 10) is staged as <dir>/levels.
//
// node tools/verify-references.mjs [NN] [--expansion]
//
// --expansion checks each module's Expansion Drop solution instead: the reference is staged as
// above, then the module's expansion/ is overlaid (see overlayExpansion) and the cumulative tests
// run together with the drop's own test. Modules without an expansion/solution are skipped.
import { cpSync, existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync, statSync } from "node:fs";
import { spawnSync } from "node:child_process";
import { tmpdir } from "node:os";
import { basename, join, relative } from "node:path";
import { cardDirs, moduleDirs, testFiles, isMain } from "./lib.mjs";
// A staged reference has no node_modules, so TS installs the reference's own
// pinned devDependencies first (a bare `npx vitest` would fetch an unrelated version).
const DEFAULTS = {
rust: "cargo test",
ts: "npm install --no-audit --no-fund --loglevel=error && npx vitest run",
};
export function commandFor(lang, env = process.env) {
return env[`VERIFY_CMD_${lang.toUpperCase()}`] || DEFAULTS[lang];
}
export function planVerification(mods, upTo) {
return mods
.filter((m) => Number(m.num) >= 1 && Number(m.num) <= upTo)
.map((m) => ({
...m,
langs: ["rust", "ts"].filter((l) => existsSync(join(m.path, "reference", l))),
testModules: mods.filter((t) => Number(t.num) >= 1 && Number(t.num) <= Number(m.num)),
}))
.filter((p) => p.langs.length > 0);
}
/** Files under `dir`, as paths relative to it ("/"-separated). */
function files(dir, root = dir, out = []) {
for (const e of readdirSync(dir)) {
const p = join(dir, e);
statSync(p).isDirectory() ? files(p, root, out) : out.push(relative(root, p).split("\\").join("/"));
}
return out;
}
/** Does this module's Expansion Drop have a solution to check for `lang`? */
export function hasExpansion(plan, lang) {
const exp = join(plan.path, "expansion");
return existsSync(join(exp, "solution")) && testFiles(exp, lang).length > 0;
}
/**
* Overlay a module's Expansion Drop solution onto a staged reference, as a learner applies it:
* expansion/solution/<lang>/ over the reference root, solution/cards/ into cards/,
* solution/words.json and solution/seams.json into src/content/, and the drop's test into tests/.
* Throws if the solution touches src/engine/ (a drop changes content only).
*/
export function overlayExpansion(plan, lang, dir) {
const sol = join(plan.path, "expansion", "solution");
const code = join(sol, lang);
if (existsSync(code)) {
const engine = files(code).filter((f) => f.startsWith("src/engine/"));
if (engine.length) throw new Error(`${plan.num} expansion solution changes the engine: ${engine.join(", ")}`);
cpSync(code, dir, { recursive: true });
}
if (existsSync(join(sol, "cards"))) cpSync(join(sol, "cards"), join(dir, "cards"), { recursive: true });
for (const f of ["words.json", "seams.json"]) {
if (existsSync(join(sol, f))) cpSync(join(sol, f), join(dir, "src", "content", f));
}
for (const f of testFiles(join(plan.path, "expansion"), lang)) cpSync(f, join(dir, "tests", basename(f)));
return dir;
}
export function stage(plan, lang) {
const dir = mkdtempSync(join(tmpdir(), `verify-${plan.num}-${lang}-`));
cpSync(join(plan.path, "reference", lang), dir, { recursive: true });
mkdirSync(join(dir, "tests"), { recursive: true });
for (const t of plan.testModules) {
for (const f of testFiles(t.path, lang)) cpSync(f, join(dir, "tests", basename(f)));
if (existsSync(join(t.path, "levels"))) cpSync(join(t.path, "levels"), join(dir, "levels"), { recursive: true });
}
for (const cards of cardDirs(plan.num)) cpSync(cards, join(dir, "cards"), { recursive: true });
return dir;
}
if (isMain(import.meta.url)) {
const args = process.argv.slice(2);
const expansion = args.includes("--expansion");
const num = args.find((a) => a !== "--expansion");
const all = moduleDirs();
const upTo = num ? Number(num) : Math.max(0, ...all.map((m) => Number(m.num)));
const plans = planVerification(all, upTo);
if (!plans.length) console.log("verify-references: no modules with reference/ up to " + upTo);
let failed = false;
for (const p of plans) {
for (const lang of p.langs) {
if (expansion && !hasExpansion(p, lang)) {
console.log(`module ${p.num}-${p.slug} [${lang}] expansion: skipped (no expansion solution)`);
continue;
}
const dir = stage(p, lang);
let r;
try {
if (expansion) overlayExpansion(p, lang, dir);
r = spawnSync(commandFor(lang), { cwd: dir, shell: true, encoding: "utf8" });
} catch (e) {
r = { status: 1, stdout: "", stderr: String(e.message) };
}
const ok = r.status === 0;
failed ||= !ok;
console.log(`module ${p.num}-${p.slug} [${lang}]${expansion ? " expansion" : ""}: ${ok ? "ok" : "FAIL"}`);
if (!ok) console.log((r.stdout || "") + (r.stderr || ""));
rmSync(dir, { recursive: true, force: true });
}
}
if (failed) process.exit(1);
}