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>
This commit is contained in:
slaguru666
2026-09-13 15:41:19 +01:00
co-authored by Claude Opus 5
parent e3d222cacf
commit 2cf9721fa3
4 changed files with 141 additions and 4 deletions
+39
View File
@@ -6765,3 +6765,42 @@ strength of an agent the scenario does not cast.
Replacing CLEAN GROUND's declaration with `<!-- cast: -->` passes before this change and
fails after it, naming the line. A scenario with no marker still falls back, unchanged.
## R-289 — the cast reader, tested on strings, and the spelling that testing found
c0's point after R-286: both cast defects were demonstrated by editing CLEAN_GROUND.md in the
shared tree to watch what the reader did with it. That is how one session came within a
`git checkout` of another's uncommitted work — and it is also the weaker test. It holds one
shape at a time, it leaves nothing behind, and it cannot be run again next month.
Seven cases now live in `check-behaviour`, as strings: the real document's shape; the mention
moved above the declaration (the R-286 bug, which must still read the six); two real
declarations both seen, with line numbers surviving the code-blanking; a fenced declaration
ignored; present-and-empty told apart from absent (R-288); spelling; and the odd-marker
counter.
**Writing them found a live one.** `<!-- cast : ... -->` — one space before the colon —
matched nothing. Not an empty declaration that R-288 would refuse: *no declaration at all*, so
check-rollable fell back to the whole duty roster and printed its usual OK line having checked
every skill in the document against people the scenario does not cast. This is check-cited's
defect exactly, where `Cite:`, `CITE:` and `cite :` all failed open while the guard reported a
count identical to a clean tree. The reader now takes `cast\s*:`, case-insensitively.
**And the asymmetric half, because widening only fixes the spellings we thought of.**
`castLikeIn` recognises anything comment-shaped containing `cast\w*` that the strict reader did
not consume, and check-rollable names it: `cast =`, `casting:`, `the cast is` all fail the
build rather than silently declaring nobody. It is deliberately looser than the reader — an
ordinary comment near the casting section can trip it, and the message says prose belongs in
backticks, where nothing here looks at it. A false alarm costs a reword; the opposite error
costs a guard that checks the wrong six people and says OK.
**The tests were then checked for being load-bearing**, by mutating the reader in a worktree
and reading which of them went red — each mutation asserted to have applied, after a first
pass where three silently did not and the green that followed proved nothing:
| mutation | red |
|---|---|
| `cast\s*:` back to `cast:` | spacing |
| code-span blanking removed | four, including both R-286 cases |
| marker list truncated to the first hit | two declarations both seen |
| odd-marker counter disabled | cast-shaped marker named |
+63
View File
@@ -31,6 +31,7 @@ import { ROLES, TRADES, INDUCTION, TIERS, TRADE_BANDS, CHARACTERISTIC_DICE }
from "../postings.mjs";
import { STARTER_AUTHORITY, STARTER_GROUPS, STARTER_CASE, STARTER_TEAM }
from "./scenario-starter.mjs";
import { castMarkersIn, castLikeIn } from "./declared-cast.mjs";
let passed = 0, failed = 0;
// Async tests are awaited in order rather than fired off — a failure that lands after
@@ -756,6 +757,68 @@ test("R-255 the supporter's hide reaches each location at the right value, flyin
`grounded hide is ${Math.floor(hide / 2)}, which an ordinary round still cannot beat`);
});
/* ── R-286 / R-288 / R-289: who a scenario casts ─────────────────────────────────────
ON STRINGS, NOT ON THE SCENARIO. Both of these defects were first shown by editing
CLEAN_GROUND.md in the shared tree to see what the reader did with it, which is how one
session came within a `git checkout` of another's uncommitted work, and it is also a worse
test: it can only hold one shape at a time and it leaves nothing behind. c0 made the point.
The shapes below are the whole finding, they run on every build, and they cost nothing. */
const SIX = "pc_ashcroft, pc_bhattacharya, pc_renshaw, pc_braithwaite, pc_pollard, pc_okonkwo";
const keysOf = t => castMarkersIn(t).map(m => m.keys);
test("a declaration is read, and a mention of one in backticks is not", () => {
// The real document's shape: the marker, then the warning that quotes it. R-286.
const doc = `## Casting\n\n<!-- cast: ${SIX} -->\n\nThe \`<!-- cast: -->\` comment above is read by two guards.\n`;
assert.deepEqual(keysOf(doc), [SIX]);
});
test("the mention above the declaration still reads the declaration", () => {
/* THE BUG ITSELF. Before R-286 this returned the empty capture, check-rollable fell back to
the whole duty roster and printed the same OK line. The arrangement was only ever correct
because the real marker happened to sort first. */
const doc = `The \`<!-- cast: -->\` comment below is read by two guards.\n\n<!-- cast: ${SIX} -->\n`;
assert.deepEqual(keysOf(doc), [SIX]);
});
test("two real declarations are both seen, so neither can win by position", () => {
const doc = `<!-- cast: pc_ashcroft -->\nfiller\n<!-- cast: ${SIX} -->\n`;
const found = castMarkersIn(doc);
assert.equal(found.length, 2);
assert.deepEqual(found.map(m => m.line), [1, 3], "line numbers must survive the code blanking");
});
test("a declaration inside a fenced block is documentation, not a cast", () => {
assert.deepEqual(keysOf("```md\n<!-- cast: " + SIX + " -->\n```\n"), []);
});
test("a declaration that names nobody is read as present and empty", () => {
// R-288 needs to tell this from "no marker at all", which falls back on purpose.
assert.deepEqual(keysOf("<!-- cast: -->"), [""]);
assert.deepEqual(keysOf("no marker here"), []);
});
test("spacing around the colon does not hide a declaration", () => {
/* R-289, found by writing these tests. `<!-- cast : ... -->` matched nothing, so the guard
saw no declaration, widened to the full roster and went green — and R-288 could not catch
it because nothing was present to be empty. check-cited's three spellings, again. */
for (const m of [`<!-- cast : ${SIX} -->`, `<!-- Cast: ${SIX} -->`, `<!--cast:${SIX}-->`,
`<!-- CAST : ${SIX} -->`]) {
assert.deepEqual(keysOf(m), [SIX], `this spelling reads nothing: ${m}`);
}
});
test("a cast-shaped marker the reader cannot parse is named, not skipped", () => {
/* The asymmetric half. Widening the reader fixes the spellings we thought of; this is what
names the one we did not, instead of silently declaring nobody. */
for (const m of [`<!-- cast = ${SIX} -->`, `<!-- casting: ${SIX} -->`, `<!-- the cast is ${SIX} -->`]) {
assert.equal(castMarkersIn(m).length, 0);
assert.equal(castLikeIn(m).length, 1, `not flagged as cast-shaped: ${m}`);
}
assert.deepEqual(castLikeIn(`<!-- cast: ${SIX} -->`), [], "a readable marker is not an oddity");
assert.deepEqual(castLikeIn("`<!-- cast = x -->`"), [], "prose in backticks is not an oddity");
});
/* ---------------------------------------------------------------- */
await runAll();
+8 -1
View File
@@ -39,7 +39,7 @@ import { SKILL_CATALOGUE } from "./content.mjs";
import { ROSTER } from "./roster.mjs";
import { expandFromRegister } from "./expand-spec.mjs";
import { scenarioFiles, scenarioText, tagsIn, skillsIn } from "./check-scenarios.mjs";
import { castMarkersIn } from "./declared-cast.mjs";
import { castMarkersIn, castLikeIn } from "./declared-cast.mjs";
/**
* The lowest chance that still counts as a route, in percent.
@@ -98,6 +98,13 @@ for (const [label, path] of scenarioFiles()) {
measures the fight cannot disagree about who is cast — and so a marker mentioned in
prose is not read as a second declaration. CLEAN_GROUND.md carries one of each. */
const markers = castMarkersIn(raw);
for (const odd of castLikeIn(raw)) {
problems.push(`${label}: line ${odd.line} looks like a cast declaration and is not one this `
+ `guard can read — \`${odd.raw}\`. The shape is \`<!-- cast: key, key -->\` (case and `
+ `spaces around the colon are fine). Unread, it declares nothing and every skill in the `
+ `document is checked against the whole duty roster instead. If it is prose, put it in `
+ `backticks or a fenced block, where nothing here looks at it.`);
}
if (markers.length > 1) {
problems.push(`${label}: declares a cast ${markers.length} times, at lines `
+ `${markers.map(m => m.line).join(", ")}. This guard read the first and held every `
+31 -3
View File
@@ -42,10 +42,38 @@ const DROPPED_AT_FOUR = ["pollard", "okonkwo"];
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) {
const scannable = text.replace(CODE, m => " ".repeat(m.length));
return [...scannable.matchAll(/<!--\s*cast:\s*([^>]*?)\s*-->/gi)]
.map(m => ({ keys: m[1], line: text.slice(0, m.index).split("\n").length }));
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. */