R-290: the same spelling gap in tagsIn and the POWER marker

Swept every reader that parses a human-written marker. Two more had R-289's
fail-open.

tagsIn matched a literal [CUS:, so [cus: Spot], [Cus: Spot], [CUS : Spot] and
[ CUS: Spot] all read as nothing -- and it is the corpus reader behind
check-scenarios' skill validation and check-rollable's reachability, so a beat
spelled any of those four ways was checked by neither while both printed OK. The
corpus contains no such tag today; the 88-to-89 roll count during this work was c0
writing v0.18, verified against HEAD's reader on the same tree.

The POWER: marker had it with nothing covering it. bestiary.mjs and check-powers'
own scan both used the literal, so a creature added with "Power:" generates no
entry line and is never reported unclassified -- check-powers passes green on it,
measured on a clone with its dodge skills stripped so the dodger-count assertion
could not fire instead. Reader now takes POWER\s*: and stays uppercase, because
power: occurs in ordinary prose; an asymmetric counter names the rest, and was
measured against content.mjs first (41 strict, zero loose outside them).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
slaguru666
2026-09-13 15:47:35 +01:00
co-authored by Claude Opus 5
parent 9c2ce9d905
commit ae8179bd9b
4 changed files with 78 additions and 4 deletions
+36
View File
@@ -6804,3 +6804,39 @@ pass where three silently did not and the green that followed proved nothing:
| 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 |
## R-290 — the same spelling gap in the other two readers
Swept every reader that parses a human-written marker, after R-289 found `<!-- cast : -->`
invisible. Two more had it. The probe is the same each time and it costs a minute: put the
plausible misspellings to the reader as strings and print what it returns.
**`tagsIn` in check-scenarios.** `/\[CUS:\s*([^\]]+)\]/g` — no `i`, no space tolerance.
`[cus: Spot]`, `[Cus: Spot]`, `[CUS : Spot]` and `[ CUS: Spot]` all read as nothing. This is
the corpus reader behind check-scenarios' skill validation and check-rollable's reachability,
so a beat spelled any of those four ways was checked by neither guard while both printed OK.
Now `\[\s*CUS\s*:\s*...\]`, case-insensitive. **The corpus contains no such tag today** — the
88-to-89 roll count during this work was c0 writing v0.18, not the widening finding one, and
HEAD's reader gives 89 on the same tree. Checked rather than claimed, because a preventive fix
reported as a discovery is a lie about what the tree contained.
Worth recording that c0's `outcome-coverage.mjs` loose pattern already catches all four as
unparsed. Its guard was the only thing between those spellings and silence.
**The `POWER:` marker, which nothing covered.** `bestiary.mjs` and check-powers' own
classification scan both used the literal, so `Power:` and `POWER :` generated no entry line
and were never reported unclassified. A creature added tomorrow with `Power:` in its tactics
**passes check-powers green** — 41 powers classified, the forty-second invisible. Measured on a
clone with its dodge skills stripped, so the dodger-count assertion could not fire and take the
credit: HEAD OK, this change names the marker and the creature.
The reader now takes `POWER\s*:` and stays uppercase, because `power:` occurs in ordinary
tactics prose and matching it would invent powers. The other half is the counter: anything
power-marker-shaped, case and spacing free, that the strict reader did not consume. Measured
against content.mjs before wiring — 41 strict markers, zero loose ones outside them — so it
starts from a clean corpus rather than from an exception list.
**All three fixes share one shape, which is check-cited's from R-283 and is now the house
pattern**: widen the reader for the spellings you thought of, and add a looser counter that
names the one you did not. A marker nothing reads is worse than no marker, because whoever
wrote it believes the thing is declared.
+4 -1
View File
@@ -76,7 +76,10 @@ const essenceOf = spec => String(spec.role ?? "").replace(/^[A-Z]{3,}[^.]*\.\s*/
/** The POWER line is the mechanic a GM needs in front of them; the rest is staging. */
function powerOf(spec) {
const m = String(spec.tactics ?? "").match(/POWER:\s*([A-Z][A-Z '’-]*)\.\s*([^]*?)(?=\s*(?:POWER:|$))/);
/* `POWER\s*:` — the marker stays uppercase, because `power:` occurs in ordinary tactics
prose and matching it would invent powers, but a space before the colon must not hide one.
check-powers carries the counter that names the spellings this deliberately refuses. */
const m = String(spec.tactics ?? "").match(/POWER\s*:\s*([A-Z][A-Z '’-]*)\.\s*([^]*?)(?=\s*(?:POWER\s*:|$))/);
if (!m) return null;
let body = m[2].trim().replace(/\s+/g, " ");
// Trim to the first two sentences: the rule, and the qualification on it.
+29 -1
View File
@@ -40,7 +40,35 @@ const problems = [];
/* 1. Classification. The prose is the authority for what a power SAYS; powers.mjs is the
authority for what the harness does about it, and every one must be in both. */
const withPower = NPCS.filter(n => /POWER:/.test(n.tactics ?? ""));
const withPower = NPCS.filter(n => /POWER\s*:/.test(n.tactics ?? ""));
/* A POWER THIS READER CANNOT SEE IS A POWER NOBODY REPORTS UNCLASSIFIED. The marker was the
literal `POWER:` in both this guard and bestiary.mjs, so `Power:` or `POWER :` generated no
entry line AND passed the classification check — R-275's defect with the guard written to
prevent it looking straight past it, and R-289's fail-open one file over.
Widening the reader fixes the spellings we thought of. This is the other half: anything
power-marker-shaped, case and spacing free, that the strict reader did not consume. It is
deliberately looser, so a creature whose tactics prose happens to say "power:" trips it and
has to be reworded — which costs a sentence, where the opposite error costs a power the
whole book measures without. Measured against content.mjs before wiring: 41 strict markers,
zero loose ones outside them, so this starts from a clean corpus rather than from an
exception list. */
{
const STRICT = /POWER\s*:/g, LOOSE = /\bpowers?\s*:/gi;
for (const n of NPCS) {
const t = String(n.tactics ?? "");
const spans = [...t.matchAll(STRICT)].map(m => [m.index, m.index + m[0].length]);
for (const m of t.matchAll(LOOSE)) {
if (spans.some(([a, b]) => m.index >= a && m.index < b)) continue;
problems.push(` ${n.key}: tactics carry "${m[0]}", which looks like a POWER marker and is `
+ `not one either reader can see — "…${t.slice(Math.max(0, m.index - 40), m.index + 30)
.replace(/\s+/g, " ")}…". Spelled this way it generates no bestiary line and is never `
+ `reported unclassified. The marker is \`POWER:\` in capitals; if the word is ordinary `
+ `prose here, reword it.`);
}
}
}
for (const spec of withPower) {
const p = POWERS[spec.key];
if (!p) {
+9 -2
View File
@@ -54,10 +54,17 @@ export function skillsIn(body) {
.filter(x => x && !NOISE.has(x.toLowerCase()) && !/^\d/.test(x));
}
/* [CUS: ...] as written in the markdown, and the HTML-escaped form in the generators.
Case and spacing are tolerated. The literal `\[CUS:` was measured against the obvious
near-misses and `[cus: Spot]`, `[Cus: Spot]`, `[CUS : Spot]` and `[ CUS: Spot]` all read as
nothing — and reading as nothing is the bad direction here, because this function is what
check-scenarios validates skills through and what check-rollable checks reachability
through. A beat spelled any of those four ways was checked by neither guard while both
printed OK. Same fail-open as R-289's `<!-- cast : -->` and check-cited's three spellings of
`cite:`; found the same way, by putting the spellings to the reader instead of to the tree. */
export function tagsIn(text) {
// [CUS: ...] as written in the markdown, and the HTML-escaped form in the generators
const out = [];
for (const m of text.matchAll(/\[CUS:\s*([^\]]+)\]/g)) out.push(m[1]);
for (const m of text.matchAll(/\[\s*CUS\s*:\s*([^\]]+)\]/gi)) out.push(m[1]);
return out;
}