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 <noreply@anthropic.com>
This commit is contained in:
Lucas WintherandClaude Opus 5.5 committed 2026-10-29 19:06:56 +01:00
1 parent 0700cac492
commit ad0701395d
26 files changed
+213 -47

No files matched your search

+9 -1
View File
@@ -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);
});
+39 -1
View File
@@ -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/);
});
+10
View File
@@ -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/);
});
@@ -0,0 +1,2 @@
[package]
name = "ref"
@@ -0,0 +1 @@
{"name":"ref"}
+1
View File
@@ -0,0 +1 @@
### test_04_a
+2
View File
@@ -0,0 +1,2 @@
#[test]
fn test_04_a() {}
@@ -0,0 +1 @@
test("test_04_a", () => {});
@@ -0,0 +1,2 @@
[package]
name = "ref"
@@ -0,0 +1 @@
{"name":"ref"}
+1
View File
@@ -0,0 +1 @@
### test_05_a
+2
View File
@@ -0,0 +1,2 @@
#[test]
fn test_05_a() {}
@@ -0,0 +1 @@
test("test_05_a", () => {});
+1
View File
@@ -0,0 +1 @@
{"track":"rust","engineDirs":["src/engine"],"contentDirs":["src/content"],"module":"01"}
@@ -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";
+1
View File
@@ -0,0 +1 @@
{"track":"rust","engineDirs":["src/engine"],"contentDirs":["src/content"],"module":"01"}
@@ -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; }
+26 -4
View File
@@ -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 });
+7 -5
View File
@@ -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 <crate or package root>/cards.
test("verify_stage_copies_course_cards_from_module_5", async () => {
// From Module 5 the references load card data from <crate or package root>/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);
}
});