- 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 <[email protected]>
98 lines
3.6 KiB
JavaScript
98 lines
3.6 KiB
JavaScript
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 (or another file in `path`). */
|
|
export function scenarioNames(path, file = "scenarios.md") {
|
|
const f = join(path, file);
|
|
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();
|
|
}
|
|
|
|
/** 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 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 = 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));
|
|
}
|
|
|
|
/** 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() {
|
|
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);
|
|
}
|