From ad0701395dee44bcefc5eece8180b78c9c144bf5 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Thu, 29 Oct 2026 19:06:56 +0100 Subject: [PATCH] fix(tools): stage course cards from M5, audit noun words without flagging identifiers, Node<24.2 main guard, rename-proof expansion-check, safe check-links - cardDirs: course/cards only from Module 5 (spec 6.2a); fixture modules 04/05 and tests - coupling-audit: noun words match as UPPERCASE, PascalCase or a whole string literal; lowercase identifiers like key/flag pass; card names keep every variant - isMain() argv guard replaces import.meta.main in coupling-audit and verify-references; engines.node >=22 - expansion-check: git diff --no-renames so moving engine code into content fails - start-module and other tools: clear error when .course.json is missing - check-links: malformed URLs reported as invalid URL instead of throwing Co-Authored-By: Claude Opus 5.5 --- package.json | 3 ++ tools/check-links.mjs | 20 ++++++-- tools/coupling-audit.mjs | 50 ++++++++++++++----- tools/expansion-check.mjs | 9 ++-- tools/lib.mjs | 32 ++++++++++-- tools/start-module.mjs | 11 ++-- tools/test/check-links.test.mjs | 10 +++- tools/test/coupling-audit.test.mjs | 40 ++++++++++++++- tools/test/expansion-check.test.mjs | 10 ++++ .../course/04-m04/reference/rust/Cargo.toml | 2 + .../course/04-m04/reference/ts/package.json | 1 + .../test/fixtures/course/04-m04/scenarios.md | 1 + .../fixtures/course/04-m04/tests/rust/m04.rs | 2 + .../course/04-m04/tests/ts/m04.test.ts | 1 + .../course/05-m05/reference/rust/Cargo.toml | 2 + .../course/05-m05/reference/ts/package.json | 1 + .../test/fixtures/course/05-m05/scenarios.md | 1 + .../fixtures/course/05-m05/tests/rust/m05.rs | 2 + .../course/05-m05/tests/ts/m05.test.ts | 1 + .../fixtures/ws-noun-coupled/.course.json | 1 + .../ws-noun-coupled/src/engine/core.rs | 5 ++ .../fixtures/ws-ordinary-words/.course.json | 1 + .../ws-ordinary-words/src/engine/core.ts | 3 ++ tools/test/start-module.test.mjs | 30 +++++++++-- tools/test/verify-references.test.mjs | 12 +++-- tools/verify-references.mjs | 9 ++-- 26 files changed, 213 insertions(+), 47 deletions(-) create mode 100644 tools/test/fixtures/course/04-m04/reference/rust/Cargo.toml create mode 100644 tools/test/fixtures/course/04-m04/reference/ts/package.json create mode 100644 tools/test/fixtures/course/04-m04/scenarios.md create mode 100644 tools/test/fixtures/course/04-m04/tests/rust/m04.rs create mode 100644 tools/test/fixtures/course/04-m04/tests/ts/m04.test.ts create mode 100644 tools/test/fixtures/course/05-m05/reference/rust/Cargo.toml create mode 100644 tools/test/fixtures/course/05-m05/reference/ts/package.json create mode 100644 tools/test/fixtures/course/05-m05/scenarios.md create mode 100644 tools/test/fixtures/course/05-m05/tests/rust/m05.rs create mode 100644 tools/test/fixtures/course/05-m05/tests/ts/m05.test.ts create mode 100644 tools/test/fixtures/ws-noun-coupled/.course.json create mode 100644 tools/test/fixtures/ws-noun-coupled/src/engine/core.rs create mode 100644 tools/test/fixtures/ws-ordinary-words/.course.json create mode 100644 tools/test/fixtures/ws-ordinary-words/src/engine/core.ts diff --git a/package.json b/package.json index 28b3129..69fe080 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,9 @@ "private": true, "type": "module", "license": "MIT", + "engines": { + "node": ">=22" + }, "scripts": { "test:tools": "node --test tools/test/*.test.mjs", "check": "node tools/sync-check.mjs", diff --git a/tools/check-links.mjs b/tools/check-links.mjs index ea99298..75b2619 100644 --- a/tools/check-links.mjs +++ b/tools/check-links.mjs @@ -7,7 +7,7 @@ // Reddit URLs are listed as "unverifiable": reddit blocks automated fetches, // and the course already excludes reddit as a source. import { readdirSync, readFileSync, statSync } from "node:fs"; -import { join, relative, extname } from "node:path"; +import { join, relative, extname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; const SKIP_DIRS = new Set(["node_modules", "target", "dist", ".astro", ".git"]); @@ -46,8 +46,18 @@ function* walk(path) { } } -function classifyUnverifiable(url) { - const host = new URL(url).hostname; +/** Hostname of `url`, or null when `url` does not parse (reported as "invalid URL"). */ +export function hostOf(url) { + try { + return new URL(url).hostname; + } catch { + return null; + } +} + +export function classifyUnverifiable(url) { + const host = hostOf(url); + if (host === null) return "invalid URL"; if (/(^|\.)reddit\.com$/.test(host)) return "unverifiable (reddit blocks fetch tools; excluded as a source)"; return null; } @@ -106,7 +116,7 @@ async function main() { // One request at a time per host (wikis rate-limit), up to CONCURRENCY hosts at once. const byHost = new Map(); for (const url of urls) { - const host = new URL(url).hostname; + const host = hostOf(url) ?? "(invalid)"; if (!byHost.has(host)) byHost.set(host, []); byHost.get(host).push(url); } @@ -133,6 +143,6 @@ async function main() { console.log("Report only: exit code is always 0."); } -if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) { +if (process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1])) { await main(); } diff --git a/tools/coupling-audit.mjs b/tools/coupling-audit.mjs index fb60c04..642a885 100644 --- a/tools/coupling-audit.mjs +++ b/tools/coupling-audit.mjs @@ -2,17 +2,22 @@ // 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"; +import { loadCatalog, fail, isMain, readCourseConfig } from "./lib.mjs"; const META_NOUNS = new Set(["TEXT", "ALL", "EMPTY"]); -export function auditNames(catalog) { +/** Catalog entries the engine must not name: every card, and every noun word except the meta-nouns. */ +export function auditEntries(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); + .filter((e) => e.name.length >= 2); } +export function auditNames(catalog) { + return auditEntries(catalog).map((e) => e.name); +} + +/** Spellings of a card name: exact, snake_case, camelCase, PascalCase, SCREAMING_SNAKE. */ export function variants(name) { const words = name.split(/[^A-Za-z0-9]+/).filter(Boolean); const lower = words.map((w) => w.toLowerCase()); @@ -27,6 +32,30 @@ export function variants(name) { } const esc = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +const B = "(? new RegExp(`${B}${esc(v)}${E}`)); + } + const word = entry.name; + const pascal = word[0].toUpperCase() + word.slice(1).toLowerCase(); + return [ + new RegExp(`${B}${esc(word)}${E}`), + new RegExp(`${B}${esc(pascal)}${E}`), + new RegExp(`(["'\`])${esc(word)}\\1`, "i"), + ]; +} function walk(dir, out = []) { for (const e of readdirSync(dir)) { @@ -38,13 +67,8 @@ function walk(dir, 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(`(? matchers(e).map((re) => ({ name: e.name, re }))); const hits = []; for (const d of cfg.engineDirs || []) { const dir = join(ws, d); @@ -52,7 +76,7 @@ export function audit(ws, catalog) { for (const file of walk(dir)) { readFileSync(file, "utf8").split("\n").forEach((line, i) => { const seen = new Set(); - for (const m of matchers) { + for (const m of all) { if (!seen.has(m.name) && m.re.test(line)) { seen.add(m.name); hits.push(`${relative(ws, file)}:${i + 1}: names "${m.name}"`); @@ -64,7 +88,7 @@ export function audit(ws, catalog) { return hits; } -if (import.meta.main) { +if (isMain(import.meta.url)) { const ws = process.argv[2]; if (!ws) fail("usage: coupling-audit.mjs "); const hits = audit(ws, loadCatalog()); diff --git a/tools/expansion-check.mjs b/tools/expansion-check.mjs index 8181893..f909417 100644 --- a/tools/expansion-check.mjs +++ b/tools/expansion-check.mjs @@ -1,17 +1,16 @@ #!/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"; +import { posix } from "node:path"; +import { fail, readCourseConfig } from "./lib.mjs"; const [ws, baseRef] = process.argv.slice(2); if (!ws || !baseRef) fail("usage: expansion-check.mjs "); 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 cfg = readCourseConfig(ws); const changed = new Set([ - ...git("diff", "--name-only", "--relative", baseRef), + ...git("diff", "--name-only", "--no-renames", "--relative", baseRef), ...git("ls-files", "--others", "--exclude-standard"), ]); const engine = (cfg.engineDirs || []).map((d) => posix.normalize(d).replace(/\/$/, "")); diff --git a/tools/lib.mjs b/tools/lib.mjs index 2d9e91d..518cc8c 100644 --- a/tools/lib.mjs +++ b/tools/lib.mjs @@ -48,17 +48,41 @@ export function scenarioNames(path, file = "scenarios.md") { return [...names].sort(); } +/** First module whose workspace gets course/cards (Module 5 introduces the card data format). */ +export const COURSE_CARDS_FROM = 5; + /** - * The card data a workspace at module `upTo` has: course/cards (every module's, from - * Module 5), then the cards/ of each module up to `upTo` that has its own (Module 10's + * The card data a workspace at module `upTo` has: course/cards (every module's) from + * Module 5 only, since Modules 1-4 keep their cards in the learner's own code (spec + * §6.2a), then the cards/ of each module up to `upTo` that has its own (Module 10's * SpellGrid cards), so earlier modules never see later modules' cards. */ export function cardDirs(upTo) { - const shared = join(courseRoot(), "cards"); + const shared = Number(upTo) >= COURSE_CARDS_FROM ? [join(courseRoot(), "cards")] : []; const local = moduleDirs() .filter((m) => Number(m.num) <= Number(upTo)) .map((m) => join(m.path, "cards")); - return [shared, ...local].filter((d) => existsSync(d)); + return [...shared, ...local].filter((d) => existsSync(d)); +} + +/** Read /.course.json, failing with a clear message if it is missing or invalid. */ +export function readCourseConfig(ws) { + const f = join(ws, ".course.json"); + if (!existsSync(f)) { + fail(`No .course.json in ${resolve(ws)}.\n` + + "Create your workspace first: copy workspace-template/rust or workspace-template/ts " + + "to a new directory and run `git init` there (see README.md, Quick start)."); + } + try { + return JSON.parse(readFileSync(f, "utf8")); + } catch (e) { + fail(`Cannot parse ${f}: ${e.message}`); + } +} + +/** True when this module is the script node was asked to run (works on every Node version). */ +export function isMain(metaUrl) { + return Boolean(process.argv[1]) && fileURLToPath(metaUrl) === resolve(process.argv[1]); } export function loadCatalog() { diff --git a/tools/start-module.mjs b/tools/start-module.mjs index 276a895..bb6afb6 100644 --- a/tools/start-module.mjs +++ b/tools/start-module.mjs @@ -2,13 +2,13 @@ // Copies module NN's tests for the learner's track into /tests/. import { cpSync, existsSync, mkdirSync, readFileSync } from "node:fs"; import { basename, join } from "node:path"; -import { cardDirs, moduleDirs, courseRoot, testFiles, fail } from "./lib.mjs"; +import { cardDirs, moduleDirs, courseRoot, testFiles, fail, readCourseConfig } from "./lib.mjs"; const [num, ws] = process.argv.slice(2); if (!/^\d\d$/.test(num || "") || !ws) fail("usage: start-module.mjs "); 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 cfg = readCourseConfig(ws); const track = cfg.track; if (track !== "rust" && track !== "ts") fail(`bad track in .course.json: ${track}`); @@ -32,9 +32,10 @@ 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")}`); -// The course's card data (read by your content from Module 5): course/cards, plus the cards -// of each module up to this one that has its own (Module 10's SpellGrid cards). Refreshed on -// every start; card files you add yourself are kept. +// The course's card data (read by your content from Module 5): course/cards from Module 5 on +// (Modules 1-4 keep their cards in your own code), plus the cards of each module up to this +// one that has its own (Module 10's SpellGrid cards). Refreshed on every start; card files you +// add yourself are kept. const cardSources = cardDirs(num); for (const cards of cardSources) cpSync(cards, join(ws, "cards"), { recursive: true }); if (cardSources.length) console.log(`Copied the course card data into ${join(ws, "cards")}`); diff --git a/tools/test/check-links.test.mjs b/tools/test/check-links.test.mjs index 30f3b4c..17fffee 100644 --- a/tools/test/check-links.test.mjs +++ b/tools/test/check-links.test.mjs @@ -1,6 +1,6 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { extractUrls } from "../check-links.mjs"; +import { extractUrls, hostOf, classifyUnverifiable } from "../check-links.mjs"; test("check_links_extracts_markdown_and_bare_urls", () => { const md = @@ -13,3 +13,11 @@ test("check_links_extracts_markdown_and_bare_urls", () => { "https://x.org/y", ]); }); + +test("check_links_reports_malformed_urls_instead_of_throwing", () => { + // `new URL()` throws on these; the checker must still finish and exit 0. + assert.equal(hostOf("http://[bad"), null); + assert.equal(classifyUnverifiable("http://[bad"), "invalid URL"); + assert.equal(classifyUnverifiable("https://www.reddit.com/r/x").startsWith("unverifiable"), true); + assert.equal(classifyUnverifiable("https://example.com/a"), null); +}); diff --git a/tools/test/coupling-audit.test.mjs b/tools/test/coupling-audit.test.mjs index 28f2d66..c363d2e 100644 --- a/tools/test/coupling-audit.test.mjs +++ b/tools/test/coupling-audit.test.mjs @@ -1,6 +1,8 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { run, fixtures } from "./helpers.mjs"; +import { readdirSync, readFileSync } from "node:fs"; +import { spawnSync } from "node:child_process"; +import { run, fixtures, toolsDir } from "./helpers.mjs"; import { join } from "node:path"; const env = { COURSE_ROOT: join(fixtures, "course") }; @@ -30,3 +32,39 @@ test("audit_ignores_mechanic_words_in_real_catalog", () => { const r = run("coupling-audit.mjs", [join(fixtures, "ws-mechanics")]); assert.equal(r.code, 0, r.out); }); + +test("audit_allows_ordinary_lowercase_identifiers", () => { + // `key`, `flag`, `wall`, `water` are noun words in the real catalog, but as lowercase + // identifiers they are ordinary code, not content coupling. + const r = run("coupling-audit.mjs", [join(fixtures, "ws-ordinary-words")]); + assert.equal(r.code, 0, r.out); +}); + +test("audit_flags_noun_words_as_names_strings_and_variants", () => { + // Uses the real catalog: string literal "flag", KEY, Kind::Door and "text:WALL" are coupling; + // the lowercase identifiers on line 1 are not. + const r = run("coupling-audit.mjs", [join(fixtures, "ws-noun-coupled")]); + assert.equal(r.code, 1); + assert.doesNotMatch(r.out, /core\.rs:1:/); + assert.match(r.out, /core\.rs:2: names "FLAG"/); + assert.match(r.out, /core\.rs:3: names "KEY"/); + assert.match(r.out, /core\.rs:4: names "DOOR"/); + assert.match(r.out, /core\.rs:5: names "WALL"/); +}); + +test("tools_do_not_rely_on_import_meta_main", () => { + // `import.meta.main` is undefined before Node 24.2, so a tool guarded by it exits 0 + // having checked nothing. Tools use isMain() from lib.mjs (an argv comparison). + for (const f of readdirSync(toolsDir).filter((f) => f.endsWith(".mjs"))) { + assert.doesNotMatch(readFileSync(join(toolsDir, f), "utf8"), /import\.meta\.main/, f); + } +}); + +test("audit_runs_from_a_relative_path", () => { + const root = join(toolsDir, ".."); + const r = spawnSync("node", ["tools/coupling-audit.mjs", join(fixtures, "ws-coupled")], { + cwd: root, encoding: "utf8", env: { ...process.env, COURSE_ROOT: join(fixtures, "course") }, + }); + assert.equal(r.status, 1, r.stdout + r.stderr); + assert.match(r.stderr, /Baron Rivendare/); +}); diff --git a/tools/test/expansion-check.test.mjs b/tools/test/expansion-check.test.mjs index d313de1..720d640 100644 --- a/tools/test/expansion-check.test.mjs +++ b/tools/test/expansion-check.test.mjs @@ -34,3 +34,13 @@ test("expansion_check_accepts_content_only", () => { const r = run("expansion-check.mjs", [ws, "base"]); assert.equal(r.code, 0, r.out); }); + +test("expansion_check_rejects_engine_file_moved_into_content", () => { + // A rename shows only its new path under `git diff --name-only`; --no-renames lists + // the deleted engine path too. + const ws = repo(); + git(ws, "mv", "src/engine/core.rs", "src/content/core.rs"); + const r = run("expansion-check.mjs", [ws, "base"]); + assert.equal(r.code, 1, r.out); + assert.match(r.out, /src\/engine\/core\.rs/); +}); diff --git a/tools/test/fixtures/course/04-m04/reference/rust/Cargo.toml b/tools/test/fixtures/course/04-m04/reference/rust/Cargo.toml new file mode 100644 index 0000000..9a9fd77 --- /dev/null +++ b/tools/test/fixtures/course/04-m04/reference/rust/Cargo.toml @@ -0,0 +1,2 @@ +[package] +name = "ref" diff --git a/tools/test/fixtures/course/04-m04/reference/ts/package.json b/tools/test/fixtures/course/04-m04/reference/ts/package.json new file mode 100644 index 0000000..40e054a --- /dev/null +++ b/tools/test/fixtures/course/04-m04/reference/ts/package.json @@ -0,0 +1 @@ +{"name":"ref"} diff --git a/tools/test/fixtures/course/04-m04/scenarios.md b/tools/test/fixtures/course/04-m04/scenarios.md new file mode 100644 index 0000000..0925f10 --- /dev/null +++ b/tools/test/fixtures/course/04-m04/scenarios.md @@ -0,0 +1 @@ +### test_04_a diff --git a/tools/test/fixtures/course/04-m04/tests/rust/m04.rs b/tools/test/fixtures/course/04-m04/tests/rust/m04.rs new file mode 100644 index 0000000..e08c526 --- /dev/null +++ b/tools/test/fixtures/course/04-m04/tests/rust/m04.rs @@ -0,0 +1,2 @@ +#[test] +fn test_04_a() {} diff --git a/tools/test/fixtures/course/04-m04/tests/ts/m04.test.ts b/tools/test/fixtures/course/04-m04/tests/ts/m04.test.ts new file mode 100644 index 0000000..5c60654 --- /dev/null +++ b/tools/test/fixtures/course/04-m04/tests/ts/m04.test.ts @@ -0,0 +1 @@ +test("test_04_a", () => {}); diff --git a/tools/test/fixtures/course/05-m05/reference/rust/Cargo.toml b/tools/test/fixtures/course/05-m05/reference/rust/Cargo.toml new file mode 100644 index 0000000..9a9fd77 --- /dev/null +++ b/tools/test/fixtures/course/05-m05/reference/rust/Cargo.toml @@ -0,0 +1,2 @@ +[package] +name = "ref" diff --git a/tools/test/fixtures/course/05-m05/reference/ts/package.json b/tools/test/fixtures/course/05-m05/reference/ts/package.json new file mode 100644 index 0000000..40e054a --- /dev/null +++ b/tools/test/fixtures/course/05-m05/reference/ts/package.json @@ -0,0 +1 @@ +{"name":"ref"} diff --git a/tools/test/fixtures/course/05-m05/scenarios.md b/tools/test/fixtures/course/05-m05/scenarios.md new file mode 100644 index 0000000..70f6d0b --- /dev/null +++ b/tools/test/fixtures/course/05-m05/scenarios.md @@ -0,0 +1 @@ +### test_05_a diff --git a/tools/test/fixtures/course/05-m05/tests/rust/m05.rs b/tools/test/fixtures/course/05-m05/tests/rust/m05.rs new file mode 100644 index 0000000..5b88ecb --- /dev/null +++ b/tools/test/fixtures/course/05-m05/tests/rust/m05.rs @@ -0,0 +1,2 @@ +#[test] +fn test_05_a() {} diff --git a/tools/test/fixtures/course/05-m05/tests/ts/m05.test.ts b/tools/test/fixtures/course/05-m05/tests/ts/m05.test.ts new file mode 100644 index 0000000..de6c5d3 --- /dev/null +++ b/tools/test/fixtures/course/05-m05/tests/ts/m05.test.ts @@ -0,0 +1 @@ +test("test_05_a", () => {}); diff --git a/tools/test/fixtures/ws-noun-coupled/.course.json b/tools/test/fixtures/ws-noun-coupled/.course.json new file mode 100644 index 0000000..49aea3b --- /dev/null +++ b/tools/test/fixtures/ws-noun-coupled/.course.json @@ -0,0 +1 @@ +{"track":"rust","engineDirs":["src/engine"],"contentDirs":["src/content"],"module":"01"} diff --git a/tools/test/fixtures/ws-noun-coupled/src/engine/core.rs b/tools/test/fixtures/ws-noun-coupled/src/engine/core.rs new file mode 100644 index 0000000..61969e4 --- /dev/null +++ b/tools/test/fixtures/ws-noun-coupled/src/engine/core.rs @@ -0,0 +1,5 @@ +fn ok(key: u32) -> u32 { let flag = key; flag } +if kind == "flag" { win(); } +const KEY: &str = "KEY"; +match k { Kind::Door => open() } +let t = "text:WALL"; diff --git a/tools/test/fixtures/ws-ordinary-words/.course.json b/tools/test/fixtures/ws-ordinary-words/.course.json new file mode 100644 index 0000000..49aea3b --- /dev/null +++ b/tools/test/fixtures/ws-ordinary-words/.course.json @@ -0,0 +1 @@ +{"track":"rust","engineDirs":["src/engine"],"contentDirs":["src/content"],"module":"01"} diff --git a/tools/test/fixtures/ws-ordinary-words/src/engine/core.ts b/tools/test/fixtures/ws-ordinary-words/src/engine/core.ts new file mode 100644 index 0000000..61e91a2 --- /dev/null +++ b/tools/test/fixtures/ws-ordinary-words/src/engine/core.ts @@ -0,0 +1,3 @@ +for (const [key, v] of m) { use(key, v); } +let flag = false; const door_open = flag; let wall = 0; +function lookup(key: string, water: number) { return key.length + water; } diff --git a/tools/test/start-module.test.mjs b/tools/test/start-module.test.mjs index 77c717e..d2fb24e 100644 --- a/tools/test/start-module.test.mjs +++ b/tools/test/start-module.test.mjs @@ -14,9 +14,23 @@ test("start_module_copies_tests", () => { assert.match(r.out, /newGame, summon, play/); }); -test("start_module_copies_course_cards", () => { +test("start_module_copies_course_cards_from_module_5", () => { + // Spec §6.2a: Modules 1-4 keep their cards in the learner's code; course/cards + // (with the M3/M4 drop answers and the M5 data format) arrives with Module 5. + for (const [num, prev] of [["01", null], ["04", "m03.rs"]]) { + const ws = copyFixture("ws-start"); + if (prev) { + mkdirSync(join(ws, "tests"), { recursive: true }); + writeFileSync(join(ws, "tests", prev), ""); + } + const r = run("start-module.mjs", [num, ws], env); + assert.equal(r.code, 0, r.out); + assert.ok(!existsSync(join(ws, "cards/test-fixture.json")), `module ${num} gets no course cards`); + } const ws = copyFixture("ws-start"); - const r = run("start-module.mjs", ["01", ws], env); + mkdirSync(join(ws, "tests"), { recursive: true }); + writeFileSync(join(ws, "tests/m04.rs"), ""); + const r = run("start-module.mjs", ["05", ws], env); assert.equal(r.code, 0, r.out); assert.ok(existsSync(join(ws, "cards/test-fixture.json"))); }); @@ -24,17 +38,25 @@ test("start_module_copies_course_cards", () => { test("start_module_copies_module_cards_up_to_the_module", () => { const early = copyFixture("ws-start"); assert.equal(run("start-module.mjs", ["01", early], env).code, 0); - assert.ok(existsSync(join(early, "cards/test-fixture.json"))); assert.ok(!existsSync(join(early, "cards/module-fixture.json"))); const later = copyFixture("ws-start"); mkdirSync(join(later, "tests"), { recursive: true }); writeFileSync(join(later, "tests/m02.rs"), ""); const r = run("start-module.mjs", ["03", later], env); assert.equal(r.code, 0, r.out); - assert.ok(existsSync(join(later, "cards/test-fixture.json"))); + assert.ok(!existsSync(join(later, "cards/test-fixture.json"))); assert.ok(existsSync(join(later, "cards/module-fixture.json"))); }); +test("start_module_explains_missing_course_json", () => { + const ws = copyFixture("ws-content-only"); + const r = run("start-module.mjs", ["01", join(ws, "no-such-dir")], env); + assert.equal(r.code, 1); + assert.match(r.out, /No \.course\.json/); + assert.match(r.out, /workspace-template/); + assert.doesNotMatch(r.out, /ENOENT|at .*\.mjs:\d+/); +}); + test("start_module_copies_module_levels", () => { const ws = copyFixture("ws-start"); mkdirSync(join(ws, "tests"), { recursive: true }); diff --git a/tools/test/verify-references.test.mjs b/tools/test/verify-references.test.mjs index 5ca0eea..8edf7ab 100644 --- a/tools/test/verify-references.test.mjs +++ b/tools/test/verify-references.test.mjs @@ -46,14 +46,17 @@ test("verify_stage_copies_all_earlier_module_tests", async () => { assert.ok(existsSync(join(dir, "Cargo.toml"))); }); -test("verify_stage_copies_course_cards", async () => { - // From Module 5 the references load card data from /cards. +test("verify_stage_copies_course_cards_from_module_5", async () => { + // From Module 5 the references load card data from /cards; + // Modules 1-4 keep their cards in code and get no course/cards (spec §6.2a). process.env.COURSE_ROOT = root; const { planVerification, stage } = await import("../verify-references.mjs"); const { existsSync } = await import("node:fs"); + const plans = planVerification(moduleDirs(), 5); for (const lang of ["rust", "ts"]) { - const dir = stage(planVerification(moduleDirs(), 1)[0], lang); - assert.ok(existsSync(join(dir, "cards", "test-fixture.json")), lang); + assert.ok(!existsSync(join(stage(plans[0], lang), "cards", "test-fixture.json")), `01 ${lang}`); + assert.ok(!existsSync(join(stage(plans[3], lang), "cards", "test-fixture.json")), `04 ${lang}`); + assert.ok(existsSync(join(stage(plans[4], lang), "cards", "test-fixture.json")), `05 ${lang}`); } }); @@ -88,6 +91,5 @@ test("verify_stage_copies_module_cards_up_to_the_module", async () => { assert.ok(existsSync(join(stage(plans[1], lang), "cards", "module-fixture.json")), lang); const later = stage(plans[2], lang); assert.ok(existsSync(join(later, "cards", "module-fixture.json")), lang); - assert.ok(existsSync(join(later, "cards", "test-fixture.json")), lang); } }); diff --git a/tools/verify-references.mjs b/tools/verify-references.mjs index 1937878..5ca8a30 100644 --- a/tools/verify-references.mjs +++ b/tools/verify-references.mjs @@ -1,13 +1,14 @@ #!/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 -// 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. +// 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. 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 { cardDirs, moduleDirs, testFiles, fail } from "./lib.mjs"; +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). @@ -43,7 +44,7 @@ export function stage(plan, lang) { return dir; } -if (import.meta.main) { +if (isMain(import.meta.url)) { 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);