Skip to content

Commit 52db5df

Browse files
os-steveclaude
andauthored
fix(gate): check-role-word's green line reports what it READ, not just the ledger (#9931)
The success line's only number came from the ledger (`current`, which on a green run is exactly the baseline), so "the debt is paid" and "the scan read nothing" printed the same sentence. It is safe today only by accident: with a non-empty ledger a dead scan is caught by the ratchet-DOWN branch, and that protection evaporates the moment this ratchet succeeds at its purpose. Both success texts now state the input volume, per root — `existsSync` skips a missing root in silence, so a bare total would hide one root behind the other. `--update` carries it too, where the ambiguity is destructive rather than merely misleading: it rewrites the baseline from the tree it just read. Visibility only. Verdicts, populations and exit codes are byte-for-byte what they were; the self-test pins the PROPERTY (a scanned tree and an unscanned one cannot render the same success with the ledger empty), not the new wording. Claude-Session: https://claude.ai/code/session_01XqDQYVU5smx29ts9pAErja Co-authored-by: Claude <noreply@anthropic.com>
1 parent ae555f4 commit 52db5df

1 file changed

Lines changed: 149 additions & 4 deletions

File tree

scripts/check-role-word.mjs

Lines changed: 149 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,98 @@ function newUseMessage(file, count) {
126126
);
127127
}
128128

