@@ -314,6 +314,143 @@ export function workspacePackages(root) {
314314 return out ;
315315}
316316
317+ // ── The self-test's own battery roster and floor (#13489) ──────────────────
318+ //
319+ // `failures.length === 0` used to be this self-test's ONLY success condition,
320+ // so "every case held" and "the cases never ran" printed the same line — and
321+ // here that line is printed by SEVEN other files, none of which could tell the
322+ // difference either. Closed the way PR #13487 validated on check-doc-authoring:
323+ // what is pinned is the registered NAMES, not a number.
324+ //
325+ // This body carries FOUR named section banners, so each banner is one battery,
326+ // opened by a `battery()` call on the banner's own line — the sectioned shape
327+ // PR #15327 landed, rather than the single hoisted battery PR #15217 landed for
328+ // bodies carrying fewer than two. Sectioning is what lets the parse cases stop
329+ // running while the expansion cases keep going and still have something name
330+ // the half that went quiet.
331+ //
332+ // The counts are a FLOOR, not an equality — adding cases is ordinary work and
333+ // must not red. A battery BELOW its floor means cases stopped running; the
334+ // remedy is to find what stopped registering.
335+ //
336+ // ── Why the LEDGER is module-level and the CHECK sits in the CALLERS ───────
337+ //
338+ // This module is deliberately NOT a gate (see the header), so unlike every
339+ // other file in this recipe it has no `--self-test` dispatch and no verdict
340+ // site of its own: `selfTest()` REGISTERS and returns its failures, and the
341+ // seven folding callers DECIDE. So the check is exported instead of placed —
342+ // `workspaceEnumeratorFloorFailures()` below — and each caller adopts it
343+ // exactly where it already adopts the failures, immediately after the call.
344+ // The ledger it reads therefore has to outlive `selfTest()`'s frame — hence
345+ // module scope rather than the local map the single-body recipe closes over.
346+ //
347+ // ⛔ The floor is NOT evaluated at the end of `selfTest()` before its `return`:
348+ // an early return anywhere above that line would skip the check and the cases
349+ // TOGETHER, which is the one defect this card exists to catch. Adopted AFTER
350+ // the call, in a caller, the same early return lands as a count below the floor
351+ // and reds — in all seven callers at once, which is the accepted cost of a
352+ // module that seven gates fold in and none of them owns.
353+ //
354+ // ⛔ A pinned TOTAL is not the repair: this body's 25 registrations split 18 /
355+ // 3 / 1 / 3, so the parse battery could drop from 18 cases to 3 and keep a
356+ // total "right" the moment a sibling grows.
357+ const SELF_TEST_BATTERIES = Object . freeze ( {
358+ 'the parse, one case per divergence the consolidation settled' : 18 ,
359+ 'the expansion' : 3 ,
360+ 'the property this module exists to keep: NO path population' : 1 ,
361+ 'the live half, when a caller supplies the repo root' : 3 ,
362+ } ) ;
363+
364+ // DELETING an entry silences that battery's floor exactly as effectively as
365+ // zeroing it, so the roster's own size is pinned too.
366+ const SELF_TEST_BATTERY_FLOOR = 4 ;
367+
368+ // The key an assertion is filed under when no battery is open. It is not a
369+ // declared battery, so it reds by the same set difference rather than silently
370+ // inflating whichever battery happened to run last.
371+ const UNATTRIBUTED_BATTERY = '(no battery open)' ;
372+
373+ // The battery ledger, read by `workspaceEnumeratorFloorFailures()` below from
374+ // the OTHER function. `battery()` opens a battery; every assertion registered
375+ // after that line is attributed to the one most recently opened, so a section
376+ // that stops running stops registering and names ITSELF at the floor rather
377+ // than going quiet.
378+ //
379+ // ⚠️ Named for the roster's role, deliberately NOT with a self-test spelling:
380+ // `check:pm-dispatch-gates` anchors on a top-level declaration whose NAME spells
381+ // self-test and every such name owes a row in its COMPOUND_ANCHOR_LEDGER. This
382+ // machinery holds no fixtures to mask and reads no path literal, so the accurate
383+ // name is the one that says `battery`.
384+ const batterySeen = new Map ( ) ;
385+ let openBattery = null ;
386+
387+ /** Open a battery. Every assertion registered after this line is attributed to it. */
388+ function battery ( name ) {
389+ openBattery = name ;
390+ }
391+
392+ /** Called by `selfTest()`'s own assertion sink, once per assertion. */
393+ function registerCase ( ) {
394+ const name = openBattery ?? UNATTRIBUTED_BATTERY ;
395+ batterySeen . set ( name , ( batterySeen . get ( name ) ?? 0 ) + 1 ) ;
396+ }
397+
398+ /**
399+ * The floor: every declared battery RAN, and ran its cases (#13489).
400+ *
401+ * Guards the registrations made by **`selfTest()`** — the body whose assertion
402+ * sink `t()` routes through `registerCase()`. Every folding caller calls this
403+ * immediately after it adopts `selfTest()`'s failures and BEFORE its own
404+ * verdict line, so that line can only be printed by a run in which the set of
405+ * batteries that registered assertions EQUALS the set declared, each at or
406+ * above its own count. A set difference says WHICH battery stopped; a count
407+ * says only that something did.
408+ *
409+ * ⚠️ EXPORTED, and prefixed with this module's name, because the callers are
410+ * the verdict sites: seven gates import it, and each of them already declares
411+ * a `batteryFloorFailures` of its OWN for its OWN roster. The unprefixed name
412+ * this recipe uses everywhere else would collide in all seven import lists —
413+ * the same reason they all import `selfTest as workspaceEnumeratorSelfTest`.
414+ *
415+ * @returns {string[] } floor breaches; empty means the floor held
416+ */
417+ export function workspaceEnumeratorFloorFailures ( ) {
418+ const declared = Object . keys ( SELF_TEST_BATTERIES ) ;
419+ const problems = [ ] ;
420+ if ( declared . length < SELF_TEST_BATTERY_FLOOR ) {
421+ problems . push (
422+ `workspace-enumerator: SELF_TEST_BATTERIES declares ${ declared . length } batteries, below the pinned `
423+ + `${ SELF_TEST_BATTERY_FLOOR } — a battery deleted from the roster takes its own floor with it.` ,
424+ ) ;
425+ }
426+ for ( const [ name , count ] of batterySeen ) {
427+ if ( declared . includes ( name ) ) continue ;
428+ problems . push (
429+ `workspace-enumerator: self-test battery "${ name } " registered ${ count } case(s) but is not declared in `
430+ + 'SELF_TEST_BATTERIES — an assertion attributed to no declared battery is one nothing floors.' ,
431+ ) ;
432+ }
433+ for ( const name of declared ) {
434+ const count = batterySeen . get ( name ) ?? 0 ;
435+ if ( count >= SELF_TEST_BATTERIES [ name ] ) continue ;
436+ problems . push (
437+ count === 0
438+ ? `workspace-enumerator: self-test battery "${ name } " DID NOT RUN — 0 cases registered, `
439+ + `${ SELF_TEST_BATTERIES [ name ] } pinned. The verdict below would have claimed those cases hold.`
440+ : `workspace-enumerator: self-test battery "${ name } " registered ${ count } case(s), below its pinned floor of `
441+ + `${ SELF_TEST_BATTERIES [ name ] } — cases that used to run no longer do.` ,
442+ ) ;
443+ }
444+ if ( problems . length ) {
445+ problems . push (
446+ 'workspace-enumerator: a battery at or below its floor means cases STOPPED RUNNING — the battery is the '
447+ + 'bug, not the number. Find what stopped registering (an early return, a deleted block, a guard that '
448+ + 'now skips) and restore it.' ,
449+ ) ;
450+ }
451+ return problems ;
452+ }
453+
317454/**
318455 * The shared assertions, returned rather than printed so each importing gate
319456 * can fold them into its own `--self-test` report.
@@ -328,6 +465,7 @@ export function workspacePackages(root) {
328465export function selfTest ( { root = null } = { } ) {
329466 const failures = [ ] ;
330467 const t = ( name , ok ) => {
468+ registerCase ( ) ;
331469 if ( ! ok ) failures . push ( `workspace-enumerator: ${ name } ` ) ;
332470 } ;
333471 // Every fixture path is ASSEMBLED, never spelled. A path-shaped literal
@@ -352,6 +490,7 @@ export function selfTest({ root = null } = {}) {
352490 const flat = JSON . stringify ( [ PKGS ] ) ;
353491
354492 // ── the parse, one case per divergence the consolidation settled ──────────
493+ battery ( 'the parse, one case per divergence the consolidation settled' ) ;
355494 t ( 'a plain list parses' , answer ( `packages:\n - ${ PKGS } \n - ${ APPS } \n` ) === both ) ;
356495 t ( 'quotes are stripped' , answer ( `packages:\n - '${ PKGS } '\n - "${ APPS } "\n` ) === both ) ;
357496 t ( 'CRLF parses the same' , answer ( `packages:\r\n - ${ PKGS } \r\n - ${ APPS } \r\n` ) === both ) ;
@@ -384,6 +523,7 @@ export function selfTest({ root = null } = {}) {
384523 t ( 'the flow-sequence form is REFUSED rather than read as empty' , answer ( `packages: [${ PKGS } , ${ APPS } ]\n` ) === 'REFUSED' ) ;
385524
386525 // ── the expansion ─────────────────────────────────────────────────────────
526+ battery ( 'the expansion' ) ;
387527 const NOWHERE = P ( '' , 'nonexistent' ) ;
388528 const expandRefused = ( glob ) => {
389529 try {
@@ -401,6 +541,7 @@ export function selfTest({ root = null } = {}) {
401541 ) ;
402542
403543 // ── the property this module exists to keep: NO path population ───────────
544+ battery ( 'the property this module exists to keep: NO path population' ) ;
404545 //
405546 // Pinned mechanically, not by review, and read off THIS FILE's own bytes so
406547 // a stale copy cannot satisfy it. A path-shaped literal added here — in the
@@ -448,6 +589,7 @@ export function selfTest({ root = null } = {}) {
448589 }
449590
450591 // ── the live half, when a caller supplies the repo root ───────────────────
592+ battery ( 'the live half, when a caller supplies the repo root' ) ;
451593 if ( root !== null ) {
452594 let live = null ;
453595 try {
0 commit comments