Skip to content

fix(pm): governed-merges self-test refuses as NOT MEASURED when its generator toolchain is absent - #15283

Merged
os-steve merged 3 commits into
mainfrom
claude/issue-15272-governed-merges-prerequisite-exit
Sep 4, 2026
Merged

os-steve merged 3 commits into
mainfrom
claude/issue-15272-governed-merges-prerequisite-exit

Conversation

@claude

@claude claude Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Fixes #15272

In a freshly created worktree, before pnpm install, pnpm check:pm-governed-merges exited 1 — a finding's code — and reported:

self-test battery "#11705 end to end, against the REAL generator" registered 5 case(s),
below its pinned floor of 7 — cases that used to run no longer do.
A battery at or below its floor means cases STOPPED RUNNING — the battery is the bug, not
the number. Find what stopped registering (an early return, a deleted block, a guard that
now skips) and restore it.

Nothing had stopped running. Three sibling gates met the same worktree in the same minute and answered correctly with exit 3 PREREQUISITE NOT MET. This one reported a NOT MEASURED condition as a measured red, on a governance gate, and prescribed a hunt for code nobody deleted.

The mechanism, measured

The card deliberately did not measure which dependency that battery loads. It is the generator toolchain, and the chain is:

  1. The live case calls runSinkGenerator, which spawns pnpm --filter @objectstack/spec exec tsx scripts/build-skill-references.ts --check.
  2. In an uninstalled checkout that exits 254 with ERR_PNPM_RECURSIVE_EXEC_FIRST_FAIL Command "tsx" not found (measured directly, both through the function and through pnpm).
  3. runSinkGenerator maps every failure — absent toolchain, spawn error, unreadable manifest, real drift — to the same outputs: null shape with a reason string. That is correct and deliberate for the AUDIT, where fail-closed is the ruled answer.
  4. The self-test's live battery branches on that shape, taking its one-assertion path instead of its three-assertion path: 5 registered against a pinned 7.
  5. The floor, which is doing exactly its job, renders that as cases that stopped running.

So the registration is silent rather than throwing because spawnSync returns a status and the code turns it into a reason — there is no exception anywhere on the path.

The repair

  • generatorToolchainPrerequisite(root, entry) asks whether the row's recompute can run at all, by looking for the same binary pnpm exec would resolve. Two remedies kept apart: nothing installed at all, and an install that does not carry the runner.
  • It is consulted at the top of selfTest(), before a single case runs — not beside the battery — so the shared frame's load-bearing clause ("this gate exited before running a single check") is literally true when it prints.
  • It is strictly a narrowing of one exit code into another. A checkout whose toolchain resolves takes the identical path it took before; a checkout whose toolchain does not exits 3 instead of 1. No branch in it can turn a red into a green, it cannot lift a governed path or lower a floor, and the audit paths (--test, the sweep) never consult it.
  • ⛔ The reason string is deliberately not classified: an uninstalled checkout and a generator reporting genuine drift both arrive as a non-zero --check exit, so reading that text would fold a real finding into a refusal — the expensive direction.

Reuse, not a sixth spelling

The exit-3 path is a shared helper: scripts/import-prerequisite.mjs exports EXIT_PREREQUISITE_NOT_MET, INSTALL_FIX and reportPrerequisiteNotMet, and every sibling that answers this class routes through it. This gate now does too — the code, the frame, the "Nothing was measured … It is NOT a finding" clause and the pipe advisory are all the repo's. The helper is not edited.

One thing it cannot express, reported rather than worked around: its advisory names the importer by path, which is right for the single-mode gates it was written for. This file has modes, and node scripts/pm/check-governed-merges.mjs without --self-test runs the network sweep. The helper takes no invocation argument, so the correct entry point (pnpm check:pm-governed-merges) is stated in this gate's own detail lines, where the claim about what went unmeasured already lives.

Both directions pinned