129+
// ── What a PASSING run tells the reader (#9910) ────────────────────────
130+
//
131+
// The green line used to be, in full:
132+
//
133+
// check-role-word: OK (43 baselined file(s), no new occurrences).
134+
//
135+
// Every number in it came from the LEDGER. `current` holds only the files that
136+
// still carry the word, and a green run is precisely the run where its key set
137+
// equals the baseline's — so the population actually READ (`files`, walked over
138+
// ROOTS) never reached the output at all.
139+
//
140+
// That was safe only while the ledger was non-empty, and safe by ACCIDENT: a
141+
// scan that reads nothing drops every baselined file out of `current`, and the
142+
// ratchet-DOWN branch below then reports one "clean/gone" problem per baselined
143+
// file. Measured on this tree — ROOTS pointed at two non-existent directories,
144+
// ledger untouched — `43 problem(s)`, exit 1. That protection is a side effect
145+
// of still owing debt, and it evaporates at the exact moment this ratchet
146+
// succeeds at its purpose: with the ledger empty, `current = {}` and
147+
// `baseline = {}` raise nothing in either direction, and the same ablation
148+
// printed
149+
//
150+
// check-role-word: OK (0 baselined file(s), no new occurrences). EXIT=0
151+
//
152+
// — a gate that read zero files, over an empty ledger, reporting success. So
153+
// the green body states the INPUT VOLUME, which no clean tree can make vacuous:
154+
// a zero in `0 .md/.mdx file(s) read` is an alarm a reader can act on, whereas
155+
// a zero in `0 baselined file(s)` says nothing at all.
156+
//
157+
// PER ROOT, not just a total. `walk()` runs behind `existsSync(root)`, so a
158+
// root that is renamed or moved away is skipped in SILENCE, and a bare total
159+
// hides that behind whatever the other root still contributes. Every configured
160+
// root is therefore named on every green run — including one that contributed
161+
// nothing, because a root omitted from the line is the same silence in a new
162+
// place.
163+
//
164+
// Verdicts, populations and exit codes are untouched. This is the success text
165+
// and nothing else: the gate refuses exactly what it refused before.
166+
167+
/**
168+
* The input volume, as one clause, shared by BOTH success paths so they cannot
169+
* drift apart. Derived from EXTENSIONS rather than spelling the suffixes again,
170+
* so widening the scan cannot leave the sentence describing the old one.
171+
*
172+
* @param {{root: string, files: number}[]} scanned per-ROOT counts, in ROOTS order
173+
* @returns {string}
174+
*/
175+
function scanClause(scanned) {
176+
const total = scanned.reduce((n, r) => n + r.files, 0);
177+
const kinds = [...EXTENSIONS].sort().join('/');
178+
const perRoot = scanned.map((r) => `${r.root} ${r.files}`).join(', ');
179+
return `${total} ${kinds} file(s) read across ${scanned.length} root(s) — ${perRoot}`;
180+
}
181+
182+
/**
183+
* The GREEN body, named and pure so the self-test can assert on the sentence an
184+
* author actually reads — the counts are interpolated, so reading this file's
185+
* SOURCE is not evidence about the rendered text.
186+
*
187+
* @param {{root: string, files: number}[]} scanned per-ROOT counts, in ROOTS order
188+
* @param {Record<string, number>} ledger files still carrying the word (== the
189+
* baseline on any run that reaches this line)
190+
* @returns {string}
191+
*/
192+
function successSummary(scanned, ledger) {
193+
const fileCount = Object.keys(ledger).length;
194+
const occurrences = Object.values(ledger).reduce((n, c) => n + c, 0);
195+
return (
196+
'check-role-word: OK, no new occurrences of the reserved word.\n'
197+
+ ` Scanned: ${scanClause(scanned)}.\n`
198+
+ ` Ledger: ${fileCount} baselined file(s) still carrying it `
199+
+ `(${occurrences} occurrence(s)) in ${BASELINE_PATH}.`
200+
);
201+
}
202+
203+
/**
204+
* The `--update` confirmation. It carries the scan clause for the same reason,
205+
* and with more at stake: `--update` REWRITES the baseline from the current
206+
* tree, so running it over a dead scan does not merely print a misleading
207+
* number — it writes `{}` over the ledger, and its old line (`role-word
208+
* baseline updated: 0 file(s).`) read exactly like a debt fully paid.
209+
*
210+
* @param {{root: string, files: number}[]} scanned per-ROOT counts, in ROOTS order
211+
* @param {Record<string, number>} ledger the freshly written baseline
212+
* @returns {string}
213+
*/
214+
function updateSummary(scanned, ledger) {
215+
return (
216+
`role-word baseline updated: ${Object.keys(ledger).length} file(s) baselined `
217+
+ `from ${scanClause(scanned)}.`
218+
);
219+
}
220+
129221
function selfTest() {
130222
const failures = [];
131223
const expect = (label, cond) => {
@@ -173,22 +265,75 @@ function selfTest() {
173265
+ '(marking the improvement path maintainer-only would teach the opposite of the rule)',
174266
!RATCHET_EXPANSION_OFFER.test(ratchetDown) && ratchetRemedyCarriesAuthority(ratchetDown));
175267

268+
// ── The green body reports what was READ (#9910) ──────────────────────
269+
//
270+
// Interpolated counts again, so the source proves nothing about the rendered
271+
// sentence — driven here instead, in the states that used to be identical.
272+
const SCANNED = [{ root: 'content/docs', files: 179 }, { root: 'skills', files: 36 }];
273+
const DEAD_SCAN = [{ root: 'content/docs', files: 0 }, { root: 'skills', files: 0 }];
274+
const PAID_OFF = {};
275+
276+
const greenPaid = successSummary(SCANNED, PAID_OFF);
277+
const greenDead = successSummary(DEAD_SCAN, PAID_OFF);
278+
279+
// (1) THE property this card exists for, pinned as a property and not as
280+
// text: once the debt is paid — the state this ratchet is BUILT to reach — a
281+
// tree that was read and one that was not must not render the same success.
282+
// A pin on the new sentence's wording would rot at the first rephrasing, and
283+
// worse, a rephrasing that went back to printing only ledger numbers would
284+
// keep such a pin green.
285+
expect('#9910 — the GREEN body renders DIFFERENTLY for a scanned tree and an unscanned one '
286+
+ 'with the ledger EMPTY (the state in which every ledger-derived number is 0 either way)',
287+
greenPaid !== greenDead);
288+
289+
// (2) The alarm has to be legible, not merely different: (1) alone passes on
290+
// any two strings that differ at all.
291+
expect('#9910 — an unscanned tree prints a ZERO input volume a reader can act on',
292+
/\b0 [^\n]*file\(s\) read\b/.test(greenDead));
293+
expect('#9910 — a scanned tree prints its real input volume, not a ledger-derived count',
294+
/\b215 [^\n]*file\(s\) read\b/.test(greenPaid));
295+
296+
// (3) `walk()` sits behind `existsSync(root)`, so a root that moved away is
297+
// skipped in silence. A line that named only the roots which contributed
298+
// would put that silence straight back, one root at a time.
299+
const oneRootGone = successSummary(
300+
[{ root: 'content/docs', files: 179 }, { root: 'skills', files: 0 }], PAID_OFF);
301+
expect('#9910 — a root that contributed NOTHING is still named, with its zero (existsSync '
302+
+ 'skips a missing root silently, so dropping it from the line hides the same failure)',
303+
/\bskills 0\b/.test(oneRootGone) && oneRootGone !== greenPaid);
304+
305+
// (4) The same ambiguity on the privileged path, where it is destructive
306+
// rather than merely misleading: `--update` rewrites the baseline from the
307+
// tree it just read.
308+
expect('#9910 — the --update confirmation states its input volume too, so re-baselining '
309+
+ 'over a dead scan cannot read like a debt fully paid',
310+
updateSummary(DEAD_SCAN, PAID_OFF) !== updateSummary(SCANNED, PAID_OFF));
311+
176312
if (failures.length) {
177313
for (const f of failures) console.error(` x self-test: ${f}`);
178314
console.error(`\ncheck-role-word --self-test: ${failures.length} failure(s).\n`);
179315
process.exit(1);
180316
}
181317
console.log(
182318
'OK self-test: the NEW-use remedy marks baseline expansion as maintainer-only, the predicate '
183-
+ 'rejects an unmarked offer, and the ratchet-DOWN remedy stays the author\'s own.',
319+
+ 'rejects an unmarked offer, the ratchet-DOWN remedy stays the author\'s own, and both '
320+
+ 'success texts report what was READ \u2014 so a scanned tree and an unscanned one cannot print '
321+
+ 'the same result once the ledger is empty.',
184322
);
185323
process.exit(0);
186324
}
187325

188326
if (process.argv.includes('--self-test')) selfTest();
189327

190328
const files = [];
191-
for (const root of ROOTS) if (existsSync(root)) walk(root, files);
329+
/* The input volume, per root, recorded as the scan runs — the same pass, not a
330+
* second one. `files` is built exactly as before; only the tally is new. */
331+
const scanned = [];
332+
for (const root of ROOTS) {
333+
const before = files.length;
334+
if (existsSync(root)) walk(root, files);
335+
scanned.push({ root, files: files.length - before });
336+
}
192337

193338
const current = {};
194339
for (const f of files.sort()) {
@@ -202,7 +347,7 @@ for (const f of files.sort()) {
202347

203348
if (update) {
204349
writeFileSync(BASELINE_PATH, JSON.stringify(current, null, 2) + '\n');
205-
console.log(`role-word baseline updated: ${Object.keys(current).length} file(s).`);
350+
console.log(updateSummary(scanned, current));
206351
process.exit(0);
207352
}
208353

@@ -233,4 +378,4 @@ if (errors.length) {
233378
for (const e of errors) console.error(' • ' + e);
234379
process.exit(1);
235380
}
236-
console.log(`check-role-word: OK (${Object.keys(current).length} baselined file(s), no new occurrences).`);
381+
console.log(successSummary(scanned, current));

0 commit comments

Comments
 (0)