Files
slaguru666andClaude Opus 5 8dbe885a34 Add check-seam: one definition of the NPC population
collectSpecs() in tools/all-specs.mjs is the single place that decides
which specs exist. Nine modules were importing NPCS and PREGENS straight
from content.mjs instead, so each carried its own idea of the population
and measured a different subset of the game. check-lethality was the
clearest case: it scored 47 creatures and reported OK for all 184.

check-seam (guard 24, first in the suite) scans every module and fails if
anything but all-specs.mjs names NPCS or PREGENS in a content.mjs import.
The nine violators are re-pointed at the seam.

Re-recording check-focus's baseline against the full 184 raised it from
47 packs to 138 and surfaced one creature focus fire does not help: the
dun cow. Rather than re-record that away, the guard now requires the
advice section of BESTIARY.md to name every such exception, and
bestiary.mjs generates the sentence.

The first version of that check asked whether the name appeared anywhere
in BESTIARY.md, which every creature's own heading satisfies — deleting
the exception sentence still passed. It reads only the
"Shooting at something that moves" section now, and the mutation test
fails as it should.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 23:52:18 +01:00

88 lines
4.2 KiB
JavaScript

/**
* check-seam — one list of creatures, and one way to reach it.
*
* `tools/all-specs.mjs` opens by calling it "this repository's one standing law" that a
* definition may not exist twice, and then names the rule: a scenario cast is any exported
* array whose entries carry characteristics, so a new scenario is covered the day it is
* written rather than the day somebody remembers to add it to a list.
*
* Nothing enforced that. Importing `NPCS` from content.mjs still worked, and it kept
* working right up to the moment the bestiary moved into its own files — at which point
* every tool holding that import silently began describing 47 creatures out of 184.
* Nothing failed. The tools carried on, and each one was wrong in its own way:
*
* build-packs.mjs built a compendium the 100 new creatures were not in, so they
* validated, were drawn, had prompts written, and did not exist
* in the game
* check-lethality.mjs measured 47 and printed OK — a regression guard blind to two
* thirds of what it guards
* bestiary.mjs wrote a reference document missing the creatures
* check-powers.mjs reconciled its counts against that document while reading a
* different population, so it called the document wrong when the
* document was right
*
* The individual fixes are each one line. They are also each a thing somebody has to
* remember, which is what failed the first time. This is the guard that makes forgetting
* loud: the population has one door, and all-specs.mjs is the only file allowed through
* it.
*
* bun tools/check-seam.mjs
*/
import { readdir, readFile } from "node:fs/promises";
import path from "node:path";
const ROOT = path.join(path.dirname(new URL(import.meta.url).pathname), "..");
/* The bindings that ARE the population. A tool reading any of these from content.mjs is
reading a subset and calling it the whole, which is the defect this exists to catch. */
const POPULATION = ["NPCS", "PREGENS"];
/* all-specs.mjs is the door. It reads content.mjs precisely so that nothing else has to,
and a guard that forbade it would forbid the fix as well as the fault. */
const ALLOWED = new Set(["tools/all-specs.mjs"]);
/* Multi-line on purpose. build-packs.mjs spells its import across two lines and a
single-line grep does not see it — which is exactly how it was missed when this set was
counted by hand, and a guard that repeats the mistake it is checking for is no guard. */
const IMPORT_RE = /import\s*\{([^}]*)\}\s*from\s*["'](\.{1,2}\/[^"']*content\.mjs)["']/gs;
async function* walk(dir) {
for (const e of await readdir(dir, { withFileTypes: true })) {
if (e.name === "node_modules" || e.name.startsWith(".")) continue;
const p = path.join(dir, e.name);
if (e.isDirectory()) yield* walk(p);
else if (e.name.endsWith(".mjs")) yield p;
}
}
const problems = [];
let scanned = 0;
for await (const file of walk(ROOT)) {
const rel = path.relative(ROOT, file);
scanned++;
if (ALLOWED.has(rel)) continue;
const src = await readFile(file, "utf8");
for (const m of src.matchAll(IMPORT_RE)) {
const named = m[1].split(",").map(s => s.trim().split(/\s+as\s+/)[0].trim()).filter(Boolean);
const hit = named.filter(n => POPULATION.includes(n));
if (!hit.length) continue;
const line = src.slice(0, m.index).split("\n").length;
problems.push(`${rel}:${line} imports ${hit.join(", ")} from content.mjs — `
+ `read collectSpecs() from ./all-specs.mjs instead, or this tool will describe `
+ `content.mjs's creatures and call them the bestiary`);
}
}
if (problems.length) {
console.error(`check-seam: FAILED — ${problems.length} tool(s) read the creature `
+ `population directly instead of through all-specs.mjs`);
for (const p of problems) console.error(" " + p);
console.error(`\n all-specs.mjs is the only file allowed to import ${POPULATION.join(" or ")}. `
+ `Everything else reads collectSpecs(), so a new source file reaches every tool at once.`);
process.exit(1);
}
console.log(`check-seam: OK — ${scanned} modules scanned, only all-specs.mjs reads `
+ `${POPULATION.join("/")} from content.mjs`);