batteryFloorFailures() is lifted to module scope and takes its ledger as an argument. It had no pin of its own — until it took its inputs there was no way to hand it a ledger — so the direction that matters most was asserted nowhere. Its behaviour, its order and every message it prints are unchanged; ledger fixtures are built from this roster, so each message quotes the real pinned number.

New battery, 17 cases (245 → 262 assertions):

  • an uninstalled checkout is a prerequisite miss naming node_modules, and prescribes the install
  • an install lacking the runner names the runner, not a missing tree
  • the checkout this run loaded from meets it, so every floor below judges exactly as before
  • the refusal code is the repo-wide 3, never a finding's 1
  • end to end, on a copy of this file's own module graph in a checkout with no node_modules: exit 3, naming the class, the prerequisite and the remedy, stating in words that nothing was measured, and no longer printing the shrunken-battery diagnosis
  • a ledger one case short still reds and still names that battery; one that never ran is named DID NOT RUN; an assertion under no declared battery and a shrunk roster are both named; a full ledger produces nothing

A defect the ablation found, and closed

Ablating the probe turned the end-to-end fixture into an unbounded chain of self-tests — the child reaches that battery only when the probe did not refuse, and there its own copied tree is a perfect seed for the next copy. Measured: one process per second, 67 of them before it was cut off by hand. A hang is the worst reading a gate can return, neither green nor red and indistinguishable from slow. The child is now marked in its argv, a marked run refuses to spawn a grandchild, and the cases it would have measured fail with that reason instead — the same number of cases in both worlds, and a loud red where there was an unbounded wait.

No floor lowered, no case removed

SELF_TEST_BATTERIES floors are untouched; the roster's own size floor moves 22 → 23 with the battery added to it. No case is deleted or skipped, no other script is touched, and the post-merge audit's verdicts are unchanged — --test on this file still answers NOT governed at exit 0.

Verification (union re-run at d510d4ec, after the final commit)

Repro, exit codes captured by redirect, never through a pipe:

