From f5773ce08ff1cd61aa9463080f9cfc14874b4771 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 17 Sep 2026 13:36:37 +0000 Subject: [PATCH] fix(pm): every `--pair` run states the inputs it judged from MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `check-clause2-carriers --pair` answered 0 / 4 / 0 on one pair with an identical script blob, and nothing either run printed could settle which of them had read what. The judging half is already deterministic given a fixed document (`--pair-json` proves it); what was unpinned is what document the LIVE path builds. Every run past the board resolution now closes with a fenced `clause2 input record` block on stderr — the board and which of the three sources answered, the read path and every request it issued with its row count, the pair and the evidence it was derived from, the comments read by count/ids/newest, the claim comment SELECTED as the carrier with the rule that selected it and every candidate it rejected with a reason, each pooled claim's body fingerprint, the PR-body line, and this file's own blob hash and path. The field roster is the same on exit 0, exit 4 and every refusal, so two runs that disagree are diffed rather than re-run. The `--json` sweep carries the same record under `inputs`. No verdict, row, count or exit code reads any of it. Claude-Session: https://claude.ai/code/session_01Gqi43smmqjJ5sUrhfoPeKu Co-authored-by: Claude --- scripts/pm/check-clause2-carriers.mjs | 783 +++++++++++++++++++++++++- 1 file changed, 755 insertions(+), 28 deletions(-) diff --git a/scripts/pm/check-clause2-carriers.mjs b/scripts/pm/check-clause2-carriers.mjs index ed7d952d4ac..25d204236aa 100644 --- a/scripts/pm/check-clause2-carriers.mjs +++ b/scripts/pm/check-clause2-carriers.mjs @@ -14,15 +14,35 @@ * node scripts/pm/check-clause2-carriers.mjs --template # the record, copyable; no network * node scripts/pm/check-clause2-carriers.mjs --help # usage; no network * + * ## Every run STATES what it judged from (#18456) + * + * Every run past the board resolution closes with a fenced `clause2 input + * record` block on stderr: the board and which of the three sources answered, + * the read path and every request it issued, the pair and the evidence it was + * derived from, the comments read, the claim comment SELECTED as the carrier + * with the rule that selected it and every candidate it rejected, each pooled + * claim's body fingerprint, and this file's own blob hash and path. + * + * ⭐ Two runs that DISAGREE about one pair are settled by DIFFING their two + * blocks — ⛔ never by re-running until one side wins. The block has the same + * field roster on every exit (0, 4, a refusal, a transport failure) precisely + * so the diff is line for line, and the blob line says whether the two runs + * were even the same instrument. The full reasoning is at INPUT_RECORD_VERSION. + * * ## Which board this answers about, and how a reader can tell (#16623) * * The board is a PARAMETER, resolved once per run by `resolveSweepRepo` * (imported): `PM_SWEEP_REPO`, else `GITHUB_REPOSITORY`, else the default. It - * is not a property of this checkout -- this file reads no file in the tree at - * all, so an environment variable really does retarget it, and a sibling repo's + * is not a property of this checkout -- this file reads no BOARD DATA out of a + * tree, so an environment variable really does retarget it, and a sibling repo's * seat runs `PM_SWEEP_REPO= node scripts/pm/check-clause2-carriers.mjs * --pair N` to get an answer about its own board. * + * ⚠️ Since #18456 it reads exactly ONE file out of the checkout: its OWN source, + * for the blob hash the input record prints. That is provenance and nothing + * else -- no state, row, count or exit consults it -- so the sentence above + * holds where it matters and is amended rather than quietly left false. + * * ⚠️ That was TRUE before this file said so, and saying so is the fix (#16623). * The filing seat grepped THIS file for a repo flag, found nothing, and * concluded the capability was absent -- while `resolveSweepRepo` sat in the @@ -629,6 +649,7 @@ import process from 'node:process'; import { spawnSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; import { readFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { isEntrypoint } from '../invoked-as.mjs'; @@ -676,8 +697,10 @@ import { CONTRACT_REVIEW_TIER } from './dispatch-gates.mjs'; // that this gate "reads no file in the tree at all; its whole input is the // GitHub API (PRs, their labels, and the claim comments on their cards), so no // card's file surface can predict it" (#13519). The INPUT half of that is still -// exactly true -- nothing here opens a tracked file, and the three read paths -// above are the whole of what this gate consumes. +// exactly true -- no BOARD DATA is read out of a tree, and the three read paths +// above are the whole of what this gate consumes. (#18456 opens one tracked +// file, this one, to hash it for the input record; it feeds no reading, so the +// prediction argument is untouched.) // // ⭐ The OTHER half stopped being true, and a declaration that stopped being // true is the shape C7 itself exists against. C7 compares against @@ -742,6 +765,7 @@ const SELF_TEST_BATTERIES = Object.freeze({ '#18141: the head sha sits in a span of ITS OWN — the key-in-span spelling, refused and NAMED': 19, '#17919: the correction remedy names THIS card\'s claim comment, never another card\'s': 24, '#16833: an UNJUDGED refusal names the CHANNEL that answered, what it answered, and which carrier': 30, + '#18456: the `--pair` input record — the same block on every exit, so two runs that disagree can be diffed': 38, }); // DELETING an entry silences that battery's floor exactly as effectively as @@ -752,8 +776,8 @@ const SELF_TEST_BATTERIES = Object.freeze({ // effect, and once more by the one #17149 adds, by the one #17098 adds, by the // one #17915 adds, by the one #17959 adds, by the one #18042 adds, and by the // one #18174 adds, and by the one #18141 adds, and by the one #17919 adds, and -// by the one #16833 adds. -const SELF_TEST_BATTERY_FLOOR = 26; +// by the one #16833 adds, and by the one #18456 adds. +const SELF_TEST_BATTERY_FLOOR = 27; // The key an assertion is filed under when no battery is open. It is not a // declared battery, so it reds by the same set difference rather than silently @@ -1475,6 +1499,65 @@ function applicableCorrection(commentRows, pool) { } /** The key as prose, for the sentences above — declared once, beside the regex that reads it. */ +/** + * The rule that picks the declaration limb's CARRIER, written out once. + * + * It is a sentence and not a comment because the record below PRINTS it: a + * reader diffing two runs has to be able to tell "the two runs selected + * different comments" from "the two runs applied different rules", and a rule + * that lives only in a docblock cannot be compared against a run. + */ +export const CLAIM_SELECTION_RULE = + 'the GOVERNING claim — the NEWEST comment whose body carries a line beginning `Claim:`/`Claimed:` ' + + 'AND whose `Branch:` line parses at least one protocol-shaped branch (newest by `created_at`; an ' + + 'unreadable stamp or a tie falls back to thread order, later row wins). The pool is every claim ' + + 'comment sharing that `created_at`; when NO claim names a branch at all, every claim comment is ' + + 'the pool. ⛔ Not earliest, ⛔ not a session match, ⛔ not the one whose body mentions the key.'; + +/** + * WHICH claim comment this reading is built from, and which it is not — the + * input half of the declaration limb (#18456). + * + * ⭐ ONE derivation, and that is the whole point. `cardDeclaration` used to + * compute the governance and the pool inline, so nothing outside it could + * state which comment had been selected without RE-deriving it — and a second + * derivation of a selection rule is exactly how a record ends up describing a + * reading the verdict did not take. This function is that derivation; + * `cardDeclaration` calls it and the record renders it, so the two cannot + * disagree about the carrier by construction. + * + * ⛔ It resolves no state, no row and no exit code: it reports WHICH comments + * the reading is built from and WHY each other claim is not one of them. The + * declaration itself is still read by `readClause2Line` from the pool, exactly + * as before, and this function is a pure function of the rows it is handed. + * + * @param {{ id?: number|string, body?: string, created_at?: string }[]|null} commentRows + * @returns {{ readable: boolean, rule: string, claims: object[], pool: object[], + * governing: { branches: string[], createdAt: string|null }|null, + * malformed: object|null, rejected: { row: object, reason: string }[] }} + */ +export function claimCarrierSelection(commentRows) { + const rule = CLAIM_SELECTION_RULE; + if (!Array.isArray(commentRows)) { + return { readable: false, rule, claims: [], pool: [], governing: null, malformed: null, rejected: [] }; + } + const governance = claimGovernance(commentRows); + const claims = commentRows.filter((row) => CLAIM_COMMENT_MARKER.test(String(row?.body ?? ''))); + const governing = governance.governing; + const matched = governing ? claims.filter((row) => (row?.created_at ?? null) === governing.createdAt) : []; + const pool = governing && matched.length > 0 ? matched : claims; + const rejected = claims + .filter((row) => !pool.includes(row)) + .map((row) => ({ + row, + reason: + `a SUPERSEDED claim — it is not the newest claim that parses a branch, so it is not the ` + + `governing claim (this one is stamped ${row?.created_at ?? 'with no readable date'}; the ` + + `governing claim is stamped ${governing?.createdAt ?? 'unreadably'})`, + })); + return { readable: true, rule, claims, pool, governing, malformed: governance.malformed, rejected }; +} + const CLAUSE2_CORRECTION_KEY_TEXT = 'Clause-②-correction'; /** @@ -1532,7 +1615,12 @@ const CLAUSE2_CORRECTION_KEY_TEXT = 'Clause-②-correction'; */ export function cardDeclaration(commentRows, { card = null } = {}) { if (!Array.isArray(commentRows)) return { state: 'unreadable' }; - const governance = claimGovernance(commentRows); + // ⭐ ONE derivation of the carrier, shared with the input record (#18456): + // the governance, the claim rows and the pool below are this function's + // return, so the block that STATES which comment was selected and the + // reading that was taken FROM it cannot describe two different comments. + const selection = claimCarrierSelection(commentRows); + const governance = { governing: selection.governing, malformed: selection.malformed }; // ⛔ FIRST, and ahead of the correction read (#17366) as well as of every // line read below. When the newest claim comment parses to zero branches, // `governing` is an OLDER claim or nothing at all — so the pool the reads @@ -1548,16 +1636,12 @@ export function cardDeclaration(commentRows, { card = null } = {}) { governingClaim: governance.governing, }; } - const claim = governance.governing; - const claimRows = commentRows.filter((row) => CLAIM_COMMENT_MARKER.test(String(row?.body ?? ''))); // The governing claim is the one the board is waiting on; when no comment // names a branch, every claim-marked comment is still a claim carrier and is // read, so a claim written without a branch cannot make the declaration - // invisible. - const governing = claim - ? claimRows.filter((row) => (row?.created_at ?? null) === claim.createdAt) - : claimRows; - const pool = governing.length > 0 ? governing : claimRows; + // invisible. Both sets come from the selection above. + const claimRows = selection.claims; + const pool = selection.pool; // WHICH comment id a remedy printed below may name, resolved from the SAME // pool the readings are built from and from nowhere else (#17919). It rides // alongside exactly as `correctionNote` does: the rows below print it, and @@ -3886,8 +3970,32 @@ const readPathState = { rate: null, /** the path id that last served a read, for the cap/short-read diagnosis. */ lastServed: null, + /** + * EVERY read this run issued, in order — the run's own statement of what it + * judged from (#18456). Written by the readers, read only by the record. + * ⛔ Never consulted by a predicate: a request ledger is provenance. + */ + requests: [], }; +/** + * File one request — the channel, the exact path, what came back, and how many + * rows it carried. + * + * ⭐ The row COUNT is the field that earns its place: a page requested with + * `per_page=100` that answers with exactly 100 rows is indistinguishable, in + * every other line this file prints, from a thread that simply ends there. + */ +function noteRequest(channel, path, answer, rows = null) { + readPathState.requests.push({ + n: readPathState.requests.length + 1, + channel, + path, + answer, + rows: typeof rows === 'number' ? rows : null, + }); +} + function noteServed(pathId) { readPathState.served.set(pathId, (readPathState.served.get(pathId) ?? 0) + 1); // The channel that last ANSWERED, so a read that fails without a refusal — @@ -4097,16 +4205,20 @@ async function restOnce(path, token) { // exactly the reading a seat behind a refusing proxy needs, and it is the // one a status-only diagnosis would render as silence. noteAttempt(channel, `no HTTP response (${err?.message ?? 'transport error'})`); + noteRequest(channel, path, `no HTTP response (${err?.message ?? 'transport error'})`); throw err; } noteRateLimit(res); if (!res.ok) { noteAttempt(channel, `HTTP ${res.status}`); + noteRequest(channel, path, `HTTP ${res.status}`); const err = new Error(`GET ${path} -> HTTP ${res.status}`); err.status = res.status; throw err; } - return res.json(); + const json = await res.json(); + noteRequest(channel, path, `HTTP ${res.status}`, Array.isArray(json) ? json.length : null); + return json; } /** @@ -4377,8 +4489,17 @@ export function pairJsonReader(doc, { source = 'the --pair-json document' } = {} 'PR row is the minimum a pair can be formed from (number, draft, body, head.ref, head.sha, labels).', ); } - const serve = (value) => { + const serve = (value, what) => { noteServed(READ_PATH_PAIR_JSON); + // The document is a read path like any other, so it files the same request + // ledger entry the network paths do (#18456) — a record whose `requests` + // block went empty on this path would read as a run that read nothing. + noteRequest( + READ_PATH_PAIR_JSON, + `${source} -> ${what}`, + value === null || value === undefined ? 'absent from the document' : 'present', + Array.isArray(value) ? value.length : null, + ); return value; }; // ⭐ The document's own refusal, in the same register the network channels @@ -4394,12 +4515,12 @@ export function pairJsonReader(doc, { source = 'the --pair-json document' } = {} }, ]); } - return serve(value); + return serve(value, `${bag}[${id}]`); }; return Object.freeze({ id: READ_PATH_PAIR_JSON, repo: typeof doc.repo === 'string' && doc.repo.trim() ? doc.repo.trim() : null, - listOpenPulls: () => serve(pulls), + listOpenPulls: () => serve(pulls, 'pulls'), readCard: (_repo, n) => served(readDiagnosisKey('card', n), 'cards', n, fromDocument(doc.cards, n)), readCardComments: (_repo, n) => { const rows = fromDocument(doc.comments, n); @@ -4467,6 +4588,13 @@ async function gather(repo, prFilter = null, reader = NETWORK_READER, { landingR draft: Boolean(pr.draft), card: Number(n), headSha: pr?.head?.sha ?? null, + // ⭐ The pairing's own inputs, carried for the input record (#18456) + // and read by nothing else: the evidence kind is the SAME call + // `prDeliversCard` just made, so the block states the derivation that + // actually formed this pair rather than a second opinion about it. + evidence: deliveryEvidence(pr, n), + prBody: body, + headRef: pr?.head?.ref ?? null, prLabels: Array.isArray(pr.labels) ? labelNames(pr) : null, cardLabels: card && Array.isArray(card.labels) ? labelNames(card) : null, cardComments: Array.isArray(comments) ? comments : null, @@ -4558,7 +4686,452 @@ async function gather(repo, prFilter = null, reader = NETWORK_READER, { landingR return { pulls, pairs }; } -function renderSweep({ repo, pulls, pairs }, { json = false } = {}) { +// --------------------------------------------------------------------------- +// The INPUT RECORD — what this run judged FROM, stated (#18456) +// --------------------------------------------------------------------------- + +/** + * ## The defect: `--pair` was not reproducible, and nothing it printed could + * settle which of two disagreeing runs had read what + * + * Measured on ONE pair — PR #17917 / card #17425 — on 2026-09-13, three + * first-hand runs of the same command with an identical script blob: **0 at + * 02:57Z, 4 (MISPLACED) at 03:04:09Z, 0 at 03:58:33Z**. Two explanations were + * ruled out with controls: no comment on that thread was ever edited (all 16 + * rows carry `created_at == updated_at`), and this file resolves its board from + * the environment alone, so the working directory cannot retarget it. ⇒ The + * cause is still UNKNOWN, and the two runs could not be compared because + * neither had SAID what it read. + * + * ⭐ That is the gap this block closes, and it is deliberately not a fix for + * the non-determinism: it makes the INPUT of a run a printed artefact, so two + * runs that disagree are settled by DIFFING their two blocks — ⛔ never by + * re-running until one side wins, which is what the board did three times and + * learned nothing from. A verdict a second reader cannot reproduce is not a + * clearance, and the landing pre-check ② is exactly where that costs something. + * + * ## What it states, and why each field is in it + * + * · the BOARD and which of the three sources answered — a report about the + * wrong repo reads exactly like a report about this one; + * · the READ PATH and the API surface behind it, plus EVERY request the run + * issued, in order, with its channel, its answer and its ROW COUNT — a page + * asked for with `per_page=100` that answers with exactly 100 rows is the + * one shape a truncated read and a complete one share; + * · the PAIRING: which PR, which card, and the evidence `prDeliversCard` + * derived it from, quoted off the body line that carried it; + * · the COMMENTS read, by count, id list and newest id — the set the + * declaration limb is judged over; + * · the CLAIM COMMENT selected as the carrier, the RULE that selected it, + * and every other claim it rejected WITH the reason — so "the two runs + * selected different comments" is distinguishable from "the two runs + * applied different rules"; + * · a BODY FINGERPRINT (bytes + `sha256:`) on each claim in the pool. ⭐ This + * is the field the measured 0/4/0 actually needs: the 4 was `misplaced`, + * which on that thread requires the governing claim to have carried NO + * readable declaration while the superseded one did — and the governing + * claim's line 3 is `Clause-②: no` in the fixed spelling. Same ids and a + * different verdict is only possible if the BYTES differed, and nothing + * printed the bytes; + * · the PR-BODY line, read by the same reader — ⚠️ stated as an input and + * ⛔ not as a limb: no row here judges the PR body, and this field changes + * that by not one character; + * · this file's own blob hash and the path it ran from, plus a UTC stamp — + * "the blob was identical on both sides" was a CLAIM in the measured + * incident, and this makes it a printed fact a seat can check with + * `git hash-object` against the path the block names. + * + * ## Where it goes, and its shape + * + * STDERR, in every mode, beside the board provenance line and the read-path + * report and for the same reason those are there: stdout is contractually the + * ANSWER, and provenance on stdout travels into a round report that pastes it + * as though it were part of the finding. The `--json` sweep carries the same + * record under `inputs` — ⭐ the same record, never a second format. + * + * The block is fence-delimited and line-oriented: `key: value`, one declared + * key per line, in roster order, on EVERY exit — 0, 4, a refusal, a transport + * failure. A field this run could not fill renders an explicit token; ⛔ a + * field is never dropped, because a block whose shape moves with the verdict + * cannot be diffed against the other one. A value too long for one line + * continues on indented lines below its key. + * + * ⛔ Nothing here resolves a state, a row, a count or an exit code, and the + * record reads no verdict. It is what the run READ, never what it concluded. + */ +export const INPUT_RECORD_VERSION = 1; +export const INPUT_RECORD_OPEN = `----- clause2 input record v${INPUT_RECORD_VERSION} -----`; +export const INPUT_RECORD_CLOSE = '----- end clause2 input record -----'; + +/** What a declared field renders as when this run never filled it. */ +export const INPUT_RECORD_UNSET = '(not set by this run — ⛔ a declared field is never dropped)'; + +/** + * The RUN half of the roster: the fields every block carries, in order. + * + * ⭐ Pinned as NAMES, the same call `SELF_TEST_BATTERIES` and `KNOWN_FLAGS` + * make one family over: a renderer that walks a declared roster cannot lose a + * field by dropping the code that filled it — the field renders unset and + * SAYS so — and a field added to the builder without an entry here is named by + * this file's own self-test instead of appearing in half the blocks. + */ +export const INPUT_RECORD_RUN_FIELDS = Object.freeze([ + 'record.version', + 'run.utc', + 'run.mode', + 'run.script.path', + 'run.script.blob', + 'run.script.bytes', + 'run.node', + 'board.repo', + 'board.source', + 'read.plan', + 'read.api', + 'read.token', + 'read.served', + 'read.pair-json', + 'run.requests', + 'pairs.derived', +]); + +/** The PAIR half of the roster — repeated per derived pair, in order. */ +export const INPUT_RECORD_PAIR_FIELDS = Object.freeze([ + 'pr', + 'card', + 'derivation', + 'head-sha', + 'card-comments', + 'card-comment-ids', + 'card-comment-newest', + 'pr-comments', + 'pr-comment-ids', + 'pr-comment-newest', + 'claim.rule', + 'claim.selected', + 'claim.rejected', + 'claim.clause2-line', + 'pr-body.clause2-line', +]); + +/** + * The git blob sha1 of some bytes — the hash `git hash-object` prints. + * + * Git's, and not a plain digest, precisely so a reader can CHECK it: + * `git hash-object scripts/pm/check-clause2-carriers.mjs` against the path the + * block names is a one-command verification of "same blob on both sides", + * which was asserted rather than shown in the incident this record exists for. + * + * ⚠️ The NUL separator git's format requires is written as a byte rather than + * as a literal in this source: `check:nul-bytes` refuses a raw control byte in + * a tracked file, and an escape that renders to one is the same byte. + */ +export function gitBlobSha1(bytes) { + const body = Buffer.isBuffer(bytes) ? bytes : Buffer.from(String(bytes ?? ''), 'utf8'); + return createHash('sha1') + .update(Buffer.from(`blob ${body.length}`, 'utf8')) + .update(Buffer.from([0])) + .update(body) + .digest('hex'); +} + +/** Bytes + a short content digest — the "same ids, different bytes" field. */ +export function bodyFingerprint(body) { + const buf = Buffer.from(String(body ?? ''), 'utf8'); + return { bytes: buf.length, sha256: createHash('sha256').update(buf).digest('hex').slice(0, 12) }; +} + +let selfProvenanceCache = null; + +/** + * This file, as it is ON DISK in the tree this run was invoked from. + * + * ⚠️ The ONE file this gate reads out of a checkout, and the header's sentence + * upstairs is amended rather than quietly falsified: it reads its own source + * for PROVENANCE and reads no board data from any tree. Nothing about the + * verdict moves — no state, row, count or exit consults this — so an + * environment variable still retargets the board exactly as before, and an + * unreadable file yields a stated absence rather than a refusal. + */ +export function selfProvenance() { + if (selfProvenanceCache === null) { + try { + const bytes = readFileSync(SELF_PATH); + selfProvenanceCache = { path: SELF_PATH, blob: gitBlobSha1(bytes), bytes: bytes.length }; + } catch (err) { + selfProvenanceCache = { + path: SELF_PATH, + blob: null, + bytes: null, + reason: err?.message ?? 'unreadable', + }; + } + } + return selfProvenanceCache; +} + +/** An id list, whole while it is short and first/last/count once it is not. */ +export function renderIdList(rows, cap = 12) { + const ids = (Array.isArray(rows) ? rows : []).map((r) => String(r?.id ?? '(no id)')); + if (ids.length === 0) return 'none'; + if (ids.length <= cap) return ids.join(','); + return `${ids[0]} … ${ids[ids.length - 1]} (${ids.length} ids; the middle ${ids.length - 2} are elided)`; +} + +/** + * The NEWEST row of a thread, by the SAME recency rule the carrier selection + * uses — `created_at`, ties and unreadable stamps by thread order, later wins. + * ⛔ Not "the last row the API returned": that is what a re-ordered page would + * change, and telling the two apart is half of what this record is for. + */ +export function newestRow(rows) { + let best = null; + (Array.isArray(rows) ? rows : []).forEach((row, index) => { + const parsed = Date.parse(row?.created_at ?? ''); + const stamp = Number.isFinite(parsed) ? parsed : null; + const candidate = { row, stamp, index }; + if (best === null) best = candidate; + else if (candidate.stamp === null || best.stamp === null) { + if (candidate.index > best.index) best = candidate; + } else if (candidate.stamp >= best.stamp) best = candidate; + }); + return best?.row ?? null; +} + +/** ` at ` — one row named the way every field here names one. */ +function namedRow(row) { + if (!row) return 'none'; + return `${String(row?.id ?? '(no id)')} at ${row?.created_at ?? '(no readable date)'}`; +} + +/** What `readClause2Line` read out of one body, stated as an INPUT. */ +function clause2Reading(body) { + const read = readClause2Line(body); + if (read === null) return 'no line in this body reaches the reader — neither a declaration nor a near miss'; + if (read.kind === 'declared') { + return `DECLARED \`${read.value}\`${read.arm ? ` (arm: ${read.arm})` : ''} — ${quoteLine(read.line)}`; + } + return `${read.kind.toUpperCase()}${read.reason ? `/${read.reason}` : ''} — ${quoteLine(read.line)}`; +} + +/** The PR body line that carried the pairing, quoted — or what stood in for it. */ +function derivationLine(pair) { + const kind = pair?.evidence ?? null; + const note = deliveryEvidenceNote(kind); + if (kind === 'branch-name') { + return `\`branch-name\` (${note}) — head.ref: ${pair?.headRef ?? '(unread)'}`; + } + const body = String(pair?.prBody ?? ''); + const marker = `#${pair?.card}`; + const line = body.split('\n').find((l) => l.includes(marker)) ?? null; + return `\`${kind ?? 'unread'}\` (${note})${line === null ? ' — no body line naming this card was found' : ` — body line: ${quoteLine(line)}`}`; +} + +/** + * ONE pair's input half, as a flat map of declared keys. + * + * Every key in `INPUT_RECORD_PAIR_FIELDS` is filled, including the ones a + * given path does not buy: `--pair` reads the PR's own thread and a sweep does + * not, and "this path does not read it" is a different fact from "it came back + * unread" — the two render as two sentences and ⛔ never as one silence. + */ +export function pairInputRecord(pair) { + const selection = claimCarrierSelection(pair?.cardComments ?? null); + const pool = selection.pool ?? []; + const out = { + pr: String(pair?.pr ?? '(none)'), + card: String(pair?.card ?? '(none)'), + derivation: derivationLine(pair), + 'head-sha': pair?.headSha ?? '(unread)', + 'card-comments': Array.isArray(pair?.cardComments) + ? `${pair.cardComments.length} row(s)` + : 'UNREAD — the thread could not be read, so this pair is UNJUDGED', + 'card-comment-ids': Array.isArray(pair?.cardComments) ? renderIdList(pair.cardComments) : '(unread)', + 'card-comment-newest': Array.isArray(pair?.cardComments) ? namedRow(newestRow(pair.cardComments)) : '(unread)', + 'pr-comments': + pair?.prComments === undefined + ? '(not read on this path — the sweep buys the PR thread only for a pair that owes a record)' + : Array.isArray(pair.prComments) + ? `${pair.prComments.length} row(s)` + : 'UNREAD — the PR thread could not be read', + 'pr-comment-ids': Array.isArray(pair?.prComments) + ? renderIdList(pair.prComments) + : pair?.prComments === undefined ? '(not read on this path)' : '(unread)', + 'pr-comment-newest': Array.isArray(pair?.prComments) + ? namedRow(newestRow(pair.prComments)) + : pair?.prComments === undefined ? '(not read on this path)' : '(unread)', + 'claim.rule': selection.rule, + }; + + if (!selection.readable) { + out['claim.selected'] = 'none — the card thread is UNREAD, so no carrier could be selected'; + } else if (selection.malformed) { + out['claim.selected'] = + `NONE — the newest claim comment (${selection.malformed.id ?? '(no readable id)'} at ` + + `${selection.malformed.createdAt ?? '(no readable date)'}) parses ZERO branches, so governance ` + + 'is unresolvable and no declaration is read from any comment (state `claim-branch-unparsed`)'; + } else if (pool.length === 0) { + out['claim.selected'] = 'none — no comment on this thread carries a line beginning `Claim:`'; + } else { + out['claim.selected'] = [ + `${pool.length} comment(s) in the pool`, + ...pool.map((row) => { + const fp = bodyFingerprint(row?.body); + return `${namedRow(row)} — ${fp.bytes} bytes, sha256:${fp.sha256}`; + }), + ]; + } + + const rejected = selection.rejected ?? []; + out['claim.rejected'] = rejected.length === 0 + ? 'none — every claim comment on this thread is in the pool' + : [ + `${rejected.length} claim comment(s) rejected`, + ...rejected.map((r) => `${namedRow(r.row)} — ${r.reason}`), + ]; + + out['claim.clause2-line'] = pool.length === 0 + ? '(no carrier, so no line was read from one)' + : pool.length === 1 + ? clause2Reading(pool[0]?.body) + : [ + `${pool.length} carriers; the FIRST that declares wins`, + ...pool.map((row) => `${String(row?.id ?? '(no id)')}: ${clause2Reading(row?.body)}`), + ]; + + out['pr-body.clause2-line'] = `${clause2Reading(pair?.prBody)} ` + + '⚠️ stated as an INPUT only — ⛔ no row here judges the PR body; the declaration limb is ' + + 'judged from the card, and `check-changeset-no-major.mjs` is what reads this line.'; + + return out; +} + +/** + * The whole record — the run half plus one map per derived pair. + * + * Pure in its arguments, so the self-test drives every shape of it offline and + * a block can never claim a path, a request or a pair that did not happen. + */ +export function buildInputRecord({ + repoRes = null, + mode = null, + state = null, + pairs = null, + self = null, + now = null, + node = process.version, +} = {}) { + const served = state?.served instanceof Map ? state.served : new Map(); + const count = (id) => served.get(id) ?? 0; + const requests = Array.isArray(state?.requests) ? state.requests : []; + const plan = readPathPlan({ + token: state?.tokenPresent ? 'present' : '', + pairJson: Boolean(state?.pairJsonSource), + }); + const run = { + 'record.version': String(INPUT_RECORD_VERSION), + 'run.utc': (now instanceof Date ? now : new Date()).toISOString(), + 'run.mode': mode ?? '(unstated)', + 'run.script.path': self?.path ?? '(unstated)', + 'run.script.blob': self?.blob + ? `${self.blob} (git blob sha1 — check it with \`git hash-object\` on the path above)` + : `UNREAD — ${self?.reason ?? 'this run could not read its own source'}`, + 'run.script.bytes': self?.bytes === null || self?.bytes === undefined ? '(unread)' : String(self.bytes), + 'run.node': String(node), + 'board.repo': repoRes?.repo ?? '(the board was never resolved on this run)', + 'board.source': repoRes + ? repoRes.source === 'default' + ? 'default — NEITHER PM_SWEEP_REPO NOR GITHUB_REPOSITORY answered' + : `${repoRes.source} — this run was deliberately targeted` + : '(the board was never resolved on this run)', + 'read.plan': plan.map((id) => READ_PATH_LABELS[id] ?? String(id)).join(' then '), + 'read.api': state?.pairJsonSource + ? `no network: every read is served from ${state.pairJsonSource}` + : `${API} (REST, accept application/vnd.github+json)`, + 'read.token': !state?.tokenPresent + ? 'absent from this environment (GITHUB_TOKEN / GH_TOKEN)' + : state.tokenRetired + ? `present but REFUSED (HTTP ${state.tokenRetired.status}) — retired for the rest of this run` + : 'present', + 'read.served': `${READ_PATH_TOKEN}=${count(READ_PATH_TOKEN)}, ${READ_PATH_PUBLIC}=` + + `${count(READ_PATH_PUBLIC)}, ${READ_PATH_PAIR_JSON}=${count(READ_PATH_PAIR_JSON)}`, + 'read.pair-json': state?.pairJsonSource ?? '(not named — this run read the network)', + 'run.requests': requests.length === 0 + ? '0 — this run issued no read at all' + : [ + `${requests.length} read(s), in the order they were issued`, + ...requests.map((r) => { + const label = READ_PATH_LABELS[r.channel] ?? `(?) ${String(r.channel)}`; + const rows = r.rows === null ? '' : ` (${r.rows} row(s))`; + return `#${r.n} ${label} ${r.path} -> ${r.answer}${rows}`; + }), + ], + 'pairs.derived': pairs === null + ? 'NONE — no pair was formed on this run, so nothing below was judged' + : `${pairs.length} pair(s)`, + }; + return { run, pairs: (pairs ?? []).map((pair) => pairInputRecord(pair)) }; +} + +/** + * Keys the builder produced that the roster does not declare — ⛔ empty, or + * the block has a field nothing pins. Read by the self-test, and printed in + * the block itself so a live run cannot hide one either. + */ +export function undeclaredRecordFields(record) { + const out = []; + for (const key of Object.keys(record?.run ?? {})) { + if (!INPUT_RECORD_RUN_FIELDS.includes(key)) out.push(key); + } + for (const pair of record?.pairs ?? []) { + for (const key of Object.keys(pair ?? {})) { + if (!INPUT_RECORD_PAIR_FIELDS.includes(key) && !out.includes(`pair.${key}`)) out.push(`pair.${key}`); + } + } + return out; +} + +/** One declared field, plus its indented continuation lines when it has any. */ +function recordFieldLines(key, value) { + if (value === undefined) return [`${key}: ${INPUT_RECORD_UNSET}`]; + if (Array.isArray(value)) { + return [`${key}: ${value[0] ?? INPUT_RECORD_UNSET}`, ...value.slice(1).map((line) => ` ${line}`)]; + } + return [`${key}: ${value}`]; +} + +/** + * The block, rendered from the roster and from nowhere else. + * + * ⭐ It walks the DECLARED keys rather than the record's own: that is what + * makes the shape the same on exit 0, exit 4 and every refusal, which is the + * property the whole card turns on — two blocks are diffable line for line, + * and a field that stopped being filled shows up as an unset field rather than + * as a line that is simply not there. + */ +export function renderInputRecord(record) { + const lines = [INPUT_RECORD_OPEN]; + for (const key of INPUT_RECORD_RUN_FIELDS) lines.push(...recordFieldLines(key, record?.run?.[key])); + const pairs = Array.isArray(record?.pairs) ? record.pairs : []; + pairs.forEach((pair, i) => { + for (const key of INPUT_RECORD_PAIR_FIELDS) lines.push(...recordFieldLines(`pair.${i + 1}.${key}`, pair?.[key])); + }); + const undeclared = undeclaredRecordFields(record); + if (undeclared.length > 0) { + lines.push( + `record.undeclared: ${undeclared.join(', ')} — field(s) this run filled that the roster does ` + + 'not declare, so nothing pins them. Add them to the roster.', + ); + } + lines.push( + 'record.how-to-read: two runs that DISAGREE about one pair are settled by diffing their two ' + + 'blocks — ⛔ never by re-running until one side wins. The blob line says whether the two ' + + 'runs were even the same instrument.', + INPUT_RECORD_CLOSE, + ); + return lines; +} + +function renderSweep({ repo, pulls, pairs, inputs = null }, { json = false } = {}) { const rows = []; const notes = []; const unjudged = []; @@ -4570,7 +5143,10 @@ function renderSweep({ repo, pulls, pairs }, { json = false } = {}) { } const declarationLimb = declarationLimbTally(pairs); if (json) { - console.log(JSON.stringify({ repo, openPrs: pulls.length, pairs: pairs.length, declarationLimb, rows, notes, unjudged }, null, 2)); + // ⭐ The SAME record the block on stderr renders, in the SAME shape, under + // one key — ⛔ never a second format (#18456). A round report that pastes + // this JSON carries what the run read beside what it found. + console.log(JSON.stringify({ repo, openPrs: pulls.length, pairs: pairs.length, declarationLimb, rows, notes, unjudged, inputs }, null, 2)); } else { console.log( `check-clause2-carriers: ${pairs.length} card/PR pair(s) derived from ${pulls.length} open ` + @@ -6515,6 +7091,118 @@ export function selfTest() { t('…and the reading is the same with and without the card number — the guard reads no verdict', cardDeclaration([CLAIM('Clause-②: no')]).value === cardDeclaration([CLAIM('Clause-②: no')], { card: 13476 }).value); t('⛔ …an ABSENT thread\'s row is untouched: it names no claim comment to correct in the first place', C19_ROW([{ body: 'a triage note, and nothing that begins a line with the claim key', created_at: '2026-08-31T10:00:00Z' }]) === noClaim); + // -- #18456: the `--pair` input record ------------------------------------ + // + // The pins are about the BLOCK's shape rather than about any verdict: what + // the card measured was two runs that disagreed and could not be compared, + // so what must not rot is (a) every declared field is present on every exit, + // (b) the exit-0 and exit-4 blocks carry the SAME keys, and (c) the fields a + // diff actually turns on — the selected carrier, its body fingerprint and the + // line read from it — say what they read. + battery('#18456: the `--pair` input record — the same block on every exit, so two runs that disagree can be diffed'); + const R56_REPO = { valid: true, repo: 'objectstack-ai/objectstack', source: 'default' }; + const R56_TARGETED = { valid: true, repo: 'objectstack-ai/objectui', source: 'PM_SWEEP_REPO' }; + const R56_SELF = { path: '/w/scripts/pm/check-clause2-carriers.mjs', blob: 'a'.repeat(40), bytes: 1234 }; + const R56_NOW = new Date('2026-09-17T12:00:00Z'); + const R56_REQ = [ + { n: 1, channel: READ_PATH_TOKEN, path: '/pulls?state=open&per_page=100&page=1', answer: 'HTTP 200', rows: 100 }, + { n: 2, channel: READ_PATH_PUBLIC, path: '/issues/17425/comments?per_page=100', answer: 'HTTP 403', rows: null }, + ]; + const R56_STATE = (extra = {}) => ({ + tokenPresent: true, + tokenRetired: null, + served: new Map([[READ_PATH_TOKEN, 5], [READ_PATH_PUBLIC, 1]]), + pairJsonSource: null, + rate: null, + lastServed: READ_PATH_TOKEN, + requests: R56_REQ, + ...extra, + }); + const R56_CLAIM = (id, createdAt, extra) => ({ + id, + created_at: createdAt, + body: `Claim: PM loop round R1\nBranch: \`claude/issue-17425-x\`\n${extra ?? ''}`, + }); + const R56_GOVERNING = R56_CLAIM(5650083758, '2026-09-13T01:57:23Z', 'Clause-②: no'); + const R56_SUPERSEDED = R56_CLAIM(5622080790, '2026-09-10T16:34:31Z', 'Clause-②: yes'); + const R56_PAIR = (extra = {}) => ({ + pr: 17917, + card: 17425, + headSha: 'd7d22bf4bebc4f0065b932556b07a9330d7822b2', + evidence: 'closing-keyword', + prBody: 'Fixes #17425\n\nClause-②: no', + headRef: 'claude/issue-17425-x', + cardComments: [R56_SUPERSEDED, R56_GOVERNING], + prComments: [], + ...extra, + }); + const R56_BUILD = (pairs, extra = {}) => + buildInputRecord({ + repoRes: R56_REPO, mode: '--pair 17917', state: R56_STATE(), pairs, self: R56_SELF, now: R56_NOW, + node: 'v22.0.0', ...extra, + }); + // Exit 0 shape (a declaring governing claim), exit 4 shape (the same thread + // with the governing claim's line unreadable — the MISPLACED state), and a + // refusal that formed no pair at all. + const R56_OK = R56_BUILD([R56_PAIR()]); + const R56_MISPLACED = R56_BUILD([R56_PAIR({ + cardComments: [R56_SUPERSEDED, R56_CLAIM(5650083758, '2026-09-13T01:57:23Z', 'Domain: `domain:spec`')], + })]); + const R56_REFUSAL = R56_BUILD(null); + const R56_LINES = (rec) => renderInputRecord(rec); + const R56_KEYS = (rec) => R56_LINES(rec).filter((l) => /^[a-z]/.test(l)).map((l) => l.slice(0, l.indexOf(':'))); + const R56_FIELD = (rec, key) => { + const lines = R56_LINES(rec); + const at = lines.findIndex((l) => l.startsWith(`${key}: `)); + if (at === -1) return null; + const out = [lines[at].slice(key.length + 2)]; + for (let i = at + 1; i < lines.length && lines[i].startsWith(' '); i++) out.push(lines[i].trim()); + return out.join('\n'); + }; + + t('the block is fenced, so a seat can cut exactly it out of a log', R56_LINES(R56_OK)[0] === INPUT_RECORD_OPEN && R56_LINES(R56_OK).at(-1) === INPUT_RECORD_CLOSE); + t('every declared RUN field is present, in roster order, on exit 0', INPUT_RECORD_RUN_FIELDS.every((f, i) => R56_KEYS(R56_OK)[i] === f)); + t('…and on a refusal that formed NO pair — the same run half, ⛔ never a shorter block', INPUT_RECORD_RUN_FIELDS.every((f, i) => R56_KEYS(R56_REFUSAL)[i] === f)); + t('every declared PAIR field is present once per derived pair, prefixed by its index', INPUT_RECORD_PAIR_FIELDS.every((f) => R56_KEYS(R56_OK).includes(`pair.1.${f}`))); + t('⭐ the exit-0 block and the exit-4 (MISPLACED) block carry an IDENTICAL key list — the diffability property this card exists for', R56_KEYS(R56_OK).join('|') === R56_KEYS(R56_MISPLACED).join('|')); + t('…and the refusal block\'s run half is that same key list, so all three diff against each other', R56_KEYS(R56_REFUSAL).slice(0, INPUT_RECORD_RUN_FIELDS.length).join('|') === R56_KEYS(R56_OK).slice(0, INPUT_RECORD_RUN_FIELDS.length).join('|')); + t('⛔ a declared field this run never filled RENDERS, with a token saying so — it is never dropped', says(renderInputRecord({ run: {}, pairs: [] }).join('\n'), INPUT_RECORD_UNSET)); + t('…and a block with nothing in it still carries every declared key', INPUT_RECORD_RUN_FIELDS.every((f) => renderInputRecord({ run: {}, pairs: [] }).some((l) => l.startsWith(`${f}: `)))); + t('the roster declares EVERY key the builder fills — a field outside it is a field nothing pins', undeclaredRecordFields(R56_OK).length === 0); + t('…and one that is outside it is NAMED in the block rather than printed in silence', says(renderInputRecord({ run: { 'run.invented': 'x' }, pairs: [] }).join('\n'), 'record.undeclared: run.invented')); + t('the board is stated with WHICH source answered — a fallback and a deliberate target are two sentences', says(R56_FIELD(R56_OK, 'board.source'), 'NEITHER PM_SWEEP_REPO') && says(R56_FIELD(R56_BUILD([R56_PAIR()], { repoRes: R56_TARGETED }), 'board.source'), 'PM_SWEEP_REPO — this run was deliberately targeted')); + t('…and the board VALUE is printed beside it', R56_FIELD(R56_BUILD([R56_PAIR()], { repoRes: R56_TARGETED }), 'board.repo') === 'objectstack-ai/objectui'); + t('the read path is named from the SAME labels every refusal uses', says(R56_FIELD(R56_OK, 'read.plan'), READ_PATH_LABELS[READ_PATH_TOKEN])); + t('a `--pair-json` run names that path AND the document it was served from', (() => { const r = R56_BUILD([R56_PAIR()], { state: R56_STATE({ pairJsonSource: 'pair.json', served: new Map([[READ_PATH_PAIR_JSON, 6]]) }) }); return says(R56_FIELD(r, 'read.plan'), READ_PATH_LABELS[READ_PATH_PAIR_JSON]) && R56_FIELD(r, 'read.pair-json') === 'pair.json'; })()); + t('…and says the network was not read at all on that path', says(R56_BUILD([R56_PAIR()], { state: R56_STATE({ pairJsonSource: 'pair.json' }) }).run['read.api'], 'no network')); + t('every request is numbered and carries its channel, its path and the answer', says(R56_FIELD(R56_OK, 'run.requests'), '#1') && says(R56_FIELD(R56_OK, 'run.requests'), '/pulls?state=open&per_page=100&page=1') && says(R56_FIELD(R56_OK, 'run.requests'), 'HTTP 200')); + t('⭐ a page that came back FULL states its row count — a truncated read and a complete one differ nowhere else', says(R56_FIELD(R56_OK, 'run.requests'), '(100 row(s))')); + t('a refused request states the status it was refused with', says(R56_FIELD(R56_OK, 'run.requests'), 'HTTP 403')); + t('⛔ a run that issued NO read says so, rather than rendering an empty field', says(R56_BUILD(null, { state: R56_STATE({ requests: [] }) }).run['run.requests'], 'issued no read at all')); + t('the SELECTION RULE is printed, not merely applied — two runs must be comparable on the rule too', says(R56_FIELD(R56_OK, 'pair.1.claim.rule'), 'NEWEST') && says(R56_FIELD(R56_OK, 'pair.1.claim.rule'), '`Branch:`')); + t('…and it is the one constant, so the printed rule cannot drift from the applied one', R56_OK.pairs[0]?.['claim.rule'] === CLAIM_SELECTION_RULE && claimCarrierSelection([]).rule === CLAIM_SELECTION_RULE); + t('the SELECTED carrier is named by id and by date', says(R56_FIELD(R56_OK, 'pair.1.claim.selected'), '5650083758') && says(R56_FIELD(R56_OK, 'pair.1.claim.selected'), '2026-09-13T01:57:23Z')); + t('⭐ …with a BODY FINGERPRINT: the one field that tells "same ids, different bytes" apart', says(R56_FIELD(R56_OK, 'pair.1.claim.selected'), 'sha256:') && says(R56_FIELD(R56_OK, 'pair.1.claim.selected'), ' bytes,')); + t('⭐ …and it MOVES when only the bytes move: same ids, same count, same newest, different verdict', R56_FIELD(R56_OK, 'pair.1.card-comment-ids') === R56_FIELD(R56_MISPLACED, 'pair.1.card-comment-ids') && R56_FIELD(R56_OK, 'pair.1.claim.selected') !== R56_FIELD(R56_MISPLACED, 'pair.1.claim.selected')); + t('every REJECTED candidate is named, with the reason it is not the carrier', says(R56_FIELD(R56_OK, 'pair.1.claim.rejected'), '5622080790') && says(R56_FIELD(R56_OK, 'pair.1.claim.rejected'), 'SUPERSEDED')); + t('…and a thread whose claims are all in the pool says THAT, rather than going quiet', says(R56_BUILD([R56_PAIR({ cardComments: [R56_GOVERNING] })]).pairs[0]?.['claim.rejected'], 'none — every claim comment')); + t('a claim that parses ZERO branches leaves NO carrier, and the block names that claim', (() => { const r = R56_BUILD([R56_PAIR({ cardComments: [{ id: 7, created_at: '2026-09-13T05:00:00Z', body: 'Claim: round R2\nClause-②: no' }] })]); return says(r.pairs[0]?.['claim.selected'], 'parses ZERO branches') && says(r.pairs[0]?.['claim.selected'], '7'); })()); + t('an UNREAD thread reads UNREAD, ⛔ never 0 rows', says(R56_BUILD([R56_PAIR({ cardComments: null })]).pairs[0]?.['card-comments'], 'UNREAD')); + t('the line READ from the carrier is stated — declared, near miss or nothing', says(R56_FIELD(R56_OK, 'pair.1.claim.clause2-line'), 'DECLARED `no`') && says(R56_MISPLACED.pairs[0]?.['claim.clause2-line'], 'no line in this body reaches the reader')); + t('a short id list is printed whole; a long one keeps its FIRST, its LAST and the true count', renderIdList([{ id: 1 }, { id: 2 }]) === '1,2' && says(renderIdList(Array.from({ length: 40 }, (_, i) => ({ id: i + 1 }))), '1 … 40 (40 ids')); + t('the NEWEST row is the newest by `created_at`, ⛔ not the last row the API returned', newestRow([{ id: 2, created_at: '2026-09-13T09:00:00Z' }, { id: 1, created_at: '2026-09-13T01:00:00Z' }])?.id === 2); + t('…and an unreadable stamp falls back to thread order, the recency rule the carrier selection uses', newestRow([{ id: 1, created_at: 'nonsense' }, { id: 2, created_at: 'nonsense' }])?.id === 2); + t('the PAIRING quotes the body line it was derived from', says(R56_FIELD(R56_OK, 'pair.1.derivation'), 'closing-keyword') && says(R56_FIELD(R56_OK, 'pair.1.derivation'), 'Fixes #17425')); + t('…and the branch-name fallback names the head ref instead of quoting a line that does not exist', says(R56_BUILD([R56_PAIR({ evidence: 'branch-name', prBody: 'no declaration at all' })]).pairs[0]?.derivation, 'head.ref: claude/issue-17425-x')); + t('the PR-BODY line is read and stated — ⛔ and stated as an INPUT, never as a limb', says(R56_FIELD(R56_OK, 'pair.1.pr-body.clause2-line'), 'DECLARED `no`') && says(R56_FIELD(R56_OK, 'pair.1.pr-body.clause2-line'), '⛔ no row here judges the PR body')); + t('the blob hash is git\'s, so `git hash-object` on the path the block names verifies it', gitBlobSha1('') === 'e69de29bb2d1d6434b8b29ae775ad8c2e48c5391' && gitBlobSha1('hello') === 'b6fc4c620b67d95f953a5c1c1230aaab5db5a1b0'); + t('…and this run states the file it ran FROM, which is what "the same blob on both sides" needs', says(R56_FIELD(R56_OK, 'run.script.path'), 'check-clause2-carriers.mjs') && says(R56_FIELD(R56_OK, 'run.script.blob'), 'git hash-object')); + t('a source this run could not read says so, rather than printing a hash of nothing', says(buildInputRecord({ self: { path: '/x', blob: null, bytes: null, reason: 'ENOENT' } }).run['run.script.blob'], 'UNREAD')); + // ⛔ CONTROLS — the record reads no verdict and derives no second selection. + t('⛔ CONTROL: the selection the block prints IS the pool `cardDeclaration` judged — ONE derivation', (() => { const rows = [R56_SUPERSEDED, R56_GOVERNING]; const sel = claimCarrierSelection(rows); return sel.pool.length === 1 && sel.pool[0] === R56_GOVERNING && cardDeclaration(rows).value === 'no'; })()); + t('⛔ CONTROL: building the record changes no reading — the same rows read the same way after it', (() => { const rows = [R56_SUPERSEDED, R56_GOVERNING]; const before = cardDeclaration(rows).state; buildInputRecord({ pairs: [R56_PAIR()] }); return cardDeclaration(rows).state === before; })()); + t('⛔ CONTROL: the record carries no verdict, no exit code and no finding row', !says(R56_LINES(R56_OK).join('\n'), 'exit ') && !says(R56_LINES(R56_OK).join('\n'), 'PASS')); + // -- The floor: every declared battery RAN, and ran its cases (#13489) ----- // // Evaluated after every battery has had its chance and BEFORE the verdict, so @@ -6581,7 +7269,9 @@ export function selfTest() { + 'refusal, the board provenance line, the claim whose `Branch:` line parses to ZERO ' + 'branches — reported as an unresolvable carrier rather than discarded, the key-INITIAL ' + 'line that QUOTES the spelling held apart from one that declares a value in BOTH halves ' - + 'of that property — and the exit register).', + + 'of that property, the input record whose field roster is the same on exit 0, on exit 4 ' + + 'and on a refusal — with the selected carrier, its body fingerprint and the rejected ' + + 'candidates each stated — and the exit register).', ); selfTestReachedVerdict = true; @@ -6836,6 +7526,40 @@ async function main(argv) { // said which board it was about -- the state the filing seat was in. console.error(boardProvenanceLine(repoRes)); + // ⭐ Everything from here on runs inside ONE try/finally, so the input record + // (#18456) and the read-path report reach a reader on EVERY exit past the + // board -- a usage refusal on `--pair-json` included. A block that printed + // only beside a verdict would be missing from exactly the refusals two seats + // most need to compare. + let record = null; + const pairFlagIdx = flagIndex(argv, '--pair'); + const mode = pairFlagIdx === -1 + ? (flagIndex(argv, '--json') === -1 ? 'sweep' : 'sweep --json') + : `--pair ${argv[pairFlagIdx + 1] ?? '(no value)'}`; + try { + return await runBoard(argv, repo, repoRes, (built) => { record = built; }); + } finally { + console.error(renderReadPathReport(readPathState)); + for (const line of renderInputRecord( + record ?? buildInputRecord({ + repoRes, mode, state: readPathState, pairs: null, self: selfProvenance(), now: new Date(), + }), + )) { + console.error(line); + } + } +} + +/** + * The board half of `main` -- everything that needs a resolved repo. + * + * Split out for one reason: the input record and the read-path report are owed + * on every exit below, and a `finally` around the whole of it is the only shape + * that cannot be lost by a `return` added later (#18456). `keepRecord` hands + * the built record back so the caller's `finally` prints the SAME one `--json` + * emitted rather than a second reading of the same run. + */ +async function runBoard(argv, repo, repoRes, keepRecord) { const pairFlag = flagIndex(argv, '--pair'); let only = null; if (pairFlag !== -1) { @@ -6889,9 +7613,18 @@ async function main(argv) { } let swept = 0; + const mode = only === null + ? (flagIndex(argv, '--json') === -1 ? 'sweep' : 'sweep --json') + : `--pair ${only}`; try { const { pulls, pairs } = await gather(repo, only, reader, { landingReads: only !== null }); swept = pairs.length; + // Built ONCE, after every read this run makes and before anything is + // printed: `--json` emits it and the caller's `finally` renders it. + const built = buildInputRecord({ + repoRes, mode, state: readPathState, pairs, self: selfProvenance(), now: new Date(), + }); + keepRecord(built); if (only !== null) { if (pairs.length === 0) { console.error( @@ -6913,15 +7646,9 @@ async function main(argv) { } return worst; } - return renderSweep({ repo, pulls, pairs }, { json: flagIndex(argv, '--json') !== -1 }); + return renderSweep({ repo, pulls, pairs, inputs: built }, { json: flagIndex(argv, '--json') !== -1 }); } catch (err) { return reportTransportFailure(err, { swept }); - } finally { - // On stderr in every mode, including `--json`: the budget is a fact about - // the RUN, and folding it into the machine channel would change a shape - // round reports already read. A verdict that does not say which path - // answered is a verdict a seat cannot reproduce. - console.error(renderReadPathReport(readPathState)); } }