fix(pm): the merge-queue guard refuses an oversized PR and the post-merge sweep lists one — the SIZE limb reaches the queue - #19175
Conversation
…lists one — the SIZE limb reaches the queue The 2026-09-18 ruling landed seat-side (PR #19033) while the queue leg of check-governed-queue-guard.mjs kept handing testVerdict no size, so a PR over HUMAN_MERGE_LINE_THRESHOLD changed lines that a seat enqueued still merged. Queue guard: a fourth leg reads every queued pull request's additions / deletions off the same pull object the head read uses (makePullReader grew a `size`, no second endpoint), judges it through the sibling's IMPORTED predicate (testVerdict([], { size }) + landsByHumanMerge — this file declares no threshold and spells no comparison, pinned against its own source), and REFUSES on exit 8 with the two numbers, the threshold and the human-merge remedy printed; an unreadable size fails CLOSED on exit 9; the pull_request leg stays silent and byte-identical; exit precedence governed > size > carrier is a pure function, pinned on every combination. Post-merge sweep: classifyCommit is now landsByHumanMerge on testVerdict, the size read LOCALLY off the landed diff by one `git diff-tree --numstat` per mainline commit (commitChanges, replacing commitPaths' --name-only with a byte-identical path list), so an oversized landing with no governed path is an entry on the size limb alone — counted apart in the head, listed with a ⛔ SIZE row and the same attribution column. Self-tests: guard 261 → 296, governed-merges 410 → 435. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W5y9kRg1YtYaMQYExVLRc2
…AME, never by a TypeError Under the landing PR's ablation (the comparison inverted in `sizeVerdict`) the oversized fixture classifies to nothing; two pins dereferenced it and aborted the whole self-test with a TypeError, hiding every other failure. Guard the null so a red case is a named one — measured: 30 named failures, no crash. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W5y9kRg1YtYaMQYExVLRc2
…eue-guard-size-limb
Contract reviewServed-tier: In-seat review by the ① Derived judgments
② Semver levelNone owed — ③ Boundary flags
Implemented-by: VERDICT: PASS Generated by Claude Code |
Fixes #19036
Clause-②: no
What this PR does
The maintainer's 2026-09-18 ruling — 「修改代码量超过某个行数(比如5000)就应该人工审核」 — landed seat-side in PR #19033 (
HUMAN_MERGE_LINE_THRESHOLD = 5000,testVerdict(paths, { size }),sizeVerdict,landsByHumanMergeinscripts/pm/check-governed-merges.mjs). The merge-queue guard'smerge_groupleg kept handing the predicate no size, so a PR over 5,000 changed lines that a seat enqueued still merged — exactly what PR #18971 did (+238,310 / −119, nothing governed). This PR carries the limb to the two places the card names.Queue guard (
scripts/pm/check-governed-queue-guard.mjs) — a fourth leg,runSizeGuard/sizeGuardVerdict/renderSizeVerdict:additions/deletionsoff the same pull object the head read uses —makePullReadergrew asizefield (through the sibling's ownpullSizeFrom), no second endpoint, no new workflow scope (pull-requests: readalready covers the pull object; the workflow file is untouched and the self-test pins that noissues:scope appeared);testVerdict([], { size })is the size limb alone (the path limb is the governed leg's question, already answered on the lifted rows), andlandsByHumanMergereads it — this file declares no threshold and spells no comparison, pinned against its own source;EXIT_REFUSED_OVERSIZED = 8, printing the two numbers, their sum, the threshold, the limb (SIZE), the source (GET /repos/{o}/{r}/pulls/{n}) and the one remedy — a human merge (the ruling quoted untranslated; it never advises making the diff smaller);EXIT_REFUSED_SIZE_UNREADABLE = 9, fail-CLOSED — this read DECIDES, unlike the PR head, which has decided nothing since 2026-09-04;pull_requestleg is silent and read-free (renders''), so that leg's output stays byte-identical;groupExitCode: governed, then size, then carrier — every block is always printed, one code exits.Post-merge sweep (
check-governed-merges.mjsdefault mode):classifyCommitis nowlandsByHumanMerge(testVerdict(paths, { size })), so a landing is an entry when EITHER limb fired — a governed merge as before, and an oversized landing with no governed path at all;commitChangesruns onegit diff-tree --numstat --no-renames -m --first-parentper mainline commit (replacingcommitPaths'--name-only; the self-test pins the path list byte-identical against--name-onlyon a real fixture, a merge commit read against its first parent, a binary row at zero);renderReportcounts such rows apart (N governed merge(s) and M oversized landing(s) with no governed path), prints a⛔ SIZE:row with the numbers and the threshold, keeps the same attribution column, and prints GitHub's own pair beside the landed number only when the two differ (it rides the attribution GET the row already pays for; it never decides the listing);--jsonentries carrysizeandhumanMerge.The PM's mechanism assumptions, measured
merged_by)」 — falsified. The attribution loop readsGET /pulls/{n}only for rowsclassifyCommitalready produced (governed merges); an ungoverned landing is never read at all, so no API read could have made it an entry. Route taken instead: the local--numstatreading above, the same source--branchalready uses in this file, zero API, and it also lets the sweep classify before any attribution is spent.GET /pulls/Nper PR in the group」 — held. The size leg reads every queued PR through the same reader; the call count is pinned (apiCalls === carrierPullsInGroup(rows).length, deduplicated, group order).Acceptance (the seat's checklist)
⭐ #18971-replay-an-OVERSIZED-PR-with-NO-governed-path-is-REFUSED-at-the-queue-on-the-size-code) — governed leg clear at zero reads, size leg exit 8,groupExitCode8; the rendered text names#18971,238429 changed line(s) (+238310 / -119),EXCEEDS the human-merge line 5000,HUMAN MERGE;⭐ exactly-the-threshold-is-WITHIN-the-comparison-is-strictly-greater; sweep:⭐ exactly-the-threshold-is-NOT-listed-the-comparison-is-strictly-greater, and a real fixture commit of exactly 5,000 lines is not listed);pull_requestleg byte-identical:renderSizeVerdictreturns''there and a throwing spy proves zero reads;PR #5001listed with⛔ SIZE,PR #5000not), on stdout and in--json;Verification
Self-tests (worktree at
5c7caff— the branch merged withorigin/mainc229223, which touched neither file; afterpnpm install):node scripts/pm/check-governed-queue-guard.mjs --self-test→ exit 0,296 cases pass(was261 cases passate8667ee).node scripts/pm/check-governed-merges.mjs --self-test→ exit 0,435 assertions(was410 assertions).Ablation — the comparison inverted in the SIBLING, watched from both files (proves the guard imports the predicate rather than restating it), through
scripts/ablation-replace.mjsin WRAP mode against the committed head5c7caff(the final of three runs; the first two are recorded below because each taught something):exceeds: changedLines > HUMAN_MERGE_LINE_THRESHOLD,× 1 → the same line with the greater-than sign replaced by a less-than sign, × 1; blob6e1112bcd55e→c7a42160aabd; on-disk counts read back inside the mutated window: replacement 1, original 0.14 of 296 case(s) failed, all in the [finding] the merge-queue guard (check-governed-queue-guard.mjs, merge_group leg) reads the PATH register only — a PR over 5,000 changed lines that a seat enqueues still merges; the size limb #19012 adds to the seat-side pre-check never reaches the queue #19036 battery (threshold-plus-one-changed-line-is-OVERSIZED,one-under-is-WITHIN,an-OVERSIZED-queued-PR-REFUSES-with-code-8,a-within-sibling-does-NOT-carry-an-oversized-PR-through-the-group,#18971-replay-an-OVERSIZED-PR-with-NO-governed-path-is-REFUSED-…,governed-AND-oversized-prints-BOTH-limbs-…,an-authorized-APPROVAL-…-lifts-NOTHING-from-the-size-…,a-certified-pure-regeneration-lifts-…-NOTHING-from-the-size-at-the-queue-either, …).30 failure(s), 0 TypeErrors — the pre-existing SIZE battery (5001-changed-lines-is-OVER-it,the-PR-that-prompted-the-ruling-reads-238429-…,--test-with-5001-changed-lines-…-exits-3, …), the new [finding] the merge-queue guard (check-governed-queue-guard.mjs, merge_group leg) reads the PATH register only — a PR over 5,000 changed lines that a seat enqueues still merges; the size limb #19012 adds to the seat-side pre-check never reaches the queue #19036 battery (an-oversized-landing-with-NO-governed-path-is-a-sweep-ENTRY-…,the-head-counts-the-oversized-landing-APART-…,a-REAL-sweep-LISTS-the-over-by-one-landing-…, …) and two [finding] The governed-merge sweep'scloudrow is now a permanent false zero — the repo left the fleet's GitHub scope and the audit reads an unfetchable local snapshot instead of saying NOT MEASURED #13307 live-mirror sweep pins that now see a phantom oversized row (every small landing reads over an inverted line — the expected direction).6e1112bcd55e…==HEADblob,git diff HEADempty,git status --porcelainempty, anchor count back to 1.3c7f5ec): guard 14/296 red as above; the merges leg exited 1 by a TypeError in my new battery (a pin dereferenced the fixture that classifies to null under the mutation), so its failures were not named — fixed in28faa51(null-guarded pins; a red case must be a named one). Run 2: run against that fix while it was still UNCOMMITTED; the tool restores toHEADby design, so the fix was discarded by the restore — caught by the pre/post blob compare (6e1112…before,859965…after), re-applied, committed, and run 3 is the record above. The "commit the fix first" rule, measured on the fix to the pins.Gates (derived in the worktree with
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands— 33 commands, identical to the dispatch's list; reconciled with--ran; exit codes captured after redirection, never through a pipe; run at5c7caff, the final head, after the ablation's restore was proven):node scripts/check-ci-filter-parity.mjscoreorcrosspkg, everycrosspkgentry still covers one, and thetestjob'sif:node scripts/check-closing-keyword-parity.mjsnode scripts/check-closing-keyword-parity.mjs --self-testnode scripts/check-comment-mask-corpus.mjsnode scripts/check-declaration-mirrors.mjsnode scripts/check-declaration-mirrors.mjs --self-testnode scripts/check-scripts-symbol-anchors.mjsnode scripts/check-scripts-symbol-anchors.mjs --self-testnode scripts/check-self-test-wired.mjs--self-testhas that self-test run by CI.node scripts/check-self-test-wired.mjs --self-testnode scripts/check-self-test-workflow-commands.mjsnode scripts/check-self-test-workflow-commands.mjs --self-testnode scripts/check-skills-token-ratchet.mjsnode scripts/check-skills-token-ratchet.mjs --self-testnode scripts/check-whole-set-label-write.mjsuses:pin(s) over 18 dinode scripts/check-whole-set-label-write.mjs --self-testnode scripts/pm/bare-root-worklist.mjs --self-testnode scripts/pm/check-governed-queue-guard.mjs --self-testpnpm check:agent-test-spelling--token(s) · 1749 launcher-rooted run(s) · 13 separator(s) JUDGED · 6 vitest-backed scrippnpm check:bash32-floorpnpm check:cli-command-idspnpm check:closing-target-claimpnpm check:cross-package-test-inputspnpm check:driver-memory-censuspnpm check:entry-guardpnpm check:nul-bytespnpm check:parse-guardpnpm check:pm-governed-mergespnpm check:pnpm-filter-targets--filteroccurrence(s) across 40 file(s) resolve against 81 workspace package(s); 48 not judged (2 foreign, 26 interpolatpnpm check:ratchet-remedy-authoritypnpm check:refd-timer-probepnpm check:watch-hint-literal32 command(s); 0 non-zero exit(s).
Reconciliation (
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --ranover a record whose every line carries:: exit N):33 derived, 32 run, 0 NOT-MEASURED, 1 UNRUN— the one unrun family ispnpm check:pm-dispatch-gates, which is NOT MEASURED at PR-open time: it runs only under the shared verify lock (≈1,000 s under contention); a first run started on3c7f5ecoverlapped the ablation window (dispatch-gates.mjsimportssizeVerdictfrom the sibling, so a child it spawned in that window could have read the inverted comparison), and a second run on5c7caffis queued behind it. Its verdict lands in theos-dev-reportcomment on #19036, not here — this body is written once.Lint, narrowed and measured:
npx eslint --no-inline-config --format jsonon the two changed files → exit 0, 2 files, 0 errors, 0 warnings. Narrowing evidence: ① eslint's configured population (eslint.config.mjs, the**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}block) includesscripts/pm/*.mjs, and--print-configresolves a config for the file (parsertypescript-eslint/parser); ② the JSON output counts 2 files; ③ type-aware linting is not enabled (parserOptions.projectandprojectServiceboth absent in the resolved config), so this diff cannot move any untouched file's verdict. The repo-levelpnpm lintsweep is CI's run.Acceptance notes (noted, not filed)
pull_requestevent payload carriesadditions/deletions, so a size early warning on the PR leg would cost zero reads. Not taken: the ruling is about the landing, the seat-side pre-check already refuses before arming, and the PR leg's byte-identity is a standing constraint. 承接者: the skills seat, if the maintainer wants the forecast.GET /pulls/{n}once per queued PR — the same endpoint twice. A shared per-run pull read would halve it; kept separate so each leg's refusal and count stay separable in a log. Groups are small. 承接者: none.grepover.claude/skills/pm-dispatch/references/**andSKILL.mdforexit 6/exit 7/EXIT_REFUSED_CARRIER: 0 hits).skip-changeset:scripts/pm/**publishes nothing from any released package (nofiles[]of any package ships it).Generated by Claude Code