From b12a5bdaef9f1a304154875ebc4aed3986841f2f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 3 Sep 2026 13:02:39 +0000 Subject: [PATCH] fix(devx): verdict handshake for check-platform-checklist and dispatch-gates self-tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both dispatches discarded their self-test's completion, so a `return` above the verdict printed nothing and still exited 0 — an unfinished self-test reported as a verified gate. Measured before the repair, on this tree: * `scripts/pm/dispatch-gates.mjs` — an early return at the real definition of `selfTest()` took the run from "1288 cases pass" to ZERO bytes of output and exit 0. The card carried this row as NOT MEASURED because the mechanical probe anchors on the first `function selfTest() {` in the source, which here is a fixture STRING: the injection lands inside a template literal and only ever yields a SyntaxError. That is a limit of the instrument, not of the file; a hand-read anchor measures it in one run. * `scripts/check-platform-checklist.mjs` — five batteries returning `{ checked, failures }` and combined by the caller. A section that stops running took the verdict from 141 assertions to 119, exited 0, and still claimed in prose that the direction it had skipped "REFUSES an empty/renamed/reshaped" table. A bare `return` instead yields `undefined` and CRASHES the combine — an exit code alone reads that crash as a handshake rather than as the accident it is. dispatch-gates takes the landed sentinel form. check-platform-checklist takes the landed FLAG form (`check-durability-degradation-log-level.mjs`, `check-dispatcher-error-vocabulary.mjs`) for the reason those files state: the return value is load-bearing, so the handshake cannot BE the return value. Its guard is checked on both legs — including the inline one, which is the only leg CI reaches — before the `.failures` read, so a bare `return` now reports itself instead of crashing its reader. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012zGPuVVX3deAx9LdjK8jCk --- scripts/check-platform-checklist.mjs | 54 ++++++++++++++++++++++++++++ scripts/pm/dispatch-gates.mjs | 28 ++++++++++++++- 2 files changed, 81 insertions(+), 1 deletion(-) diff --git a/scripts/check-platform-checklist.mjs b/scripts/check-platform-checklist.mjs index 1a27a009e6..801d4de438 100644 --- a/scripts/check-platform-checklist.mjs +++ b/scripts/check-platform-checklist.mjs @@ -704,6 +704,45 @@ function foldedCallMessage(path, call, spelling, singular) { ); } +// ── Self-test verdict handshake ───────────────────────────────────────────── +// +// Five batteries, each returning `{ checked, failures }` for a caller to +// report. A `return` above a battery's own end prints nothing, registers no +// failure, and yields a SMALLER `checked` that both legs below read as a pass. +// Measured on this file: a section that stopped running took the `--self-test` +// verdict from 141 assertions to 119, exited 0, and still claimed in prose that +// the direction it had skipped "REFUSES an empty/renamed/reshaped" table. A +// bare `return` is no better — it yields `undefined` and CRASHES the combine +// below, and an exit code alone reads that crash as a handshake rather than as +// the accident it is. +// +// So each battery sets its own flag as its last act and every caller checks it. +// The return value is load-bearing here (it carries `checked` and `failures`), +// so the handshake is a flag rather than a returned sentinel — the spelling +// `check-durability-degradation-log-level.mjs` and +// `check-dispatcher-error-vocabulary.mjs` carry, for that same reason. +let trapReachedVerdict = false; +let provisioningReachedVerdict = false; +let unreferencedReachedVerdict = false; +let metaCallReachedVerdict = false; +let citationsReachedVerdict = false; + +/** + * One wording, ten call sites — five batteries across the two legs that run + * them. The check, the message and the exit code are the landed ones; only the + * duplication is factored out. + */ +function requireReachedVerdict(name, reached) { + if (reached) return; + console.error( + `\n✗ check-platform-checklist self-test: ${name}() returned without reaching its verdict,\n` + + 'so its assertions did not all run and no failure of theirs could be reported.\n' + + 'Running the gate on top of a self-test that never finished would report an\n' + + 'unverified gate as a verified one.\n', + ); + process.exit(1); +} + /** * The positive control. Proves the extractor reads a good table AND refuses an * empty / renamed / reshaped one, and that the item-side checker catches both @@ -770,6 +809,7 @@ function selfTestTrapVocabulary() { t('C8 an empty-string trap is flagged', trapProblems({ traps: [''] }, vocab).length === 1); t('C9 a trap listed twice on one item is flagged', trapProblems({ traps: ['stale-dist', 'stale-dist'] }, vocab).some((m) => m.includes('twice'))); + trapReachedVerdict = true; return { checked, failures }; } @@ -877,6 +917,7 @@ function selfTestProvisioningUse() { const hintedEmpty = check('qa-contributor-bound-member', 'records-forms'); t('Q20 the same hint reaches an area that has no recipe block of its own', hintedEmpty.length === 1 && hintedEmpty[0].includes('`search:qa-contributor-bound-member`')); + provisioningReachedVerdict = true; return { checked, failures }; } @@ -978,6 +1019,7 @@ function selfTestUnreferencedRecipes() { t('R18 a `$comment` is never reported unreferenced', flag(ALL).length === 0 && none.every((r) => !r.recipe.startsWith('$'))); t('R19 an area that defines no recipes contributes nothing to flag', !none.some((r) => r.area === 'records-forms')); + unreferencedReachedVerdict = true; return { checked, failures }; } @@ -1136,6 +1178,7 @@ export const NEIGHBOURING_MAP: Readonly> = Object.freeze( t('M52 the live map is a bijection-free lookup: no folded spelling is ALSO a canonical singular — so the refusal can never fire on a canonical `/meta/` segment', live.refusal === null && !live.folded.some((f) => live.canonical.includes(f))); + metaCallReachedVerdict = true; return { checked, failures }; } @@ -1216,6 +1259,7 @@ function selfTestSourceLineCitations() { t('S6 an ADR section reference is not a citation', n('ADR-0025 §3.3 and #13479') === 0); t('S7 the README placeholder spelling of the ban is not itself a citation', n('never pin `file.ts:NNN` or a bare `:NNN`') === 0); + citationsReachedVerdict = true; return { failures, checked }; } @@ -1225,6 +1269,11 @@ if (process.argv.slice(2).includes('--self-test')) { const unref = selfTestUnreferencedRecipes(); const metaCall = selfTestMetaCallSpelling(); const cites = selfTestSourceLineCitations(); + requireReachedVerdict('selfTestTrapVocabulary', trapReachedVerdict); + requireReachedVerdict('selfTestProvisioningUse', provisioningReachedVerdict); + requireReachedVerdict('selfTestUnreferencedRecipes', unreferencedReachedVerdict); + requireReachedVerdict('selfTestMetaCallSpelling', metaCallReachedVerdict); + requireReachedVerdict('selfTestSourceLineCitations', citationsReachedVerdict); const failures = [...trap.failures, ...prov.failures, ...unref.failures, ...metaCall.failures, ...cites.failures]; if (failures.length === 0) { console.log( @@ -1243,6 +1292,7 @@ if (process.argv.slice(2).includes('--self-test')) { // The extractor's own positive control, before it is trusted with anything. const trapControl = selfTestTrapVocabulary(); +requireReachedVerdict('selfTestTrapVocabulary', trapReachedVerdict); if (trapControl.failures.length) { console.error("check-platform-checklist: the trap-vocabulary extractor's own positive control FAILED — this check cannot be trusted, and a green from it would mean nothing.\n"); for (const f of trapControl.failures) console.error(` ✗ ${f}`); @@ -1252,6 +1302,7 @@ if (trapControl.failures.length) { // Same, for the provisioning resolve: a green from a check that cannot fire is // indistinguishable from the green this gate printed before it existed. const provisioningControl = selfTestProvisioningUse(); +requireReachedVerdict('selfTestProvisioningUse', provisioningReachedVerdict); if (provisioningControl.failures.length) { console.error("check-platform-checklist: the provisioning-resolve check's own positive control FAILED — a `use` that resolves to nothing would pass, which is the exact defect this check was added to close.\n"); for (const f of provisioningControl.failures) console.error(` ✗ ${f}`); @@ -1263,6 +1314,7 @@ if (provisioningControl.failures.length) { // recipes on the real ledger are referenced, so this direction's output is // permanently empty and its green says nothing on its own. const unreferencedControl = selfTestUnreferencedRecipes(); +requireReachedVerdict('selfTestUnreferencedRecipes', unreferencedReachedVerdict); if (unreferencedControl.failures.length) { console.error('check-platform-checklist: the unreferenced-recipe direction\'s own positive control FAILED — a recipe no item references would pass unreported, and because every real recipe IS referenced, nothing else in this gate would ever notice.\n'); for (const f of unreferencedControl.failures) console.error(` ✗ ${f}`); @@ -1275,6 +1327,7 @@ if (unreferencedControl.failures.length) { // subject population is zero — so nothing but this battery can tell a working // direction from a deleted one. const metaCallControl = selfTestMetaCallSpelling(); +requireReachedVerdict('selfTestMetaCallSpelling', metaCallReachedVerdict); if (metaCallControl.failures.length) { console.error("check-platform-checklist: the `/meta` call-spelling refusal's own positive control FAILED — an executable step instructing a folded plural spelling would pass unreported, which is the exact defect this check was added to close.\n"); for (const f of metaCallControl.failures) console.error(` ✗ ${f}`); @@ -1288,6 +1341,7 @@ if (metaCallControl.failures.length) { // from the ledger staying clean — which is precisely the exit-0-by-construction // shape this check was added to end. const citationControl = selfTestSourceLineCitations(); +requireReachedVerdict('selfTestSourceLineCitations', citationsReachedVerdict); if (citationControl.failures.length) { console.error('check-platform-checklist: the source-line-citation refusal\'s own positive control FAILED — a rotting `file:line` pointer would pass unreported, and because the ledger is clean nothing else here would ever notice.\n'); for (const f of citationControl.failures) console.error(` ✗ ${f}`); diff --git a/scripts/pm/dispatch-gates.mjs b/scripts/pm/dispatch-gates.mjs index 76e646120f..d50fd681d8 100644 --- a/scripts/pm/dispatch-gates.mjs +++ b/scripts/pm/dispatch-gates.mjs @@ -10514,6 +10514,23 @@ export function bannerLines({ identity, paths = [], drift = null }) { // the tree, in both directions. // --------------------------------------------------------------------------- +/** + * Returned by `selfTest()` only after its verdict line is printed, and compared + * at the dispatch: a `return` that leaves the function above that line prints + * NOTHING and still exits 0, because the dispatch discarded the result. Measured + * on this file before this guard existed: an early return took the run from + * "1288 cases pass" to zero bytes of output and exit 0 — a self-test that never + * finished, reported as one that passed. + * + * The mechanical probe in `scripts/measure-self-test-floor.mjs` cannot read this + * file (its anchor matches the first `function selfTest() {` in the source, which + * here is a FIXTURE STRING, so the injection lands inside a template literal and + * only ever produces a SyntaxError). That is a limit of the instrument, not a + * property of this file, and it is why the entry is hand-read there. Anchoring an + * early return on the real definition below measures it in one run. + */ +const SELF_TEST_VERDICT = 'dispatch-gates self-test reached its verdict'; + function selfTest() { const cases = []; // Stream the verdict the moment it is decided (#14281) rather than only at @@ -17737,6 +17754,8 @@ function selfTest() { process.exit(1); } console.log(`✓ dispatch-gates self-test: ${cases.length} cases pass.`); + + return SELF_TEST_VERDICT; } // ── CLI ───────────────────────────────────────────────────────────────────── @@ -17791,7 +17810,14 @@ if (invokedDirectly) { const argvPaths = argv.paths; const wantsChanged = process.argv.includes('--changed'); if (process.argv.includes('--self-test')) { - selfTest(); + if (selfTest() !== SELF_TEST_VERDICT) { + console.error( + '\n✗ dispatch-gates self-test: selfTest() returned without reaching its verdict,\n' + + 'so no success line was printed. Exiting 0 here would report a self-test\n' + + 'that never finished as a self-test that passed.\n', + ); + process.exit(1); + } } else if (argv.malformed) { console.error(`dispatch-gates: ${argv.malformed}.`); process.exit(2);