before install after install
on origin/main exit 1 — "below its pinned floor of 7" exit 0 — 245 assertions
on this branch exit 3 — PREREQUISITE NOT MET, prerequisite and remedy named exit 0 — 262 assertions
  • 24/24 derived gates green, re-derived for the actual changed path with scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack (identical to the dispatched list; no family added).
  • 7 artifact rosters whose list lives under scripts/ also run: 6 green. check:published-readme-exports reports 1, entirely on unbuilt dist/ entries in packages this diff does not touch (its own --self-test passes; filed separately as a finding of the same class as this card).
  • Whole-repo pnpm lint green through scripts/pm/os-verify-lock.sh (VERDICT command-exit 0, held 75s, waited 0s) — the full scan, not a narrowing.
  • bash .claude/hooks/guard-governed-enqueue.selftest.sh: 49 passed, 0 failed (the hook imports this file's --test).
  • Ablation (a): probe removed → the uninstalled checkout reports registered 5 case(s), below its pinned floor of 7 again, exit 1 — the card's exact shape.
  • Ablation (b): probe in place, one case genuinely deleted → exit 1 naming "the governed predicate: the 2026-08-18 unified list, exactly" registered 7 case(s), below its pinned floor of 8. The floor still bites.
  • Both ablations ran from the committed state with restore traps on absolute paths; each restore proven by git hash-object equal to the HEAD blob with an empty git diff HEAD and an empty git status --porcelain.

Tooling-only (scripts/pm/**), publishes nothing ⇒ skip-changeset.


Generated by Claude Code

…s generator toolchain is absent

In a freshly created worktree, before `pnpm install`, `pnpm
check:pm-governed-merges` exited 1 — a finding's code — and reported

    self-test battery "#11705 end to end, against the REAL generator"
    registered 5 case(s), below its pinned floor of 7 — cases that used to
    run no longer do.

Nothing had stopped running. The battery's live case execs the real
generator (`pnpm --filter @objectstack/spec exec tsx …`), which in an
uninstalled checkout exits 254 with `Command "tsx" not found`; that arrives
at `sinkGeneratorVerdict` wearing the same `outputs: null` shape as every
other generator failure, the battery takes its one-assertion branch, and the
floor renders a NOT-MEASURED condition as a measured red — prescribing a
hunt through the generator for a deleted block that was never deleted.

Three sibling gates met the same worktree in the same minute and answered
correctly with exit 3 PREREQUISITE NOT MET. This gate now does too:

- `generatorToolchainPrerequisite()` asks whether the row's recompute can
  run at all, by looking for the same binary `pnpm exec` would. It is
  consulted at the top of `selfTest()`, before a single case runs, so the
  shared frame's load-bearing clause is literally true when it prints. It is
  strictly a narrowing of one exit code into another — no branch in it can
  turn a red into a green, and the audit paths never consult it.
- The refusal reuses `scripts/import-prerequisite.mjs` — the exit code, the
  frame and the pipe advisory are the repo's, not a sixth spelling.
- The reason string is deliberately NOT classified: an uninstalled checkout
  and a generator reporting real drift both arrive as a non-zero `--check`
  exit, so reading that text would fold a finding into a refusal.
- `batteryFloorFailures()` is lifted to module scope and takes its ledger as
  an argument, so the direction that matters most — a genuinely deleted case
  still reds, and still names WHICH battery — is asserted for the first
  time. Its behaviour, order and every message it prints are unchanged.

Both directions are pinned (15 new cases, 245 -> 260 assertions): an
uninstalled checkout refuses with exit 3 end to end, on a copy of this
file's own module graph, naming the prerequisite and the remedy and no
longer printing the shrunken-battery words; a ledger one case short of any
floor still reds and still names that battery.

No floor is lowered, no case is deleted or skipped, and the post-merge
audit's verdicts are untouched.

Fixes #15272

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019RfFHiRCSs3JXLK4cwcfox
The shared advisory names the importer by PATH, which is right for the gates
it was written for but incomplete here: this file has modes, and
`node scripts/pm/check-governed-merges.mjs` without `--self-test` runs the
network SWEEP rather than the cases that just refused. The helper has no
seam for an invocation, so the correct entry point is stated in this gate's
own detail lines, where the claim about what went unmeasured already lives.

Pinned end to end: 245 -> 261 assertions.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019RfFHiRCSs3JXLK4cwcfox
… of itself

Found by ablating the probe: the fixture child reaches the prerequisite
battery only when the probe did NOT refuse, and there its own copied tree is
a perfect seed for the next copy. Measured, an ablated probe produced one
self-test per second, each waiting on the next — 67 processes before it was
cut off by hand.

A hang is the worst reading a gate can return: neither green nor red, and
nothing in CI distinguishes it from slow. The child is now marked in its
argv, a marked run refuses to spawn a grandchild, and the cases it would
have measured FAIL with that reason instead — the same number of cases, in
both worlds, and a loud red where there was an unbounded wait.

261 -> 262 assertions.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019RfFHiRCSs3JXLK4cwcfox
@claude claude Bot added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Sep 4, 2026
@github-actions github-actions Bot added the size/m label Sep 4, 2026
@os-steve
os-steve marked this pull request as ready for review September 4, 2026 08:58
@os-steve
os-steve requested a review from hotlong as a code owner September 4, 2026 08:58
@os-steve
os-steve enabled auto-merge September 4, 2026 08:58
@os-steve
os-steve added this pull request to the merge queue Sep 4, 2026
Merged via the queue into main with commit 5b09356 Sep 4, 2026
36 checks passed
@os-steve
os-steve deleted the claude/issue-15272-governed-merges-prerequisite-exit branch September 4, 2026 09:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/m skip-changeset PR has no user-facing published change; bypasses the changeset gate

Projects

None yet

2 participants