- tools/mutation-check.mjs with tools/mutants.json: thirteen mutants against the Module 12 TypeScript reference (the eight of Module 13's first table, plus Armor before redirect, conditions at parse time, no defender priority, and the summon seam's noun check); all killed. - tools/run-scenario.mjs with tools/scenario/run.ts: a designer's cards, words and seam words as data, played on the Module 10 TypeScript reference. - course/10-spellgrid/designer-capstone.md and designer-example/ (output.txt is checked against the tool's output in the tools tests). - CI: diff-tracks (300 seeds), mutation-check, verify-levels. - Module 13: the mutation table is the tool's output; differential testing, undo and the solver sections describe the repository as it is. Lesson 10 says what the level proofs prove for levels 6, 8 and 9. - schema.mjs: a value that matches no oneOf branch is reported against the branch its const fields pick. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
195 lines
9.3 KiB
JavaScript
195 lines
9.3 KiB
JavaScript
#!/usr/bin/env node
|
|
// Mutation check: are the course's ordering decisions pinned by tests?
|
|
//
|
|
// node tools/mutation-check.mjs [name ...] [--mutants <file>] [--module NN]
|
|
// npm run mutation:check
|
|
//
|
|
// Reads tools/mutants.json (or --mutants), a list of mutants
|
|
// { "name": "Hazards in reverse order", "file": "src/content/hazards.ts",
|
|
// "find": "return [sink, hotMelt, defeat];", "replace": "return [defeat, hotMelt, sink];",
|
|
// "expect": ["test_09_hazard_order_melt_before_defeat"] }
|
|
// and checks each one against the TypeScript reference of the highest module that has one
|
|
// (or --module NN). The reference is staged once with the tests of every module up to it, as
|
|
// verify-references does, and its dependencies are installed. The unmutated reference must pass.
|
|
// Then, for each mutant (or only the ones named), the tool replaces `find` in `file` with
|
|
// `replace` (`find` must occur exactly once), runs the tests, and restores the file. A mutant
|
|
// is killed when every test in `expect` fails and the run is otherwise sane: every test of the
|
|
// unmutated run ran again (no test file failed to load). Prints one line per mutant with the
|
|
// tests that failed; exits 1 if any mutant survives, any `find` is stale, or any run is not sane.
|
|
//
|
|
// MUTATION_CMD replaces the test command, and then nothing is installed (the tool's tests use it).
|
|
// Whatever runs must write a vitest JSON report to `.mutation-report.json` in the staged directory.
|
|
import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
import { spawnSync } from "node:child_process";
|
|
import { join } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import { fail, isMain, moduleDirs } from "./lib.mjs";
|
|
import { planVerification, stage } from "./verify-references.mjs";
|
|
|
|
const REPORT = ".mutation-report.json";
|
|
const DEFAULT_CMD = `npx vitest run --reporter=json --outputFile=${REPORT}`;
|
|
const KEYS = ["name", "file", "find", "replace", "expect"];
|
|
|
|
export function parseArgs(argv) {
|
|
const opts = { names: [], mutants: null, module: null };
|
|
for (let i = 0; i < argv.length; i++) {
|
|
if (argv[i] === "--mutants") opts.mutants = argv[++i];
|
|
else if (argv[i] === "--module") opts.module = String(argv[++i]).padStart(2, "0");
|
|
else if (argv[i].startsWith("--")) fail(`mutation-check: unknown option ${argv[i]}\nusage: mutation-check.mjs [name ...] [--mutants <file>] [--module NN]`);
|
|
else opts.names.push(argv[i]);
|
|
}
|
|
return opts;
|
|
}
|
|
|
|
/** What is wrong with a mutant entry's shape, or [] if nothing. */
|
|
export function mutantProblems(m) {
|
|
if (typeof m !== "object" || m === null || Array.isArray(m)) return ["a mutant must be an object"];
|
|
const problems = [];
|
|
for (const key of Object.keys(m)) if (!KEYS.includes(key)) problems.push(`unknown key "${key}"`);
|
|
for (const key of ["name", "file", "find"]) {
|
|
if (typeof m[key] !== "string" || m[key] === "") problems.push(`${key} must be a non-empty string`);
|
|
}
|
|
if (typeof m.replace !== "string") problems.push("replace must be a string");
|
|
else if (m.replace === m.find) problems.push("replace is the same as find: nothing would change");
|
|
if (!Array.isArray(m.expect) || m.expect.length === 0 || !m.expect.every((t) => typeof t === "string" && /^test_\w+$/.test(t))) {
|
|
problems.push("expect must list at least one test name (test_...)");
|
|
}
|
|
return problems;
|
|
}
|
|
|
|
/** `text` with the mutant applied. Throws if `find` does not occur exactly once (a stale mutant). */
|
|
export function applyMutant(text, mutant) {
|
|
const count = text.split(mutant.find).length - 1;
|
|
if (count !== 1) {
|
|
throw new Error(`stale: \`find\` occurs ${count} times in ${mutant.file}, not once`);
|
|
}
|
|
return text.replace(mutant.find, () => mutant.replace);
|
|
}
|
|
|
|
/**
|
|
* Read a vitest JSON report: the names of the tests that passed and failed, and the test files
|
|
* that failed without a failing test (a file that did not load, or an error outside a test).
|
|
*/
|
|
export function readReport(json) {
|
|
const passed = new Set();
|
|
const failed = new Set();
|
|
const broken = [];
|
|
for (const file of json.testResults ?? []) {
|
|
const results = file.assertionResults ?? [];
|
|
for (const t of results) {
|
|
if (t.status === "passed") passed.add(t.title);
|
|
else if (t.status === "failed") failed.add(t.title);
|
|
}
|
|
if (file.status === "failed" && !results.some((t) => t.status === "failed")) {
|
|
broken.push(`${file.name}: ${String(file.message ?? "").split("\n")[0]}`);
|
|
}
|
|
}
|
|
return { passed, failed, broken };
|
|
}
|
|
|
|
/**
|
|
* The verdict on one mutant run, given the unmutated run (`baseline`) and this one (`run`), both
|
|
* from readReport: { killed, sane, failed: [names], problems: [text] }.
|
|
*/
|
|
export function judge(mutant, baseline, run) {
|
|
const problems = [];
|
|
const unknown = mutant.expect.filter((t) => !baseline.passed.has(t));
|
|
if (unknown.length) problems.push(`expects tests the reference doesn't have: ${unknown.join(", ")}`);
|
|
for (const b of run.broken) problems.push(`a test file did not run: ${b}`);
|
|
const missing = [...baseline.passed].filter((t) => !run.passed.has(t) && !run.failed.has(t));
|
|
if (missing.length && run.broken.length === 0) problems.push(`${missing.length} tests did not run, e.g. ${missing.slice(0, 3).join(", ")}`);
|
|
const sane = problems.length === 0;
|
|
const survivors = mutant.expect.filter((t) => !run.failed.has(t));
|
|
return { killed: sane && survivors.length === 0, sane, survivors, failed: [...run.failed].sort(), problems };
|
|
}
|
|
|
|
/** Run the tests in `dir`; returns readReport of what they wrote, or throws. */
|
|
function runTests(dir, cmd) {
|
|
rmSync(join(dir, REPORT), { force: true });
|
|
const r = spawnSync(cmd, { cwd: dir, shell: true, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
|
|
const report = join(dir, REPORT);
|
|
if (!existsSync(report)) throw new Error(`the tests wrote no report (exit ${r.status}):\n${(r.stdout || "") + (r.stderr || "")}`);
|
|
return readReport(JSON.parse(readFileSync(report, "utf8")));
|
|
}
|
|
|
|
/** The plan entry (as verify-references makes it) for module `num`, or the highest with a TS reference. */
|
|
export function targetModule(num) {
|
|
const plans = planVerification(moduleDirs(), 99).filter((p) => p.langs.includes("ts"));
|
|
const plan = num === null ? plans.at(-1) : plans.find((p) => p.num === num);
|
|
if (!plan) fail(num === null ? "mutation-check: no module has a TypeScript reference" : `mutation-check: module ${num} has no TypeScript reference`);
|
|
return plan;
|
|
}
|
|
|
|
function main() {
|
|
const opts = parseArgs(process.argv.slice(2));
|
|
const file = opts.mutants ?? fileURLToPath(new URL("./mutants.json", import.meta.url));
|
|
let mutants = JSON.parse(readFileSync(file, "utf8"));
|
|
if (!Array.isArray(mutants)) fail(`${file}: expected a list of mutants`);
|
|
const shapes = mutants.flatMap((m, i) => mutantProblems(m).map((p) => `mutant ${i + 1} (${m?.name ?? "no name"}): ${p}`));
|
|
if (shapes.length) fail(shapes.join("\n"));
|
|
if (opts.names.length) {
|
|
mutants = mutants.filter((m) => opts.names.includes(m.name));
|
|
if (mutants.length === 0) fail(`no mutant named ${opts.names.join(", ")} in ${file}`);
|
|
}
|
|
|
|
const plan = targetModule(opts.module);
|
|
const dir = stage(plan, "ts");
|
|
const cmd = process.env.MUTATION_CMD || DEFAULT_CMD;
|
|
let bad = 0;
|
|
try {
|
|
if (existsSync(join(dir, "package.json")) && !process.env.MUTATION_CMD) {
|
|
const r = spawnSync("npm", ["install", "--no-audit", "--no-fund", "--loglevel=error"], { cwd: dir, encoding: "utf8" });
|
|
if (r.status !== 0) throw new Error(`npm install failed:\n${r.stdout}${r.stderr}`);
|
|
}
|
|
console.log(`Module ${plan.num} TypeScript reference, tests of Modules ${plan.testModules[0].num}-${plan.num}`);
|
|
const baseline = runTests(dir, cmd);
|
|
if (baseline.failed.size || baseline.broken.length || baseline.passed.size === 0) {
|
|
throw new Error(`the unmutated reference does not pass: ${[...baseline.failed, ...baseline.broken].join(", ") || "no tests ran"}`);
|
|
}
|
|
console.log(`unmutated: ${baseline.passed.size} tests pass`);
|
|
for (const mutant of mutants) {
|
|
const path = join(dir, mutant.file);
|
|
if (!existsSync(path)) {
|
|
bad++;
|
|
console.log(`STALE ${mutant.name}: no file ${mutant.file}`);
|
|
continue;
|
|
}
|
|
const original = readFileSync(path, "utf8");
|
|
let mutated;
|
|
try {
|
|
mutated = applyMutant(original, mutant);
|
|
} catch (e) {
|
|
bad++;
|
|
console.log(`STALE ${mutant.name}: ${e.message}`);
|
|
continue;
|
|
}
|
|
writeFileSync(path, mutated);
|
|
let verdict;
|
|
try {
|
|
verdict = judge(mutant, baseline, runTests(dir, cmd));
|
|
} catch (e) {
|
|
verdict = { killed: false, sane: false, survivors: [], failed: [], problems: [e.message] };
|
|
} finally {
|
|
writeFileSync(path, original);
|
|
}
|
|
const failed = verdict.failed.join(", ") || "none";
|
|
if (verdict.killed) console.log(`killed ${mutant.name}: ${failed}`);
|
|
else {
|
|
bad++;
|
|
if (!verdict.sane) console.log(`INSANE ${mutant.name}: ${verdict.problems.join("; ")}`);
|
|
else console.log(`SURVIVED ${mutant.name}: ${verdict.survivors.join(", ")} passed; failed: ${failed}`);
|
|
}
|
|
}
|
|
} catch (e) {
|
|
console.error(`mutation-check: ${e.message}`);
|
|
process.exitCode = 1;
|
|
return;
|
|
} finally {
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
console.log(bad === 0 ? `${mutants.length} mutants killed` : `${bad} of ${mutants.length} mutants not killed`);
|
|
process.exit(bad === 0 ? 0 : 1);
|
|
}
|
|
|
|
if (isMain(import.meta.url)) main();
|