@@ -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+
129221function 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+ / \b 0 [ ^ \n ] * f i l e \( s \) r e a d \b / . test ( greenDead ) ) ;
293+ expect ( '#9910 — a scanned tree prints its real input volume, not a ledger-derived count' ,
294+ / \b 2 1 5 [ ^ \n ] * f i l e \( s \) r e a d \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+ / \b s k i l l s 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
188326if ( process . argv . includes ( '--self-test' ) ) selfTest ( ) ;
189327
190328const 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
193338const current = { } ;
194339for ( const f of files . sort ( ) ) {
@@ -202,7 +347,7 @@ for (const f of files.sort()) {
202347
203348if ( 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