diff --git a/docs/REVIEW_LOG.md b/docs/REVIEW_LOG.md index c18d734..eab4dd5 100644 --- a/docs/REVIEW_LOG.md +++ b/docs/REVIEW_LOG.md @@ -6765,3 +6765,42 @@ strength of an agent the scenario does not cast. Replacing CLEAN GROUND's declaration with `` 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.** `` — 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 | diff --git a/tools/check-behaviour.mjs b/tools/check-behaviour.mjs index c0e51d9..a7ef3d5 100644 --- a/tools/check-behaviour.mjs +++ b/tools/check-behaviour.mjs @@ -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\n\nThe \`\` 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 \`\` comment below is read by two guards.\n\n\n`; + assert.deepEqual(keysOf(doc), [SIX]); +}); + +test("two real declarations are both seen, so neither can win by position", () => { + const doc = `\nfiller\n\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\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(""), [""]); + assert.deepEqual(keysOf("no marker here"), []); +}); + +test("spacing around the colon does not hide a declaration", () => { + /* R-289, found by writing these tests. `` 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 [``, ``, ``, + ``]) { + 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 [``, ``, ``]) { + assert.equal(castMarkersIn(m).length, 0); + assert.equal(castLikeIn(m).length, 1, `not flagged as cast-shaped: ${m}`); + } + assert.deepEqual(castLikeIn(``), [], "a readable marker is not an oddity"); + assert.deepEqual(castLikeIn("``"), [], "prose in backticks is not an oddity"); +}); + /* ---------------------------------------------------------------- */ await runAll(); diff --git a/tools/check-rollable.mjs b/tools/check-rollable.mjs index 3ed9d2e..d380c7d 100644 --- a/tools/check-rollable.mjs +++ b/tools/check-rollable.mjs @@ -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 \`\` (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 ` diff --git a/tools/declared-cast.mjs b/tools/declared-cast.mjs index 10ea0f7..48a37c9 100644 --- a/tools/declared-cast.mjs +++ b/tools/declared-cast.mjs @@ -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: `` 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(//gi)] - .map(m => ({ keys: m[1], line: text.slice(0, m.index).split("\n").length })); + return [...scannable(text).matchAll(//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(//gi)] + .map(m => [m.index, m.index + m[0].length]); + return [...text2.matchAll(//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. */