From 53c4e2f47d74e80e91338e26f4d15542b468aed9 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 3 Sep 2026 04:52:49 +0000 Subject: [PATCH 1/2] fix(scripts): give 24 more self-tests a verdict handshake at their dispatch Batch 2 of the #13798 remedy: the boundary-only PR #13797 shape, transplanted to the 14 files the probe still reads DEFEATED and released by their holding PRs, the six that hold today only by accident (a downstream TypeError or a usage error, not a handshake), the three that also call their self-test on the production path and discard the result, and check-workspace-manifest-cycles.mjs (runSelfTest() shape, NOT MEASURED by the census for want of an ENTRY_BY_HAND row, defeated in fact). Two shapes, one per dispatch spelling, both from batch 1 verbatim: - sentinel: the self-test returns SELF_TEST_VERDICT only after its verdict line, and the dispatch refuses anything else (where the return value is discarded); - module-level `selfTestReachedVerdict` flag (where the self-test's own exit code is load-bearing, or where it ends in process.exit and an appended sentinel would be dead code). Nothing inside any self-test body changes beyond the verdict marker on the line after the success line. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01LAwHpn4uVuf4N1geBcD5i3 --- scripts/check-exported-any-returns.mts | 20 ++++++++++++- scripts/check-filter-alias-parity.mjs | 9 +++++- scripts/check-meta-type-normalized.mjs | 9 +++++- scripts/check-osv-exemptions.mjs | 16 ++++++++++ scripts/check-page-declaration-shape.mjs | 19 +++++++++++- scripts/check-skill-identifier-liveness.mjs | 20 +++++++++++-- scripts/check-skills-token-ratchet.mjs | 20 +++++++++++-- scripts/check-test-completeness.mjs | 9 +++++- scripts/check-type-source-resolution.mjs | 17 ++++++++++- scripts/check-workspace-manifest-cycles.mjs | 20 +++++++++++-- scripts/checklist-select.mjs | 20 ++++++++++++- scripts/pm/check-clause2-carriers.mjs | 33 +++++++++++++++++++-- scripts/pm/check-governed-merges.mjs | 17 ++++++++++- scripts/pm/check-governed-prose.mjs | 20 ++++++++++++- scripts/pm/check-governed-queue-guard.mjs | 20 ++++++++++++- scripts/pm/check-half-states.mjs | 17 ++++++++++- scripts/pm/check-label-desc-cap.mjs | 20 +++++++++++-- scripts/pm/check-skill-id-lint.mjs | 20 +++++++++++-- scripts/pm/check-skill-line-ratchet.mjs | 20 +++++++++++-- scripts/pm/ci-failure.mjs | 17 ++++++++++- scripts/pm/git-history.mjs | 22 +++++++++++++- scripts/pm/release-rehearsal-clone.mjs | 22 +++++++++++++- scripts/run-with-stall-guard.mjs | 23 ++++++++++++-- scripts/typecheck-configs.mjs | 16 ++++++++++ 24 files changed, 416 insertions(+), 30 deletions(-) diff --git a/scripts/check-exported-any-returns.mts b/scripts/check-exported-any-returns.mts index 15d5ae1c6e..92082495ab 100644 --- a/scripts/check-exported-any-returns.mts +++ b/scripts/check-exported-any-returns.mts @@ -385,6 +385,13 @@ function readLedger(target: Target): Ledger { * nested type-literal namespaces), so it exercises the walk and not just the * predicate. */ +// Set by `selfTest()` only after its verdict is printed, and read at the +// dispatch: a `return` that leaves the function above that line prints nothing +// and still exits 0 — a self-test that never finished, reported as one that +// passed (#13798). The self-test's own exit code stays load-bearing, so the +// handshake is a flag rather than a returned sentinel. +let selfTestReachedVerdict = false; + function selfTest(): never { const fail = (msg: string): never => { console.error(`✗ self-test: ${msg}`); @@ -484,6 +491,7 @@ function selfTest(): never { '✅ self-test: flags awaited-`any` returns through nested namespaces, and NOT caller-supplied generics, ' + '`any`-containing types, or named data properties. Ledger is exact in both directions.', ); + selfTestReachedVerdict = true; process.exit(0); } @@ -492,7 +500,17 @@ function selfTest(): never { // Both modes live behind the guard: `--self-test` calls `process.exit` too, so // running it on import would be the same defect wearing a friendlier name. if (isEntrypoint(import.meta.url)) { - if (SELF_TEST) selfTest(); + if (SELF_TEST) { + selfTest(); + if (!selfTestReachedVerdict) { + console.error( + '\n✗ check-exported-any-returns 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); + } + } const target = resolveTarget(); diff --git a/scripts/check-filter-alias-parity.mjs b/scripts/check-filter-alias-parity.mjs index ec42e2b21f..7b44a9333e 100644 --- a/scripts/check-filter-alias-parity.mjs +++ b/scripts/check-filter-alias-parity.mjs @@ -636,7 +636,14 @@ function main() { } return; } - selfTest(); + if (selfTest() !== SELF_TEST_VERDICT) { + console.error( + '\n✗ check-filter-alias-parity self-test: selfTest() returned without reaching its verdict,\n' + + 'so no success line was printed. Running the gate on top of a self-test\n' + + 'that never finished would report an unverified gate as a verified one.\n', + ); + process.exit(1); + } const read = (rel) => readFileSync(join(ROOT, rel), 'utf8'); const { problems, protocolSet, restSet } = judge({ diff --git a/scripts/check-meta-type-normalized.mjs b/scripts/check-meta-type-normalized.mjs index 7aab9ed79d..08cd48173d 100644 --- a/scripts/check-meta-type-normalized.mjs +++ b/scripts/check-meta-type-normalized.mjs @@ -251,7 +251,14 @@ function main() { } return; } - selfTest(); + if (selfTest() !== SELF_TEST_VERDICT) { + console.error( + '\n✗ check-meta-type-normalized self-test: selfTest() returned without reaching its verdict,\n' + + 'so no success line was printed. Running the gate on top of a self-test\n' + + 'that never finished would report an unverified gate as a verified one.\n', + ); + process.exit(1); + } const files = []; for (const dir of SCAN_DIRS) walkFiles(join(ROOT, dir), files); diff --git a/scripts/check-osv-exemptions.mjs b/scripts/check-osv-exemptions.mjs index 3c30da53bf..a74d939e40 100644 --- a/scripts/check-osv-exemptions.mjs +++ b/scripts/check-osv-exemptions.mjs @@ -378,6 +378,13 @@ function validateLedger(text, today) { } /** @returns {{ passed: boolean, lines: string[] }} */ +// Set by `selfTest()` only after its verdict is printed, and read at the +// dispatch: a `return` that leaves the function above that line prints nothing +// and still exits 0 — a self-test that never finished, reported as one that +// passed (#13798). The self-test's own exit code stays load-bearing, so the +// handshake is a flag rather than a returned sentinel. +let selfTestReachedVerdict = false; + function selfTest() { const today = new Date(Date.UTC(2026, 7, 4)); // 2026-08-04, fixed const good = [ @@ -487,12 +494,21 @@ function selfTest() { (ok ? '' : `\n got: ${problems.length === 0 ? '(no problems)' : problems.join('\n ')}`), ); } + selfTestReachedVerdict = true; return { passed, lines }; } function main() { if (process.argv.includes('--self-test')) { const { passed, lines } = selfTest(); + if (!selfTestReachedVerdict) { + console.error( + '\n✗ check-osv-exemptions 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); + } console.log('check-osv-exemptions self-test (both directions):'); for (const line of lines) console.log(line); if (!passed) { diff --git a/scripts/check-page-declaration-shape.mjs b/scripts/check-page-declaration-shape.mjs index 7b112ada7f..2ff82b21c8 100644 --- a/scripts/check-page-declaration-shape.mjs +++ b/scripts/check-page-declaration-shape.mjs @@ -365,6 +365,13 @@ function findingMessage({ file, line, name, decl }) { // Self-test // --------------------------------------------------------------------------- +// Set by `selfTest()` only after its verdict is printed, and read at the +// dispatch: a `return` that leaves the function above that line prints nothing +// and still exits 0 — a self-test that never finished, reported as one that +// passed (#13798). The self-test's own exit code stays load-bearing, so the +// handshake is a flag rather than a returned sentinel. +let selfTestReachedVerdict = false; + function selfTest() { let failed = 0; const t = (label, ok) => { @@ -481,6 +488,7 @@ function selfTest() { PAGE_CARRIER_GLOBS.every((g) => g.includes('/'))); console.log(failed ? `\ncheck-page-declaration-shape --self-test: ${failed} FAILED` : '\ncheck-page-declaration-shape --self-test: all passed'); + selfTestReachedVerdict = true; return failed === 0; } @@ -488,7 +496,16 @@ function selfTest() { if (isEntrypoint(import.meta.url)) { if (process.argv.includes('--self-test')) { - process.exit(selfTest() ? 0 : 1); + const selfTestOk = selfTest(); + if (!selfTestReachedVerdict) { + console.error( + '\n✗ check-page-declaration-shape 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); + } + process.exit(selfTestOk ? 0 : 1); } const result = scan(); const computed = computedCarrierSites(); diff --git a/scripts/check-skill-identifier-liveness.mjs b/scripts/check-skill-identifier-liveness.mjs index b99571ea6c..4d56f2c7b5 100644 --- a/scripts/check-skill-identifier-liveness.mjs +++ b/scripts/check-skill-identifier-liveness.mjs @@ -868,6 +868,12 @@ function allSpecSources() { // ── Self-test ─────────────────────────────────────────────────────────────── +// Returned by `selfTest()` only after its verdict is printed. The dispatch +// refuses anything else: a `return` that leaves the function above that line +// prints nothing and still exits 0 — a self-test that never finished, reported +// as one that passed (#13798). +const SELF_TEST_VERDICT = 'check-skill-identifier-liveness self-test reached its verdict'; + /** * Why this exists at all, in the words of the wiring gate that requires it: a * gate whose defect class is its MATCHING RULE cannot detect its own regression @@ -1141,6 +1147,8 @@ function selfTest() { process.exit(1); } console.log('check-skill-identifier-liveness --self-test OK'); + + return SELF_TEST_VERDICT; } let SELF_SOURCE = null; @@ -1152,6 +1160,14 @@ function selfSource() { } if (isEntrypoint(import.meta.url)) { - if (argv.includes('--self-test')) selfTest(); - else main(); + if (argv.includes('--self-test')) { + if (selfTest() !== SELF_TEST_VERDICT) { + console.error( + '\n✗ check-skill-identifier-liveness 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 main(); } diff --git a/scripts/check-skills-token-ratchet.mjs b/scripts/check-skills-token-ratchet.mjs index c75cb446ab..8217afbb95 100644 --- a/scripts/check-skills-token-ratchet.mjs +++ b/scripts/check-skills-token-ratchet.mjs @@ -704,6 +704,12 @@ function fixtureTree() { return { root, cleanup: () => rmSync(root, { recursive: true, force: true }) }; } +// Returned by `selfTest()` only after its verdict is printed. The dispatch +// refuses anything else: a `return` that leaves the function above that line +// prints nothing and still exits 0 — a self-test that never finished, reported +// as one that passed (#13798). +const SELF_TEST_VERDICT = 'check-skills-token-ratchet self-test reached its verdict'; + function selfTest() { const rel = 'skills/objectstack-ui/SKILL.md'; const over = verdict(rel, 26000, 25154).msg; @@ -922,9 +928,19 @@ function selfTest() { process.exit(1); } console.log(`✓ check-skills-token-ratchet self-test: ${cases.length} cases pass.`); + + return SELF_TEST_VERDICT; } if (isEntrypoint(import.meta.url)) { - if (process.argv.includes('--self-test')) selfTest(); - else run(); + if (process.argv.includes('--self-test')) { + if (selfTest() !== SELF_TEST_VERDICT) { + console.error( + '\n✗ check-skills-token-ratchet 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 run(); } diff --git a/scripts/check-test-completeness.mjs b/scripts/check-test-completeness.mjs index e111fce1e7..6a3c8abbf3 100644 --- a/scripts/check-test-completeness.mjs +++ b/scripts/check-test-completeness.mjs @@ -925,7 +925,14 @@ function main() { return; } // Every invocation, not a lint step -- see the header note on why. - selfTest({ quiet: true }); + if (selfTest({ quiet: true }) !== SELF_TEST_VERDICT) { + console.error( + '\n✗ check-test-completeness self-test: selfTest() returned without reaching its verdict,\n' + + 'so no success line was printed. Running the gate on top of a self-test\n' + + 'that never finished would report an unverified gate as a verified one.\n', + ); + process.exit(1); + } const { verdict, paths } = invocationVerdict(argv); if (verdict) reportVerdict(verdict); diff --git a/scripts/check-type-source-resolution.mjs b/scripts/check-type-source-resolution.mjs index cc83534e99..177e17b896 100644 --- a/scripts/check-type-source-resolution.mjs +++ b/scripts/check-type-source-resolution.mjs @@ -1724,6 +1724,12 @@ function buildFixtureTree() { return root; } +// Returned by `selfTest()` only after its verdict is printed. The dispatch +// refuses anything else: a `return` that leaves the function above that line +// prints nothing and still exits 0 — a self-test that never finished, reported +// as one that passed (#13798). +const SELF_TEST_VERDICT = 'check-type-source-resolution self-test reached its verdict'; + function selfTest() { const root = buildFixtureTree(); const problems = []; @@ -2062,13 +2068,22 @@ function selfTest() { process.exit(1); } console.log('check-type-source-resolution --self-test OK'); + + return SELF_TEST_VERDICT; } // ── entry point ───────────────────────────────────────────────────────────── const argv = process.argv.slice(2); if (argv.includes('--self-test')) { - selfTest(); + if (selfTest() !== SELF_TEST_VERDICT) { + console.error( + '\n✗ check-type-source-resolution 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.includes('--list')) { printList(REPO_ROOT); } else { diff --git a/scripts/check-workspace-manifest-cycles.mjs b/scripts/check-workspace-manifest-cycles.mjs index d0cb53b22f..4e9133ef99 100644 --- a/scripts/check-workspace-manifest-cycles.mjs +++ b/scripts/check-workspace-manifest-cycles.mjs @@ -697,6 +697,12 @@ export function selfTest() { return failures; } +// Returned by `runSelfTest()` only after its verdict is printed. The dispatch +// refuses anything else: a `return` that leaves the function above that line +// prints nothing and still exits 0 — a self-test that never finished, reported +// as one that passed (#13798). +const SELF_TEST_VERDICT = 'check-workspace-manifest-cycles self-test reached its verdict'; + function runSelfTest() { const failures = selfTest(); if (failures.length) { @@ -705,10 +711,20 @@ function runSelfTest() { process.exit(1); } console.log('OK: check-workspace-manifest-cycles --self-test — all cases passed.'); + + return SELF_TEST_VERDICT; } // Exports bindings, so an import for those exports alone must run nothing (#10667). if (isEntrypoint(import.meta.url)) { - if (process.argv.includes('--self-test')) runSelfTest(); - else main(); + if (process.argv.includes('--self-test')) { + if (runSelfTest() !== SELF_TEST_VERDICT) { + console.error( + '\n✗ check-workspace-manifest-cycles self-test: runSelfTest() 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 main(); } diff --git a/scripts/checklist-select.mjs b/scripts/checklist-select.mjs index e51849517c..a0286fad8a 100644 --- a/scripts/checklist-select.mjs +++ b/scripts/checklist-select.mjs @@ -118,6 +118,13 @@ function isBlocked(it) { } // ── self-test ──────────────────────────────────────────────────────────────── +// Set by `selfTest()` only after its verdict is printed, and read at the +// dispatch: a `return` that leaves the function above that line prints nothing +// and still exits 0 — a self-test that never finished, reported as one that +// passed (#13798). The self-test's own exit code stays load-bearing, so the +// handshake is a flag rather than a returned sentinel. +let selfTestReachedVerdict = false; + function selfTest() { const FIX = [ { id: 'a.one', status: 'active', priority: 'P0', surface: 'browser', since: 'v16', source: ['packages/foo/bar.ts'] }, @@ -150,6 +157,7 @@ function selfTest() { eq(ids('bar.ts'), ['a.one'], 'bare source basename (code ext) → file: mode'); eq(ids('missing.json'), [], 'unmatched .json name → empty, no throw'); console.log('✓ checklist-select self-test: 17 cases pass.'); + selfTestReachedVerdict = true; process.exit(0); } @@ -197,6 +205,16 @@ function main() { // (the skill's front half is a pure resolver), the old top-level CLI printed a // usage block to the importer's stderr and killed it with exit 2 mid-import. if (isEntrypoint(import.meta.url)) { - if (process.argv.includes('--self-test')) selfTest(); + if (process.argv.includes('--self-test')) { + selfTest(); + if (!selfTestReachedVerdict) { + console.error( + '\n✗ checklist-select 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); + } + } main(); } diff --git a/scripts/pm/check-clause2-carriers.mjs b/scripts/pm/check-clause2-carriers.mjs index 2ea30125ae..da121123dc 100644 --- a/scripts/pm/check-clause2-carriers.mjs +++ b/scripts/pm/check-clause2-carriers.mjs @@ -1234,6 +1234,13 @@ const CLAIM = (extra) => ({ body: `Claim: PM loop round R1\nBranch: \`claude/issue-13476-unresolvable-engine-403\`\n${extra ?? ''}`, }); +// Set by `selfTest()` only after its verdict is printed, and read at the +// dispatch: a `return` that leaves the function above that line prints nothing +// and still exits 0 — a self-test that never finished, reported as one that +// passed (#13798). The self-test's own exit code stays load-bearing, so the +// handshake is a flag rather than a returned sentinel. +let selfTestReachedVerdict = false; + export function selfTest() { const cases = []; const t = (name, ok, detail) => cases.push({ name, ok: Boolean(ok), detail }); @@ -1514,13 +1521,26 @@ export function selfTest() { 'replayed from the 2026-09-01 clear, the verdict-authorship pair and its legacy silence, ' + 'and the exit register).', ); + + selfTestReachedVerdict = true; return 0; } // --------------------------------------------------------------------------- async function main(argv) { - if (argv.includes('--self-test')) return selfTest(); + if (argv.includes('--self-test')) { + const selfTestCode = selfTest(); + if (!selfTestReachedVerdict) { + console.error( + '\n✗ check-clause2-carriers 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); + } + return selfTestCode; + } const repoRes = resolveSweepRepo(process.env); if (!repoRes.valid) { @@ -1577,7 +1597,16 @@ async function main(argv) { if (isEntrypoint(import.meta.url)) { if (process.argv.includes('--self-test')) { - process.exit(selfTest()); + const selfTestCode = selfTest(); + if (!selfTestReachedVerdict) { + console.error( + '\n✗ check-clause2-carriers 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); + } + process.exit(selfTestCode); } else { const rearmed = rearmThroughProxy(process.argv.slice(2)); if (rearmed !== null) process.exit(rearmed); diff --git a/scripts/pm/check-governed-merges.mjs b/scripts/pm/check-governed-merges.mjs index 2afe608a24..f561d9bf19 100644 --- a/scripts/pm/check-governed-merges.mjs +++ b/scripts/pm/check-governed-merges.mjs @@ -2295,6 +2295,12 @@ const REGISTER_SAMPLES = { 'spec-react-blocks': 'skills/objectstack-ui/contracts/react-blocks.contract.json', }; +// Returned by `selfTest()` only after its verdict is printed. The dispatch +// refuses anything else: a `return` that leaves the function above that line +// prints nothing and still exits 0 — a self-test that never finished, reported +// as one that passed (#13798). +const SELF_TEST_VERDICT = 'check-governed-merges self-test reached its verdict'; + async function selfTest() { let checked = 0; const failures = []; @@ -3414,6 +3420,8 @@ async function selfTest() { process.exit(1); } console.log(`✓ check-governed-merges --self-test: ${checked} assertions (the unified governed predicate + near misses, subject→PR spellings, window parsing, the #12633 landing window — the QS-7 regression pin in both directions, the topological close beyond the budget, the unproven-boundary EDGE, the listed-or-INCOMPLETE invariant over every fixture, the escalating floors, per-repo --since-ref resolution and its named fallback, and the window words — the replay fixtures, the four-repo resolution incl. absent/wrong-origin/relocated checkouts, the attribution channel chain + its proxy-transport re-arm plan and its one named fallback line, the three-way attribution column (resolved · every-channel-failed · NOT LOOKED UP, and the note pointer that belongs to the middle one alone), the --test pre-arm predicate, the generated-artifact provenance exception — the register's invariants incl. the RETIRED #9866 row staying retired (no row lifts anything under .claude/**, and the audit workflow is plainly governed again), a row with no recompute failing closed, lift/reject/absent-provenance semantics, the untouched mixed-diff rule, named-rows-not-a-class, the #11084 generator co-edit fence in both directions incl. a row with no instrument tree, and its render words — the #11705 generator-owned rows inside skills/** (a genuine generated file passes, the same path hand-edited does not, a path no generator declares is hand-authored content, per-row fences, and the enumeration read from the real generator), the exit table, the report wording pins, and the #13307 remote-reachability leg — the pure freshness verdicts in every branch (unreachable · a remote naming no commit · an unreadable local tip · a mirror behind its remote · the two-unreadable-shas degenerate case that must never read as a match), the report words in both directions (an unreachable repo never renders the tick, a reachable one still says a MEASURED zero, and a row with no remote reading never claims one), and the REAL prober on local bare-repo fixtures over the file transport — a live remote, a deleted one, the --exit-code branch, and a mirror the remote moved past — the #13423 identity leg (an origin no slug parses from refuses, pure and end-to-end, with audited reachable only through a parsed matching slug), the #13424 per-repo window resolution (a sibling-only pin resolves in its own repo, the self-only control still errors, and the end-to-end sibling-pin sweep reports instead of exiting 1), the #13307 sweep-code provenance line in all three branches, and the #13836 attribution set — every refusal carries its precondition category on the row, in the footer, and in --json; the shallow-clone path in both directions; and the run-1-vs-run-2 flip reproduced on real fixtures with zero local writes).\n ${liveNote}`); + + return SELF_TEST_VERDICT; } /** The exit code `--test` would return for a path list — pinned without spawning. */ @@ -3440,5 +3448,12 @@ function runTestModeExitFor(paths) { // back into a reading. A self-test is a mode of the file that is being RUN, // never a side effect of importing it. if (invokedDirectly && process.argv.includes('--self-test')) { - await selfTest(); + if ((await selfTest()) !== SELF_TEST_VERDICT) { + console.error( + '\n✗ check-governed-merges 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); + } } diff --git a/scripts/pm/check-governed-prose.mjs b/scripts/pm/check-governed-prose.mjs index 01deb5d901..3365306bdf 100644 --- a/scripts/pm/check-governed-prose.mjs +++ b/scripts/pm/check-governed-prose.mjs @@ -253,6 +253,13 @@ function runGate() { return 0; } +// Set by `selfTest()` only after its verdict is printed, and read at the +// dispatch: a `return` that leaves the function above that line prints nothing +// and still exits 0 — a self-test that never finished, reported as one that +// passed (#13798). The self-test's own exit code stays load-bearing, so the +// handshake is a flag rather than a returned sentinel. +let selfTestReachedVerdict = false; + function selfTest() { const cases = []; const assert = (name, actual, expected) => { @@ -342,10 +349,21 @@ function selfTest() { return 1; } console.log(`✓ check-governed-prose self-test: ${cases.length} cases pass.`); + + selfTestReachedVerdict = true; return 0; } if (isEntrypoint(import.meta.url)) { const isSelfTest = process.argv.slice(2).includes('--self-test'); - process.exit(isSelfTest ? selfTest() : runGate()); + const code = isSelfTest ? selfTest() : runGate(); + if (isSelfTest && !selfTestReachedVerdict) { + console.error( + '\n✗ check-governed-prose 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); + } + process.exit(code); } diff --git a/scripts/pm/check-governed-queue-guard.mjs b/scripts/pm/check-governed-queue-guard.mjs index 5c7027634e..805522da32 100644 --- a/scripts/pm/check-governed-queue-guard.mjs +++ b/scripts/pm/check-governed-queue-guard.mjs @@ -986,6 +986,13 @@ const REPLAYS = [ }, ]; +// Set by `selfTest()` only after its verdict is printed, and read at the +// dispatch: a `return` that leaves the function above that line prints nothing +// and still exits 0 — a self-test that never finished, reported as one that +// passed (#13798). The self-test's own exit code stays load-bearing, so the +// handshake is a flag rather than a returned sentinel. +let selfTestReachedVerdict = false; + export async function selfTest() { let checked = 0; const failures = []; @@ -1586,9 +1593,20 @@ export async function selfTest() { 'dependency install the recompute needs, its register-agnostic filter-free form, and its continue-on-error ' + 'degradation).', ); + + selfTestReachedVerdict = true; return 0; } if (isEntrypoint(import.meta.url) && process.argv.includes('--self-test')) { - process.exit(await selfTest()); + const selfTestCode = await selfTest(); + if (!selfTestReachedVerdict) { + console.error( + '\n✗ check-governed-queue-guard 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); + } + process.exit(selfTestCode); } diff --git a/scripts/pm/check-half-states.mjs b/scripts/pm/check-half-states.mjs index d01cf419f9..44331c47ba 100644 --- a/scripts/pm/check-half-states.mjs +++ b/scripts/pm/check-half-states.mjs @@ -11930,6 +11930,12 @@ async function sweepInto(findings, seen, seenPrs, seenMerged, seenUnscoped, seen // Self-test — predicates and the transport classifier; no network. // --------------------------------------------------------------------------- +// Returned by `selfTest()` only after its verdict is printed. The dispatch +// refuses anything else: a `return` that leaves the function above that line +// prints nothing and still exits 0 — a self-test that never finished, reported +// as one that passed (#13798). +const SELF_TEST_VERDICT = 'check-half-states self-test reached its verdict'; + function selfTest() { const cases = []; const t = (name, actual, expected) => cases.push([name, actual, expected]); @@ -17109,6 +17115,8 @@ function selfTest() { process.exit(1); } console.log(`✓ check-half-states self-test: ${cases.length} cases pass.`); + + return SELF_TEST_VERDICT; } const isMain = isEntrypoint(import.meta.url); @@ -17140,7 +17148,14 @@ if (isMain) { process.exit(2); } if (process.argv.includes('--self-test')) { - selfTest(); + if (selfTest() !== SELF_TEST_VERDICT) { + console.error( + '\n✗ check-half-states 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 (process.argv.includes('--probe')) { // Transport before questions about it (#13544): the probe's own requests go // through the same fetch the sweep uses, so a probe that answers on the diff --git a/scripts/pm/check-label-desc-cap.mjs b/scripts/pm/check-label-desc-cap.mjs index bb1cb8e7b3..f603980a5d 100644 --- a/scripts/pm/check-label-desc-cap.mjs +++ b/scripts/pm/check-label-desc-cap.mjs @@ -312,6 +312,12 @@ function run() { ); } +// Returned by `selfTest()` only after its verdict is printed. The dispatch +// refuses anything else: a `return` that leaves the function above that line +// prints nothing and still exits 0 — a self-test that never finished, reported +// as one that passed (#13798). +const SELF_TEST_VERDICT = 'check-label-desc-cap self-test reached its verdict'; + function selfTest() { let failed = 0; const t = (name, actual, expected) => { @@ -476,6 +482,8 @@ function selfTest() { process.exit(1); } console.log('\n✓ check-label-desc-cap --self-test: all cases passed'); + + return SELF_TEST_VERDICT; } // Exports bindings, so an import for those exports alone must run nothing (#10667). @@ -483,5 +491,13 @@ const invokedDirectly = isEntrypoint(import.meta.url); if (!invokedDirectly) { // imported as a module — expose the exports and do nothing else -} else if (process.argv.includes('--self-test')) selfTest(); -else run(); +} else if (process.argv.includes('--self-test')) { + if (selfTest() !== SELF_TEST_VERDICT) { + console.error( + '\n✗ check-label-desc-cap 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 run(); diff --git a/scripts/pm/check-skill-id-lint.mjs b/scripts/pm/check-skill-id-lint.mjs index a00c6354f6..0dc7a3f4ef 100644 --- a/scripts/pm/check-skill-id-lint.mjs +++ b/scripts/pm/check-skill-id-lint.mjs @@ -163,6 +163,12 @@ function run() { console.log(`✓ check-skill-id-lint: ${scanned} file(s) clean (pattern ${ID_PATTERN}).`); } +// Returned by `selfTest()` only after its verdict is printed. The dispatch +// refuses anything else: a `return` that leaves the function above that line +// prints nothing and still exits 0 — a self-test that never finished, reported +// as one that passed (#13798). +const SELF_TEST_VERDICT = 'check-skill-id-lint self-test reached its verdict'; + function selfTest() { // The waiver mechanism is tested with a synthetic entry so it stays covered // while LEGACY_EXACT is empty (see header). @@ -202,6 +208,8 @@ function selfTest() { process.exit(1); } console.log(`✓ check-skill-id-lint self-test: ${cases.length} cases pass.`); + + return SELF_TEST_VERDICT; } // Exports bindings, so an import for those exports alone must run nothing (#10667). @@ -209,5 +217,13 @@ const invokedDirectly = isEntrypoint(import.meta.url); if (!invokedDirectly) { // imported as a module — expose the exports and do nothing else -} else if (process.argv.includes('--self-test')) selfTest(); -else run(); +} else if (process.argv.includes('--self-test')) { + if (selfTest() !== SELF_TEST_VERDICT) { + console.error( + '\n✗ check-skill-id-lint 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 run(); diff --git a/scripts/pm/check-skill-line-ratchet.mjs b/scripts/pm/check-skill-line-ratchet.mjs index 95a1a82b67..e82f95dbaf 100644 --- a/scripts/pm/check-skill-line-ratchet.mjs +++ b/scripts/pm/check-skill-line-ratchet.mjs @@ -1049,6 +1049,12 @@ function run() { if (failed) process.exit(1); } +// Returned by `selfTest()` only after its verdict is printed. The dispatch +// refuses anything else: a `return` that leaves the function above that line +// prints nothing and still exits 0 — a self-test that never finished, reported +// as one that passed (#13798). +const SELF_TEST_VERDICT = 'check-skill-line-ratchet self-test reached its verdict'; + function selfTest() { const rel = '.claude/skills/pm-dispatch/SKILL.md'; const cases = [ @@ -1259,6 +1265,8 @@ function selfTest() { process.exit(1); } console.log(`✓ check-skill-line-ratchet self-test: ${cases.length} cases pass.`); + + return SELF_TEST_VERDICT; } // Exports bindings, so an import for those exports alone must run nothing (#10667). @@ -1266,5 +1274,13 @@ const invokedDirectly = isEntrypoint(import.meta.url); if (!invokedDirectly) { // imported as a module — expose the exports and do nothing else -} else if (process.argv.includes('--self-test')) selfTest(); -else run(); +} else if (process.argv.includes('--self-test')) { + if (selfTest() !== SELF_TEST_VERDICT) { + console.error( + '\n✗ check-skill-line-ratchet 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 run(); diff --git a/scripts/pm/ci-failure.mjs b/scripts/pm/ci-failure.mjs index 40e79a9d2a..f474d6d9fd 100644 --- a/scripts/pm/ci-failure.mjs +++ b/scripts/pm/ci-failure.mjs @@ -1559,6 +1559,12 @@ function render(result, target) { // exercised only `classifyTransportProbe` would restate #9946's self-test // instead of covering this file. It still opens no socket: the two readers are // injected. +// Returned by `selfTest()` only after its verdict is printed. The dispatch +// refuses anything else: a `return` that leaves the function above that line +// prints nothing and still exits 0 — a self-test that never finished, reported +// as one that passed (#13798). +const SELF_TEST_VERDICT = 'ci-failure self-test reached its verdict'; + async function selfTest() { const failures = []; const t = (label, actual, expected = true) => { @@ -2374,6 +2380,8 @@ async function selfTest() { ' And a `fix` renders as the ONE remedy it is: a single `fix:` marker with its\n' + ' continuations padded under it, keeping a copy-pasteable command on a line of its own.', ); + + return SELF_TEST_VERDICT; } /** @@ -2448,7 +2456,14 @@ if (!invokedDirectly) { // as an import side effect would make this file impossible to reuse without // also spending someone else's rate limit. } else if (process.argv.includes('--self-test')) { - await selfTest(); + if ((await selfTest()) !== SELF_TEST_VERDICT) { + console.error( + '\n✗ ci-failure 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 (process.argv.includes('--help') || process.argv.includes('-h')) { console.log(usageText(readFileSync(fileURLToPath(import.meta.url), 'utf8'))); } else { diff --git a/scripts/pm/git-history.mjs b/scripts/pm/git-history.mjs index 4520ae27c0..b405ad82fc 100644 --- a/scripts/pm/git-history.mjs +++ b/scripts/pm/git-history.mjs @@ -358,7 +358,18 @@ function resolveSince(opts) { } function main(argv) { - if (argv.includes('--self-test')) return selfTest(); + if (argv.includes('--self-test')) { + const selfTestCode = selfTest(); + if (!selfTestReachedVerdict) { + console.error( + '\n✗ git-history 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); + } + return selfTestCode; + } const { cmd, opts } = parseArgs(argv); if (cmd === null) usage('a command is required'); if (!['count', 'log', 'ensure'].includes(cmd)) usage(`unknown command '${cmd}'`); @@ -416,6 +427,13 @@ function main(argv) { // ── self-test ──────────────────────────────────────────────────────────────── +// Set by `selfTest()` only after its verdict is printed, and read at the +// dispatch: a `return` that leaves the function above that line prints nothing +// and still exits 0 — a self-test that never finished, reported as one that +// passed (#13798). The self-test's own exit code stays load-bearing, so the +// handshake is a flag rather than a returned sentinel. +let selfTestReachedVerdict = false; + function selfTest() { let failures = 0; const t = (name, ok, detail = '') => { @@ -621,6 +639,8 @@ function selfTest() { } process.stdout.write(failures === 0 ? '\ngit-history --self-test: all cases passed.\n' : `\ngit-history --self-test: ${failures} FAILED.\n`); + + selfTestReachedVerdict = true; return failures === 0 ? 0 : 1; } diff --git a/scripts/pm/release-rehearsal-clone.mjs b/scripts/pm/release-rehearsal-clone.mjs index 8eed3dc6b0..abc7d00755 100755 --- a/scripts/pm/release-rehearsal-clone.mjs +++ b/scripts/pm/release-rehearsal-clone.mjs @@ -401,7 +401,18 @@ function main(argv) { ); return 0; } - if (args.includes('--self-test')) return selfTest(); + if (args.includes('--self-test')) { + const selfTestCode = selfTest(); + if (!selfTestReachedVerdict) { + console.error( + '\n✗ release-rehearsal-clone 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); + } + return selfTestCode; + } const doPrepare = args.includes('--prepare'); // `--check` is accepted as an explicit spelling of the default so a caller can @@ -522,6 +533,13 @@ function cloneOf(root, source, name, { depth = 0 } = {}) { return dest; } +// Set by `selfTest()` only after its verdict is printed, and read at the +// dispatch: a `return` that leaves the function above that line prints nothing +// and still exits 0 — a self-test that never finished, reported as one that +// passed (#13798). The self-test's own exit code stays load-bearing, so the +// handshake is a flag rather than a returned sentinel. +let selfTestReachedVerdict = false; + function selfTest() { const root = mkdtempSync(join(tmpdir(), 'rehearsal-clone-selftest-')); let failures = 0; @@ -650,6 +668,8 @@ function selfTest() { } process.stdout.write(failures === 0 ? '\n✓ self-test passed\n' : `\n✗ self-test: ${failures} failure(s)\n`); + + selfTestReachedVerdict = true; return failures === 0 ? 0 : 1; } diff --git a/scripts/run-with-stall-guard.mjs b/scripts/run-with-stall-guard.mjs index 98365a7fcb..8f7151049b 100644 --- a/scripts/run-with-stall-guard.mjs +++ b/scripts/run-with-stall-guard.mjs @@ -212,9 +212,27 @@ const DEFER_NOTE_EVERY = 12; const argv = process.argv.slice(2); +// Set by `selfTest()` only after its verdict is printed, and read at the +// dispatch: a `return` that leaves the function above that line prints nothing +// and still exits 0 — a self-test that never finished, reported as one that +// passed (#13798). The self-test's own exit code stays load-bearing, so the +// handshake is a flag rather than a returned sentinel. +let selfTestReachedVerdict = false; + // Handled before the option loop below: --self-test takes no --log and must not -// spawn a wrapped command. selfTest() never returns. -if (argv.includes('--self-test')) await selfTest(); +// spawn a wrapped command. selfTest() never returns -- unless it left early, which +// is exactly what the handshake below refuses. +if (argv.includes('--self-test')) { + await selfTest(); + if (!selfTestReachedVerdict) { + console.error( + '\n✗ run-with-stall-guard 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); + } +} let logPath = ''; let stallMinutes = 10; @@ -1091,5 +1109,6 @@ async function selfTest() { process.exit(1); } console.log(`\n✓ ${results.length} case(s) passed — the guard fires, classifies and tears down.`); + selfTestReachedVerdict = true; process.exit(0); } diff --git a/scripts/typecheck-configs.mjs b/scripts/typecheck-configs.mjs index 69a6627c36..2d08e6951b 100644 --- a/scripts/typecheck-configs.mjs +++ b/scripts/typecheck-configs.mjs @@ -158,6 +158,13 @@ const CHAIN_CASES = [ /** How many cases `selfTest` holds -- for a folding gate's printed tally. */ export const SELF_TEST_CASE_COUNT = NAMED_CASES.length + CHAIN_CASES.length; +// Set by `selfTest()` only after its verdict is printed, and read at the +// dispatch: a `return` that leaves the function above that line prints nothing +// and still exits 0 — a self-test that never finished, reported as one that +// passed (#13798). The self-test's own exit code stays load-bearing, so the +// handshake is a flag rather than a returned sentinel. +let selfTestReachedVerdict = false; + /** * @returns {string[]} One string per failed case; empty means pass. */ @@ -180,12 +187,21 @@ export function selfTest() { } } + selfTestReachedVerdict = true; return failures; } if (isEntrypoint(import.meta.url)) { if (process.argv.includes('--self-test')) { const failures = selfTest(); + if (!selfTestReachedVerdict) { + console.error( + '\n✗ typecheck-configs 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); + } for (const failure of failures) console.error(` - ${failure}`); if (failures.length > 0) { console.error(`typecheck-configs --self-test FAILED: ${failures.length} of ${SELF_TEST_CASE_COUNT} case(s)`); From ec3dd7fc53ac7465e2348e15d449504e8e9ae7de Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 3 Sep 2026 05:00:02 +0000 Subject: [PATCH 2/2] fix(scripts): read check-osv-exemptions selfTest() before destructuring it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The handshake was placed after `const { passed, lines } = selfTest()`, so an early return made the destructuring throw a TypeError before the check could run — the very accident that made this gate read HELD without a handshake. The census now reports the named refusal instead of a stack trace. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01LAwHpn4uVuf4N1geBcD5i3 --- scripts/check-osv-exemptions.mjs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/scripts/check-osv-exemptions.mjs b/scripts/check-osv-exemptions.mjs index a74d939e40..95c8fc23bb 100644 --- a/scripts/check-osv-exemptions.mjs +++ b/scripts/check-osv-exemptions.mjs @@ -500,7 +500,10 @@ function selfTest() { function main() { if (process.argv.includes('--self-test')) { - const { passed, lines } = selfTest(); + // Read BEFORE destructuring: an early return yields `undefined`, and + // destructuring that throws a TypeError before the handshake is reached — + // an accidental non-zero exit is not a verdict handshake (#13798). + const selfTestResult = selfTest(); if (!selfTestReachedVerdict) { console.error( '\n✗ check-osv-exemptions self-test: selfTest() returned without reaching its verdict,\n' @@ -509,6 +512,7 @@ function main() { ); process.exit(1); } + const { passed, lines } = selfTestResult; console.log('check-osv-exemptions self-test (both directions):'); for (const line of lines) console.log(line); if (!passed) {