feat: repo foundation and course tooling

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
This commit is contained in:
Lucas Winther
2026-10-28 23:54:55 +01:00
co-authored by Claude Sonnet 5.5
parent 12081223ef
commit 7854f6f531
50 changed files with 607 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
node_modules/
target/
dist/
.DS_Store
*.log
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Lucas Winther
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+39
View File
@@ -0,0 +1,39 @@
# Systemic Design Course
A free, hands-on course on **systemic game design**. You learn by rebuilding the rules machinery of two games, then extending it:
- **Part I: Hearthstone** (cards, zones, triggers, death phases, auras, text assembly)
- **Part II: Baba Is You** (rules as pushable text, parsing, movement, win/lose)
- **Part III: SpellGrid**, a small original game that combines both systems
Every module follows the same beats: Observe, Predict, Spec, Build, Name it, Expansion Drop, Further reading. The Expansion Drop is the test of your design: you add new content without touching the engine.
## Two paths
- **Designer path**: read the lessons and scenarios, predict outcomes, and reason about the design.
- **Builder path**: also make the tests pass in your own engine.
## Two tracks
Builder path exercises exist in **Rust** and **TypeScript**. Both tracks have identical test names, matching the scenarios in each module's `scenarios.md`.
## Quick start
```sh
node --version # Node 24 for the course tooling
npm run test:tools # tooling tests
npm run check # test names agree across Rust, TS and scenarios
node tools/start-module.mjs 01 path/to/your/workspace
```
`start-module` copies a module's tests for your track (from your workspace's `.course.json`) and refuses to start a module if you have not yet taken the previous one. Module 00 uses the `naive-engine/` project and is not started this way.
Other tools: `tools/coupling-audit.mjs <workspace>` (engine code must not name specific cards or nouns), `tools/expansion-check.mjs <workspace> <baseRef>` (an Expansion Drop must change content only), `tools/verify-references.mjs [NN]` (reference solutions pass the tests).
## Licence
MIT, for both prose and code. See [LICENSE](LICENSE). The course is and stays free.
## Disclaimer
Not affiliated with Blizzard or Hempuli. See the [disclaimer](/disclaimer/).
+10
View File
@@ -0,0 +1,10 @@
{
"name": "systemic-design-course",
"private": true,
"type": "module",
"license": "MIT",
"scripts": {
"test:tools": "node --test tools/test/*.test.mjs",
"check": "node tools/sync-check.mjs"
}
}
+73
View File
@@ -0,0 +1,73 @@
#!/usr/bin/env node
// Zero-Coupling Law: engine dirs must not name catalog cards or noun words.
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
import { join, relative } from "node:path";
import { loadCatalog, fail } from "./lib.mjs";
const META_NOUNS = new Set(["TEXT", "ALL", "EMPTY"]);
export function auditNames(catalog) {
return catalog
.filter((e) => e.kind === "card" || (e.kind === "word" && e.role === "noun" && !META_NOUNS.has(e.name)))
.map((e) => e.name)
.filter((n) => n.length >= 2);
}
export function variants(name) {
const words = name.split(/[^A-Za-z0-9]+/).filter(Boolean);
const lower = words.map((w) => w.toLowerCase());
const cap = (w) => w[0].toUpperCase() + w.slice(1);
return new Set([
name,
lower.join("_"),
lower[0] + lower.slice(1).map(cap).join(""),
lower.map(cap).join(""),
lower.join("_").toUpperCase(),
]);
}
const esc = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
function walk(dir, out = []) {
for (const e of readdirSync(dir)) {
if (e === "node_modules" || e === "target" || e.startsWith(".")) continue;
const p = join(dir, e);
statSync(p).isDirectory() ? walk(p, out) : out.push(p);
}
return out;
}
export function audit(ws, catalog) {
const cfg = JSON.parse(readFileSync(join(ws, ".course.json"), "utf8"));
const matchers = [];
for (const name of auditNames(catalog)) {
for (const v of variants(name)) {
matchers.push({ name, re: new RegExp(`(?<![A-Za-z0-9_])${esc(v)}(?![A-Za-z0-9_])`) });
}
}
const hits = [];
for (const d of cfg.engineDirs || []) {
const dir = join(ws, d);
if (!existsSync(dir)) continue;
for (const file of walk(dir)) {
readFileSync(file, "utf8").split("\n").forEach((line, i) => {
const seen = new Set();
for (const m of matchers) {
if (!seen.has(m.name) && m.re.test(line)) {
seen.add(m.name);
hits.push(`${relative(ws, file)}:${i + 1}: names "${m.name}"`);
}
}
});
}
}
return hits;
}
if (import.meta.main) {
const ws = process.argv[2];
if (!ws) fail("usage: coupling-audit.mjs <workspaceDir>");
const hits = audit(ws, loadCatalog());
if (hits.length) fail("coupling-audit: engine names specific content\n" + hits.map((h) => " " + h).join("\n"));
console.log("coupling-audit: ok");
}
+20
View File
@@ -0,0 +1,20 @@
#!/usr/bin/env node
// Expansion Drop must touch only content: fail if any changed path is under an engine dir.
import { readFileSync } from "node:fs";
import { execFileSync } from "node:child_process";
import { join, posix } from "node:path";
import { fail } from "./lib.mjs";
const [ws, baseRef] = process.argv.slice(2);
if (!ws || !baseRef) fail("usage: expansion-check.mjs <workspaceDir> <baseRef>");
const git = (...a) => execFileSync("git", a, { cwd: ws, encoding: "utf8" }).split("\n").filter(Boolean);
const cfg = JSON.parse(readFileSync(join(ws, ".course.json"), "utf8"));
const changed = new Set([
...git("diff", "--name-only", "--relative", baseRef),
...git("ls-files", "--others", "--exclude-standard"),
]);
const engine = (cfg.engineDirs || []).map((d) => posix.normalize(d).replace(/\/$/, ""));
const bad = [...changed].filter((p) => engine.some((d) => p === d || p.startsWith(d + "/")));
if (bad.length) fail("expansion-check: engine changed during Expansion Drop\n" + bad.map((p) => " " + p).join("\n"));
console.log(`expansion-check: ok (${changed.size} changed, none in engine)`);
+60
View File
@@ -0,0 +1,60 @@
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const here = dirname(fileURLToPath(import.meta.url));
/** Course directory; override with COURSE_ROOT (used by tests). */
export function courseRoot() {
return process.env.COURSE_ROOT
? resolve(process.env.COURSE_ROOT)
: join(here, "..", "course");
}
/** Module directories `course/NN-slug/`, sorted by number. */
export function moduleDirs() {
const root = courseRoot();
if (!existsSync(root)) return [];
return readdirSync(root)
.map((name) => ({ name, m: /^(\d\d)-(.+)$/.exec(name) }))
.filter(({ name, m }) => m && statSync(join(root, name)).isDirectory())
.map(({ name, m }) => ({ num: m[1], slug: m[2], path: join(root, name) }))
.sort((a, b) => a.num.localeCompare(b.num));
}
export function testFiles(path, lang) {
const dir = join(path, "tests", lang === "rust" ? "rust" : "ts");
if (!existsSync(dir)) return [];
const ext = lang === "rust" ? /\.rs$/ : /\.test\.ts$/;
return readdirSync(dir).filter((f) => ext.test(f)).sort().map((f) => join(dir, f));
}
/** Test names declared by a module's tests for `lang` ("rust" | "ts"). */
export function testNames(path, lang) {
const re = lang === "rust" ? /fn\s+(test_\w+)/g : /test\(\s*["'](test_\w+)/g;
const names = new Set();
for (const f of testFiles(path, lang)) {
for (const m of readFileSync(f, "utf8").matchAll(re)) names.add(m[1]);
}
return [...names].sort();
}
/** `### test_…` headings in a module's scenarios.md. */
export function scenarioNames(path) {
const f = join(path, "scenarios.md");
if (!existsSync(f)) return [];
const names = new Set();
for (const m of readFileSync(f, "utf8").matchAll(/^### (test_\w+)/gm)) names.add(m[1]);
return [...names].sort();
}
export function loadCatalog() {
const f = join(courseRoot(), "catalog.json");
if (!existsSync(f)) throw new Error(`catalog not found: ${f}`);
return JSON.parse(readFileSync(f, "utf8"));
}
export function fail(msg) {
console.error(msg);
process.exit(1);
}
+45
View File
@@ -0,0 +1,45 @@
#!/usr/bin/env node
// Copies module NN's tests for the learner's track into <workspace>/tests/.
import { cpSync, existsSync, mkdirSync, readFileSync } from "node:fs";
import { basename, join } from "node:path";
import { moduleDirs, courseRoot, testFiles, fail } from "./lib.mjs";
const [num, ws] = process.argv.slice(2);
if (!/^\d\d$/.test(num || "") || !ws) fail("usage: start-module.mjs <NN> <workspaceDir>");
if (num === "00") fail("Module 00 is not startable: M0 uses the naive-engine/ project, not a workspace.");
const cfg = JSON.parse(readFileSync(join(ws, ".course.json"), "utf8"));
const track = cfg.track;
if (track !== "rust" && track !== "ts") fail(`bad track in .course.json: ${track}`);
const mods = moduleDirs();
const mod = mods.find((m) => m.num === num);
if (!mod) fail(`no module ${num} in ${courseRoot()}`);
if (num !== "01") {
const prevNum = String(Number(num) - 1).padStart(2, "0");
const prev = mods.find((m) => m.num === prevNum);
if (prev) {
const missing = testFiles(prev.path, track).map((f) => basename(f))
.filter((f) => !existsSync(join(ws, "tests", f)));
if (missing.length) fail(`Cannot start module ${num}: finish module ${prevNum} first. Missing tests/${missing.join(", tests/")}`);
}
}
const files = testFiles(mod.path, track);
if (!files.length) fail(`module ${num} has no ${track} tests`);
mkdirSync(join(ws, "tests"), { recursive: true });
for (const f of files) cpSync(f, join(ws, "tests", basename(f)));
console.log(`Copied ${files.length} test file(s) for module ${num} into ${join(ws, "tests")}`);
// Harness additions: module-local note, else the "- M<N>:" line of course/harness.md.
const note = join(mod.path, "harness-adds.md");
let adds = existsSync(note) ? readFileSync(note, "utf8").trim() : "";
if (!adds) {
const h = join(courseRoot(), "harness.md");
if (existsSync(h)) {
const re = new RegExp(`^\\s*[-*]\\s*\\**M${Number(num)}\\b.*$`, "m");
adds = (re.exec(readFileSync(h, "utf8")) || [""])[0].trim();
}
}
console.log(adds ? `Harness functions added by module ${num}:\n${adds}` : `No harness additions recorded for module ${num} (see course/harness.md).`);
+26
View File
@@ -0,0 +1,26 @@
#!/usr/bin/env node
// Verifies Rust tests, TS tests and scenarios.md list identical test names per module.
import { moduleDirs, testNames, scenarioNames } from "./lib.mjs";
const problems = [];
for (const mod of moduleDirs()) {
const sets = {
rust: testNames(mod.path, "rust"),
ts: testNames(mod.path, "ts"),
scenarios: scenarioNames(mod.path),
};
if (Object.values(sets).every((s) => s.length === 0)) continue;
const all = new Set(Object.values(sets).flat());
for (const name of [...all].sort()) {
for (const [where, names] of Object.entries(sets)) {
if (!names.includes(name)) {
problems.push(`module ${mod.num}-${mod.slug}: ${name} missing from ${where}`);
}
}
}
}
if (problems.length) {
console.error("sync-check: mismatches\n" + problems.map((p) => " " + p).join("\n"));
process.exit(1);
}
console.log("sync-check: ok");
+26
View File
@@ -0,0 +1,26 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { run, fixtures } from "./helpers.mjs";
import { join } from "node:path";
const env = { COURSE_ROOT: join(fixtures, "course") };
const audit = (ws) => run("coupling-audit.mjs", [join(fixtures, ws)], env);
test("audit_flags_card_name_in_engine", () => {
const r = audit("ws-coupled");
assert.equal(r.code, 1);
assert.match(r.out, /core\.rs:2/);
assert.match(r.out, /core\.rs:3/);
assert.match(r.out, /Baron Rivendare/);
assert.doesNotMatch(r.out, /cards\.rs/);
});
test("audit_ignores_content_dir", () => {
const r = audit("ws-content-only");
assert.equal(r.code, 0, r.out);
});
test("audit_ignores_grammar_words", () => {
const r = audit("ws-grammar");
assert.equal(r.code, 0, r.out);
});
+36
View File
@@ -0,0 +1,36 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { writeFileSync } from "node:fs";
import { join } from "node:path";
import { run, copyFixture, git } from "./helpers.mjs";
function repo() {
const ws = copyFixture("ws-content-only");
git(ws, "init", "-q");
git(ws, "add", "-A");
git(ws, "commit", "-qm", "base");
git(ws, "tag", "base");
return ws;
}
test("expansion_check_rejects_engine_edit", () => {
const ws = repo();
writeFileSync(join(ws, "src/engine/core.rs"), "fn changed() {}\n");
const r = run("expansion-check.mjs", [ws, "base"]);
assert.equal(r.code, 1);
assert.match(r.out, /src\/engine\/core\.rs/);
});
test("expansion_check_rejects_new_engine_file", () => {
const ws = repo();
writeFileSync(join(ws, "src/engine/new.rs"), "fn n() {}\n");
assert.equal(run("expansion-check.mjs", [ws, "base"]).code, 1);
});
test("expansion_check_accepts_content_only", () => {
const ws = repo();
writeFileSync(join(ws, "src/content/cards.rs"), "// new card\n");
writeFileSync(join(ws, "src/content/more.rs"), "// untracked\n");
const r = run("expansion-check.mjs", [ws, "base"]);
assert.equal(r.code, 0, r.out);
});
+3
View File
@@ -0,0 +1,3 @@
# Scenarios
### test_01_a
### test_01_b
+2
View File
@@ -0,0 +1,2 @@
#[test]
fn test_01_a() {}
@@ -0,0 +1,2 @@
import { test } from "vitest";
test("test_01_b", () => {});
+1
View File
@@ -0,0 +1 @@
[]
+1
View File
@@ -0,0 +1 @@
newGame, summon, play
@@ -0,0 +1,2 @@
[package]
name = "ref"
@@ -0,0 +1 @@
{"name":"ref"}
+1
View File
@@ -0,0 +1 @@
### test_01_a
+2
View File
@@ -0,0 +1,2 @@
#[test]
fn test_01_a() {}
@@ -0,0 +1 @@
test("test_01_a", () => {});
@@ -0,0 +1,2 @@
[package]
name = "ref"
@@ -0,0 +1 @@
{"name":"ref"}
+1
View File
@@ -0,0 +1 @@
### test_02_a
+2
View File
@@ -0,0 +1,2 @@
#[test]
fn test_02_a() {}
@@ -0,0 +1 @@
test("test_02_a", () => {});
@@ -0,0 +1,2 @@
[package]
name = "ref"
@@ -0,0 +1 @@
{"name":"ref"}
+1
View File
@@ -0,0 +1 @@
### test_03_a
+2
View File
@@ -0,0 +1,2 @@
#[test]
fn test_03_a() {}
@@ -0,0 +1 @@
test("test_03_a", () => {});
+9
View File
@@ -0,0 +1,9 @@
[
{"name":"Baron Rivendare","kind":"card","text":"x","source":"https://hearthstone.wiki.gg/wiki/Baron_Rivendare"},
{"name":"Test Wisp","kind":"card","text":"x","source":"-","fictional":true},
{"name":"BABA","kind":"word","role":"noun","text":"x","source":"-"},
{"name":"TEXT","kind":"word","role":"noun","text":"x","source":"-"},
{"name":"IS","kind":"word","role":"operator","text":"x","source":"-"},
{"name":"PUSH","kind":"word","role":"property","text":"x","source":"-"},
{"name":"YOU","kind":"word","role":"property","text":"x","source":"-"}
]
+1
View File
@@ -0,0 +1 @@
{"track":"rust","engineDirs":["src/engine"],"contentDirs":["src/content"],"module":"01"}
@@ -0,0 +1 @@
let c = "Baron Rivendare"; fn baron_rivendare() {}
@@ -0,0 +1 @@
fn resolve() {}
+1
View File
@@ -0,0 +1 @@
{"track":"rust","engineDirs":["src/engine"],"contentDirs":["src/content"],"module":"01"}
+1
View File
@@ -0,0 +1 @@
let c = "Baron Rivendare";
+3
View File
@@ -0,0 +1,3 @@
fn a() {}
let c = "Baron Rivendare";
fn baron_rivendare() {}
+1
View File
@@ -0,0 +1 @@
{"track":"rust","engineDirs":["src/engine"],"contentDirs":["src/content"],"module":"01"}
+2
View File
@@ -0,0 +1,2 @@
const PUSH: u8 = 1; const IS: u8 = 2; const YOU: u8 = 3;
let t = "Test"; let x = "TEXT";
+1
View File
@@ -0,0 +1 @@
{"track":"ts","engineDirs":["src/engine"],"contentDirs":["src/content"],"module":"01"}
View File
+1
View File
@@ -0,0 +1 @@
{"track":"rust","engineDirs":["src/engine"],"contentDirs":["src/content"],"module":"01"}
View File
+31
View File
@@ -0,0 +1,31 @@
import { spawnSync, execFileSync } from "node:child_process";
import { cpSync, mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
const here = dirname(fileURLToPath(import.meta.url));
export const fixtures = join(here, "fixtures");
export const toolsDir = join(here, "..");
export function run(script, args = [], env = {}) {
const r = spawnSync("node", [join(toolsDir, script), ...args], {
encoding: "utf8",
env: { ...process.env, ...env },
});
return { code: r.status, out: (r.stdout || "") + (r.stderr || "") };
}
export function copyFixture(name) {
const dir = mkdtempSync(join(tmpdir(), "sdc-"));
cpSync(join(fixtures, name), dir, { recursive: true });
return dir;
}
export function git(cwd, ...args) {
return execFileSync(
"git",
["-c", "user.name=t", "-c", "user.email=t@t", ...args],
{ cwd, encoding: "utf8" },
);
}
+47
View File
@@ -0,0 +1,47 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { run, copyFixture, fixtures } from "./helpers.mjs";
const env = { COURSE_ROOT: join(fixtures, "course") };
test("start_module_copies_tests", () => {
const ws = copyFixture("ws-start");
const r = run("start-module.mjs", ["01", ws], env);
assert.equal(r.code, 0, r.out);
assert.ok(existsSync(join(ws, "tests/m01.rs")));
assert.match(r.out, /newGame, summon, play/);
});
test("start_module_uses_track", () => {
const ws = copyFixture("ws-start-ts");
assert.equal(run("start-module.mjs", ["01", ws], env).code, 0);
assert.ok(existsSync(join(ws, "tests/m01.test.ts")));
assert.ok(!existsSync(join(ws, "tests/m01.rs")));
});
test("start_module_accepts_when_previous_present", () => {
const ws = copyFixture("ws-start");
mkdirSync(join(ws, "tests"), { recursive: true });
writeFileSync(join(ws, "tests/m02.rs"), "");
const r = run("start-module.mjs", ["03", ws], env);
assert.equal(r.code, 0, r.out);
assert.ok(existsSync(join(ws, "tests/m03.rs")));
});
test("start_module_refuses_skipping", () => {
const ws = copyFixture("ws-start");
writeFileSync(join(ws, "tests/m01.rs"), "");
const r = run("start-module.mjs", ["03", ws], env);
assert.equal(r.code, 1);
assert.match(r.out, /finish module 02 first/);
assert.ok(!existsSync(join(ws, "tests/m03.rs")));
});
test("start_module_00_not_startable", () => {
const ws = copyFixture("ws-start");
const r = run("start-module.mjs", ["00", ws], env);
assert.notEqual(r.code, 0);
assert.match(r.out, /naive-engine/);
});
+16
View File
@@ -0,0 +1,16 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { run, fixtures } from "./helpers.mjs";
import { join } from "node:path";
test("sync_detects_missing_ts_test", () => {
const r = run("sync-check.mjs", [], { COURSE_ROOT: join(fixtures, "course-bad") });
assert.equal(r.code, 1);
assert.match(r.out, /test_01_a/);
assert.match(r.out, /test_01_b/);
});
test("sync_passes_consistent_course", () => {
const r = run("sync-check.mjs", [], { COURSE_ROOT: join(fixtures, "course") });
assert.equal(r.code, 0, r.out);
});
+47
View File
@@ -0,0 +1,47 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { join } from "node:path";
import { run, fixtures } from "./helpers.mjs";
import { planVerification } from "../verify-references.mjs";
import { moduleDirs } from "../lib.mjs";
const root = join(fixtures, "course");
test("verify_plan_skips_modules_without_reference", () => {
process.env.COURSE_ROOT = root;
const plan = planVerification(moduleDirs(), 3);
assert.deepEqual(plan.map((p) => p.num), ["01", "02", "03"]);
assert.deepEqual(plan[2].testModules.map((m) => m.num), ["01", "02", "03"]);
assert.deepEqual(plan[2].langs, ["rust", "ts"]);
});
test("verify_runs_with_copied_reference_and_tests", () => {
const r = run("verify-references.mjs", ["02"], {
COURSE_ROOT: root,
VERIFY_CMD_RUST: "test -f tests/m01.rs && test -f Cargo.toml && ! test -f tests/m03.rs",
VERIFY_CMD_TS: "test -f tests/m01.test.ts && test -f package.json",
});
assert.equal(r.code, 0, r.out);
assert.match(r.out, /01.*ok/s);
assert.match(r.out, /02.*ok/s);
});
test("verify_reports_failure", () => {
const r = run("verify-references.mjs", ["01"], {
COURSE_ROOT: root,
VERIFY_CMD_RUST: "false",
VERIFY_CMD_TS: "true",
});
assert.equal(r.code, 1);
assert.match(r.out, /FAIL/);
});
test("verify_stage_copies_all_earlier_module_tests", async () => {
process.env.COURSE_ROOT = root;
const { planVerification, stage } = await import("../verify-references.mjs");
const { existsSync } = await import("node:fs");
const plan = planVerification(moduleDirs(), 3)[2];
const dir = stage(plan, "rust");
for (const f of ["m01.rs", "m02.rs", "m03.rs"]) assert.ok(existsSync(join(dir, "tests", f)));
assert.ok(existsSync(join(dir, "Cargo.toml")));
});
+53
View File
@@ -0,0 +1,53 @@
#!/usr/bin/env node
// For each module <= N with a reference/: copy reference + tests of modules 01..module into a temp dir, run tests.
import { cpSync, existsSync, mkdirSync, mkdtempSync, rmSync } from "node:fs";
import { spawnSync } from "node:child_process";
import { tmpdir } from "node:os";
import { basename, join } from "node:path";
import { moduleDirs, testFiles, fail } from "./lib.mjs";
const COMMANDS = {
rust: () => process.env.VERIFY_CMD_RUST || "cargo test",
ts: () => process.env.VERIFY_CMD_TS || "npx vitest run",
};
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);
}
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)));
}
return dir;
}
if (import.meta.main) {
const all = moduleDirs();
const upTo = process.argv[2] ? Number(process.argv[2]) : 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) {
const dir = stage(p, lang);
const r = spawnSync(COMMANDS[lang](), { cwd: dir, shell: true, encoding: "utf8" });
const ok = r.status === 0;
failed ||= !ok;
console.log(`module ${p.num}-${p.slug} [${lang}]: ${ok ? "ok" : "FAIL"}`);
if (!ok) console.log((r.stdout || "") + (r.stderr || ""));
rmSync(dir, { recursive: true, force: true });
}
}
if (failed) process.exit(1);
}