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:
co-authored by
Claude Opus 5.5
parent
0700cac492
commit
ad0701395d
@@ -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",
|
||||
|
||||
+15
-5
@@ -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();
|
||||
}
|
||||
|
||||
+37
-13
@@ -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 = "(?<![A-Za-z0-9_])";
|
||||
const E = "(?![A-Za-z0-9_])";
|
||||
|
||||
/**
|
||||
* Regexes that find an entry in a line of engine code.
|
||||
* A card name (multi-word, distinctive) is caught in every spelling.
|
||||
* A noun word (KEY, FLAG, WALL, ...) is also an everyday English word, so its lowercase
|
||||
* form is an ordinary identifier (`for (const [key, v] of m)`, `let flag = false`).
|
||||
* Nouns are caught as: the exact uppercase word (`BABA`, `"text:BABA"`), the PascalCase
|
||||
* name of a type or variant (`Kind::Baba`), or a whole string literal in any case
|
||||
* (`kind == "baba"`). Lowercase bare identifiers pass.
|
||||
*/
|
||||
export function matchers(entry) {
|
||||
if (entry.kind === "card") {
|
||||
return [...variants(entry.name)].map((v) => 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(`(?<![A-Za-z0-9_])${esc(v)}(?![A-Za-z0-9_])`) });
|
||||
}
|
||||
}
|
||||
const cfg = readCourseConfig(ws);
|
||||
const all = auditEntries(catalog).flatMap((e) => 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 <workspaceDir>");
|
||||
const hits = audit(ws, loadCatalog());
|
||||
|
||||
@@ -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 <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 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(/\/$/, ""));
|
||||
|
||||
+28
-4
@@ -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 <ws>/.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() {
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
// 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 { 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 <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 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")}`);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
|
||||
@@ -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"}
|
||||
@@ -0,0 +1 @@
|
||||
### test_04_a
|
||||
@@ -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"}
|
||||
@@ -0,0 +1 @@
|
||||
### test_05_a
|
||||
@@ -0,0 +1,2 @@
|
||||
#[test]
|
||||
fn test_05_a() {}
|
||||
@@ -0,0 +1 @@
|
||||
test("test_05_a", () => {});
|
||||
@@ -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";
|
||||
@@ -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; }
|
||||
@@ -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 });
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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 <dir>/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 <dir>/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 <dir>/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);
|
||||
|
||||
Reference in New Issue
Block a user