Verdict handshake for 134 scripts/** self-tests that exit 0 on an early return - #14479
Merged
Conversation
…h discards the result `scripts/**` gates spelled `if (--self-test) selfTest();` had no handshake: a `return` anywhere above the verdict printed nothing, evaluated no floor and still exited 0 — a self-test that never finished, reported as one that passed. Transplants the boundary-only shape landed on `check-self-test-wired.mjs`: a per-file `SELF_TEST_VERDICT` sentinel returned as the last statement of the self-test, after its verdict line, and compared at the dispatch. Nothing inside any self-test body changes. An unbraced branch body (`else if (c) selfTest();`) is braced first, so the multi-statement replacement cannot re-bind a following `else`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013Euac24FtaPN3AXf9uiUWA
…turn selfTest()` Here the self-test's own return value reaches a caller, so the sentinel shape cannot be used without changing what the dispatch propagates. A module-level `selfTestReachedVerdict` is set only after the verdict prints and read between the call and the return, leaving the returned value exactly as it was. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013Euac24FtaPN3AXf9uiUWA
…ocess.exit(selfTest())` The shape that reads like a handshake and is not: an early bare `return` yields `undefined`, and `process.exit(undefined)` is exit 0. The self-test's numeric exit code is load-bearing, so it is captured and re-exited unchanged; the flag is read in between. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013Euac24FtaPN3AXf9uiUWA
… a ternary
`process.exit(argv.includes('--self-test') ? selfTest() : main())` and the
`const code = ... ? selfTest() : main(argv)` variant carry the same hole. The
ternary is split so the self-test arm can be handshaked; the production arm
keeps its exact expression and exit code.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013Euac24FtaPN3AXf9uiUWA
…atches Nine gates whose dispatch discards the result but whose self-test ends in `return <code>` or `process.exit(...)`, so an appended sentinel return would be unreachable; the flag is set immediately before that terminal statement. Plus `check-durability-degradation-log-level.mjs`, whose dispatch calls TWO self-test entries and combines their statuses with `||`. An early return in either yields `undefined`, which that `||` reads as a pass, so each entry gets its own handshake rather than a wrapper. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013Euac24FtaPN3AXf9uiUWA
…lf-test-handshake
Braces were added around 51 unbraced branch bodies so the multi-statement handshake could not re-bind a following `else`. In the 16 whose dispatch was already nested, the block that moved inside them kept its old indentation and came out one level deep. Whitespace only — no statement moves. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013Euac24FtaPN3AXf9uiUWA
…-test `selfTest()` there is annotated `: void`, so returning the verdict sentinel added two raw tsc errors to the ROOT program and drifted the @objectstack/spec-monorepo DEBT entry 26 -> 28. The ledger is shrink-only and raising it is maintainer-only, so the errors are fixed instead: the annotation now says what the function returns. Root `tsc --noEmit` is back to 26. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013Euac24FtaPN3AXf9uiUWA
…lf-test-handshake
Contributor
📓 Docs Drift CheckNothing in this diff resolved to a documentable surface (no symbol, route or SDK anchor derived from 0 changed package(s)), so this run has no opinion about the docs. What this run could not see
Coarse fallback — 0 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): |
baozhoutao
marked this pull request as ready for review
September 2, 2026 10:15
baozhoutao
enabled auto-merge
September 2, 2026 10:15
This was referenced Sep 2, 2026
This was referenced Sep 2, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Part of #13798 (batch 1)
Transplants the verdict handshake PR #13797 landed on
check-self-test-wired.mjsandcheck-self-test-workflow-commands.mjsto every otherscripts/**gate the censusinstrument reads as
DEFEATED, minus the files held by other lanes' open PRs. Nothinginside any self-test body changes — no battery, no floor, no case is touched. The
strictly-sequenced sibling #13799 (hole 1, the roster floor) is deliberately NOT in this
diff and stays in
pm:queue; per triage 5478876397 the handshake must land before or withthe floor on any given file, so this PR is first and #13799 rebases onto it.
The reading, measured — not re-derived from the card
The card's 144/154 was taken on
597020aa5. The population has moved since. Both numbersbelow come from
node scripts/measure-self-test-floor.mjs --probeon this branch's ownmerge base,
96b627d13, which is also the commit the dispatch was cut against.96b627d13)142 of the 150 defeated printed nothing at all and still exited 0.
The 16 that remain are exactly the excluded set — set equality asserted, not eyeballed:
{DEFEATED after} == {DEFEATED before} \ {worklist}. Every one is held by another lane'sopen PR or in-flight card, so a hunk here would collide:
scripts/check-skill-identifier-liveness.mjs,scripts/check-skills-token-ratchet.mjsscripts/check-type-check-coverage.mjs,scripts/check-type-source-resolution.mjsscripts/pm/bare-root-worklist.mjs,check-clause2-carriers.mjs,check-governed-merges.mjs,check-governed-prose.mjs,check-governed-queue-guard.mjs,check-half-states.mjs,check-label-desc-cap.mjs,check-skill-id-lint.mjs,check-skill-line-ratchet.mjs,ci-failure.mjs,git-history.mjs,release-rehearsal-clone.mjsscripts/pm/directory is carved out of batch 1They are batch 2, once their holding PRs land.
scripts/pm/dispatch-gates.mjsis NOTMEASURED, not clean, and is carved out separately in #13800 — it is not in the 16.
Four more exclusion-list entries never appear because they are not in the population at
all (no
--self-testdispatch in code):regen-artifacts.mjs,measure-durability-swallow-family.mjs,cross-package-test-inputs.mjs,check-adr-symbol-anchors.mjs,symbol-anchors.mjs;build-console.shis a shell scriptand the census only walks
.mjs/.mts/.js/.ts.Two shapes, one per dispatch spelling
⛔ Candidates were taken with the AST, decided with the probe. The worklist is
DEFEATED − exclusions, never a grep forreturn selfTest().Shape 1 — the returned sentinel (68 files). PR #13797's shape verbatim: a per-file
SELF_TEST_VERDICTnaming the gate, returned as the last statement of the self-test afterits verdict line, compared at the dispatch. Applied where the dispatch discards the
result (
selfTest();,await selfTest();, including as an unbracedelse ifbody) andthe self-test does not already end in a
return/process.exitthat would make an appendedreturn unreachable.
Shape 2 — the verdict flag (66 files). A module-level
selfTestReachedVerdict, setonly after the verdict prints and read at the dispatch. Applied wherever the self-test's
own exit code is load-bearing, because a returned sentinel would either destroy that
code or be unreachable:
process.exit(selfTest())process.exit(undefined)is exit 0, which is why this spelling reads like a handshake and is not onereturn selfTest()(insidemain())process.exit(cond ? selfTest() : …)andconst code = cond ? selfTest() : …return EXITCODE/process.exit(…)check-durability-degradation-log-level.mjscheck-durability-degradation-log-level.mjsis the one Tier C file in the worklist: itsdispatch calls two self-test entries and combines them with
process.exit(a || b ? 1 : 0). An early return in either yieldsundefined, which that||reads as a pass. PR #13797 recorded the choice as "one handshake per callee, or onewrapper". One handshake per callee is what landed — both entries are structurally
identical (verdict print, then
return 0), so it stays a transplant rather than aredesign. Only the first entry is what the probe measures; the second is repaired because
leaving half a dispatch handshaked is the same silence one call deeper.
Two mechanical traps the transplant had to survive
} else if (argv.includes('--self-test')) selfTest();becomes several statements, and without braces the following
elsere-binds to thehandshake's own
if. 51 of the 134 files needed the brace; they are braced.the function is hoisted and a
const/letis not. In those the declaration is placedbefore the dispatch's top-level statement instead of before the function, or the run
would throw a
ReferenceErrorwhere it used to handshake.Reverse verification — four ablations, one per dispatch spelling
Each injects
return;as the first statement of the entry function in a scratch copywritten beside the original (so relative imports and repo-root resolution answer
identically — the same placement the census instrument uses), runs it, then deletes the
copy. The tracked file is never mutated: the marker count is read back from disk on
both files before the reading is accepted (
x1in the copy,x0in the original — aneditor step that matched nothing exits 0 exactly like one that landed), and
git status --porcelainwas empty afterwards.returncheck-nul-bytes.mjs✓ … 75 assertions over a temp git repo✗ check-nul-bytes self-test: selfTest() returned without reaching its verdictcheck-cli-command-ids.mjsprocess.exit(selfTest())✓ … 39 cases passcheck-error-code-casing.mjsreturn selfTest()✓ … 46 recognizer + 5 registry case(s) passcheck-comment-mask-adoption.mjsPASS … (0 failure(s))There is no build or
dist/on this path — every gate runs from source — so there is norebuild leg to get wrong. The pre-fix direction needs no separate ablation: the before
column of the census is that measurement, and it recorded all four as
DEFEATED,printing nothing.
What did not change, asserted rather than assumed
--self-testto the same exit code andbyte-identical output. Captured before the codemod and again after, and diffed. Two
files differ and neither is a behaviour change:
objectui-changeset-digest.mjsprintsfresh
mktemppaths and fresh commit shas on every run, andcheck-plugin-teardown-shape.mjswas red before and after on a shallow checkout —its positive control is pinned to a commit a
--depth=1clone cannot reach. Aftergit fetch --depth=6000it is green (✓ … 47 cases pass).to reach that commit the gate now prints its own "Deepen the clone" refusal and the
handshake refusal, because it really does
return 1without reaching its verdict. Exitcode is 1 either way; the extra line is noise on an already-red path, not a new failure.
(
ROSTER/COUNT/NONE) is byte-identical for all 164 files before and after. That isthe mechanical proof this remedy is boundary-only, and it is why 155 of 158 scripts/** self-tests have no assertion floor: a battery that never ran is indistinguishable from one that passed #13799 still has its
whole job to do.
Verification
Every exit code captured before any pipe, from a variable assigned directly after the
command.
Union re-derived and re-run after the final commit, on
83916a44b(this branch's head,which is the merge of
origin/mainata98b61b3e).node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack— DG_EXIT=0,134 path(s) vs the merge base, 149 families (148 by path + 3 by change kind, 2 reached
both ways). Every one was run from
--commands, so the harvest cannot end at one section.146 of the 149 exit 0. The other three are NOT MEASURED — never read as passes:
node scripts/check-partof-closing-keyword.mjsNOT WIRED — neither PR_BODY nor PR_NUMBER is set; its own text says this is a usage failure, not a verdict. Re-run against this PR's body below.node scripts/check-single-claim-paths.mjsNOT WIRED — PR_NUMBER is not set, same shape; needs the PR to existnode scripts/check-test-completeness.mjsPREREQUISITE NOT MET— it grades a savedturbo run testlog and no log exists locally. The same reading PR Survey: which scripts/** self-tests cannot prove they ran — and the two that now can #13797 recorded.PREREQUISITE NOT MET/ "Run
pnpm buildfirst", not findings.pnpm build(exit 0, 71/71 tasks) turnedtwelve of them green; the thirteenth was a real red and is fixed in this branch, below.
Named because the dispatch names them, all exit 0:
pnpm check:ratchet-remedy-authority,pnpm check:declared-population-live,pnpm check:nul-bytes,node scripts/pm/bare-root-worklist.mjs --self-test, andpnpm check:pm-dispatch-gates— the ~13-minute one, run to completion (781s).The two precedent gates in both modes, no pnpm alias, the way
lint.ymlruns them:node scripts/check-self-test-wired.mjs --self-testexit 0(
6 declared batteries, 43 cases registered) thennode scripts/check-self-test-wired.mjsexit 0;
node scripts/check-self-test-workflow-commands.mjs --self-testexit 0(
6 declared batteries, 23 cases registered) then the production run exit 0.pnpm lint—eslint . --no-inline-configover the whole repo, not narrowed:exit 0, 5703 files, 0 errors, 0 warnings (count read from
--format json).pnpm check:nul-bytesexit 0; the touched files were additionally swept directly forraw control bytes (
grep -naP '[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]') — no match.The one real red this produced, and why it was fixed rather than ledgered
pnpm check:type-check-debtreddened at exit 1:@objectstack/spec-monorepo: DEBT records 26 raw tsc error(s), tsc --noEmit now reports 28 (+2).scripts/check-test-typecheck.mtsis the one TypeScript file in this diff and it lives inthe root tsc program — the convention block of the derivation names it for exactly this
reason. Its
selfTest()is annotated: void, so returning the sentinel producedTS2322: Type 'string' is not assignable to type 'void'and the comparison at the dispatchproduced
TS2367.⛔ Raising the ledger entry is MAINTAINER-ONLY and was not taken. The gate's own author
remedy — fix the errors — is what landed: the annotation now says
: string, which is whatthe function returns. Root
tsc --noEmit -p tsconfig.jsonis back to 26 errors, equalto the frozen entry, and the single remaining one (
TS2550 Object.hasOwnat line 368) ispre-existing and untouched.
pnpm check:type-check-debtexit 0 on the final head.No changeset, and the label
scripts/**publishes nothing from any package, so this releases nothing — the closedskip-changesetlist inlint.ymlnames exactly this case ("this PR edits a CI-internalscript"), and PR #13797 took the same route for the same reason. No changeset is written;
the
skip-changesetlabel is applied on the PR.Not in this batch — named so it is not mistaken for done
in PR Survey: which scripts/** self-tests cannot prove they ran — and the two that now can #13797:
check-osv-exemptions.mjs,typecheck-configs.mjs,check-exported-any-returns.mts(a downstreamTypeError),checklist-select.mjs,run-with-stall-guard.mjs(a usage/argument error), pluscheck-page-declaration-shape.mjswhoseprocess.exit(selfTest() ? 0 : 1)stops a barereturnand prints nothing while doing it. The probe reads all sixHELD, so they falloutside batch 1's stated surface — "every file that reads DEFEATED, minus the exclusion
list" — and are not repaired here. Their green is still an accident one refactor
deep; they need the same five-line transplant.
check-platform-checklist.mjs(four callees combined),check-regen-pending.mjs,git-merge-regen.mjs,setup-git-hooks.mjs(inline top-levelblocks with no callee to leave early) and
scripts/pm/dispatch-gates.mjs(Four scripts/** gates hit by the #13489 survey were excluded by occupancy — carry them forward #13800). Eachneeds reshaping before the probe measures anything, which is a decision, not a
transplant.
check-filter-alias-parity.mjs,check-meta-type-normalized.mjs,check-test-completeness.mjs) also call their self-test on the production path. Thatcall still discards its result. It is the same silence, one seam over, and outside the
measured surface; recorded here rather than fixed.
#13799 is not addressed here. #13014 (the empty-scan class) is not addressed here and does
not overlap:
check-adr-links.mjsandcheck-doc-anchors.mjsboth carry a full empty-scanrefusal and were both defeated by this hole.
🤖 Generated with Claude Code
https://claude.ai/code/session_013Euac24FtaPN3AXf9uiUWA
Generated by Claude Code