fix(pm): check-governed-merges binds the rearm plan its INCOMPLETE banner reads, and pins the attribution-failure path (#18055) - #18093
Conversation
… attribution-failure path `main()` read `rearm.hint` out of a scope that never bound `rearm`, inside the argument of the `console.error` that prints the sweep's `sweep INCOMPLETE` banner. An argument that throws means the call never happens, so the ReferenceError deleted the one warning saying the printed list must not read as clean — and the `return EXIT_INCOMPLETE` behind it. The plan is now bound in the scope that reads it, out of the same inputs `rearmProxyOrNull` builds its own from. The banner moves into a function of its own, byte-identical in what it says and what it returns, so `--self-test` can drive the attribution-failure path offline: `main()` reads git and the network and cannot be. Pinned by 11 new cases in a battery of their own: the banner is built and emitted on the attribution-failure path, it returns EXIT_INCOMPLETE, the proxy hint renders from a hinting plan and stays empty otherwise (including on an incompleteness that is not about attribution), and — end to end — a real sweep over a governed fixture merge whose every attribution channel fails prints the banner on stderr and exits 2. Claude-Session: https://claude.ai/code/session_01DAcomhvR9kKizeYgg89Vo8 Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewHead: ① derived judgments (seat-measured on the fetched head, ⛔ not taken from the report):
② semver: unchanged — PM tooling, nothing published. ③ boundary flags: none. The dev's refinement of premise P3 (one pre-existing case already reaches the banner via the unaudited-repo path; only the attribution branch was unpinned) is recorded in the PR body. Implemented-by: Verdict: PASS — ready + auto-merge by this seat. Generated by Claude Code |
Fixes #18055
scripts/pm/check-governed-merges.mjsmain()readrearm.hintout of a scope that never boundrearm, and it did so inside the argument of theconsole.errorthat prints the sweep'ssweep INCOMPLETEbanner. An argument that throws means the call never happens: theReferenceErrordeleted the one warning saying the printed list must not read as clean, and thereturn EXIT_INCOMPLETEbehind it.What changed (one file,
scripts/pm/check-governed-merges.mjs)main()now builds the re-arm plan at the pointthe banner is built, from the same three inputs
rearmProxyOrNullbuilds its own from(
process.env,process.execArgv,process.allowedNodeEnvironmentFlags.has(PROXY_FLAG)), andthe banner reads
.hint/.reasonfrom that binding. One binding; nothing widened.reportSweepIncomplete(state, emit = console.error)— the samewords, the same order, the same stream, the same
EXIT_INCOMPLETE. It is extracted for exactlyone reason:
main()reads git and the network and cannot be driven down theattribution-failure path offline, and a banner that dies while being built is pinnable only by
something that builds it.
SELF_TEST_BATTERIESwith its floor, so a casethat stops registering names itself instead of going quiet.
Premise readings — measured before writing, worktree at base
d438b3a9aattributionFailed && rearm.hintinmain()and norearmexists in that scopegit grep -n rearmover the file returns hits only at:2273-:2281(theproxyRearmPlanreturn objects),:2821-:2838(rearmProxyOrNull's ownconst rearm),:3087-:3088(the banner) and:3951-:3958(self-test, all callingproxyRearmPlandirectly). Nothing bindsrearminmain().console.error(...)argument, so a throw loses the banner AND thereturn EXIT_INCOMPLETEafter it:3084-:3093: the ternary is the last operand of the string concatenation passed as the sole argument, andreturn EXIT_INCOMPLETE;is the next statement.main()down theattributionFailedpathEXITtrap, verified absent): replacing onlyrearm.hintwith an unconditionally throwing IIFE — so the throw fires exactly whenattributionFailedis true — left the battery green: 317 assertions, exit 0. Refinement: replacing the whole conditionattributionFailed && rearm.hintwith a throwing IIFE reds one pre-existing case,a-mixed-sweep-attributes-the-dropped-repo-in-the-INCOMPLETE-footer-itself, which drivesmain()down the INCOMPLETE path for an unaudited repo, never for attribution. So the banner was reached by the battery; theattributionFailedbranch of it was not.Baseline for both probes:
node scripts/pm/check-governed-merges.mjs --self-teston the unmodifiedtree,
✓ ... 317 assertions, exit 0, empty stderr (2026-09-14T01:17Z).The battery, and the falsifiability pair
node scripts/pm/check-governed-merges.mjs --self-test→✓ check-governed-merges --self-test: 328 assertions, exit 0, empty stderr (2026-09-14T01:21Z).317 before, 328 after: the 11 new cases, battery only grows, nothing deleted or weakened.
Ablation (one-shot, on the committed fix, restore proven by state): delete the
const rearm = proxyRearmPlan({...})bindingmain()now carries. On-disk proof, not an editor exit code —git hash-objectbeforeeab2fbd5ec0010fe84584f8acd61d2022dc7e2cf(equal to theHEADblob),after
c455a42e43f704f1bd504a34cccdc1f235eca9ff; the file still parses (node --checkclean), asit did before the fix, because the defect is a runtime
ReferenceError.Ablated run: exit 1, 4 failures, by name —
with the card's own stack reproduced inside the failure detail:
ReferenceError: rearm is not defined at main (.../check-governed-merges.mjs:3128:73).defect. Removing the binding makes the reference throw on every INCOMPLETE path (the plan is
now passed as a call argument), where the shipped defect threw only when
attributionFailedwastrue — which is why the pre-existing unaudited-path case reds here too and did not red on
main.The three new end-to-end cases are the ones that answer for this card. The six offline cases stay
green under the ablation by construction: they call the banner function directly and do not go
through
main(), which is exactly why the end-to-end case exists.Restored (
git checkout HEAD -- scripts/pm/check-governed-merges.mjs): worktree blob back toeab2fbd5ec0010fe84584f8acd61d2022dc7e2cf,git status --porcelainempty,git diff HEADempty,and the battery green again at 328 assertions, exit 0 (2026-09-14T01:23Z). No ablation artefact is
left in the tree.
The live invocation the card measured — a reading, not the pin
node scripts/pm/check-governed-merges.mjs --since 2026-09-13T13:00:00Z, stdout and stderrcaptured to separate files by redirect, exit captured on the command itself (2026-09-14T01:23Z):
attribution unavailablemerged_by names an ACCOUNTPR #sweep INCOMPLETEReferenceErrorLIVE_EXIT=2—EXIT_INCOMPLETE.⛔ The
attributionFailedpath is NOT taken in this container, so this run does notdiscriminate the fix from the defect: the re-exec worked (
ℹ️ re-exec with --use-env-proxy ...),both lookups resolved through the env token, and the card's positive control
attribution unavailablereads 0/0 here where the card measured 1/0. The INCOMPLETE thisrun reports is the other kind — 4 governed repos unaudited (one stale mirror, three with no
checkout in this container). What the run does show is that the banner now reaches stderr and the
exit is 2 on a path that reaches the same statement. The pin is the self-test case, which
drives the attribution-failure path deliberately: a fixture governed merge in the window and
GITHUB_API_URLpointed at a port nothing listens on, so every channel fails at the transport,offline, in milliseconds.
Gates
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackfrom theworktree derived 34 commands; every one was run, each exit captured by redirect-then-capture,
each recorded as
command :: exit N, all exit 0. Reconciliation:Included in that 34 and green:
pnpm check:pm-governed-merges(this script's own self-test),pnpm check:pm-dispatch-gates,pnpm check:nul-bytes,pnpm check:entry-guard,pnpm check:parse-guard,node scripts/check-self-test-wired.mjs,node scripts/check-scripts-symbol-anchors.mjs,node scripts/check-declaration-mirrors.mjs.The tool's own accounting names what stays outside that total (50 artifact-roster families, 11
declared-wide families, 14 pending-changeset families, 1 path-scheduled CI job and the always-runs
tail); those are CI's run, not a local claim.
Beyond the derived set, the repo-wide
grep -naPcontrol-character scan over the edited filereturned nothing and
pnpm check:nul-bytespassed over 8634 tracked text files.Changeset
No changeset; this PR takes
skip-changeset. What the check reads: the changeset gate ispr-automation.yml'schangeset-checkjob, and it has no path exemption — its only twoexemptions are the
skip-changesetlabel (read live from the PR, not from the frozen eventpayload) and the changesets release branch. The diff is one file under
scripts/pm/, repo toolingthat sits outside every published package directory and therefore outside every package's
files[]; nothing published moves. Same declaration shape as PR #18089.Acceptance notes
reportSweepIncompleteis module-local, not exported: the self-test livesin the same module, and the exported surface (which
scripts/pm/ci-failure.mjsimports) isunchanged.
#4690citation and theNODE_OPTIONSprescription. The only textual additions are the new battery name inSELF_TEST_BATTERIES, its floor (11), and one clause appended to the self-test's verdictenumeration so that line stays an honest inventory.
rearm.hintis still read strictly (no?.): a defensive optional-chain would hide the nextmissing binding instead of failing, and the new end-to-end case is what catches it by name.
[stale-mirror]becauseorigin/mainmoved while this card was in flight. That is the audit working as designed, not adefect. Carrier: none — it is a property of the container, not of the tree.
Clause-②: no— no contract accept/reject behaviour changes and no public surface widens; theedit is repo tooling.
Generated by Claude Code