Files
RingBRP/tools/declared-cast.mjs
T
slaguru666andClaude Opus 5 2cf9721fa3 R-289: test the cast reader on strings, and fix the spelling that found
Both earlier cast defects were shown by editing CLEAN_GROUND.md in the shared tree,
which is how this session came within a git checkout of c0's uncommitted work and
is also the weaker test. Seven cases now live in check-behaviour as strings.

Writing them found a live one: "<!-- cast : ... -->", one space before the colon,
matched nothing -- not an empty declaration R-288 would refuse but no declaration
at all, so check-rollable widened to the whole duty roster and printed its usual OK
line. check-cited's three spellings again. The reader now takes cast\s*:.

castLikeIn adds the asymmetric half: anything comment-shaped containing cast\w* the
reader did not consume is named by check-rollable, so a spelling nobody anticipated
fails the build instead of silently declaring nobody. Looser than the reader on
purpose -- a false alarm costs a reword, the opposite error costs a guard that
checks the wrong six people and says OK.

Tests then mutation-checked for being load-bearing, each mutation asserted to have
applied after a first pass where three silently did not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 15:41:19 +01:00

116 lines
6.6 KiB
JavaScript

/**
* CLEAN GROUND's declared cast, read from the document rather than copied out of it.
*
* Two guards now measure that scenario's own encounter — check-firstblood and
* check-attackers — and both must be measuring the people the document actually casts.
* A cast written out by hand in either of them would go on measuring the old six after a
* re-cast and report success, which is the defect this project keeps finding in itself.
* R-267 committed exactly that inside the guard built to prevent a weaker version of it.
*
* One reader rather than two, because two copies of this would be the duplication
* check-rules exists to forbid, and would let the two guards disagree about who is in the
* fight. check-rollable established the `<!-- cast: ... -->` marker; this reads the same
* one and asks nothing further of the document.
*
* NOT named scenario-something: check-scenarios sweeps `tools/scenario-*` into the
* scenario corpus, so a file with that prefix has its prose read as a scenario's and the
* marker quoted below read as a real cast declaration. It was, and check-rollable said so.
*/
import { existsSync, readFileSync } from "node:fs";
import path from "node:path";
const ROOT = path.resolve(path.dirname(new URL(import.meta.url).pathname.replace(/^\/([A-Za-z]:)/, "$1")), "..");
export const SCENARIO = path.join(ROOT, "docs", "scenarios", "CLEAN_GROUND.md");
/* The scaling note drops Pollard at five and Okonkwo as well at four, so the cut is the
declared cast less those two. Named rather than sliced: "the first four" would survive
a reordering of the cast list and quietly measure different people. */
const DROPPED_AT_FOUR = ["pollard", "okonkwo"];
/* A DECLARATION AND A MENTION OF ONE LOOK IDENTICAL TO A REGEX. CLEAN_GROUND.md carries the
real marker under "## Casting" and, ten lines later, the warning that explains it —
`<!-- cast: -->` inside backticks, which matches this pattern with an empty capture. Both
readers took `.match()`, the FIRST hit, so the whole arrangement worked because the
declaration happens to come first. Move the warning above the list, or add a second
worked example, and the guards read a cast of nobody without saying so; check-firstblood
and check-attackers would then measure a fight this scenario does not cast, which is the
defect declared-cast.mjs was written to prevent, arriving through the door it left open.
R-268 already had this exact bug from the other side, when a tools file's own example was
read as a real declaration.
So: code spans and fenced blocks are blanked before scanning (padded, so line numbers
still point at the source), and more than one surviving marker is fatal rather than
first-wins. */
const CODE = /```[\s\S]*?```|`[^`\n]*`/g;
const scannable = text => text.replace(CODE, m => " ".repeat(m.length));
const lineAt = (text, i) => text.slice(0, i).split("\n").length;
/* `cast\s*:` rather than `cast:`. Tested on strings rather than by editing the scenario, and
the spacing is why: `<!-- cast : ... -->` was invisible — no marker, so check-rollable fell
back to the whole duty roster and went green, and R-288 could not catch it because there was
nothing present to be empty. Putting a space before a colon is not an exotic mistake. It is
the check-cited defect exactly, where `Cite:`, `CITE:` and `cite :` all failed open while the
guard reported a count identical to a clean tree. */
export function castMarkersIn(text) {
return [...scannable(text).matchAll(/<!--\s*cast\s*:\s*([^>]*?)\s*-->/gi)]
.map(m => ({ keys: m[1], line: lineAt(text, m.index) }));
}
/* ANYTHING a person might have meant as a cast declaration, however spelled. Deliberately
ASYMMETRIC with the reader above: the reader accepts what the format specifies, this
recognises anything cast-shaped, and the difference is reported rather than skipped. Widening
`castMarkersIn` fixes the spelling we thought of; this is what names the one we did not. A
marker nothing reads is worse than no marker, because whoever wrote it believes the cast is
declared.
`cast\w*` catches casting/caster too, so an ordinary comment near the casting section can trip
it. That is the intended direction: a false alarm costs a reword, and the message says a
comment meant as prose belongs in backticks or a fence, where nothing here looks at it. The
opposite error costs a guard that checks the wrong six people and says OK. */
export function castLikeIn(text) {
const text2 = scannable(text);
const covered = [...text2.matchAll(/<!--\s*cast\s*:\s*([^>]*?)\s*-->/gi)]
.map(m => [m.index, m.index + m[0].length]);
return [...text2.matchAll(/<!--[^>]*\bcast\w*\b[^>]*-->/gi)]
.filter(m => !covered.some(([a, b]) => m.index >= a && m.index < b))
.map(m => ({ raw: m[0].trim().slice(0, 80), line: lineAt(text, m.index) }));
}
/** The six the document declares, without their `pc_` prefixes. Fatal if it declares none. */
export function declaredCast(who = "scenario-cast") {
if (!existsSync(SCENARIO)) {
console.error(`${who}: ${path.relative(ROOT, SCENARIO)} is missing, and the cast is read `
+ `from it. Nothing here can be measured against a scenario that is not there.`);
process.exit(1);
}
const rel = path.relative(ROOT, SCENARIO);
const markers = castMarkersIn(readFileSync(SCENARIO, "utf8"));
if (!markers.length) {
console.error(`${who}: ${rel} declares no cast. Add the `
+ `<!-- cast: ... --> marker check-rollable already reads, or this guard is measuring `
+ `a fight the scenario no longer casts.`);
process.exit(1);
}
if (markers.length > 1) {
console.error(`${who}: ${rel} carries ${markers.length} cast markers, at lines `
+ `${markers.map(m => m.line).join(", ")}. Reading the first one is how this guard would `
+ `measure a fight the scenario does not cast while reporting success. One declaration; `
+ `write any example inside backticks or a fenced block, where this reader ignores it.`);
process.exit(1);
}
return markers[0].keys.split(",").map(x => x.trim().replace(/^pc_/, "")).filter(Boolean);
}
/** The declared cast and its four-player cut, checked for the shape the guards recorded. */
export function castAndCut(who = "scenario-cast") {
const line = declaredCast(who);
const cut = line.filter(k => !DROPPED_AT_FOUR.includes(k));
if (cut.length !== 4 || line.length !== 6) {
console.error(`${who}: CLEAN GROUND now casts ${line.length} and its four-player cut works `
+ `out at ${cut.length}. The recorded fights are six against six and four against three; `
+ `re-read the scaling note and re-record with --update rather than reverting.`);
process.exit(1);
}
return { line, cut };
}