@@ -377,7 +377,153 @@ function validateLedger(text, today) {
377377 return { problems, count : entries . length } ;
378378}
379379
380- /** @returns {{ passed: boolean, lines: string[] } } */
380+ // ── The self-test's own battery roster and floor (#13489) ──────────────────
381+ //
382+ // `passed` used to be this self-test's ONLY success condition, so "every case
383+ // held" and "the cases never ran" printed the same line. Closed the way PR
384+ // #13487 validated on check-doc-authoring: what is pinned is the registered
385+ // NAMES, not a number.
386+ //
387+ // This self-test is TABLE-DRIVEN — one literal `cases` table, one loop over it,
388+ // and a sink that writes only when a case FAILS. Routing THAT sink through
389+ // `registerCase()` would register a case only when it fails: a fully green run
390+ // would register 0 and every battery would read DID NOT RUN, the floor inverted
391+ // rather than installed. So the roster is the table's own rows. Each row's
392+ // `name` is a declared battery, verbatim, with a floor of 1, and
393+ // `registerCase(name)` is the FIRST statement of the driving loop body — so the
394+ // case is attributed to the row actually being run, whatever that row asserts
395+ // afterwards. There is no `battery()` opener: for a table-driven self-test the
396+ // ROW is the battery, so attribution is the loop variable rather than a
397+ // most-recently-opened section.
398+ //
399+ // ⛔ A pinned TOTAL is not the repair, and neither is a roster DERIVED from the
400+ // table: `cases.length` moves with the table, so a deleted row would delete its
401+ // own floor. The roster below is a LITERAL the table is checked against, which
402+ // is what lets a deleted or renamed row name ITSELF in the refusal.
403+ //
404+ // The counts are a FLOOR, not an equality — a row that grows into several
405+ // registrations must not red. 1 is the honest floor for a table row: the loop
406+ // reaches it exactly once per run.
407+ //
408+ // ── Why the LEDGER is module-level and the CHECK sits at the verdict site ──
409+ //
410+ // This gate splits its self-test in two: `selfTest()` REGISTERS and returns its
411+ // failure count, and `main()` DECIDES — it prints the per-case lines, the red
412+ // line or the green one. There is no verdict site inside the registering body,
413+ // so the floor is evaluated where the green line already is (inside `main()`'s
414+ // `--self-test` branch, so it can never fire on a production run). The ledger it
415+ // reads therefore has to outlive `selfTest()`'s frame — hence module scope
416+ // rather than the local map the single-body recipe closes over. Only the CHECK's
417+ // location moves; attribution and scope are untouched. This is the class-3
418+ // placement PR #15309 settled.
419+ //
420+ // ⛔ The floor is NOT placed at the end of `selfTest()` before its `return`: an
421+ // early return anywhere above that line would skip the check entirely — the
422+ // exact defect the #13798 verdict handshake exists to catch — coupling hole 1
423+ // to hole 2 after the card ruled them orthogonal. Evaluated at the verdict site,
424+ // the same early return lands as a count BELOW the floor and reds.
425+ const SELF_TEST_BATTERIES = Object . freeze ( {
426+ 'missing/empty ledger → green' : 1 ,
427+ 'comments-only ledger (zero exemptions) → green' : 1 ,
428+ 'well-formed exemption inside the window → green' : 1 ,
429+ 'multi-line reason → green' : 1 ,
430+ 'expired ignoreUntil → red' : 1 ,
431+ 'ignoreUntil == today → red (the scanner already stopped ignoring it)' : 1 ,
432+ 'missing ignoreUntil → red' : 1 ,
433+ 'quoted ignoreUntil → red' : 1 ,
434+ 'ignoreUntil beyond the ceiling → red' : 1 ,
435+ 'reason without an advisory link → red' : 1 ,
436+ 'reason that is only a link → red' : 1 ,
437+ 'untouched template placeholders → red' : 1 ,
438+ 'missing reason → red' : 1 ,
439+ 'unknown key → red' : 1 ,
440+ 'duplicate id → red' : 1 ,
441+ '[[PackageOverrides]] escape hatch → red' : 1 ,
442+ 'top-level key outside a table → red' : 1 ,
443+ } ) ;
444+
445+ // DELETING an entry silences that battery's floor exactly as effectively as
446+ // zeroing it, so the roster's own size is pinned too. This pin is also half of
447+ // the duplicate-label refusal: two rows sharing a name collapse to ONE key in
448+ // the literal above, so the roster falls below this number; the table
449+ // cross-check in `batteryFloorFailures()` is the other half, and names WHICH
450+ // label collided.
451+ const SELF_TEST_BATTERY_FLOOR = 17 ;
452+
453+ // The ledger `batteryFloorFailures()` reads from the OTHER function, and the
454+ // row labels the table actually presented on this run — both module-level
455+ // because the body that fills them is not the body that reads them.
456+ //
457+ // ⚠️ Named for the roster's role, deliberately NOT with a self-test spelling:
458+ // `check:pm-dispatch-gates` anchors on a top-level declaration whose NAME spells
459+ // self-test and every such name owes a row in its COMPOUND_ANCHOR_LEDGER. This
460+ // machinery holds no fixtures to mask and reads no path literal, so the accurate
461+ // name is the one that says `battery`.
462+ const batterySeen = new Map ( ) ;
463+ let batteryRowLabels = [ ] ;
464+
465+ /** Called by `selfTest()`'s driving loop, once per row, before the row runs. */
466+ function registerCase ( name ) {
467+ batterySeen . set ( name , ( batterySeen . get ( name ) ?? 0 ) + 1 ) ;
468+ }
469+
470+ /**
471+ * The floor: every declared row RAN, and ran its case (#13489).
472+ *
473+ * Guards the registrations made by **`selfTest()`** — the body whose driving
474+ * loop calls `registerCase()`. It is called from `main()`'s `--self-test`
475+ * branch immediately before the success line, so that line can only be printed
476+ * by a run in which the set of rows that registered EQUALS the set declared,
477+ * each at or above its own count. A set difference says WHICH row stopped; a
478+ * count says only that something did.
479+ *
480+ * @returns {string[] } floor breaches; empty means the floor held
481+ */
482+ function batteryFloorFailures ( ) {
483+ const declared = Object . keys ( SELF_TEST_BATTERIES ) ;
484+ const problems = [ ] ;
485+ if ( declared . length < SELF_TEST_BATTERY_FLOOR ) {
486+ problems . push (
487+ `SELF_TEST_BATTERIES declares ${ declared . length } batteries, below the pinned ` +
488+ `${ SELF_TEST_BATTERY_FLOOR } — a battery deleted from the roster takes its own floor with it.` ,
489+ ) ;
490+ }
491+ const duplicated = [ ...new Set ( batteryRowLabels . filter ( ( name , i ) => batteryRowLabels . indexOf ( name ) !== i ) ) ] ;
492+ if ( duplicated . length > 0 ) {
493+ problems . push (
494+ `the cases table uses ${ duplicated . map ( ( n ) => JSON . stringify ( n ) ) . join ( ', ' ) } as a row label more than once — ` +
495+ 'two rows sharing a label are ONE battery, so the second can stop running while the first keeps the floor met.' ,
496+ ) ;
497+ }
498+ for ( const [ name , count ] of batterySeen ) {
499+ if ( declared . includes ( name ) ) continue ;
500+ problems . push (
501+ `self-test battery "${ name } " registered ${ count } case(s) but is not declared in ` +
502+ 'SELF_TEST_BATTERIES — a case attributed to no declared battery is one nothing floors.' ,
503+ ) ;
504+ }
505+ for ( const name of declared ) {
506+ const count = batterySeen . get ( name ) ?? 0 ;
507+ if ( count >= SELF_TEST_BATTERIES [ name ] ) continue ;
508+ problems . push (
509+ count === 0
510+ ? `self-test battery "${ name } " DID NOT RUN — 0 cases registered, ${ SELF_TEST_BATTERIES [ name ] } pinned. ` +
511+ 'The verdict below would have claimed that case holds.'
512+ : `self-test battery "${ name } " registered ${ count } case(s), below its pinned floor of ` +
513+ `${ SELF_TEST_BATTERIES [ name ] } — cases that used to run no longer do.` ,
514+ ) ;
515+ }
516+ if ( problems . length ) {
517+ problems . push (
518+ 'A battery at or below its floor means cases STOPPED RUNNING — the battery is the bug, not the ' +
519+ 'number. Find what stopped registering (a deleted row, a renamed label, a loop that no longer ' +
520+ 'reaches it) and restore it.' ,
521+ ) ;
522+ }
523+ return problems ;
524+ }
525+
526+ /** @returns {{ failures: number, lines: string[] } } */
381527// Set by `selfTest()` only after its verdict is printed, and read at the
382528// dispatch: a `return` that leaves the function above that line prints nothing
383529// and still exits 0 — a self-test that never finished, reported as one that
@@ -479,23 +625,27 @@ function selfTest() {
479625 ] ;
480626
481627 const lines = [ ] ;
482- let passed = true ;
628+ // The row labels this run actually presented, for the floor's table
629+ // cross-check at the verdict site (#13489).
630+ batteryRowLabels = cases . map ( ( testCase ) => testCase . name ) ;
631+ let failures = 0 ;
483632 for ( const testCase of cases ) {
633+ registerCase ( testCase . name ) ;
484634 const { problems } = validateLedger ( testCase . text , today ) ;
485635 let ok ;
486636 if ( testCase . expect === null ) {
487637 ok = problems . length === 0 ;
488638 } else {
489639 ok = problems . length > 0 && problems . some ( ( p ) => testCase . expect . test ( p ) ) ;
490640 }
491- if ( ! ok ) passed = false ;
641+ if ( ! ok ) failures ++ ;
492642 lines . push (
493643 `${ ok ? ' ✓' : ' ✗' } ${ testCase . name } ` +
494644 ( ok ? '' : `\n got: ${ problems . length === 0 ? '(no problems)' : problems . join ( '\n ' ) } ` ) ,
495645 ) ;
496646 }
497647 selfTestReachedVerdict = true ;
498- return { passed , lines } ;
648+ return { failures , lines } ;
499649}
500650
501651function main ( ) {
@@ -512,11 +662,21 @@ function main() {
512662 ) ;
513663 process . exit ( 1 ) ;
514664 }
515- const { passed , lines } = selfTestResult ;
665+ const { failures , lines } = selfTestResult ;
516666 console . log ( 'check-osv-exemptions self-test (both directions):' ) ;
517667 for ( const line of lines ) console . log ( line ) ;
518- if ( ! passed ) {
519- console . error ( '\n✗ self-test failed — the ledger check does not do what it claims.' ) ;
668+ // ── The assertion floor, at the verdict site (#13489) ─────────────────
669+ // `selfTest()` registers but does not decide, so the floor over ITS
670+ // registrations is evaluated here, after every row has had its chance and
671+ // immediately before the success line — the only place a run that
672+ // registered nothing can still be stopped from reporting that every case
673+ // held. Its breaches share this branch's counted sink, so one red line
674+ // covers cases and floor alike.
675+ const floorProblems = batteryFloorFailures ( ) ;
676+ for ( const problem of floorProblems ) console . error ( `✗ self-test floor: ${ problem } ` ) ;
677+ const total = failures + floorProblems . length ;
678+ if ( total > 0 ) {
679+ console . error ( `\n✗ check-osv-exemptions self-test: ${ total } failure(s) (cases and floor).` ) ;
520680 process . exit ( 1 ) ;
521681 }
522682 console . log ( '\n✓ self-test passed: valid ledgers accepted, every convention breach rejected.' ) ;
0 commit comments