eead663 committed a document citing `step5 unsettledFactor` while the module
that would export it was still uncommitted in another session's working tree,
so `check-cited` fails on a fresh checkout of HEAD. step5-split.mjs is included
here to repair that; the key derives Act Four's 1.29 from the raw proportions,
which is the figure R-294 exists about.
check-figures (R-299) reports OK on the "24" it was written to catch. Its own
entry names why: the eight shapes were read off figures that are already cited,
so the vocabulary is learned from the marked figures and cannot contain the
phrasing of the one nobody marked. Line 1272 is a table cell whose measurement
status lives in the header two rows above it, and figuresIn reads one line.
check-unmarked reads structure instead of vocabulary: a bold percentage or
decimal in an opted-in document, and a bold number in a table whose header row
names a measurement. Twelve unmarked figures in CLEAN GROUND; eleven correct
and unheld, one the stale 24 that line 1142 had been citing correctly as 25 for
130 lines. Waivers carry a reason, an empty one is fatal, and every waiver
prints on a green build.
Two guards now cover one question, which is one too many. The right end state
is the structural classes folded in beside check-figures' SHAPES, keeping its
opt-in rule and ratchet. That fold is offered to its author rather than taken.
Twelve string tests on unmarkedIn (88 -> 100), both halves mutation-checked.
Twenty-two guards green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
151 lines
7.6 KiB
JavaScript
151 lines
7.6 KiB
JavaScript
/**
|
|
* A figure with no citation marker is a figure no guard can see.
|
|
*
|
|
* check-cited resolves every `<!-- cite: ... -->` in the corpus against the artifact it
|
|
* names, and it is exact. But it can only resolve the markers that EXIST. A number written
|
|
* without one is not checked, not reported, and not distinguishable from prose — and the
|
|
* guard's green line says "160 cited figures resolve" either way, which reads like coverage.
|
|
*
|
|
* That is how "a median 24 rounds" survived eleven desk passes in three places. Nothing was
|
|
* wrong with check-cited. The figure simply never entered its field of view.
|
|
*
|
|
* So this is the other half, and it is deliberately ASYMMETRIC — the shape this project
|
|
* arrived at for cast markers, beat tags and `POWER:`. The strict reader above says whether
|
|
* what is declared is right. This one says what was never declared at all:
|
|
*
|
|
* STRICT a published measurement — a bold percentage or decimal, or a bold number in a
|
|
* table whose own header calls the column a measurement — must carry `cite:`,
|
|
* or an `uncited:` waiver that says why. Anything else stops the build.
|
|
*
|
|
* LOOSE a bare percentage or decimal sitting in a sentence about medians, wipes or
|
|
* runs is COUNTED and named, never fatal. It is the class this guard knows it
|
|
* cannot judge: most are ratings and prose, some are the next stale 24.
|
|
*
|
|
* The waiver is not a silencer. It must carry a reason, an empty one is fatal (the call made
|
|
* for the empty cast marker in R-288), and every waiver is printed on every green build so
|
|
* that a decision to stop checking something stays visible rather than becoming the default.
|
|
*
|
|
* Skill ratings are not measurements. "Brawl 55%" is a fact about a sheet, not a figure
|
|
* derived from running the game, and bolding is how this corpus distinguishes them: the
|
|
* documents bold what they publish and leave ratings plain. That convention is the whole
|
|
* basis of the strict class, and it is why the loose class exists to catch its exceptions.
|
|
*
|
|
* node tools/check-unmarked.mjs check the corpus
|
|
* node tools/check-unmarked.mjs --loose also print every loose hit, not just the count
|
|
*/
|
|
import { readFileSync } from "node:fs";
|
|
import { playableFiles } from "./check-scenarios.mjs";
|
|
|
|
const LOOSE_LIST = process.argv.slice(2).includes("--loose");
|
|
|
|
/* Code fences hold printed baselines and harness output — figures that are already the
|
|
artifact rather than a claim about it. Blanked rather than removed so every index still
|
|
points at the right line. */
|
|
const CODE = /```[\s\S]*?```|`[^`\n]*`/g;
|
|
const scannable = text => text.replace(CODE, m => " ".repeat(m.length));
|
|
|
|
const PERCENT_OR_DECIMAL = String.raw`\d[\d,]*(?:\.\d+)?\s*%|\d[\d,]*\.\d+`;
|
|
const BOLD_MEASURED = new RegExp(String.raw`\*\*\s*(?:[~<>≈]\s*)?(${PERCENT_OR_DECIMAL})\s*\*\*`, "g");
|
|
const BOLD_NUMBER = /\*\*\s*(?:[~<>≈]\s*)?(\d[\d,]*(?:\.\d+)?\s*%?)\s*\*\*/g;
|
|
const BARE_FIGURE = new RegExp(String.raw`(?<![\*\d.])(${PERCENT_OR_DECIMAL})`, "g");
|
|
|
|
/* A marker may follow the figure directly or after the closing pipe of a table cell. */
|
|
const MARKER_AFTER = /^[\s|]*<!--\s*(cite|uncited)\s*:\s*([^>]*?)\s*-->/;
|
|
|
|
/* What makes a COLUMN a measurement. Note `%` is absent on purpose: a column of percentages
|
|
may be skill ratings, and the first draft of this guard called all 135 of them figures. */
|
|
const MEASURE_WORD = /\b(median|mean|average|wiped?|deaths?|dead|rounds?|runs?|rate|odds|chance|share|percentile)\b/i;
|
|
/* The same test for a sentence, plus the phrasings prose uses that a column heading does not. */
|
|
const MEASURED_SENTENCE = new RegExp(MEASURE_WORD.source + String.raw`|\bof the time\b|\bin \d+ (?:runs|sessions|seeds)\b`, "i");
|
|
|
|
/** Header of the markdown table a line belongs to, or null when it is not in one. */
|
|
function tableHeaders(lines) {
|
|
const owner = new Array(lines.length).fill(null);
|
|
for (let i = 1; i < lines.length; i++) {
|
|
if (!/^\s*\|[\s:|-]+\|\s*$/.test(lines[i])) continue; // the |---|---| separator
|
|
if (!/^\s*\|/.test(lines[i - 1])) continue;
|
|
const header = lines[i - 1];
|
|
for (let j = i + 1; j < lines.length && /^\s*\|/.test(lines[j]); j++) owner[j] = header;
|
|
}
|
|
return owner;
|
|
}
|
|
|
|
const lineIndexOf = (text, at) => text.slice(0, at).split("\n").length - 1;
|
|
|
|
export function unmarkedIn(label, raw) {
|
|
const text = scannable(raw);
|
|
const lines = raw.split("\n");
|
|
const owner = tableHeaders(lines);
|
|
const strict = [], loose = [], waived = [];
|
|
const claimed = new Set();
|
|
|
|
const consider = (m, why) => {
|
|
const end = m.index + m[0].length;
|
|
const after = MARKER_AFTER.exec(raw.slice(end, end + 120));
|
|
const li = lineIndexOf(raw, m.index);
|
|
const where = `${label}:${li + 1}`;
|
|
if (after) {
|
|
if (after[1] === "uncited") {
|
|
if (!after[2]) strict.push({ where, figure: m[1], why:
|
|
`carries an empty \`uncited:\` waiver. A waiver with no reason is a decision nobody `
|
|
+ `recorded — say why this figure is not citable, or cite it.` });
|
|
else waived.push({ where, figure: m[1], reason: after[2] });
|
|
}
|
|
claimed.add(`${li}:${m.index}`);
|
|
return; // a cite: is check-cited's business, not ours
|
|
}
|
|
strict.push({ where, figure: m[1], why, line: lines[li].trim() });
|
|
};
|
|
|
|
for (const m of text.matchAll(BOLD_MEASURED))
|
|
consider(m, "a published percentage or decimal carrying no marker");
|
|
|
|
for (const m of text.matchAll(BOLD_NUMBER)) {
|
|
const li = lineIndexOf(raw, m.index);
|
|
const head = owner[li];
|
|
if (!head || !MEASURE_WORD.test(head)) continue;
|
|
if (PERCENT_OR_DECIMAL && new RegExp(`^(?:${PERCENT_OR_DECIMAL})$`).test(m[1].trim())) continue;
|
|
consider(m, `a bold figure in a table whose header reads "${head.replace(/\|/g, "").trim()}"`);
|
|
}
|
|
|
|
for (const m of text.matchAll(BARE_FIGURE)) {
|
|
const li = lineIndexOf(raw, m.index);
|
|
if (claimed.has(`${li}:${m.index}`)) continue;
|
|
if (!MEASURED_SENTENCE.test(lines[li])) continue;
|
|
const end = m.index + m[0].length;
|
|
if (MARKER_AFTER.test(raw.slice(end, end + 120))) continue;
|
|
loose.push({ where: `${label}:${li + 1}`, figure: m[1], line: lines[li].trim() });
|
|
}
|
|
return { strict, loose, waived };
|
|
}
|
|
|
|
if (import.meta.main || process.argv[1]?.endsWith("check-unmarked.mjs")) {
|
|
const strict = [], loose = [], waived = [];
|
|
for (const [label, abs] of playableFiles()) {
|
|
const r = unmarkedIn(label, readFileSync(abs, "utf8"));
|
|
strict.push(...r.strict); loose.push(...r.loose); waived.push(...r.waived);
|
|
}
|
|
|
|
if (strict.length) {
|
|
console.error(`check-unmarked: FAILED — ${strict.length} published figure(s) carry no citation marker\n`);
|
|
for (const s of strict) {
|
|
console.error(` ${s.where} ${s.figure}`);
|
|
console.error(` ${s.why}`);
|
|
if (s.line) console.error(` ${s.line.slice(0, 96)}`);
|
|
}
|
|
console.error(`\n A figure without a marker is not checked by anything. Cite it against its`);
|
|
console.error(` artifact, or waive it with <!-- uncited: why this figure has no artifact -->.`);
|
|
process.exit(1);
|
|
}
|
|
|
|
if (waived.length) {
|
|
console.log(`check-unmarked: ${waived.length} figure(s) waived, each on the record:`);
|
|
for (const w of waived) console.log(` ${w.where} ${w.figure} — ${w.reason}`);
|
|
}
|
|
if (LOOSE_LIST) for (const l of loose) console.log(` loose ${l.where} ${l.figure} | ${l.line.slice(0, 80)}`);
|
|
|
|
console.log(`check-unmarked: OK — every published figure in ${playableFiles().length} playable files `
|
|
+ `carries a marker, ${waived.length} waived, ${loose.length} unbolded figure(s) in measured `
|
|
+ `sentences left unjudged (--loose to list them)`);
|
|
}
|