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>
116 lines
6.6 KiB
JavaScript
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 };
|
|
}
|