Files
RingBRP/tools/check-cited.mjs
T
slaguru666andClaude Opus 5 4d7d93abc8 check-cited: a citation marker nothing reads now fails the build
A misplaced marker failed open. CITE requires the comment to sit immediately
after its figure; put one word between them and the citation is silently
skipped while the run still reports OK with the same count as a clean tree:

  The longest fight seen ran **67** rounds.<!-- cite: fight-tail cut.longest -->
  check-cited: OK — 25 cited figures ...

That marker names a field the guard is supposed to refuse outright, and the
guard never saw it. Worse than an absent citation, because whoever wrote it
believes they added a check — and worse still in a guard written yesterday to
catch exactly this shape of defect.

Every `<!-- cite:` occurrence is now counted and compared against what the
parser actually read; any difference fails and prints the offending line.
Verified on both placement failures: a marker one word from its number, and a
marker on its own line with no number at all. The failure headline was also
wrong for this class -- it claimed a figure mismatched its artifact -- and now
covers both.

Found by a peer session writing a bad citation by accident while testing.

npm run check: 16 guards, exit 0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 10:24:32 +01:00

160 lines
7.3 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* A figure a scenario prints must still be the figure its artifact holds.
*
* check-firstblood and check-attackers both compare a baseline against a fresh run, so
* they catch the GAME changing. Neither of them reads the document. Re-record after a
* re-cast and the artifact updates, the guard goes green, and the paragraph goes on
* printing the old number with a citation under it saying where the new one lives. The
* citation makes it worse, not better: it tells a GM the figure is checked.
*
* So the citations are machine-readable. A figure copied out of an artifact is written
*
* a swing of **40**<!-- cite: first-blood cut.swing -->
*
* and this guard resolves every one of them against the named JSON. Copy a number wrong,
* or re-record and leave the prose alone, and the build stops with both values named.
*
* This is the check-bestiary arrangement one size down. That document is GENERATED from
* the game, so it cannot drift; a scenario is written by hand and cannot be, but the
* numbers inside it can be held to the same standard.
*
* node tools/check-cited.mjs check every citation in every scenario
* node tools/check-cited.mjs --list print what each one currently resolves to
*/
import { readFileSync, existsSync } from "node:fs";
import path from "node:path";
import { scenarioFiles } from "./check-scenarios.mjs";
const ROOT = path.resolve(path.dirname(new URL(import.meta.url).pathname.replace(/^\/([A-Za-z]:)/, "$1")), "..");
const LIST = process.argv.slice(2).includes("--list");
/* Artifact name as written in a citation -> the file it means. Adding a baseline here is
what makes it citeable; nothing else in the document needs to know. */
const ARTIFACTS = {
"first-blood": "tools/first-blood-baseline.json",
"attackers": "tools/attackers-baseline.json",
"fight-tail": "tools/fight-tail-baseline.json",
"lethality": "tools/lethality-baseline.json"
};
/* Fields that exist in an artifact but must never appear in prose. A sample maximum is
the clearest case: same party, same seeds, same runs, and renaming a config moved
fight-tail's `longest` from 71 to 90 rounds, because the stream is derived from the id.
It reads like a bound on the encounter and is a property of the label. Citing it would
be the n=1 mistake one level up, so the citation itself is refused. */
const UNCITEABLE = {
longest: "a sample maximum, not a bound — it moves by a third of its value on a re-seed. "
+ "Cite p99 instead: 'one fight in a hundred runs past N rounds' is stable to about three rounds."
};
const loaded = new Map();
function artifact(name) {
if (loaded.has(name)) return loaded.get(name);
const rel = ARTIFACTS[name];
if (!rel) return null;
const file = path.join(ROOT, rel);
const json = existsSync(file) ? JSON.parse(readFileSync(file, "utf8")) : null;
loaded.set(name, json);
return json;
}
/** "configs.cut.swing" or "cut.swing" — the leading container is optional. */
function resolve(json, dotted) {
const direct = dotted.split(".").reduce((o, k) => (o == null ? o : o[k]), json);
if (direct !== undefined) return direct;
for (const container of ["configs", "agents"]) {
const v = dotted.split(".").reduce((o, k) => (o == null ? o : o[k]), json?.[container]);
if (v !== undefined) return v;
}
return undefined;
}
/* The number immediately before the marker, allowing for markup and a trailing unit:
"**40**", "25.5%", "1.4x". Anchored to the end so it is the nearest one. */
const CITE = /([\d]+(?:\.[\d]+)?)\s*(?:%|x)?\**\s*(?:—|-|–)?\s*<!--\s*cite:\s*([\w-]+)\s+([\w.]+)\s*-->/g;
/* Every marker, however badly placed. The two counts must agree — see below. */
const ANY_CITE = /<!--\s*cite:[^>]*-->/g;
let checked = 0;
const problems = [];
const rows = [];
/* Deliberately NOT scenarioText(): that strips HTML comments, which is where the
citations live. The raw file is the thing being checked. */
for (const [rel, abs] of scenarioFiles()) {
const text = readFileSync(abs, "utf8");
/* A MARKER THIS GUARD CANNOT READ IS WORSE THAN NO MARKER AT ALL, because whoever wrote
it believes they added a check. CITE requires the comment to sit immediately after its
number; put one word between them and the citation is silently skipped while the run
still reports OK with the same count as a clean tree. Found by a peer session writing
a bad citation by accident. So every occurrence is counted and the totals must agree. */
const seen = (text.match(ANY_CITE) ?? []).length;
const read = [...text.matchAll(CITE)].length;
if (seen !== read) {
const unread = text.split("\n")
.map((l, i) => [i + 1, l])
.filter(([, l]) => ANY_CITE.test(l) && (ANY_CITE.lastIndex = 0) === 0
&& [...l.matchAll(CITE)].length < (l.match(ANY_CITE) ?? []).length);
problems.push(` ${rel}: ${seen - read} citation marker(s) not attached to a number, so `
+ `nothing checks them — a marker that looks like a citation and is never read is the `
+ `defect this guard exists for. The comment must follow its figure immediately `
+ `(\`**40**<!-- cite: ... -->\`), with nothing but markup between.`);
unread.forEach(([n, l]) => problems.push(` line ${n}: ${l.trim().slice(0, 110)}`));
}
for (const m of text.matchAll(CITE)) {
const [, printed, name, dotted] = m;
checked++;
const json = artifact(name);
if (!json) {
problems.push(` ${rel}: cites "${name}", which is not a known artifact `
+ `(${Object.keys(ARTIFACTS).join(", ")})`);
continue;
}
const held = resolve(json, dotted);
if (held === undefined) {
problems.push(` ${rel}: cites ${name} ${dotted}, which that artifact does not hold`);
continue;
}
const leaf = dotted.split(".").pop();
if (UNCITEABLE[leaf]) {
problems.push(` ${rel}: cites ${name} ${dotted}, which must not be quoted — ${UNCITEABLE[leaf]}`);
continue;
}
rows.push([rel, `${name} ${dotted}`, printed, String(held)]);
if (Number(printed) !== Number(held)) {
problems.push(` ${rel}: prints ${printed} but ${ARTIFACTS[name]} holds ${held} `
+ `(${dotted}). Re-recording updated the artifact and left the prose behind — fix the sentence.`);
}
}
}
if (LIST) {
for (const [f, src, printed, held] of rows) {
console.log(` ${f.padEnd(32)} ${src.padEnd(28)} prints ${printed.padStart(6)} holds ${held}`);
}
process.exit(0);
}
/* A guard that passes because it found nothing to check is the defect this suite has now
been caught by four times, so it is not allowed here: the citations exist, and a run
that cannot see them means the markers were reformatted away, not that the prose is
clean. */
if (!checked) {
console.error("check-cited: FAILED — no cited figures found at all. The scenarios carry "
+ "`<!-- cite: artifact path -->` markers next to every measured number; finding none "
+ "means they have been stripped or reformatted, not that there is nothing to check.");
process.exit(1);
}
if (problems.length) {
console.error("check-cited: FAILED — a scenario's cited figures do not match their artifacts");
problems.forEach(p => console.error(p));
process.exit(1);
}
const artifacts = new Set(rows.map(r => r[1].split(" ")[0]));
console.log(`check-cited: OK — ${checked} cited figures across ${scenarioFiles().length} scenario files `
+ `resolve against ${artifacts.size} baselines`);