Skip to content

Commit 7af6983

Browse files
os-elon-muskclaude
andauthored
fix(scripts): check:single-claim-paths keeps its own two contracts — a failed board read exits PREREQUISITE NOT MET, and the self-test handshake is set by the verdict it certifies (#18986)
Fixes #18940 Clause-②: no Two defects in one file, both of the same class — `scripts/check-single-claim-paths.mjs` against the contracts it declares about itself. One commit each, so each is readable alone. No other file is edited. ## A. A transport failure exited 1, and this file defines 1 as the accusation The live path read a PR's file list with nothing catching a throw. `githubApi` throws on any non-ok response and `fetch` throws on a dead socket, so a 404, a dead credential or a dropped connection escaped as an unhandled rejection and node exited **1** — which this file's own exit register defines as "judged, an earlier open PR already claims a listed path". An accusation, about a board the run never read, against an author who did nothing. The file already forbade exactly that, in its own header: "a mis-wired gate must not read as an accusation, because it would be red on every PR at once for something no author did." So this is the file keeping its own rule, not a new policy. **The probe, run in an agent container. The card carried it unrun; this is the first run of it.** Before, at the base commit `26c73fb4e`: ``` $ PR_NUMBER=99999999 GITHUB_REPOSITORY=objectstack-ai/objectstack node scripts/check-single-claim-paths.mjs info re-exec with --use-env-proxy: HTTPS_PROXY is set and node's fetch does not read it. file:///home/user/objectstack-issue-18940/scripts/check-single-claim-paths.mjs:543 if (!response.ok) throw new Error(`GitHub API ${response.status} for ${path}`); ^ Error: GitHub API 404 for /repos/objectstack-ai/objectstack/pulls/99999999/files?per_page=100&page=1 at async collect (.../check-single-claim-paths.mjs:502:16) at async .../check-single-claim-paths.mjs:901:66 Node.js v22.22.2 exit=1 == EXIT_CONFLICT: "an earlier open PR already claims a listed path" ``` After: ``` $ PR_NUMBER=99999999 GITHUB_REPOSITORY=objectstack-ai/objectstack node scripts/check-single-claim-paths.mjs info re-exec with --use-env-proxy: HTTPS_PROXY is set and node's fetch does not read it. X check:single-claim-paths: PREREQUISITE NOT MET - the board was not read - GitHub API 404 for /repos/objectstack-ai/objectstack/pulls/99999999/files?per_page=100&page=1 - NOT a verdict, not a clean tree. Nothing below this line is a reading. This run never learned which paths any pull request claims, so it says nothing about whether one is claimed twice, and it accuses no author of anything. Exit 3 rather than 1: that code means an EARLIER open PR already claims a listed path, and a failed read is not evidence of any such PR. Exit 3 rather than 0 too - a board that could not be read is not a clean board. Usual causes, in the order worth checking: the token cannot read this repository, the PR number resolves to no pull request, or the request never left the container [...] exit=3 == EXIT_PREREQUISITE_NOT_MET ``` Live reverse control, same tree, a PR number that really resolves — unchanged: ``` $ PR_NUMBER=18935 GITHUB_REPOSITORY=objectstack-ai/objectstack node scripts/check-single-claim-paths.mjs OK check:single-claim-paths: PR #18935 modifies none of the 1 declared at-most-one-writer path(s), so there is nothing to serialise. exit=0 ``` Shape of the change: - The **read only** is wrapped. `judge` stays outside the `try` on purpose: it is pure and cannot throw on a transport, and a catch spanning it would relabel a real crash in the verdict layer as "the board was not read" — the same lie in the other direction. - The failure becomes a refusal through a pure handler, `boardNotReadRefusal(error)`, returning `{ exit, lines }` exactly as `judge` does. That is what lets the self-test drive the arm with no process exit and no network. The single `process.exit` stays at the dispatch, where every other verdict's exit already lives. - The code is `EXIT_PREREQUISITE_NOT_MET`, **imported** from the fleet's shared register, the way `scripts/check-issue-citations.mjs` and `scripts/pm/post-stamped.mjs` already spell it, so the wording family is one phrase across the tree: "PREREQUISITE NOT MET — the board was not read". - It stays **non-zero**. A board that could not be read is not a clean board, so the CI consumer (`.github/workflows/single-claim-path-guard.yml`) is still red on it. That is the right answer, and 0 would have been the anti-pattern the header names. - The header's exit register gains code 3 plus the paragraph saying why 1 could not be left to node's unhandled-rejection status. **The CI consumer needs no change, measured.** Its only comment about an exit code, at `single-claim-path-guard.yml:94-96`, names 2 for one specific condition (`PR_NUMBER` absent) and does not enumerate the register, so there is nothing there to extend. The `run:` line is unchanged and any non-zero exit is a red job. ## B. The self-test handshake was set before the block that produces the verdict `selfTestReachedVerdict = true` sat on the line directly above `return (async () => {` — the block that runs every assertion, evaluates the battery floor and prints the verdict. The flag was therefore true before a single case had run, so an early return or a throw anywhere inside that block left it true and the dispatch's `if (!selfTestReachedVerdict)` branch was unreachable. The one sentence the flag exists to make possible could never be said by this gate. AGENTS.md, verbatim: "Set the flag as the self-test's last statement, after its success line prints" and "SAY the self-test never reached its verdict. An exit code is not a handshake." The assignment is now that last statement. The failing arm deliberately gets none: it calls `process.exit(1)` on the next instruction, so no reader survives to consult the flag — the handshake is for the SILENT ways out, which is exactly what the new position catches. ### The negative control — red, then green, both legs on committed files The instrument is `scripts/ablation-replace.mjs`, so the mutation and the restore are both proven against the disk rather than against an exit code. The injected mutation is identical in both legs: an early `return;` as the first statement **inside** the async verdict block. **Leg 1 — the fixed file (this branch's HEAD):** ``` ablation-replace: anchor x1 -> x0 ablation-replace: blob 8850395 -> 8387e64acd8bacddc7a8b07b4cd991414edbe216 ablation-replace: ok mutation landed: anchor 1 -> 0, blob 8850395 -> 8387e64acd8b ablation-replace: running: node scripts/check-single-claim-paths.mjs --self-test X check-single-claim-paths self-test: selfTest() returned without reaching its verdict, so no success line was printed. Exiting 0 here would report a self-test that never finished as a self-test that passed. ablation-replace: command exited 1 ablation-replace: blob after restore 8850395 ablation-replace: blob at HEAD 8850395 ablation-replace: git diff HEAD empty ablation-replace: ok restored: blob == HEAD (8850395) and `git diff HEAD` is empty ``` **Leg 2 — the same injection on the pre-fix file (commit `d4b1f2817`, i.e. after A and before B), restored into the tree with `git restore --source=... --worktree` so the index is untouched:** ``` ablation-replace: anchor x1 -> x0 ablation-replace: blob 2bedc9c -> e5b5d1c042e6735f8e40eb874e3adcdca2f2429e ablation-replace: ok mutation landed: anchor 1 -> 0, blob 2bedc9c -> e5b5d1c042e6 ablation-replace: running: node scripts/check-single-claim-paths.mjs --self-test ablation-replace: command exited 0 ablation-replace: blob after restore 8850395 ablation-replace: blob at HEAD 8850395 ablation-replace: git diff HEAD empty ablation-replace: ok restored: blob == HEAD (8850395) and `git diff HEAD` is empty ``` Leg 2 printed **zero bytes** from the self-test and exited **0**. That is the defect, executed: the runner had nothing to say, and "every case held" and "no case ran" were the same output. This is why the triage asked for a RED negative control — without it, fixed and unfixed look identical in the gate's own output on a healthy run. `git status --porcelain` is empty after both legs, and the working blob equals the HEAD blob. ## Self-test counts | | batteries | cases | |:--|--:|--:| | base `26c73fb4e` | 8 | 74 | | this branch | 9 | 93 | `SELF_TEST_BATTERY_FLOOR` rises 8 to 9 with the roster, because leaving it at 8 would let the new battery be deleted without a red — which is what the roster comment already says about deletion. The measured base is 74 cases, not the 83 the dispatch carried; the 74 is also exactly the sum of the eight declared floors, so the floors sat at the live counts before this change and still do. The 19 new cases pin the code and the words, drive a throwing fake api through `collect` into the same handler, keep a healthy api reaching a real verdict as the reverse control, and pin structurally that the handler is reached after the LAST network read — the live one — so deleting the live catch reds instead of being satisfied by the self-test's own calls to the handler. No existing verdict moved: the CLEAN / CONFLICT / NOT WIRED pins are byte-identical and the three older constants keep their values, asserted as a new case (the four codes are four distinct values). ## Census — who else carries this ordering bug The card left this unmeasured and asked for it. Measured on this branch, and **not fixed here**. Population: 105 files under `scripts/` that both declare `let selfTestReachedVerdict = false` and read it at the dispatch. Two independent mechanical criteria, which agree on the same 4 candidates: (1) a success line printed after the last flag assignment; (2) an assertion-registering call (`registerCase`, `batteryFloorFailures`, `checkSelfTestFloor`) after the last flag assignment. All 4 then read by hand, because the mechanical signal cannot tell "the verdict is decided at the dispatch" apart from "assertions still run after the flag". | file | flag line | what follows the flag | verdict | |:--|--:|:--|:--| | `scripts/check-single-claim-paths.mjs` | 807 (pre-fix) | the entire async block it returns, opened at 808: every assertion, the floor, the verdict print | **ORDERING BUG** — fixed here | | `scripts/check-osv-exemptions.mjs` | 647 | nothing inside `selfTest()`; the flag is its last statement. The floor call at 675 is at the DISPATCH, after the flag is read at 657 | holds | | `scripts/typecheck-configs.mjs` | 350 | same shape: flag last in `selfTest()`, floor at 377 in the dispatch, flag read at 357 | holds | | `scripts/check-durability-degradation-log-level.mjs` | 5588 and 5592 | nothing — set adjacent to each of its two verdict lines, green and red. The sinks at 6718 and 6851 belong to a SECOND self-test carrying its OWN flag, `readSeamsReachedVerdict` (5609) | holds | The other 101 set the flag as the last statement of the function whose verdict it certifies. **So this is one card, not a class** — which also answers the card's own question about whether the B half should have been promoted to a census card. It should not. Worth recording for the next reader: `docs/audits/2026-09-self-test-shape-census.md` scores this very file **HELD** (its row, line 321). That is correct for the axis it measured — its probe injects `return;` as the first statement of the dispatched function, which lands *above* the flag assignment and therefore does leave the flag false. The ordering axis is a different injection point, inside the block, and the published census is blind to it by construction. Neither reading contradicts the other. ## Changeset `skip-changeset`, measured rather than assumed. Nothing published moves: 70 non-private packages declare `files[]` and every one ships `dist`, `README.md` and `CHANGELOG.md` only — none ships repo-root `scripts/`. The only reference to this script outside `scripts/` is the private root manifest's own `check:single-claim-paths` line. The positive control for the method is that same scan finding 70 populated `files[]` arrays, so an empty result is a reading and not a silent miss. ## Verification - `pnpm check:single-claim-paths` (the self-test) — pass, 93 cases, 9 batteries. - The 29 gate commands derived by `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` from this worktree, against the real change set (1 path, three-dot semantics vs merge base `26c73fb4e`). Every exit code captured redirect-then-`$?`, never through a pipe. **All 29 exited 0**, the slowest being `pnpm check:pm-dispatch-gates` at 745s (1848 self-test cases) under contention with a sibling worktree running the same gate. Reconciled with `dispatch-gates.mjs --ran`, each line recorded as `command :: exit N`: 29 derived, 29 run, 0 UNRUN, 0 NOT-MEASURED as a DERIVED zero rather than a claimed one, and none of the 29 exited 3. - Control-byte self-scan over the changed file (`grep -naP` over the C0 range plus DEL): no hits. - The derivation's own NOT MEASURED rows are left as it reports them, including the one for this very script's live invocation, whose argv takes a value only inside a CI run. ## Reader test A transport failure in CI reads PREREQUISITE NOT MET and a red job, never "an earlier PR claims your path". A self-test that dies mid-block is reported as never reaching its verdict. ## Acceptance notes Observations from this file, noted and deliberately not filed and not fixed — none is a reproducible defect, a declared-contract violation or a metadata-authoring trap: - The exit register's prose in the header is now four codes long and reads as a list; if a fifth ever arrives, the register would be better as a table. Style, no behaviour. - `MAX_PAGES` is 30 in both loops with one comment explaining the file-list ceiling; the open-PR loop borrows the same constant for a different endpoint's ceiling. Nothing observable follows from it today at this repo's open-PR count. --- _Generated by [Claude Code](https://claude.ai/code/session_01BTeBejoPUvRHN8WdAJC6oF)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent b146102 commit 7af6983

1 file changed

Lines changed: 160 additions & 5 deletions

File tree

‎scripts/check-single-claim-paths.mjs‎

Lines changed: 160 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -123,12 +123,26 @@
123123
* but `GITHUB_REPOSITORY` or `GITHUB_TOKEN` missing, #16329). A
124124
* usage/wiring failure, never a verdict about any PR, and never a
125125
* statement that the board is clean.
126+
* 3 PREREQUISITE NOT MET — the board could not be READ (transport, auth, a
127+
* 404 from a number that resolves to no pull request). Nothing was
128+
* judged, and it is never a statement about the board (#18940). The code
129+
* is the fleet's shared one, imported rather than restated, so every
130+
* instrument in this tree spells "could not read" identically.
126131
*
127132
* A gate that cannot read its input has verified nothing, and exiting 0 there
128133
* reads as "no violations" — the anti-pattern this repo keeps paying for. The
129134
* inverse matters too: a mis-wired gate must not read as an accusation, because
130135
* it would be red on every PR at once for something no author did.
131136
*
137+
* That inverse is why 3 exists and why a transport failure may not be left to
138+
* node's unhandled-rejection status. That status is 1, and 1 here is the
139+
* ACCUSATION — an earlier open PR already claims a listed path — so a 404, a
140+
* dead credential or a dropped connection printed a stack trace and told the
141+
* author their PR was racing someone else's. Every word of that is about a
142+
* board this run never read. The read is wrapped, the failure is named, and
143+
* the exit stays NON-ZERO, because a board that could not be read is not a
144+
* clean one either.
145+
*
132146
* A file list that could not be walked to the end is a third thing again. It is
133147
* reported as UNDETERMINED, loudly, and never silently folded into the clean
134148
* answer — a degraded list and a complete one must never look alike. It does
@@ -156,7 +170,16 @@ import { maskCommentsAndLiterals } from './js-comment-mask.mjs';
156170
// tree, so this gate and the sweeps can never disagree about whether this
157171
// container's fetch reaches GitHub at all. Only the guard variable below is
158172
// this file's own, and the block above `rearmThroughProxy` says why.
159-
import { PROXY_FLAG, PROXY_REARM_GUARD, proxyRearmPlan } from './pm/check-half-states.mjs';
173+
//
174+
// The PREREQUISITE NOT MET code arrives the same way and for the same reason:
175+
// "the read did not happen" is one answer across the fleet, and a gate that
176+
// numbered it locally would make a reader learn a second dialect per script.
177+
import {
178+
EXIT_PREREQUISITE_NOT_MET,
179+
PROXY_FLAG,
180+
PROXY_REARM_GUARD,
181+
proxyRearmPlan,
182+
} from './pm/check-half-states.mjs';
160183

161184
// ── The self-test's own battery roster and floor (#13489) ──────────────────
162185
//
@@ -191,11 +214,12 @@ const SELF_TEST_BATTERIES = Object.freeze({
191214
'Wiring absent: never clean, never an accusation.': 16,
192215
'The short-circuit. This is the property that makes the gate affordable,': 22,
193216
'The route to GitHub. A 401 from a bypassed proxy reads as a dead': 9,
217+
'A transport failure is a PREREQUISITE, never the accusation this': 19,
194218
});
195219

196220
// DELETING an entry silences that battery's floor exactly as effectively as
197221
// zeroing it, so the roster's own size is pinned too.
198-
const SELF_TEST_BATTERY_FLOOR = 8;
222+
const SELF_TEST_BATTERY_FLOOR = 9;
199223

200224
// The key an assertion is filed under when no battery is open. It is not a
201225
// declared battery, so it reds by the same set difference rather than silently
@@ -544,6 +568,48 @@ const githubApi = (token) => async (path) => {
544568
return response.json();
545569
};
546570

571+
/**
572+
* The read did not happen — the refusal, as data (#18940).
573+
*
574+
* `githubApi` throws on any non-ok response and `fetch` throws on a dead
575+
* socket, so every transport, auth and not-found failure arrives here as an
576+
* exception out of `collect`. Before this existed nothing caught it: node
577+
* exited 1 on the unhandled rejection, and 1 is this gate's ACCUSATION code —
578+
* the header's own rule, that a gate which cannot read its input must never
579+
* read as an accusation, broken by its own live path.
580+
*
581+
* Pure, and returning `{ exit, lines }` exactly as `judge` does, for one
582+
* reason: the self-test drives this arm offline and asserts the code and the
583+
* words without a process exit or a network. The `process.exit` stays at the
584+
* single dispatch site below, where every other verdict's exit also lives.
585+
*
586+
* ⛔ It is not wired around `judge`. `judge` is pure and cannot throw on a
587+
* transport; a catch that spanned it would relabel a real crash in the verdict
588+
* layer as "the board was not read", which is the same lie in the other
589+
* direction.
590+
*/
591+
export function boardNotReadRefusal(error) {
592+
const detail = error instanceof Error ? error.message : String(error);
593+
return {
594+
exit: EXIT_PREREQUISITE_NOT_MET,
595+
lines: [
596+
`❌ check:single-claim-paths: PREREQUISITE NOT MET — the board was not read — ${detail} — ` +
597+
'⛔ not a verdict, not a clean tree.',
598+
'',
599+
' Nothing below this line is a reading. This run never learned which paths any pull request',
600+
' claims, so it says nothing about whether one is claimed twice, and it accuses no author of',
601+
` anything. Exit ${EXIT_PREREQUISITE_NOT_MET} rather than ${EXIT_CONFLICT}: that code means an EARLIER open PR already`,
602+
' claims a listed path, and a failed read is not evidence of any such PR. Exit',
603+
` ${EXIT_PREREQUISITE_NOT_MET} rather than ${EXIT_CLEAN} too — a board that could not be read is not a clean board.`,
604+
'',
605+
' Usual causes, in the order worth checking: the token cannot read this repository, the PR',
606+
' number resolves to no pull request, or the request never left the container (in an agent',
607+
' container `fetch` reaches GitHub only through the session proxy — this gate re-execs itself',
608+
' to arm it, and the note it prints on doing so is above).',
609+
],
610+
};
611+
}
612+
547613
// ---------------------------------------------------------------------------
548614
// The route to GitHub (#18314) — why this gate re-execs itself.
549615
//
@@ -586,7 +652,7 @@ export function proxyPlanEnv(env) {
586652
/**
587653
* Hand this run off through the proxy, or `null` to carry on in-process.
588654
*
589-
* A returned number is the child's exit status, forwarded VERBATIM: the three
655+
* A returned number is the child's exit status, forwarded VERBATIM: the four
590656
* exit codes above are this gate's contract with CI, so the hand-off has to be
591657
* invisible in them.
592658
*
@@ -632,6 +698,14 @@ function rearmThroughProxy(args) {
632698
// and still exits 0 — a self-test that never finished, reported as one that
633699
// passed (#13798). The self-test's own exit code stays load-bearing, so the
634700
// handshake is a flag rather than a returned sentinel.
701+
//
702+
// ⛔ "After its verdict is printed" is positional and it is the whole contract.
703+
// This self-test runs its assertions inside an async block that `selfTest()`
704+
// RETURNS, and the assignment used to sit on the line above that `return`
705+
// (#18940): synchronously true before a single assertion had run, so every
706+
// early return and every throw inside the block still left it true and the
707+
// dispatch could never reach its own diagnostic. The flag belongs to the code
708+
// that PRINTED the verdict, never to the code that is about to start.
635709
let selfTestReachedVerdict = false;
636710

637711
function selfTest() {
@@ -804,7 +878,6 @@ function selfTest() {
804878
};
805879

806880
const ctxOf = (number) => ({ number: String(number), repo: 'o/r', token: 't' });
807-
selfTestReachedVerdict = true;
808881
return (async () => {
809882
calls.length = 0;
810883
const quiet = await collect(ctxOf(200), fakeApi({ 200: ['fixture/tree/alpha.ts', 'fixture/tree/beta.ts'] }));
@@ -857,6 +930,68 @@ function selfTest() {
857930
t('the card-keyed duplicate gate still exists', sibling !== '', true);
858931
t('...and still asks its own question', sibling.includes('No other open PR may claim the same issue'), true);
859932

933+
// --- A transport failure is a PREREQUISITE, never the accusation this
934+
// gate reserves for a real earlier claim. The live read is the only part
935+
// of this gate that can throw, and node exits 1 on an unhandled rejection
936+
// — the exact code that here means "an earlier open PR already claims a
937+
// listed path" (#18940). The fixture paths name a tree that exists in no
938+
// repo, per this file's header rule on quoted literals.
939+
battery('A transport failure is a PREREQUISITE, never the accusation this');
940+
const notFound = boardNotReadRefusal(new Error('GitHub API 404 for /repos/o/r/pulls/424242/files'));
941+
const notFoundText = notFound.lines.join('\n');
942+
t('a failed read exits PREREQUISITE NOT MET', notFound.exit, EXIT_PREREQUISITE_NOT_MET);
943+
t('...and that exit code really is the number 3, not just the named constant', notFound.exit, 3);
944+
t('...and it is NOT the accusation code, which is the whole point', notFound.exit === EXIT_CONFLICT, false);
945+
t('...and it is NOT clean either — a board that could not be read is not a clean board', notFound.exit === EXIT_CLEAN, false);
946+
t('...and it is NOT the wiring code — the wiring was fine, the READ was not', notFound.exit === EXIT_NOT_WIRED, false);
947+
t('the refusal says the board was not read', notFoundText.includes('the board was not read'), true);
948+
t('the refusal quotes the transport failure it caught', notFoundText.includes('GitHub API 404'), true);
949+
t('the refusal says it is not a verdict and not a clean tree', notFoundText.includes('not a verdict, not a clean tree'), true);
950+
t('the refusal does not read as a clean board', notFoundText.includes('✓'), false);
951+
t('the refusal carries the fleet wording, so one phrase greps every instrument', notFoundText.includes('PREREQUISITE NOT MET'), true);
952+
t('a non-Error throw still names itself rather than printing an object', boardNotReadRefusal('socket hang up').lines.join('\n').includes('socket hang up'), true);
953+
954+
// The integration, in the shape the live failure really has: an api that
955+
// throws, driven through `collect`. No process exit and no network — the
956+
// handler is pure, and the exit stays at the single dispatch site.
957+
const throwingApi = async (path) => {
958+
throw new Error(`GitHub API 404 for ${path}`);
959+
};
960+
let liveRefusal = null;
961+
let liveResolved = null;
962+
try {
963+
liveResolved = await collect(ctxOf(200), throwingApi);
964+
} catch (error) {
965+
liveRefusal = boardNotReadRefusal(error);
966+
}
967+
t('a throwing api really does escape collect — the shape of the live defect', liveResolved, null);
968+
t('...and the handler turns that escape into the PREREQUISITE refusal', liveRefusal?.exit, EXIT_PREREQUISITE_NOT_MET);
969+
t('...naming the request that failed, so the reader knows what went unread', liveRefusal.lines.join('\n').includes('/pulls/200/files'), true);
970+
971+
// Reverse control: nothing about a HEALTHY read changed. A working api
972+
// still reaches a real verdict, and the three older codes keep their
973+
// values and stay distinct from the new one.
974+
const healthy = await collect(ctxOf(200), fakeApi({ 200: ['fixture/tree/alpha.ts'] }));
975+
t('reverse control: a healthy api still reaches a verdict, never the refusal', judge(healthy).exit, EXIT_CLEAN);
976+
t('the four exit codes are four distinct values — a caller reads exactly one', new Set([EXIT_CLEAN, EXIT_CONFLICT, EXIT_NOT_WIRED, EXIT_PREREQUISITE_NOT_MET]).size, 4);
977+
978+
// Structural: the LIVE read is the one that is wrapped. Both halves,
979+
// because the ordering alone would be vacuous — the cases above call the
980+
// handler too, and every one of those calls sits ABOVE the live `collect`,
981+
// so a deleted live catch leaves the last occurrence before it.
982+
const refusalSites = ownSource.split('boardNotReadRefusal(').length - 1;
983+
t(
984+
'structural: the handler is reached AFTER the last network read — the live one',
985+
[refusalSites >= 2, ownSource.lastIndexOf('boardNotReadRefusal(') > ownSource.lastIndexOf('await collect(')],
986+
[true, true],
987+
);
988+
t(
989+
'structural: the exit code is imported from the fleet, not restated here',
990+
/\bEXIT_PREREQUISITE_NOT_MET\b/.test(ownSource) && !/(?:const|let|var)\s+EXIT_PREREQUISITE_NOT_MET\s*=/.test(ownSource),
991+
true,
992+
);
993+
t('structural: the header exit register names the code', readFileSync(SELF_PATH, 'utf8').includes('3 PREREQUISITE NOT MET'), true);
994+
860995
// The floor runs BEFORE the verdict below, so a success line can only be
861996
// printed by a run in which every declared battery registered its cases.
862997
for (const message of batteryFloorFailures()) cases.push([message, false, true]);
@@ -869,9 +1004,19 @@ function selfTest() {
8691004
}
8701005
if (failedCount) {
8711006
console.error(`✗ check-single-claim-paths self-test: ${failedCount} of ${cases.length} case(s) failed.`);
1007+
// No handshake is owed on this arm: the process is gone on the next
1008+
// instruction, so nothing downstream can read the flag. The handshake
1009+
// exists for the SILENT ways out — a `return` or a throw that prints
1010+
// nothing — and those are exactly the ones the line below now catches.
8721011
process.exit(1);
8731012
}
8741013
console.log(`✓ check-single-claim-paths self-test: ${cases.length} cases pass.`);
1014+
// LAST statement of the block, after the success line printed (#18940).
1015+
// Set before the block, it certified a run that had not happened yet: an
1016+
// early return or a throw anywhere inside left it true, and the dispatch
1017+
// below could never say the self-test had not reached its verdict — the
1018+
// one sentence the flag exists to make possible.
1019+
selfTestReachedVerdict = true;
8751020
})();
8761021
}
8771022

@@ -898,7 +1043,17 @@ if (isMain) {
8981043
const handed = rearmThroughProxy(process.argv.slice(2));
8991044
if (handed !== null) process.exit(handed);
9001045
}
901-
const resolved = ctx === null || ctx.wired === false ? ctx : await collect(ctx, githubApi(ctx.token));
1046+
// The read is the only part that can throw, and it is the only part inside
1047+
// the try (#18940). `judge` stays outside on purpose — see the block above
1048+
// `boardNotReadRefusal`.
1049+
let resolved;
1050+
try {
1051+
resolved = ctx === null || ctx.wired === false ? ctx : await collect(ctx, githubApi(ctx.token));
1052+
} catch (error) {
1053+
const refusal = boardNotReadRefusal(error);
1054+
for (const line of refusal.lines) console.error(line);
1055+
process.exit(refusal.exit);
1056+
}
9021057
const result = judge(resolved);
9031058
const emit = result.exit === EXIT_CLEAN ? console.log : console.error;
9041059
for (const line of result.lines) emit(line);

0 commit comments

Comments
 (0)