#!/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 /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 /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// 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); }