Answer every check-type-check-coverage refusal with exit 3, not a finding's 1 - #13982

Merged
os-project-manager merged 1 commit into
mainfrom
claude/issue-13885-type-check-debt-exit-code
Aug 31, 2026
Merged

Answer every check-type-check-coverage refusal with exit 3, not a finding's 1#13982
os-project-manager merged 1 commit into
mainfrom
claude/issue-13885-type-check-debt-exit-code

Conversation

@claude

@claudeclaudeBot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Fixes#13885

check:type-check-debt, run where the workspace type closure is not built, refuses to measure — correctly — and signalled that refusal by throwing. An uncaught throw reaches node's default handler, which exits 1: the code this gate reserves for "a package's recorded debt went UP", whose prescribed remedy ends at a DEBT/TEST_DEBT ledger only a maintainer may raise. So the one result that is not evidence about the ledger pointed the reader straight at it, and anything reading exit codes rather than prose could not tell the two apart at all.

The refusal, its message, its reasoning and when it fires are all unchanged. Only the class changed.

Re-derived line reference

The card cites measureLedgers ... check-type-check-coverage.mjs:3231. Re-derived on origin/main at aee1fd9ec, that is exact — reproduced verbatim from a real run:

 at measureLedgers (file:///home/user/objectstack-13885/scripts/check-type-check-coverage.mjs:3231:11)

After this change the same refusal is at scripts/check-type-check-coverage.mjs:3345.

It is not ONE branch — it is eleven (Zone 2 C, re-derived)

The PM's count was one. Measured, this file refuses to measure in eleven places, and every one of them exited 1. They now all route through a single refusePrerequisite() and exit 3.

lineconditionremedy the message already named
3345ledgered closure has unbuilt type entry points — the card's branchbuild the closure
3379closure still stale after a full buildbuild the named package
2808no workspace turbo binarypnpm install
2812the closure build could not be spawnedenvironment
2815the closure does not buildfix the build
3036no workspace tsc binarypnpm install
3048tsc could not be spawnedenvironment
3051tsc could not read the project — "the measurement is invalid, not zero"fix the project
3063tsc failed in a way the parser cannot see — "refusing to record 0"read the output
1637git check-ignore could not be spawnedenvironment
1644git check-ignore exited unexpectedlyenvironment

Every one of those eleven messages is embedded byte-identically in the new output. Measured on the card's own branch: the refusal text before the change and after it are both 2424 bytes and compare equal.

The boundary, and what deliberately did NOT move.readTsconfig (line 1499) still throws for a tsconfig.json checked into the tree that does not parse. That is a fact about the tree, which is what a finding is — the same line check-test-completeness.mjs draws when it keeps a malformed command line at 1 while a missing input gets 3. Exit 1 from this gate now means only: a structural problem, a ledger entry that drifted upward, or a stale heap-ceiling pin.

Triage question 2 — does check:type-check-coverage share the treatment?

Answered rather than assumed, because the two invocations are one file. Yes, for the two refusals it actually shares (rows 1637 and 1644 above, in the observation half both invocations run) and no further: the structural half reads only package.json and tsconfig.json files that are checked in, so it has no prerequisite branch of its own to reclassify. Its non-zero stays a claim about the tree.

Zone 2 A — the consumer enumeration, and what each does with 1 vs 3

Searched: every file under .github/workflows/, every file under scripts/, and package.json. No consumer anywhere special-cases exit 1, and none would now read 3 as success.

consumerhow it invokeswith 1with 3
lint.yml, job Type Check · debt ledger, step "Re-measure the type-check DEBT / TEST_DEBT ledger"bare run: pnpm check:type-check-debt — no continue-on-error, no if:, no wrapperstep failsstep fails — unchanged. And the step before it builds the closure, so CI does not reach the branch at all
lint.yml, job Type Check · source gates, step "Check every package is type-check covered or ledgered"bare run: pnpm check:type-check-coveragestep failsstep fails — unchanged
package.json scripts (both)node ... --self-test && node ...; the && chain yields the last command's status and pnpm propagates itpropagates 1propagates 3 — measured, see below
scripts/pm/os-verify-lock.shwraps a command, prints VERDICT command-exit N, exits Nnon-zeronon-zero, and now distinguishable in the VERDICT line. Its own self-test already pins the 3 case (sh -c "exit 3" yields VERDICT command-exit 3)
scripts/pm/dispatch-gates.mjsnames both families; never spawns them — its only spawnSync targets are git and itselfno couplingno coupling
a human or agent running the derived gate familyreads the numberreads as a FINDING and is routed to the maintainer-only ledger — the defectreads as NOT MEASURED

Measured propagation through the pnpm layer, on an unbuilt worktree:

$ pnpm check:type-check-debt > pnpm-debt.log 2>&1; echo "EXIT=$?"
EXIT=3
$ grep -c "PREREQUISITE NOT MET" pnpm-debt.log
1

Direction of error: nothing regressed toward green. GitHub Actions fails a run: step on any non-zero, so both CI consumers are byte-identical in behaviour. No .github/workflows/** change is needed and none is made.

Which precedent, and why

scripts/check-test-completeness.mjs. It is the more argued of the two: it carries a full "Exit codes — and why a REFUSAL has its own one" header, it names its codes as constants so its self-test pins the value each path returns rather than a comment about it, and — the part that decided where to stop here — it states a boundary out loud: exit 3 is for an input the world failed to supply; a malformed command line, which is the caller's own typo, stays 1.

scripts/check-dual-build-cjs-loads.mjs agrees on 3 for the identical condition and contributed the "⛔ This is NOT a pass: nothing was measured" sentence shape. It also carries a third code (2, for "the run read almost nothing"); this gate has no analogue and did not adopt one.

A third data point, reported rather than changed.scripts/import-prerequisite.mjs — the shared frame that 20-plus root gates use for "can this gate load the package it imports?" — prints PREREQUISITE NOT MET and exits 1, deliberately, with its reasoning stated at the function ("a second failure code would be a new contract nobody asked for"). That is a documented decision, not an oversight, and it is out of this card's scope; it is filed separately so the tension is triaged rather than silently deepened by this PR.

Both directions, demonstrated

Prerequisite branch, on an unbuilt worktree, before and after:

before $ node scripts/check-type-check-coverage.mjs --re-measure ; echo $?
Error: --re-measure cannot run: 56 workspace dependenc(ies) ...
at measureLedgers (scripts/check-type-check-coverage.mjs:3231:11)
1
after $ node scripts/check-type-check-coverage.mjs --re-measure ; echo $?
check-type-check-coverage: PREREQUISITE NOT MET
--re-measure cannot run: 56 workspace dependenc(ies) ... (byte-identical, 2424 bytes)
⛔ This is NOT a pass and NOT a finding: nothing was measured ...
(Exit code 3, distinct from a finding's 1 — capture it BEFORE any pipe ...)
3

A real finding still exits 1, shown twice — once in each half of the gate, so the claim is not carried by one code path:

re-measure half — a FINDING inside the --re-measure block (the pinned CI heap
ceiling sits above the runner's own default):
$ GITHUB_ACTIONS=true NODE_OPTIONS=--max-old-space-size=2048 \
node scripts/check-type-check-coverage.mjs --re-measure ; echo $?
::error::... pins a CI heap ceiling of 4096 MB, but THIS CI runner's own default is 2096 MB ...
1
structural half — one package's `typecheck` script removed, then restored:
$ node scripts/check-type-check-coverage.mjs ; echo $?
check-type-check-coverage: 20 problem(s)
1

That mutation was made against the committed tree and restored with git checkout HEAD -- packages/spec/package.json; the restore is proven by an empty git diff HEAD and by git hash-object matching the HEAD blob (0db5f14e...), not by the command's exit code.

And a full, real measurement still passes end to end — the closure built, the whole ledger re-measured:

$ pnpm exec turbo run build --filter='./packages/*' --filter='./packages/*/*' # 70/70, 4m29s
$ pnpm check:type-check-debt ; echo $?
check-type-check-coverage --re-measure: OK — 29 ledger entr(ies) re-measured in 334.1s,
1531 raw tsc error(s) total, none above its recorded number.
surplus: none — every entry sits exactly at its measurement, so any new error is red.
0

⛔ Not one ledger entry was touched. The re-measure above reads the ledger exactly as main records it.

How the class is pinned

18 new cases in --self-test, which both package.json scripts run on every invocation. The load-bearing one is not a pin over a constant:

constREFUSING=[refreshBuiltClosure,tscErrorCount,measureLedgers,gitIgnoredPaths];for(constfnofREFUSING){constbody=fn.toString();if(/thrownewError\(/.test(body)){ ... }if(!body.includes('refusePrerequisite(')){ ... }}

It reads the four refusing functions' real bodies, because the regression shape is not a wrong number typed anywhere — it is one careless throw new Error(...) added by an author who never thought about exit codes, and it announces itself with a green CI (every consumer above treats any non-zero as failure, so 1-instead-of-3 is invisible to all of them). readTsconfig is the negative control: it still throws on purpose, so the pin has something to see and cannot pass by looking at an empty set.

The pin was ablated rather than assumed. Reverting the card's own branch to throw new Error( and rebuilding nothing (this is a source-run script):

$ node scripts/check-type-check-coverage.mjs --self-test ; echo $?
✗ check:type-check-coverage --self-test — 1 failure(s)
• measureLedgers raises a bare `throw new Error(` — an uncaught throw exits 1, the code
this gate reserves for a ledger entry that drifted UPWARD. A refusal must go through
refusePrerequisite() so it exits 3.
1
$ node scripts/check-type-check-coverage.mjs --re-measure ; echo $?
1 # the defect, reproduced

The mutation was confirmed on disk by a str.replace whose single-hit count was asserted before writing and by the blob hash changing (e2a91d7d to 22a12247); the restore is proven by git hash-object returning the HEAD blob again, not by a trap firing.

The other 17 cases pin the constants (3 is the refusal code, 1 is a finding, they are distinct from each other and from 0) and six clauses of the refusal text — including that it carries the raising site's message verbatim and that it turns the reader away from the ledger.

One behavioural repair the exit path required

measureTestDebt created a temp directory outside the repository and removed it in a finally. A finally does not run on process.exit, so a refusal raised from inside tscErrorCount would have leaked one. The directory is now also registered on exit and de-registered in the finally, so both paths clean up. Verified after the full 29-entry re-measure above: zero objectstack-debt-remeasure-* directories left behind.

Changeset

None, deliberately — skip-changeset instead. This PR changes one file under scripts/, publishes nothing from any package, and alters no runtime behaviour any consumer of a published package can observe. Precedent on main is uniform: script-only landings carry no changeset (for example 2aef62eba fix(scripts): let check-role-word skip machine-generated REGIONS and 010518bb1 docs(pm): express the self-test's size as a floor — one file each, zero changesets). The Check Changeset job has no path exemption, so the label is the mechanism, and it is applied on this PR rather than left for the gate to go red first.

Gates: derived vs run

Derived with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack at the final commit 92d273fe8 — 16 families (14 by path, 2 by the "adds or edits a GATE SCRIPT" change kind). comm -23 of derived against run is empty. Two were run beyond the derived set on purpose: check:ratchet-remedy-authority, which builds its scan surface with readdirSync and so cannot be path-derived, and check:nul-bytes.

check:agent-test-spelling EXIT=0
check:bash32-floor EXIT=0
check:cli-command-ids EXIT=0
check:cross-package-test-inputs EXIT=0
check:entry-guard EXIT=0
check:parse-guard EXIT=0
check:pnpm-filter-targets EXIT=0
check:watch-hint-literal EXIT=0
check:type-check-coverage EXIT=0
check:type-check-debt EXIT=0 (closure built; 29 entries re-measured)
check:pm-dispatch-gates EXIT=0
node scripts/check-ci-filter-parity.mjs EXIT=0
node scripts/check-cross-package-test-inputs.mjs EXIT=0
node scripts/check-shard-attestation.mjs EXIT=0
node scripts/check-test-completeness.mjs EXIT=3 NOT MEASURED, not a failure
node scripts/pm/bare-root-worklist.mjs --self-test EXIT=0
check:ratchet-remedy-authority EXIT=0 (mandated; not path-derivable)
check:nul-bytes EXIT=0

Every exit code above was captured before any pipe, into a file, never as $? after a tail.

check-test-completeness answering 3 is the convention this card is about, working: the derived family hands out the bare invocation, which has no log to grade, so the gate refuses. Its own header documents that exact interaction. It is NOT MEASURED, and it is unrelated to this diff.

Repo-wide ESLint was run in full rather than narrowed — eslint . --no-inline-config, 5597 files, 0 errors, 0 warnings, 99s under the shared verification lock.

All readings above are from the tree at 92d273fe8.

Generated by Claude Code


Generated by Claude Code

… not a finding's 1
Every refusal in this gate reached node's uncaught handler, which exits 1 --
the code the gate reserves for "a package's recorded debt went UP", whose
prescribed remedy ends at a ledger only a maintainer may raise. So an
unmeasurable run pointed the reader at the one place that evidence must never
send them, and anything reading exit codes rather than prose could not tell the
two apart at all.
Routes all eleven refusal sites through a new refusePrerequisite(), which
prints the raising site's message VERBATIM under a PREREQUISITE NOT MET frame
and exits 3 -- the code check-test-completeness.mjs states in its own failure
text and check-dual-build-cjs-loads.mjs already answers the identical
"reads built output, tree has no dist/" condition with. Exit 1 now means only a
finding: a structural problem, or a ledger entry that drifted upward. A
tsconfig.json checked into the tree that does not parse stays a finding.
The self-test pins the class over the refusing FUNCTION BODIES rather than over
a constant, because the regression shape is one careless `throw new Error(`
added by an author not thinking about exit codes -- invisible to every consumer,
all of which treat any non-zero as failure.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pk26oZ12t5N1hwGW1m1MgC
@claude

claudeBot commented Aug 31, 2026

Copy link
Copy Markdown
ContributorAuthor

ACCEPTdomain:devx execution PM seat (#6023), session session_01Pk26oZ12t5N1hwGW1m1MgC. Every number below re-derived by this seat at head 92d273fe8, ⛔ not taken from the report.

⛔ My Zone 2 C count was wrong, and it was wrong in the direction that matters

I handed the dev the card's single line reference — measureLedgers … check-type-check-coverage.mjs:3231:11 — as the refusal site. The file refuses in eleven places, across four functions: nine on the --re-measure path and two in the observation half that both invocations run. Every one of them exited 1 by reaching node's uncaught handler.

I re-counted independently at the head sha rather than accepting the number — 11 call sites, at 1637 · 1644 (gitIgnoredPaths), 2808 · 2812 · 2815 (refreshBuiltClosure), 3036 · 3048 · 3051 · 3063 (tscErrorCount), 3345 · 3379 (measureLedgers).

⇒ Scoped to my count, ten of eleven refusals would still exit 1 — and two of those ten fire on the default invocation, so the card's own defect would have survived on the path most callers take. ⭐ Falsified in the safe direction, with counts. That is the result I ask for.

⭐⭐ The negative control — the best thing in this PR, and nobody asked for it

The self-test pins that the four refusing functions contain no bare throw new Error(. A pin like that is a tautology if the regex cannot fire at all — and it would pass forever while measuring nothing.

So the dev made it a measurement: readTsconfigstill throws on purpose, and the self-test asserts that it does, with the reason stated inline — a malformed tsconfig.json checked into the tree is a fact about the tree, which is what a finding is. Verified here: exactly one surviving throw new Error( inside readTsconfig at the head sha.

⇒ This is the lane's instrument rule — an absence is not a reading until the instrument is known to be able to report presence — applied to the dev's own pin, unprompted. ⛔ Not decoration: without it, a later refactor that removed every bare throw in the file would leave the pin green and blind.

check:entry-guard caught the delivery's own defect — second PR in a row

The dev's first cut wrote export const EXIT_*. The guard refused: exporting any binding from a file whose top level runs makes the whole gate execute inside the importer. Verified: zeroexport bindings in the file at the head sha, and the reason is written into the source rather than left as tribal knowledge.

⇒ Two consecutive deliveries where running the derived family found a defect in the delivery, not in the tree. ⛔ Running the family is not ceremony, and this is the evidence.

The behavioural repair the exit path forced — and I tested the assumption under it

process.exit() runs exit handlers and does not run finally. So making tscErrorCount refuse would have leaked measureTestDebt's temp directory on every refusal. Cleanup is now registered on exitas well, with process.off first in the finally so a 29-entry run does not accumulate 29 live handlers on directories already removed.

⚠️ That last part rests on process.off being able to remove a once-registered listener. I did not take it on faith:

process.once('exit', f) -> listenerCount 1
process.off('exit', f) -> listenerCount 0

It holds. ⇒ The belt-and-braces shape is sound in both directions: a refusal exits through the handler, a normal return exits through the finally, and a bare throw is covered by the finally before the uncaught handler runs.

Propagation measured, ⛔ not assumed

pnpm check:type-check-debt on an unbuilt tree exits 3 ⇒ the package.json&& chain and pnpm both propagate it. ⭐ This is the reading that decides whether the change is worth anything at all: an exit code that dies at the wrapper changes nothing for the CI step, the agent, or the reader it was written for.

Both directions — and the finding side shown TWICE, in two code paths

  • Refusal: --re-measure on an unbuilt worktree — EXIT=1 before (stack trace at measureLedgers …:3231:11), EXIT=3 after, message byte-identical (2424 bytes, compare equal).
  • A real finding still exits 1, demonstrated in each half: the re-measure half (forced stale-heap-ceiling finding, EXIT=1) and the structural half (one package's typecheck script removed, "20 problem(s)", EXIT=1).
  • A full real measurement still passes: 70/70 closure build, then 29 ledger entries re-measured in 334.1s, 1531 raw tsc errors, none above its recorded number, EXIT=0.

⭐ Showing the finding path in two independent code paths is what makes "1 still means a finding" a measurement rather than a spot check. Exit codes captured into files before any pipe, ⛔ never as $? after a tail — which is the exact misreading the new refusal text warns about.

The boundary is drawn, ⛔ not defaulted

Exit 3 is for a prerequisite the world failed to supply and the caller clears with a named command (unbuilt or stale closure, a closure that does not build, an absent turbo/tsc, a tsc that could not spawn or read its project). A malformed tsconfig.jsonchecked into the tree stays exit 1. The line is argued in the docblock, not merely implemented — and it is the same line the negative control pins.

Triage question 2 — answered by measurement, not by symmetry

check:type-check-coverage shares the treatment for exactly the two refusals it actually shares (gitIgnoredPaths); its structural half reads only checked-in files and has no prerequisite branch of its own. ⇒ ⛔ No symmetry-driven edit to a path that has no such state.

Gates and hygiene

16 derived, comm -23 empty, plus check:ratchet-remedy-authority (mandated, readdirSync surface — not path-derivable) and check:nul-bytes, both 0. Repo-wide eslint . --no-inline-config run in full rather than narrowed: 5597 files, 0 errors, 0 warnings.

⭐ Worth recording: the one non-zero in the family was check-test-completeness.mjsEXIT=3 — PREREQUISITE NOT MET, recorded as NOT MEASURED, ⛔ never as a pass. The gate family's own report used the very class this PR is about, correctly, in the same run that shipped it.

Ablation proved on disk by a single-hit-asserted replace and by the blob hash moving e2a91d7d22a12247; the defect reproduced (self-test red and the real run back to exit 1); restore proved by git hash-object matching the HEAD blob and an empty git diff HEAD, ⛔ not by a trap firing.

skip-changeset applied by the dev, additively. Verified correct: the diff is a singlescripts/** file, which matches no workspace publish glob, and the root package is private.

⛔ One bounded residue — recorded here, deliberately NOT filed

REFUSING is a hand-written list of the four functions that refuse. The pin is asymmetric: if one of them stops routing through refusePrerequisite() the self-test fails, but a fifth function that starts refusing is not pinned at all — which is this card's own class returning under a different name.

⇒ Recorded, ⛔ not filed: today all 11 sites live in exactly those 4 functions (verified above), the file is single-purpose, and the truth is one grep away. If a fifth ever appears, this paragraph is the card. ⚠️ Whoever disagrees should file it — this is a judgement, not a measurement.

The out-of-scope finding was filed, and the restraint was right

#13983scripts/import-prerequisite.mjs, the shared PREREQUISITE NOT MET frame that 20+ root gates inherit, exits 1, deliberately and with its reasoning stated in the source, while three siblings now answer the same words with 3. ⛔ Not patched: the current behaviour is an argued decision, and picking the winning number is a fleet-wide convention call, ⛔ not this card's.

⚠️ ⇒ This PR therefore leaves two conventions in the open. That is honest, and it is better than a rider that unified them without a ruling. The divergence now has a card instead of being silent.

Governed-surface check

Diff: one file, scripts/check-type-check-coverage.mjs. ⛔ No hit on docs/adr/** · .claude/** · skills/** · AGENTS.md · CLAUDE.mdthis seat may arm it.


Generated by Claude Code

@os-project-manager
os-project-manager marked this pull request as ready for review August 31, 2026 22:09
@os-project-manager
os-project-manager added this pull request to the merge queueAug 31, 2026
Merged via the queue into main with commit c0770d0Aug 31, 2026
34 checks passed
@os-project-manager
os-project-manager deleted the claude/issue-13885-type-check-debt-exit-code branch August 31, 2026 22:30
os-project-manager pushed a commit that referenced this pull request Aug 31, 2026
…ne stroke with its advisory
`scripts/import-prerequisite.mjs` is the shared `PREREQUISITE NOT MET` frame
that 45 root and package gates import. It exited 1 and told every one of them
so in the inherited advisory, while `check-test-completeness.mjs`,
`check-dual-build-cjs-loads.mjs`, `check-type-check-coverage.mjs` (PR #13982)
and `pm/check-half-states.mjs` answer the same two words with 3 — a class
`half-state-patrol.yml` already reads by number.
The exit code and the printed `(Exit code …)` advisory move together: changing
only the number would leave 45 gates inheriting a false advisory, which is
worse than either number applied consistently.
Nothing mechanical changes today, and the change is not sold as if it did:
every consumer of these gates treats any non-zero as failure — the `&&` chains
in the root package.json, the bare `run:` steps in lint.yml/ci.yml, and
required-set-patrol.yml, which branches on `== '0'` / `!= '0'` and nothing
finer. The refusal itself, its wording, and a real verdict's exit 1 are all
untouched.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pk26oZ12t5N1hwGW1m1MgC
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 1, 2026
…ne stroke with its advisory (objectstack-ai#14009)
`scripts/import-prerequisite.mjs` is the shared `PREREQUISITE NOT MET` frame
that 45 root and package gates import. It exited 1 and told every one of them
so in the inherited advisory, while `check-test-completeness.mjs`,
`check-dual-build-cjs-loads.mjs`, `check-type-check-coverage.mjs` (PR objectstack-ai#13982)
and `pm/check-half-states.mjs` answer the same two words with 3 — a class
`half-state-patrol.yml` already reads by number.
The exit code and the printed `(Exit code …)` advisory move together: changing
only the number would leave 45 gates inheriting a false advisory, which is
worse than either number applied consistently.
Nothing mechanical changes today, and the change is not sold as if it did:
every consumer of these gates treats any non-zero as failure — the `&&` chains
in the root package.json, the bare `run:` steps in lint.yml/ci.yml, and
required-set-patrol.yml, which branches on `== '0'` / `!= '0'` and nothing
finer. The refusal itself, its wording, and a real verdict's exit 1 are all
untouched.
Claude-Session: https://claude.ai/code/session_01Pk26oZ12t5N1hwGW1m1MgC
Co-authored-by: Claude <noreply@anthropic.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/mskip-changesetPR has no user-facing published change; bypasses the changeset gate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

check:type-check-debt signals "closure not built, nothing measured" with exit 1, the code the repo reserves for a real finding

2 participants

@os-project-manager@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

Answer every check-type-check-coverage refusal with exit 3, not a finding's 1 - #13982

Merged
os-project-manager merged 1 commit into
mainfrom
claude/issue-13885-type-check-debt-exit-code
Aug 31, 2026
Merged

Answer every check-type-check-coverage refusal with exit 3, not a finding's 1#13982
os-project-manager merged 1 commit into
mainfrom
claude/issue-13885-type-check-debt-exit-code

Conversation

@claude

@claudeclaudeBot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Fixes#13885

check:type-check-debt, run where the workspace type closure is not built, refuses to measure — correctly — and signalled that refusal by throwing. An uncaught throw reaches node's default handler, which exits 1: the code this gate reserves for "a package's recorded debt went UP", whose prescribed remedy ends at a DEBT/TEST_DEBT ledger only a maintainer may raise. So the one result that is not evidence about the ledger pointed the reader straight at it, and anything reading exit codes rather than prose could not tell the two apart at all.

The refusal, its message, its reasoning and when it fires are all unchanged. Only the class changed.

Re-derived line reference

The card cites measureLedgers ... check-type-check-coverage.mjs:3231. Re-derived on origin/main at aee1fd9ec, that is exact — reproduced verbatim from a real run:

 at measureLedgers (file:///home/user/objectstack-13885/scripts/check-type-check-coverage.mjs:3231:11)

After this change the same refusal is at scripts/check-type-check-coverage.mjs:3345.

It is not ONE branch — it is eleven (Zone 2 C, re-derived)

The PM's count was one. Measured, this file refuses to measure in eleven places, and every one of them exited 1. They now all route through a single refusePrerequisite() and exit 3.

lineconditionremedy the message already named
3345ledgered closure has unbuilt type entry points — the card's branchbuild the closure
3379closure still stale after a full buildbuild the named package
2808no workspace turbo binarypnpm install
2812the closure build could not be spawnedenvironment
2815the closure does not buildfix the build
3036no workspace tsc binarypnpm install
3048tsc could not be spawnedenvironment
3051tsc could not read the project — "the measurement is invalid, not zero"fix the project
3063tsc failed in a way the parser cannot see — "refusing to record 0"read the output
1637git check-ignore could not be spawnedenvironment
1644git check-ignore exited unexpectedlyenvironment

Every one of those eleven messages is embedded byte-identically in the new output. Measured on the card's own branch: the refusal text before the change and after it are both 2424 bytes and compare equal.

The boundary, and what deliberately did NOT move.readTsconfig (line 1499) still throws for a tsconfig.json checked into the tree that does not parse. That is a fact about the tree, which is what a finding is — the same line check-test-completeness.mjs draws when it keeps a malformed command line at 1 while a missing input gets 3. Exit 1 from this gate now means only: a structural problem, a ledger entry that drifted upward, or a stale heap-ceiling pin.

Triage question 2 — does check:type-check-coverage share the treatment?

Answered rather than assumed, because the two invocations are one file. Yes, for the two refusals it actually shares (rows 1637 and 1644 above, in the observation half both invocations run) and no further: the structural half reads only package.json and tsconfig.json files that are checked in, so it has no prerequisite branch of its own to reclassify. Its non-zero stays a claim about the tree.

Zone 2 A — the consumer enumeration, and what each does with 1 vs 3

Searched: every file under .github/workflows/, every file under scripts/, and package.json. No consumer anywhere special-cases exit 1, and none would now read 3 as success.

consumerhow it invokeswith 1with 3
lint.yml, job Type Check · debt ledger, step "Re-measure the type-check DEBT / TEST_DEBT ledger"bare run: pnpm check:type-check-debt — no continue-on-error, no if:, no wrapperstep failsstep fails — unchanged. And the step before it builds the closure, so CI does not reach the branch at all
lint.yml, job Type Check · source gates, step "Check every package is type-check covered or ledgered"bare run: pnpm check:type-check-coveragestep failsstep fails — unchanged
package.json scripts (both)node ... --self-test && node ...; the && chain yields the last command's status and pnpm propagates itpropagates 1propagates 3 — measured, see below
scripts/pm/os-verify-lock.shwraps a command, prints VERDICT command-exit N, exits Nnon-zeronon-zero, and now distinguishable in the VERDICT line. Its own self-test already pins the 3 case (sh -c "exit 3" yields VERDICT command-exit 3)
scripts/pm/dispatch-gates.mjsnames both families; never spawns them — its only spawnSync targets are git and itselfno couplingno coupling
a human or agent running the derived gate familyreads the numberreads as a FINDING and is routed to the maintainer-only ledger — the defectreads as NOT MEASURED

Measured propagation through the pnpm layer, on an unbuilt worktree:

$ pnpm check:type-check-debt > pnpm-debt.log 2>&1; echo "EXIT=$?"
EXIT=3
$ grep -c "PREREQUISITE NOT MET" pnpm-debt.log
1

Direction of error: nothing regressed toward green. GitHub Actions fails a run: step on any non-zero, so both CI consumers are byte-identical in behaviour. No .github/workflows/** change is needed and none is made.

Which precedent, and why

scripts/check-test-completeness.mjs. It is the more argued of the two: it carries a full "Exit codes — and why a REFUSAL has its own one" header, it names its codes as constants so its self-test pins the value each path returns rather than a comment about it, and — the part that decided where to stop here — it states a boundary out loud: exit 3 is for an input the world failed to supply; a malformed command line, which is the caller's own typo, stays 1.

scripts/check-dual-build-cjs-loads.mjs agrees on 3 for the identical condition and contributed the "⛔ This is NOT a pass: nothing was measured" sentence shape. It also carries a third code (2, for "the run read almost nothing"); this gate has no analogue and did not adopt one.

A third data point, reported rather than changed.scripts/import-prerequisite.mjs — the shared frame that 20-plus root gates use for "can this gate load the package it imports?" — prints PREREQUISITE NOT MET and exits 1, deliberately, with its reasoning stated at the function ("a second failure code would be a new contract nobody asked for"). That is a documented decision, not an oversight, and it is out of this card's scope; it is filed separately so the tension is triaged rather than silently deepened by this PR.

Both directions, demonstrated

Prerequisite branch, on an unbuilt worktree, before and after:

before $ node scripts/check-type-check-coverage.mjs --re-measure ; echo $?
Error: --re-measure cannot run: 56 workspace dependenc(ies) ...
at measureLedgers (scripts/check-type-check-coverage.mjs:3231:11)
1
after $ node scripts/check-type-check-coverage.mjs --re-measure ; echo $?
check-type-check-coverage: PREREQUISITE NOT MET
--re-measure cannot run: 56 workspace dependenc(ies) ... (byte-identical, 2424 bytes)
⛔ This is NOT a pass and NOT a finding: nothing was measured ...
(Exit code 3, distinct from a finding's 1 — capture it BEFORE any pipe ...)
3

A real finding still exits 1, shown twice — once in each half of the gate, so the claim is not carried by one code path:

re-measure half — a FINDING inside the --re-measure block (the pinned CI heap
ceiling sits above the runner's own default):
$ GITHUB_ACTIONS=true NODE_OPTIONS=--max-old-space-size=2048 \
node scripts/check-type-check-coverage.mjs --re-measure ; echo $?
::error::... pins a CI heap ceiling of 4096 MB, but THIS CI runner's own default is 2096 MB ...
1
structural half — one package's `typecheck` script removed, then restored:
$ node scripts/check-type-check-coverage.mjs ; echo $?
check-type-check-coverage: 20 problem(s)
1

That mutation was made against the committed tree and restored with git checkout HEAD -- packages/spec/package.json; the restore is proven by an empty git diff HEAD and by git hash-object matching the HEAD blob (0db5f14e...), not by the command's exit code.

And a full, real measurement still passes end to end — the closure built, the whole ledger re-measured:

$ pnpm exec turbo run build --filter='./packages/*' --filter='./packages/*/*' # 70/70, 4m29s
$ pnpm check:type-check-debt ; echo $?
check-type-check-coverage --re-measure: OK — 29 ledger entr(ies) re-measured in 334.1s,
1531 raw tsc error(s) total, none above its recorded number.
surplus: none — every entry sits exactly at its measurement, so any new error is red.
0

⛔ Not one ledger entry was touched. The re-measure above reads the ledger exactly as main records it.

How the class is pinned

18 new cases in --self-test, which both package.json scripts run on every invocation. The load-bearing one is not a pin over a constant:

constREFUSING=[refreshBuiltClosure,tscErrorCount,measureLedgers,gitIgnoredPaths];for(constfnofREFUSING){constbody=fn.toString();if(/thrownewError\(/.test(body)){ ... }if(!body.includes('refusePrerequisite(')){ ... }}

It reads the four refusing functions' real bodies, because the regression shape is not a wrong number typed anywhere — it is one careless throw new Error(...) added by an author who never thought about exit codes, and it announces itself with a green CI (every consumer above treats any non-zero as failure, so 1-instead-of-3 is invisible to all of them). readTsconfig is the negative control: it still throws on purpose, so the pin has something to see and cannot pass by looking at an empty set.

The pin was ablated rather than assumed. Reverting the card's own branch to throw new Error( and rebuilding nothing (this is a source-run script):

$ node scripts/check-type-check-coverage.mjs --self-test ; echo $?
✗ check:type-check-coverage --self-test — 1 failure(s)
• measureLedgers raises a bare `throw new Error(` — an uncaught throw exits 1, the code
this gate reserves for a ledger entry that drifted UPWARD. A refusal must go through
refusePrerequisite() so it exits 3.
1
$ node scripts/check-type-check-coverage.mjs --re-measure ; echo $?
1 # the defect, reproduced

The mutation was confirmed on disk by a str.replace whose single-hit count was asserted before writing and by the blob hash changing (e2a91d7d to 22a12247); the restore is proven by git hash-object returning the HEAD blob again, not by a trap firing.

The other 17 cases pin the constants (3 is the refusal code, 1 is a finding, they are distinct from each other and from 0) and six clauses of the refusal text — including that it carries the raising site's message verbatim and that it turns the reader away from the ledger.

One behavioural repair the exit path required

measureTestDebt created a temp directory outside the repository and removed it in a finally. A finally does not run on process.exit, so a refusal raised from inside tscErrorCount would have leaked one. The directory is now also registered on exit and de-registered in the finally, so both paths clean up. Verified after the full 29-entry re-measure above: zero objectstack-debt-remeasure-* directories left behind.

Changeset

None, deliberately — skip-changeset instead. This PR changes one file under scripts/, publishes nothing from any package, and alters no runtime behaviour any consumer of a published package can observe. Precedent on main is uniform: script-only landings carry no changeset (for example 2aef62eba fix(scripts): let check-role-word skip machine-generated REGIONS and 010518bb1 docs(pm): express the self-test's size as a floor — one file each, zero changesets). The Check Changeset job has no path exemption, so the label is the mechanism, and it is applied on this PR rather than left for the gate to go red first.

Gates: derived vs run

Derived with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack at the final commit 92d273fe8 — 16 families (14 by path, 2 by the "adds or edits a GATE SCRIPT" change kind). comm -23 of derived against run is empty. Two were run beyond the derived set on purpose: check:ratchet-remedy-authority, which builds its scan surface with readdirSync and so cannot be path-derived, and check:nul-bytes.

check:agent-test-spelling EXIT=0
check:bash32-floor EXIT=0
check:cli-command-ids EXIT=0
check:cross-package-test-inputs EXIT=0
check:entry-guard EXIT=0
check:parse-guard EXIT=0
check:pnpm-filter-targets EXIT=0
check:watch-hint-literal EXIT=0
check:type-check-coverage EXIT=0
check:type-check-debt EXIT=0 (closure built; 29 entries re-measured)
check:pm-dispatch-gates EXIT=0
node scripts/check-ci-filter-parity.mjs EXIT=0
node scripts/check-cross-package-test-inputs.mjs EXIT=0
node scripts/check-shard-attestation.mjs EXIT=0
node scripts/check-test-completeness.mjs EXIT=3 NOT MEASURED, not a failure
node scripts/pm/bare-root-worklist.mjs --self-test EXIT=0
check:ratchet-remedy-authority EXIT=0 (mandated; not path-derivable)
check:nul-bytes EXIT=0

Every exit code above was captured before any pipe, into a file, never as $? after a tail.

check-test-completeness answering 3 is the convention this card is about, working: the derived family hands out the bare invocation, which has no log to grade, so the gate refuses. Its own header documents that exact interaction. It is NOT MEASURED, and it is unrelated to this diff.

Repo-wide ESLint was run in full rather than narrowed — eslint . --no-inline-config, 5597 files, 0 errors, 0 warnings, 99s under the shared verification lock.

All readings above are from the tree at 92d273fe8.

Generated by Claude Code


Generated by Claude Code

… not a finding's 1
Every refusal in this gate reached node's uncaught handler, which exits 1 --
the code the gate reserves for "a package's recorded debt went UP", whose
prescribed remedy ends at a ledger only a maintainer may raise. So an
unmeasurable run pointed the reader at the one place that evidence must never
send them, and anything reading exit codes rather than prose could not tell the
two apart at all.
Routes all eleven refusal sites through a new refusePrerequisite(), which
prints the raising site's message VERBATIM under a PREREQUISITE NOT MET frame
and exits 3 -- the code check-test-completeness.mjs states in its own failure
text and check-dual-build-cjs-loads.mjs already answers the identical
"reads built output, tree has no dist/" condition with. Exit 1 now means only a
finding: a structural problem, or a ledger entry that drifted upward. A
tsconfig.json checked into the tree that does not parse stays a finding.
The self-test pins the class over the refusing FUNCTION BODIES rather than over
a constant, because the regression shape is one careless `throw new Error(`
added by an author not thinking about exit codes -- invisible to every consumer,
all of which treat any non-zero as failure.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pk26oZ12t5N1hwGW1m1MgC
@claude

claudeBot commented Aug 31, 2026

Copy link
Copy Markdown
ContributorAuthor

ACCEPTdomain:devx execution PM seat (#6023), session session_01Pk26oZ12t5N1hwGW1m1MgC. Every number below re-derived by this seat at head 92d273fe8, ⛔ not taken from the report.

⛔ My Zone 2 C count was wrong, and it was wrong in the direction that matters

I handed the dev the card's single line reference — measureLedgers … check-type-check-coverage.mjs:3231:11 — as the refusal site. The file refuses in eleven places, across four functions: nine on the --re-measure path and two in the observation half that both invocations run. Every one of them exited 1 by reaching node's uncaught handler.

I re-counted independently at the head sha rather than accepting the number — 11 call sites, at 1637 · 1644 (gitIgnoredPaths), 2808 · 2812 · 2815 (refreshBuiltClosure), 3036 · 3048 · 3051 · 3063 (tscErrorCount), 3345 · 3379 (measureLedgers).

⇒ Scoped to my count, ten of eleven refusals would still exit 1 — and two of those ten fire on the default invocation, so the card's own defect would have survived on the path most callers take. ⭐ Falsified in the safe direction, with counts. That is the result I ask for.

⭐⭐ The negative control — the best thing in this PR, and nobody asked for it

The self-test pins that the four refusing functions contain no bare throw new Error(. A pin like that is a tautology if the regex cannot fire at all — and it would pass forever while measuring nothing.

So the dev made it a measurement: readTsconfigstill throws on purpose, and the self-test asserts that it does, with the reason stated inline — a malformed tsconfig.json checked into the tree is a fact about the tree, which is what a finding is. Verified here: exactly one surviving throw new Error( inside readTsconfig at the head sha.

⇒ This is the lane's instrument rule — an absence is not a reading until the instrument is known to be able to report presence — applied to the dev's own pin, unprompted. ⛔ Not decoration: without it, a later refactor that removed every bare throw in the file would leave the pin green and blind.

check:entry-guard caught the delivery's own defect — second PR in a row

The dev's first cut wrote export const EXIT_*. The guard refused: exporting any binding from a file whose top level runs makes the whole gate execute inside the importer. Verified: zeroexport bindings in the file at the head sha, and the reason is written into the source rather than left as tribal knowledge.

⇒ Two consecutive deliveries where running the derived family found a defect in the delivery, not in the tree. ⛔ Running the family is not ceremony, and this is the evidence.

The behavioural repair the exit path forced — and I tested the assumption under it

process.exit() runs exit handlers and does not run finally. So making tscErrorCount refuse would have leaked measureTestDebt's temp directory on every refusal. Cleanup is now registered on exitas well, with process.off first in the finally so a 29-entry run does not accumulate 29 live handlers on directories already removed.

⚠️ That last part rests on process.off being able to remove a once-registered listener. I did not take it on faith:

process.once('exit', f) -> listenerCount 1
process.off('exit', f) -> listenerCount 0

It holds. ⇒ The belt-and-braces shape is sound in both directions: a refusal exits through the handler, a normal return exits through the finally, and a bare throw is covered by the finally before the uncaught handler runs.

Propagation measured, ⛔ not assumed

pnpm check:type-check-debt on an unbuilt tree exits 3 ⇒ the package.json&& chain and pnpm both propagate it. ⭐ This is the reading that decides whether the change is worth anything at all: an exit code that dies at the wrapper changes nothing for the CI step, the agent, or the reader it was written for.

Both directions — and the finding side shown TWICE, in two code paths

  • Refusal: --re-measure on an unbuilt worktree — EXIT=1 before (stack trace at measureLedgers …:3231:11), EXIT=3 after, message byte-identical (2424 bytes, compare equal).
  • A real finding still exits 1, demonstrated in each half: the re-measure half (forced stale-heap-ceiling finding, EXIT=1) and the structural half (one package's typecheck script removed, "20 problem(s)", EXIT=1).
  • A full real measurement still passes: 70/70 closure build, then 29 ledger entries re-measured in 334.1s, 1531 raw tsc errors, none above its recorded number, EXIT=0.

⭐ Showing the finding path in two independent code paths is what makes "1 still means a finding" a measurement rather than a spot check. Exit codes captured into files before any pipe, ⛔ never as $? after a tail — which is the exact misreading the new refusal text warns about.

The boundary is drawn, ⛔ not defaulted

Exit 3 is for a prerequisite the world failed to supply and the caller clears with a named command (unbuilt or stale closure, a closure that does not build, an absent turbo/tsc, a tsc that could not spawn or read its project). A malformed tsconfig.jsonchecked into the tree stays exit 1. The line is argued in the docblock, not merely implemented — and it is the same line the negative control pins.

Triage question 2 — answered by measurement, not by symmetry

check:type-check-coverage shares the treatment for exactly the two refusals it actually shares (gitIgnoredPaths); its structural half reads only checked-in files and has no prerequisite branch of its own. ⇒ ⛔ No symmetry-driven edit to a path that has no such state.

Gates and hygiene

16 derived, comm -23 empty, plus check:ratchet-remedy-authority (mandated, readdirSync surface — not path-derivable) and check:nul-bytes, both 0. Repo-wide eslint . --no-inline-config run in full rather than narrowed: 5597 files, 0 errors, 0 warnings.

⭐ Worth recording: the one non-zero in the family was check-test-completeness.mjsEXIT=3 — PREREQUISITE NOT MET, recorded as NOT MEASURED, ⛔ never as a pass. The gate family's own report used the very class this PR is about, correctly, in the same run that shipped it.

Ablation proved on disk by a single-hit-asserted replace and by the blob hash moving e2a91d7d22a12247; the defect reproduced (self-test red and the real run back to exit 1); restore proved by git hash-object matching the HEAD blob and an empty git diff HEAD, ⛔ not by a trap firing.

skip-changeset applied by the dev, additively. Verified correct: the diff is a singlescripts/** file, which matches no workspace publish glob, and the root package is private.

⛔ One bounded residue — recorded here, deliberately NOT filed

REFUSING is a hand-written list of the four functions that refuse. The pin is asymmetric: if one of them stops routing through refusePrerequisite() the self-test fails, but a fifth function that starts refusing is not pinned at all — which is this card's own class returning under a different name.

⇒ Recorded, ⛔ not filed: today all 11 sites live in exactly those 4 functions (verified above), the file is single-purpose, and the truth is one grep away. If a fifth ever appears, this paragraph is the card. ⚠️ Whoever disagrees should file it — this is a judgement, not a measurement.

The out-of-scope finding was filed, and the restraint was right

#13983scripts/import-prerequisite.mjs, the shared PREREQUISITE NOT MET frame that 20+ root gates inherit, exits 1, deliberately and with its reasoning stated in the source, while three siblings now answer the same words with 3. ⛔ Not patched: the current behaviour is an argued decision, and picking the winning number is a fleet-wide convention call, ⛔ not this card's.

⚠️ ⇒ This PR therefore leaves two conventions in the open. That is honest, and it is better than a rider that unified them without a ruling. The divergence now has a card instead of being silent.

Governed-surface check

Diff: one file, scripts/check-type-check-coverage.mjs. ⛔ No hit on docs/adr/** · .claude/** · skills/** · AGENTS.md · CLAUDE.mdthis seat may arm it.


Generated by Claude Code

@os-project-manager
os-project-manager marked this pull request as ready for review August 31, 2026 22:09
@os-project-manager
os-project-manager added this pull request to the merge queueAug 31, 2026
Merged via the queue into main with commit c0770d0Aug 31, 2026
34 checks passed
@os-project-manager
os-project-manager deleted the claude/issue-13885-type-check-debt-exit-code branch August 31, 2026 22:30
os-project-manager pushed a commit that referenced this pull request Aug 31, 2026
…ne stroke with its advisory
`scripts/import-prerequisite.mjs` is the shared `PREREQUISITE NOT MET` frame
that 45 root and package gates import. It exited 1 and told every one of them
so in the inherited advisory, while `check-test-completeness.mjs`,
`check-dual-build-cjs-loads.mjs`, `check-type-check-coverage.mjs` (PR #13982)
and `pm/check-half-states.mjs` answer the same two words with 3 — a class
`half-state-patrol.yml` already reads by number.
The exit code and the printed `(Exit code …)` advisory move together: changing
only the number would leave 45 gates inheriting a false advisory, which is
worse than either number applied consistently.
Nothing mechanical changes today, and the change is not sold as if it did:
every consumer of these gates treats any non-zero as failure — the `&&` chains
in the root package.json, the bare `run:` steps in lint.yml/ci.yml, and
required-set-patrol.yml, which branches on `== '0'` / `!= '0'` and nothing
finer. The refusal itself, its wording, and a real verdict's exit 1 are all
untouched.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pk26oZ12t5N1hwGW1m1MgC
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 1, 2026
…ne stroke with its advisory (objectstack-ai#14009)
`scripts/import-prerequisite.mjs` is the shared `PREREQUISITE NOT MET` frame
that 45 root and package gates import. It exited 1 and told every one of them
so in the inherited advisory, while `check-test-completeness.mjs`,
`check-dual-build-cjs-loads.mjs`, `check-type-check-coverage.mjs` (PR objectstack-ai#13982)
and `pm/check-half-states.mjs` answer the same two words with 3 — a class
`half-state-patrol.yml` already reads by number.
The exit code and the printed `(Exit code …)` advisory move together: changing
only the number would leave 45 gates inheriting a false advisory, which is
worse than either number applied consistently.
Nothing mechanical changes today, and the change is not sold as if it did:
every consumer of these gates treats any non-zero as failure — the `&&` chains
in the root package.json, the bare `run:` steps in lint.yml/ci.yml, and
required-set-patrol.yml, which branches on `== '0'` / `!= '0'` and nothing
finer. The refusal itself, its wording, and a real verdict's exit 1 are all
untouched.
Claude-Session: https://claude.ai/code/session_01Pk26oZ12t5N1hwGW1m1MgC
Co-authored-by: Claude <noreply@anthropic.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/mskip-changesetPR has no user-facing published change; bypasses the changeset gate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

check:type-check-debt signals "closure not built, nothing measured" with exit 1, the code the repo reserves for a real finding

2 participants

@os-project-manager@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Answer every check-type-check-coverage refusal with exit 3, not a finding's 1 - #13982

Merged
os-project-manager merged 1 commit into
mainfrom
claude/issue-13885-type-check-debt-exit-code
Aug 31, 2026
Merged

Answer every check-type-check-coverage refusal with exit 3, not a finding's 1#13982
os-project-manager merged 1 commit into
mainfrom
claude/issue-13885-type-check-debt-exit-code

Conversation

@claude

@claudeclaudeBot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Fixes#13885

check:type-check-debt, run where the workspace type closure is not built, refuses to measure — correctly — and signalled that refusal by throwing. An uncaught throw reaches node's default handler, which exits 1: the code this gate reserves for "a package's recorded debt went UP", whose prescribed remedy ends at a DEBT/TEST_DEBT ledger only a maintainer may raise. So the one result that is not evidence about the ledger pointed the reader straight at it, and anything reading exit codes rather than prose could not tell the two apart at all.

The refusal, its message, its reasoning and when it fires are all unchanged. Only the class changed.

Re-derived line reference

The card cites measureLedgers ... check-type-check-coverage.mjs:3231. Re-derived on origin/main at aee1fd9ec, that is exact — reproduced verbatim from a real run:

 at measureLedgers (file:///home/user/objectstack-13885/scripts/check-type-check-coverage.mjs:3231:11)

After this change the same refusal is at scripts/check-type-check-coverage.mjs:3345.

It is not ONE branch — it is eleven (Zone 2 C, re-derived)

The PM's count was one. Measured, this file refuses to measure in eleven places, and every one of them exited 1. They now all route through a single refusePrerequisite() and exit 3.

lineconditionremedy the message already named
3345ledgered closure has unbuilt type entry points — the card's branchbuild the closure
3379closure still stale after a full buildbuild the named package
2808no workspace turbo binarypnpm install
2812the closure build could not be spawnedenvironment
2815the closure does not buildfix the build
3036no workspace tsc binarypnpm install
3048tsc could not be spawnedenvironment
3051tsc could not read the project — "the measurement is invalid, not zero"fix the project
3063tsc failed in a way the parser cannot see — "refusing to record 0"read the output
1637git check-ignore could not be spawnedenvironment
1644git check-ignore exited unexpectedlyenvironment

Every one of those eleven messages is embedded byte-identically in the new output. Measured on the card's own branch: the refusal text before the change and after it are both 2424 bytes and compare equal.

The boundary, and what deliberately did NOT move.readTsconfig (line 1499) still throws for a tsconfig.json checked into the tree that does not parse. That is a fact about the tree, which is what a finding is — the same line check-test-completeness.mjs draws when it keeps a malformed command line at 1 while a missing input gets 3. Exit 1 from this gate now means only: a structural problem, a ledger entry that drifted upward, or a stale heap-ceiling pin.

Triage question 2 — does check:type-check-coverage share the treatment?

Answered rather than assumed, because the two invocations are one file. Yes, for the two refusals it actually shares (rows 1637 and 1644 above, in the observation half both invocations run) and no further: the structural half reads only package.json and tsconfig.json files that are checked in, so it has no prerequisite branch of its own to reclassify. Its non-zero stays a claim about the tree.

Zone 2 A — the consumer enumeration, and what each does with 1 vs 3

Searched: every file under .github/workflows/, every file under scripts/, and package.json. No consumer anywhere special-cases exit 1, and none would now read 3 as success.

consumerhow it invokeswith 1with 3
lint.yml, job Type Check · debt ledger, step "Re-measure the type-check DEBT / TEST_DEBT ledger"bare run: pnpm check:type-check-debt — no continue-on-error, no if:, no wrapperstep failsstep fails — unchanged. And the step before it builds the closure, so CI does not reach the branch at all
lint.yml, job Type Check · source gates, step "Check every package is type-check covered or ledgered"bare run: pnpm check:type-check-coveragestep failsstep fails — unchanged
package.json scripts (both)node ... --self-test && node ...; the && chain yields the last command's status and pnpm propagates itpropagates 1propagates 3 — measured, see below
scripts/pm/os-verify-lock.shwraps a command, prints VERDICT command-exit N, exits Nnon-zeronon-zero, and now distinguishable in the VERDICT line. Its own self-test already pins the 3 case (sh -c "exit 3" yields VERDICT command-exit 3)
scripts/pm/dispatch-gates.mjsnames both families; never spawns them — its only spawnSync targets are git and itselfno couplingno coupling
a human or agent running the derived gate familyreads the numberreads as a FINDING and is routed to the maintainer-only ledger — the defectreads as NOT MEASURED

Measured propagation through the pnpm layer, on an unbuilt worktree:

$ pnpm check:type-check-debt > pnpm-debt.log 2>&1; echo "EXIT=$?"
EXIT=3
$ grep -c "PREREQUISITE NOT MET" pnpm-debt.log
1

Direction of error: nothing regressed toward green. GitHub Actions fails a run: step on any non-zero, so both CI consumers are byte-identical in behaviour. No .github/workflows/** change is needed and none is made.

Which precedent, and why

scripts/check-test-completeness.mjs. It is the more argued of the two: it carries a full "Exit codes — and why a REFUSAL has its own one" header, it names its codes as constants so its self-test pins the value each path returns rather than a comment about it, and — the part that decided where to stop here — it states a boundary out loud: exit 3 is for an input the world failed to supply; a malformed command line, which is the caller's own typo, stays 1.

scripts/check-dual-build-cjs-loads.mjs agrees on 3 for the identical condition and contributed the "⛔ This is NOT a pass: nothing was measured" sentence shape. It also carries a third code (2, for "the run read almost nothing"); this gate has no analogue and did not adopt one.

A third data point, reported rather than changed.scripts/import-prerequisite.mjs — the shared frame that 20-plus root gates use for "can this gate load the package it imports?" — prints PREREQUISITE NOT MET and exits 1, deliberately, with its reasoning stated at the function ("a second failure code would be a new contract nobody asked for"). That is a documented decision, not an oversight, and it is out of this card's scope; it is filed separately so the tension is triaged rather than silently deepened by this PR.

Both directions, demonstrated

Prerequisite branch, on an unbuilt worktree, before and after:

before $ node scripts/check-type-check-coverage.mjs --re-measure ; echo $?
Error: --re-measure cannot run: 56 workspace dependenc(ies) ...
at measureLedgers (scripts/check-type-check-coverage.mjs:3231:11)
1
after $ node scripts/check-type-check-coverage.mjs --re-measure ; echo $?
check-type-check-coverage: PREREQUISITE NOT MET
--re-measure cannot run: 56 workspace dependenc(ies) ... (byte-identical, 2424 bytes)
⛔ This is NOT a pass and NOT a finding: nothing was measured ...
(Exit code 3, distinct from a finding's 1 — capture it BEFORE any pipe ...)
3

A real finding still exits 1, shown twice — once in each half of the gate, so the claim is not carried by one code path:

re-measure half — a FINDING inside the --re-measure block (the pinned CI heap
ceiling sits above the runner's own default):
$ GITHUB_ACTIONS=true NODE_OPTIONS=--max-old-space-size=2048 \
node scripts/check-type-check-coverage.mjs --re-measure ; echo $?
::error::... pins a CI heap ceiling of 4096 MB, but THIS CI runner's own default is 2096 MB ...
1
structural half — one package's `typecheck` script removed, then restored:
$ node scripts/check-type-check-coverage.mjs ; echo $?
check-type-check-coverage: 20 problem(s)
1

That mutation was made against the committed tree and restored with git checkout HEAD -- packages/spec/package.json; the restore is proven by an empty git diff HEAD and by git hash-object matching the HEAD blob (0db5f14e...), not by the command's exit code.

And a full, real measurement still passes end to end — the closure built, the whole ledger re-measured:

$ pnpm exec turbo run build --filter='./packages/*' --filter='./packages/*/*' # 70/70, 4m29s
$ pnpm check:type-check-debt ; echo $?
check-type-check-coverage --re-measure: OK — 29 ledger entr(ies) re-measured in 334.1s,
1531 raw tsc error(s) total, none above its recorded number.
surplus: none — every entry sits exactly at its measurement, so any new error is red.
0

⛔ Not one ledger entry was touched. The re-measure above reads the ledger exactly as main records it.

How the class is pinned

18 new cases in --self-test, which both package.json scripts run on every invocation. The load-bearing one is not a pin over a constant:

constREFUSING=[refreshBuiltClosure,tscErrorCount,measureLedgers,gitIgnoredPaths];for(constfnofREFUSING){constbody=fn.toString();if(/thrownewError\(/.test(body)){ ... }if(!body.includes('refusePrerequisite(')){ ... }}

It reads the four refusing functions' real bodies, because the regression shape is not a wrong number typed anywhere — it is one careless throw new Error(...) added by an author who never thought about exit codes, and it announces itself with a green CI (every consumer above treats any non-zero as failure, so 1-instead-of-3 is invisible to all of them). readTsconfig is the negative control: it still throws on purpose, so the pin has something to see and cannot pass by looking at an empty set.

The pin was ablated rather than assumed. Reverting the card's own branch to throw new Error( and rebuilding nothing (this is a source-run script):

$ node scripts/check-type-check-coverage.mjs --self-test ; echo $?
✗ check:type-check-coverage --self-test — 1 failure(s)
• measureLedgers raises a bare `throw new Error(` — an uncaught throw exits 1, the code
this gate reserves for a ledger entry that drifted UPWARD. A refusal must go through
refusePrerequisite() so it exits 3.
1
$ node scripts/check-type-check-coverage.mjs --re-measure ; echo $?
1 # the defect, reproduced

The mutation was confirmed on disk by a str.replace whose single-hit count was asserted before writing and by the blob hash changing (e2a91d7d to 22a12247); the restore is proven by git hash-object returning the HEAD blob again, not by a trap firing.

The other 17 cases pin the constants (3 is the refusal code, 1 is a finding, they are distinct from each other and from 0) and six clauses of the refusal text — including that it carries the raising site's message verbatim and that it turns the reader away from the ledger.

One behavioural repair the exit path required

measureTestDebt created a temp directory outside the repository and removed it in a finally. A finally does not run on process.exit, so a refusal raised from inside tscErrorCount would have leaked one. The directory is now also registered on exit and de-registered in the finally, so both paths clean up. Verified after the full 29-entry re-measure above: zero objectstack-debt-remeasure-* directories left behind.

Changeset

None, deliberately — skip-changeset instead. This PR changes one file under scripts/, publishes nothing from any package, and alters no runtime behaviour any consumer of a published package can observe. Precedent on main is uniform: script-only landings carry no changeset (for example 2aef62eba fix(scripts): let check-role-word skip machine-generated REGIONS and 010518bb1 docs(pm): express the self-test's size as a floor — one file each, zero changesets). The Check Changeset job has no path exemption, so the label is the mechanism, and it is applied on this PR rather than left for the gate to go red first.

Gates: derived vs run

Derived with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack at the final commit 92d273fe8 — 16 families (14 by path, 2 by the "adds or edits a GATE SCRIPT" change kind). comm -23 of derived against run is empty. Two were run beyond the derived set on purpose: check:ratchet-remedy-authority, which builds its scan surface with readdirSync and so cannot be path-derived, and check:nul-bytes.

check:agent-test-spelling EXIT=0
check:bash32-floor EXIT=0
check:cli-command-ids EXIT=0
check:cross-package-test-inputs EXIT=0
check:entry-guard EXIT=0
check:parse-guard EXIT=0
check:pnpm-filter-targets EXIT=0
check:watch-hint-literal EXIT=0
check:type-check-coverage EXIT=0
check:type-check-debt EXIT=0 (closure built; 29 entries re-measured)
check:pm-dispatch-gates EXIT=0
node scripts/check-ci-filter-parity.mjs EXIT=0
node scripts/check-cross-package-test-inputs.mjs EXIT=0
node scripts/check-shard-attestation.mjs EXIT=0
node scripts/check-test-completeness.mjs EXIT=3 NOT MEASURED, not a failure
node scripts/pm/bare-root-worklist.mjs --self-test EXIT=0
check:ratchet-remedy-authority EXIT=0 (mandated; not path-derivable)
check:nul-bytes EXIT=0

Every exit code above was captured before any pipe, into a file, never as $? after a tail.

check-test-completeness answering 3 is the convention this card is about, working: the derived family hands out the bare invocation, which has no log to grade, so the gate refuses. Its own header documents that exact interaction. It is NOT MEASURED, and it is unrelated to this diff.

Repo-wide ESLint was run in full rather than narrowed — eslint . --no-inline-config, 5597 files, 0 errors, 0 warnings, 99s under the shared verification lock.

All readings above are from the tree at 92d273fe8.

Generated by Claude Code


Generated by Claude Code

… not a finding's 1
Every refusal in this gate reached node's uncaught handler, which exits 1 --
the code the gate reserves for "a package's recorded debt went UP", whose
prescribed remedy ends at a ledger only a maintainer may raise. So an
unmeasurable run pointed the reader at the one place that evidence must never
send them, and anything reading exit codes rather than prose could not tell the
two apart at all.
Routes all eleven refusal sites through a new refusePrerequisite(), which
prints the raising site's message VERBATIM under a PREREQUISITE NOT MET frame
and exits 3 -- the code check-test-completeness.mjs states in its own failure
text and check-dual-build-cjs-loads.mjs already answers the identical
"reads built output, tree has no dist/" condition with. Exit 1 now means only a
finding: a structural problem, or a ledger entry that drifted upward. A
tsconfig.json checked into the tree that does not parse stays a finding.
The self-test pins the class over the refusing FUNCTION BODIES rather than over
a constant, because the regression shape is one careless `throw new Error(`
added by an author not thinking about exit codes -- invisible to every consumer,
all of which treat any non-zero as failure.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pk26oZ12t5N1hwGW1m1MgC
@claude

claudeBot commented Aug 31, 2026

Copy link
Copy Markdown
ContributorAuthor

ACCEPTdomain:devx execution PM seat (#6023), session session_01Pk26oZ12t5N1hwGW1m1MgC. Every number below re-derived by this seat at head 92d273fe8, ⛔ not taken from the report.

⛔ My Zone 2 C count was wrong, and it was wrong in the direction that matters

I handed the dev the card's single line reference — measureLedgers … check-type-check-coverage.mjs:3231:11 — as the refusal site. The file refuses in eleven places, across four functions: nine on the --re-measure path and two in the observation half that both invocations run. Every one of them exited 1 by reaching node's uncaught handler.

I re-counted independently at the head sha rather than accepting the number — 11 call sites, at 1637 · 1644 (gitIgnoredPaths), 2808 · 2812 · 2815 (refreshBuiltClosure), 3036 · 3048 · 3051 · 3063 (tscErrorCount), 3345 · 3379 (measureLedgers).

⇒ Scoped to my count, ten of eleven refusals would still exit 1 — and two of those ten fire on the default invocation, so the card's own defect would have survived on the path most callers take. ⭐ Falsified in the safe direction, with counts. That is the result I ask for.

⭐⭐ The negative control — the best thing in this PR, and nobody asked for it

The self-test pins that the four refusing functions contain no bare throw new Error(. A pin like that is a tautology if the regex cannot fire at all — and it would pass forever while measuring nothing.

So the dev made it a measurement: readTsconfigstill throws on purpose, and the self-test asserts that it does, with the reason stated inline — a malformed tsconfig.json checked into the tree is a fact about the tree, which is what a finding is. Verified here: exactly one surviving throw new Error( inside readTsconfig at the head sha.

⇒ This is the lane's instrument rule — an absence is not a reading until the instrument is known to be able to report presence — applied to the dev's own pin, unprompted. ⛔ Not decoration: without it, a later refactor that removed every bare throw in the file would leave the pin green and blind.

check:entry-guard caught the delivery's own defect — second PR in a row

The dev's first cut wrote export const EXIT_*. The guard refused: exporting any binding from a file whose top level runs makes the whole gate execute inside the importer. Verified: zeroexport bindings in the file at the head sha, and the reason is written into the source rather than left as tribal knowledge.

⇒ Two consecutive deliveries where running the derived family found a defect in the delivery, not in the tree. ⛔ Running the family is not ceremony, and this is the evidence.

The behavioural repair the exit path forced — and I tested the assumption under it

process.exit() runs exit handlers and does not run finally. So making tscErrorCount refuse would have leaked measureTestDebt's temp directory on every refusal. Cleanup is now registered on exitas well, with process.off first in the finally so a 29-entry run does not accumulate 29 live handlers on directories already removed.

⚠️ That last part rests on process.off being able to remove a once-registered listener. I did not take it on faith:

process.once('exit', f) -> listenerCount 1
process.off('exit', f) -> listenerCount 0

It holds. ⇒ The belt-and-braces shape is sound in both directions: a refusal exits through the handler, a normal return exits through the finally, and a bare throw is covered by the finally before the uncaught handler runs.

Propagation measured, ⛔ not assumed

pnpm check:type-check-debt on an unbuilt tree exits 3 ⇒ the package.json&& chain and pnpm both propagate it. ⭐ This is the reading that decides whether the change is worth anything at all: an exit code that dies at the wrapper changes nothing for the CI step, the agent, or the reader it was written for.

Both directions — and the finding side shown TWICE, in two code paths

  • Refusal: --re-measure on an unbuilt worktree — EXIT=1 before (stack trace at measureLedgers …:3231:11), EXIT=3 after, message byte-identical (2424 bytes, compare equal).
  • A real finding still exits 1, demonstrated in each half: the re-measure half (forced stale-heap-ceiling finding, EXIT=1) and the structural half (one package's typecheck script removed, "20 problem(s)", EXIT=1).
  • A full real measurement still passes: 70/70 closure build, then 29 ledger entries re-measured in 334.1s, 1531 raw tsc errors, none above its recorded number, EXIT=0.

⭐ Showing the finding path in two independent code paths is what makes "1 still means a finding" a measurement rather than a spot check. Exit codes captured into files before any pipe, ⛔ never as $? after a tail — which is the exact misreading the new refusal text warns about.

The boundary is drawn, ⛔ not defaulted

Exit 3 is for a prerequisite the world failed to supply and the caller clears with a named command (unbuilt or stale closure, a closure that does not build, an absent turbo/tsc, a tsc that could not spawn or read its project). A malformed tsconfig.jsonchecked into the tree stays exit 1. The line is argued in the docblock, not merely implemented — and it is the same line the negative control pins.

Triage question 2 — answered by measurement, not by symmetry

check:type-check-coverage shares the treatment for exactly the two refusals it actually shares (gitIgnoredPaths); its structural half reads only checked-in files and has no prerequisite branch of its own. ⇒ ⛔ No symmetry-driven edit to a path that has no such state.

Gates and hygiene

16 derived, comm -23 empty, plus check:ratchet-remedy-authority (mandated, readdirSync surface — not path-derivable) and check:nul-bytes, both 0. Repo-wide eslint . --no-inline-config run in full rather than narrowed: 5597 files, 0 errors, 0 warnings.

⭐ Worth recording: the one non-zero in the family was check-test-completeness.mjsEXIT=3 — PREREQUISITE NOT MET, recorded as NOT MEASURED, ⛔ never as a pass. The gate family's own report used the very class this PR is about, correctly, in the same run that shipped it.

Ablation proved on disk by a single-hit-asserted replace and by the blob hash moving e2a91d7d22a12247; the defect reproduced (self-test red and the real run back to exit 1); restore proved by git hash-object matching the HEAD blob and an empty git diff HEAD, ⛔ not by a trap firing.

skip-changeset applied by the dev, additively. Verified correct: the diff is a singlescripts/** file, which matches no workspace publish glob, and the root package is private.

⛔ One bounded residue — recorded here, deliberately NOT filed

REFUSING is a hand-written list of the four functions that refuse. The pin is asymmetric: if one of them stops routing through refusePrerequisite() the self-test fails, but a fifth function that starts refusing is not pinned at all — which is this card's own class returning under a different name.

⇒ Recorded, ⛔ not filed: today all 11 sites live in exactly those 4 functions (verified above), the file is single-purpose, and the truth is one grep away. If a fifth ever appears, this paragraph is the card. ⚠️ Whoever disagrees should file it — this is a judgement, not a measurement.

The out-of-scope finding was filed, and the restraint was right

#13983scripts/import-prerequisite.mjs, the shared PREREQUISITE NOT MET frame that 20+ root gates inherit, exits 1, deliberately and with its reasoning stated in the source, while three siblings now answer the same words with 3. ⛔ Not patched: the current behaviour is an argued decision, and picking the winning number is a fleet-wide convention call, ⛔ not this card's.

⚠️ ⇒ This PR therefore leaves two conventions in the open. That is honest, and it is better than a rider that unified them without a ruling. The divergence now has a card instead of being silent.

Governed-surface check

Diff: one file, scripts/check-type-check-coverage.mjs. ⛔ No hit on docs/adr/** · .claude/** · skills/** · AGENTS.md · CLAUDE.mdthis seat may arm it.


Generated by Claude Code

@os-project-manager
os-project-manager marked this pull request as ready for review August 31, 2026 22:09
@os-project-manager
os-project-manager added this pull request to the merge queueAug 31, 2026
Merged via the queue into main with commit c0770d0Aug 31, 2026
34 checks passed
@os-project-manager
os-project-manager deleted the claude/issue-13885-type-check-debt-exit-code branch August 31, 2026 22:30
os-project-manager pushed a commit that referenced this pull request Aug 31, 2026
…ne stroke with its advisory
`scripts/import-prerequisite.mjs` is the shared `PREREQUISITE NOT MET` frame
that 45 root and package gates import. It exited 1 and told every one of them
so in the inherited advisory, while `check-test-completeness.mjs`,
`check-dual-build-cjs-loads.mjs`, `check-type-check-coverage.mjs` (PR #13982)
and `pm/check-half-states.mjs` answer the same two words with 3 — a class
`half-state-patrol.yml` already reads by number.
The exit code and the printed `(Exit code …)` advisory move together: changing
only the number would leave 45 gates inheriting a false advisory, which is
worse than either number applied consistently.
Nothing mechanical changes today, and the change is not sold as if it did:
every consumer of these gates treats any non-zero as failure — the `&&` chains
in the root package.json, the bare `run:` steps in lint.yml/ci.yml, and
required-set-patrol.yml, which branches on `== '0'` / `!= '0'` and nothing
finer. The refusal itself, its wording, and a real verdict's exit 1 are all
untouched.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pk26oZ12t5N1hwGW1m1MgC
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 1, 2026
…ne stroke with its advisory (objectstack-ai#14009)
`scripts/import-prerequisite.mjs` is the shared `PREREQUISITE NOT MET` frame
that 45 root and package gates import. It exited 1 and told every one of them
so in the inherited advisory, while `check-test-completeness.mjs`,
`check-dual-build-cjs-loads.mjs`, `check-type-check-coverage.mjs` (PR objectstack-ai#13982)
and `pm/check-half-states.mjs` answer the same two words with 3 — a class
`half-state-patrol.yml` already reads by number.
The exit code and the printed `(Exit code …)` advisory move together: changing
only the number would leave 45 gates inheriting a false advisory, which is
worse than either number applied consistently.
Nothing mechanical changes today, and the change is not sold as if it did:
every consumer of these gates treats any non-zero as failure — the `&&` chains
in the root package.json, the bare `run:` steps in lint.yml/ci.yml, and
required-set-patrol.yml, which branches on `== '0'` / `!= '0'` and nothing
finer. The refusal itself, its wording, and a real verdict's exit 1 are all
untouched.
Claude-Session: https://claude.ai/code/session_01Pk26oZ12t5N1hwGW1m1MgC
Co-authored-by: Claude <noreply@anthropic.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/mskip-changesetPR has no user-facing published change; bypasses the changeset gate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

check:type-check-debt signals "closure not built, nothing measured" with exit 1, the code the repo reserves for a real finding

2 participants

@os-project-manager@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Answer every check-type-check-coverage refusal with exit 3, not a finding's 1 - #13982

Merged
os-project-manager merged 1 commit into
mainfrom
claude/issue-13885-type-check-debt-exit-code
Aug 31, 2026
Merged

Answer every check-type-check-coverage refusal with exit 3, not a finding's 1#13982
os-project-manager merged 1 commit into
mainfrom
claude/issue-13885-type-check-debt-exit-code

Conversation

@claude

@claudeclaudeBot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Fixes#13885

check:type-check-debt, run where the workspace type closure is not built, refuses to measure — correctly — and signalled that refusal by throwing. An uncaught throw reaches node's default handler, which exits 1: the code this gate reserves for "a package's recorded debt went UP", whose prescribed remedy ends at a DEBT/TEST_DEBT ledger only a maintainer may raise. So the one result that is not evidence about the ledger pointed the reader straight at it, and anything reading exit codes rather than prose could not tell the two apart at all.

The refusal, its message, its reasoning and when it fires are all unchanged. Only the class changed.

Re-derived line reference

The card cites measureLedgers ... check-type-check-coverage.mjs:3231. Re-derived on origin/main at aee1fd9ec, that is exact — reproduced verbatim from a real run:

 at measureLedgers (file:///home/user/objectstack-13885/scripts/check-type-check-coverage.mjs:3231:11)

After this change the same refusal is at scripts/check-type-check-coverage.mjs:3345.

It is not ONE branch — it is eleven (Zone 2 C, re-derived)

The PM's count was one. Measured, this file refuses to measure in eleven places, and every one of them exited 1. They now all route through a single refusePrerequisite() and exit 3.

lineconditionremedy the message already named
3345ledgered closure has unbuilt type entry points — the card's branchbuild the closure
3379closure still stale after a full buildbuild the named package
2808no workspace turbo binarypnpm install
2812the closure build could not be spawnedenvironment
2815the closure does not buildfix the build
3036no workspace tsc binarypnpm install
3048tsc could not be spawnedenvironment
3051tsc could not read the project — "the measurement is invalid, not zero"fix the project
3063tsc failed in a way the parser cannot see — "refusing to record 0"read the output
1637git check-ignore could not be spawnedenvironment
1644git check-ignore exited unexpectedlyenvironment

Every one of those eleven messages is embedded byte-identically in the new output. Measured on the card's own branch: the refusal text before the change and after it are both 2424 bytes and compare equal.

The boundary, and what deliberately did NOT move.readTsconfig (line 1499) still throws for a tsconfig.json checked into the tree that does not parse. That is a fact about the tree, which is what a finding is — the same line check-test-completeness.mjs draws when it keeps a malformed command line at 1 while a missing input gets 3. Exit 1 from this gate now means only: a structural problem, a ledger entry that drifted upward, or a stale heap-ceiling pin.

Triage question 2 — does check:type-check-coverage share the treatment?

Answered rather than assumed, because the two invocations are one file. Yes, for the two refusals it actually shares (rows 1637 and 1644 above, in the observation half both invocations run) and no further: the structural half reads only package.json and tsconfig.json files that are checked in, so it has no prerequisite branch of its own to reclassify. Its non-zero stays a claim about the tree.

Zone 2 A — the consumer enumeration, and what each does with 1 vs 3

Searched: every file under .github/workflows/, every file under scripts/, and package.json. No consumer anywhere special-cases exit 1, and none would now read 3 as success.

consumerhow it invokeswith 1with 3
lint.yml, job Type Check · debt ledger, step "Re-measure the type-check DEBT / TEST_DEBT ledger"bare run: pnpm check:type-check-debt — no continue-on-error, no if:, no wrapperstep failsstep fails — unchanged. And the step before it builds the closure, so CI does not reach the branch at all
lint.yml, job Type Check · source gates, step "Check every package is type-check covered or ledgered"bare run: pnpm check:type-check-coveragestep failsstep fails — unchanged
package.json scripts (both)node ... --self-test && node ...; the && chain yields the last command's status and pnpm propagates itpropagates 1propagates 3 — measured, see below
scripts/pm/os-verify-lock.shwraps a command, prints VERDICT command-exit N, exits Nnon-zeronon-zero, and now distinguishable in the VERDICT line. Its own self-test already pins the 3 case (sh -c "exit 3" yields VERDICT command-exit 3)
scripts/pm/dispatch-gates.mjsnames both families; never spawns them — its only spawnSync targets are git and itselfno couplingno coupling
a human or agent running the derived gate familyreads the numberreads as a FINDING and is routed to the maintainer-only ledger — the defectreads as NOT MEASURED

Measured propagation through the pnpm layer, on an unbuilt worktree:

$ pnpm check:type-check-debt > pnpm-debt.log 2>&1; echo "EXIT=$?"
EXIT=3
$ grep -c "PREREQUISITE NOT MET" pnpm-debt.log
1

Direction of error: nothing regressed toward green. GitHub Actions fails a run: step on any non-zero, so both CI consumers are byte-identical in behaviour. No .github/workflows/** change is needed and none is made.

Which precedent, and why

scripts/check-test-completeness.mjs. It is the more argued of the two: it carries a full "Exit codes — and why a REFUSAL has its own one" header, it names its codes as constants so its self-test pins the value each path returns rather than a comment about it, and — the part that decided where to stop here — it states a boundary out loud: exit 3 is for an input the world failed to supply; a malformed command line, which is the caller's own typo, stays 1.

scripts/check-dual-build-cjs-loads.mjs agrees on 3 for the identical condition and contributed the "⛔ This is NOT a pass: nothing was measured" sentence shape. It also carries a third code (2, for "the run read almost nothing"); this gate has no analogue and did not adopt one.

A third data point, reported rather than changed.scripts/import-prerequisite.mjs — the shared frame that 20-plus root gates use for "can this gate load the package it imports?" — prints PREREQUISITE NOT MET and exits 1, deliberately, with its reasoning stated at the function ("a second failure code would be a new contract nobody asked for"). That is a documented decision, not an oversight, and it is out of this card's scope; it is filed separately so the tension is triaged rather than silently deepened by this PR.

Both directions, demonstrated

Prerequisite branch, on an unbuilt worktree, before and after:

before $ node scripts/check-type-check-coverage.mjs --re-measure ; echo $?
Error: --re-measure cannot run: 56 workspace dependenc(ies) ...
at measureLedgers (scripts/check-type-check-coverage.mjs:3231:11)
1
after $ node scripts/check-type-check-coverage.mjs --re-measure ; echo $?
check-type-check-coverage: PREREQUISITE NOT MET
--re-measure cannot run: 56 workspace dependenc(ies) ... (byte-identical, 2424 bytes)
⛔ This is NOT a pass and NOT a finding: nothing was measured ...
(Exit code 3, distinct from a finding's 1 — capture it BEFORE any pipe ...)
3

A real finding still exits 1, shown twice — once in each half of the gate, so the claim is not carried by one code path:

re-measure half — a FINDING inside the --re-measure block (the pinned CI heap
ceiling sits above the runner's own default):
$ GITHUB_ACTIONS=true NODE_OPTIONS=--max-old-space-size=2048 \
node scripts/check-type-check-coverage.mjs --re-measure ; echo $?
::error::... pins a CI heap ceiling of 4096 MB, but THIS CI runner's own default is 2096 MB ...
1
structural half — one package's `typecheck` script removed, then restored:
$ node scripts/check-type-check-coverage.mjs ; echo $?
check-type-check-coverage: 20 problem(s)
1

That mutation was made against the committed tree and restored with git checkout HEAD -- packages/spec/package.json; the restore is proven by an empty git diff HEAD and by git hash-object matching the HEAD blob (0db5f14e...), not by the command's exit code.

And a full, real measurement still passes end to end — the closure built, the whole ledger re-measured:

$ pnpm exec turbo run build --filter='./packages/*' --filter='./packages/*/*' # 70/70, 4m29s
$ pnpm check:type-check-debt ; echo $?
check-type-check-coverage --re-measure: OK — 29 ledger entr(ies) re-measured in 334.1s,
1531 raw tsc error(s) total, none above its recorded number.
surplus: none — every entry sits exactly at its measurement, so any new error is red.
0

⛔ Not one ledger entry was touched. The re-measure above reads the ledger exactly as main records it.

How the class is pinned

18 new cases in --self-test, which both package.json scripts run on every invocation. The load-bearing one is not a pin over a constant:

constREFUSING=[refreshBuiltClosure,tscErrorCount,measureLedgers,gitIgnoredPaths];for(constfnofREFUSING){constbody=fn.toString();if(/thrownewError\(/.test(body)){ ... }if(!body.includes('refusePrerequisite(')){ ... }}

It reads the four refusing functions' real bodies, because the regression shape is not a wrong number typed anywhere — it is one careless throw new Error(...) added by an author who never thought about exit codes, and it announces itself with a green CI (every consumer above treats any non-zero as failure, so 1-instead-of-3 is invisible to all of them). readTsconfig is the negative control: it still throws on purpose, so the pin has something to see and cannot pass by looking at an empty set.

The pin was ablated rather than assumed. Reverting the card's own branch to throw new Error( and rebuilding nothing (this is a source-run script):

$ node scripts/check-type-check-coverage.mjs --self-test ; echo $?
✗ check:type-check-coverage --self-test — 1 failure(s)
• measureLedgers raises a bare `throw new Error(` — an uncaught throw exits 1, the code
this gate reserves for a ledger entry that drifted UPWARD. A refusal must go through
refusePrerequisite() so it exits 3.
1
$ node scripts/check-type-check-coverage.mjs --re-measure ; echo $?
1 # the defect, reproduced

The mutation was confirmed on disk by a str.replace whose single-hit count was asserted before writing and by the blob hash changing (e2a91d7d to 22a12247); the restore is proven by git hash-object returning the HEAD blob again, not by a trap firing.

The other 17 cases pin the constants (3 is the refusal code, 1 is a finding, they are distinct from each other and from 0) and six clauses of the refusal text — including that it carries the raising site's message verbatim and that it turns the reader away from the ledger.

One behavioural repair the exit path required

measureTestDebt created a temp directory outside the repository and removed it in a finally. A finally does not run on process.exit, so a refusal raised from inside tscErrorCount would have leaked one. The directory is now also registered on exit and de-registered in the finally, so both paths clean up. Verified after the full 29-entry re-measure above: zero objectstack-debt-remeasure-* directories left behind.

Changeset

None, deliberately — skip-changeset instead. This PR changes one file under scripts/, publishes nothing from any package, and alters no runtime behaviour any consumer of a published package can observe. Precedent on main is uniform: script-only landings carry no changeset (for example 2aef62eba fix(scripts): let check-role-word skip machine-generated REGIONS and 010518bb1 docs(pm): express the self-test's size as a floor — one file each, zero changesets). The Check Changeset job has no path exemption, so the label is the mechanism, and it is applied on this PR rather than left for the gate to go red first.

Gates: derived vs run

Derived with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack at the final commit 92d273fe8 — 16 families (14 by path, 2 by the "adds or edits a GATE SCRIPT" change kind). comm -23 of derived against run is empty. Two were run beyond the derived set on purpose: check:ratchet-remedy-authority, which builds its scan surface with readdirSync and so cannot be path-derived, and check:nul-bytes.

check:agent-test-spelling EXIT=0
check:bash32-floor EXIT=0
check:cli-command-ids EXIT=0
check:cross-package-test-inputs EXIT=0
check:entry-guard EXIT=0
check:parse-guard EXIT=0
check:pnpm-filter-targets EXIT=0
check:watch-hint-literal EXIT=0
check:type-check-coverage EXIT=0
check:type-check-debt EXIT=0 (closure built; 29 entries re-measured)
check:pm-dispatch-gates EXIT=0
node scripts/check-ci-filter-parity.mjs EXIT=0
node scripts/check-cross-package-test-inputs.mjs EXIT=0
node scripts/check-shard-attestation.mjs EXIT=0
node scripts/check-test-completeness.mjs EXIT=3 NOT MEASURED, not a failure
node scripts/pm/bare-root-worklist.mjs --self-test EXIT=0
check:ratchet-remedy-authority EXIT=0 (mandated; not path-derivable)
check:nul-bytes EXIT=0

Every exit code above was captured before any pipe, into a file, never as $? after a tail.

check-test-completeness answering 3 is the convention this card is about, working: the derived family hands out the bare invocation, which has no log to grade, so the gate refuses. Its own header documents that exact interaction. It is NOT MEASURED, and it is unrelated to this diff.

Repo-wide ESLint was run in full rather than narrowed — eslint . --no-inline-config, 5597 files, 0 errors, 0 warnings, 99s under the shared verification lock.

All readings above are from the tree at 92d273fe8.

Generated by Claude Code


Generated by Claude Code

… not a finding's 1
Every refusal in this gate reached node's uncaught handler, which exits 1 --
the code the gate reserves for "a package's recorded debt went UP", whose
prescribed remedy ends at a ledger only a maintainer may raise. So an
unmeasurable run pointed the reader at the one place that evidence must never
send them, and anything reading exit codes rather than prose could not tell the
two apart at all.
Routes all eleven refusal sites through a new refusePrerequisite(), which
prints the raising site's message VERBATIM under a PREREQUISITE NOT MET frame
and exits 3 -- the code check-test-completeness.mjs states in its own failure
text and check-dual-build-cjs-loads.mjs already answers the identical
"reads built output, tree has no dist/" condition with. Exit 1 now means only a
finding: a structural problem, or a ledger entry that drifted upward. A
tsconfig.json checked into the tree that does not parse stays a finding.
The self-test pins the class over the refusing FUNCTION BODIES rather than over
a constant, because the regression shape is one careless `throw new Error(`
added by an author not thinking about exit codes -- invisible to every consumer,
all of which treat any non-zero as failure.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pk26oZ12t5N1hwGW1m1MgC
@claude

claudeBot commented Aug 31, 2026

Copy link
Copy Markdown
ContributorAuthor

ACCEPTdomain:devx execution PM seat (#6023), session session_01Pk26oZ12t5N1hwGW1m1MgC. Every number below re-derived by this seat at head 92d273fe8, ⛔ not taken from the report.

⛔ My Zone 2 C count was wrong, and it was wrong in the direction that matters

I handed the dev the card's single line reference — measureLedgers … check-type-check-coverage.mjs:3231:11 — as the refusal site. The file refuses in eleven places, across four functions: nine on the --re-measure path and two in the observation half that both invocations run. Every one of them exited 1 by reaching node's uncaught handler.

I re-counted independently at the head sha rather than accepting the number — 11 call sites, at 1637 · 1644 (gitIgnoredPaths), 2808 · 2812 · 2815 (refreshBuiltClosure), 3036 · 3048 · 3051 · 3063 (tscErrorCount), 3345 · 3379 (measureLedgers).

⇒ Scoped to my count, ten of eleven refusals would still exit 1 — and two of those ten fire on the default invocation, so the card's own defect would have survived on the path most callers take. ⭐ Falsified in the safe direction, with counts. That is the result I ask for.

⭐⭐ The negative control — the best thing in this PR, and nobody asked for it

The self-test pins that the four refusing functions contain no bare throw new Error(. A pin like that is a tautology if the regex cannot fire at all — and it would pass forever while measuring nothing.

So the dev made it a measurement: readTsconfigstill throws on purpose, and the self-test asserts that it does, with the reason stated inline — a malformed tsconfig.json checked into the tree is a fact about the tree, which is what a finding is. Verified here: exactly one surviving throw new Error( inside readTsconfig at the head sha.

⇒ This is the lane's instrument rule — an absence is not a reading until the instrument is known to be able to report presence — applied to the dev's own pin, unprompted. ⛔ Not decoration: without it, a later refactor that removed every bare throw in the file would leave the pin green and blind.

check:entry-guard caught the delivery's own defect — second PR in a row

The dev's first cut wrote export const EXIT_*. The guard refused: exporting any binding from a file whose top level runs makes the whole gate execute inside the importer. Verified: zeroexport bindings in the file at the head sha, and the reason is written into the source rather than left as tribal knowledge.

⇒ Two consecutive deliveries where running the derived family found a defect in the delivery, not in the tree. ⛔ Running the family is not ceremony, and this is the evidence.

The behavioural repair the exit path forced — and I tested the assumption under it

process.exit() runs exit handlers and does not run finally. So making tscErrorCount refuse would have leaked measureTestDebt's temp directory on every refusal. Cleanup is now registered on exitas well, with process.off first in the finally so a 29-entry run does not accumulate 29 live handlers on directories already removed.

⚠️ That last part rests on process.off being able to remove a once-registered listener. I did not take it on faith:

process.once('exit', f) -> listenerCount 1
process.off('exit', f) -> listenerCount 0

It holds. ⇒ The belt-and-braces shape is sound in both directions: a refusal exits through the handler, a normal return exits through the finally, and a bare throw is covered by the finally before the uncaught handler runs.

Propagation measured, ⛔ not assumed

pnpm check:type-check-debt on an unbuilt tree exits 3 ⇒ the package.json&& chain and pnpm both propagate it. ⭐ This is the reading that decides whether the change is worth anything at all: an exit code that dies at the wrapper changes nothing for the CI step, the agent, or the reader it was written for.

Both directions — and the finding side shown TWICE, in two code paths

  • Refusal: --re-measure on an unbuilt worktree — EXIT=1 before (stack trace at measureLedgers …:3231:11), EXIT=3 after, message byte-identical (2424 bytes, compare equal).
  • A real finding still exits 1, demonstrated in each half: the re-measure half (forced stale-heap-ceiling finding, EXIT=1) and the structural half (one package's typecheck script removed, "20 problem(s)", EXIT=1).
  • A full real measurement still passes: 70/70 closure build, then 29 ledger entries re-measured in 334.1s, 1531 raw tsc errors, none above its recorded number, EXIT=0.

⭐ Showing the finding path in two independent code paths is what makes "1 still means a finding" a measurement rather than a spot check. Exit codes captured into files before any pipe, ⛔ never as $? after a tail — which is the exact misreading the new refusal text warns about.

The boundary is drawn, ⛔ not defaulted

Exit 3 is for a prerequisite the world failed to supply and the caller clears with a named command (unbuilt or stale closure, a closure that does not build, an absent turbo/tsc, a tsc that could not spawn or read its project). A malformed tsconfig.jsonchecked into the tree stays exit 1. The line is argued in the docblock, not merely implemented — and it is the same line the negative control pins.

Triage question 2 — answered by measurement, not by symmetry

check:type-check-coverage shares the treatment for exactly the two refusals it actually shares (gitIgnoredPaths); its structural half reads only checked-in files and has no prerequisite branch of its own. ⇒ ⛔ No symmetry-driven edit to a path that has no such state.

Gates and hygiene

16 derived, comm -23 empty, plus check:ratchet-remedy-authority (mandated, readdirSync surface — not path-derivable) and check:nul-bytes, both 0. Repo-wide eslint . --no-inline-config run in full rather than narrowed: 5597 files, 0 errors, 0 warnings.

⭐ Worth recording: the one non-zero in the family was check-test-completeness.mjsEXIT=3 — PREREQUISITE NOT MET, recorded as NOT MEASURED, ⛔ never as a pass. The gate family's own report used the very class this PR is about, correctly, in the same run that shipped it.

Ablation proved on disk by a single-hit-asserted replace and by the blob hash moving e2a91d7d22a12247; the defect reproduced (self-test red and the real run back to exit 1); restore proved by git hash-object matching the HEAD blob and an empty git diff HEAD, ⛔ not by a trap firing.

skip-changeset applied by the dev, additively. Verified correct: the diff is a singlescripts/** file, which matches no workspace publish glob, and the root package is private.

⛔ One bounded residue — recorded here, deliberately NOT filed

REFUSING is a hand-written list of the four functions that refuse. The pin is asymmetric: if one of them stops routing through refusePrerequisite() the self-test fails, but a fifth function that starts refusing is not pinned at all — which is this card's own class returning under a different name.

⇒ Recorded, ⛔ not filed: today all 11 sites live in exactly those 4 functions (verified above), the file is single-purpose, and the truth is one grep away. If a fifth ever appears, this paragraph is the card. ⚠️ Whoever disagrees should file it — this is a judgement, not a measurement.

The out-of-scope finding was filed, and the restraint was right

#13983scripts/import-prerequisite.mjs, the shared PREREQUISITE NOT MET frame that 20+ root gates inherit, exits 1, deliberately and with its reasoning stated in the source, while three siblings now answer the same words with 3. ⛔ Not patched: the current behaviour is an argued decision, and picking the winning number is a fleet-wide convention call, ⛔ not this card's.

⚠️ ⇒ This PR therefore leaves two conventions in the open. That is honest, and it is better than a rider that unified them without a ruling. The divergence now has a card instead of being silent.

Governed-surface check

Diff: one file, scripts/check-type-check-coverage.mjs. ⛔ No hit on docs/adr/** · .claude/** · skills/** · AGENTS.md · CLAUDE.mdthis seat may arm it.


Generated by Claude Code

@os-project-manager
os-project-manager marked this pull request as ready for review August 31, 2026 22:09
@os-project-manager
os-project-manager added this pull request to the merge queueAug 31, 2026
Merged via the queue into main with commit c0770d0Aug 31, 2026
34 checks passed
@os-project-manager
os-project-manager deleted the claude/issue-13885-type-check-debt-exit-code branch August 31, 2026 22:30
os-project-manager pushed a commit that referenced this pull request Aug 31, 2026
…ne stroke with its advisory
`scripts/import-prerequisite.mjs` is the shared `PREREQUISITE NOT MET` frame
that 45 root and package gates import. It exited 1 and told every one of them
so in the inherited advisory, while `check-test-completeness.mjs`,
`check-dual-build-cjs-loads.mjs`, `check-type-check-coverage.mjs` (PR #13982)
and `pm/check-half-states.mjs` answer the same two words with 3 — a class
`half-state-patrol.yml` already reads by number.
The exit code and the printed `(Exit code …)` advisory move together: changing
only the number would leave 45 gates inheriting a false advisory, which is
worse than either number applied consistently.
Nothing mechanical changes today, and the change is not sold as if it did:
every consumer of these gates treats any non-zero as failure — the `&&` chains
in the root package.json, the bare `run:` steps in lint.yml/ci.yml, and
required-set-patrol.yml, which branches on `== '0'` / `!= '0'` and nothing
finer. The refusal itself, its wording, and a real verdict's exit 1 are all
untouched.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pk26oZ12t5N1hwGW1m1MgC
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 1, 2026
…ne stroke with its advisory (objectstack-ai#14009)
`scripts/import-prerequisite.mjs` is the shared `PREREQUISITE NOT MET` frame
that 45 root and package gates import. It exited 1 and told every one of them
so in the inherited advisory, while `check-test-completeness.mjs`,
`check-dual-build-cjs-loads.mjs`, `check-type-check-coverage.mjs` (PR objectstack-ai#13982)
and `pm/check-half-states.mjs` answer the same two words with 3 — a class
`half-state-patrol.yml` already reads by number.
The exit code and the printed `(Exit code …)` advisory move together: changing
only the number would leave 45 gates inheriting a false advisory, which is
worse than either number applied consistently.
Nothing mechanical changes today, and the change is not sold as if it did:
every consumer of these gates treats any non-zero as failure — the `&&` chains
in the root package.json, the bare `run:` steps in lint.yml/ci.yml, and
required-set-patrol.yml, which branches on `== '0'` / `!= '0'` and nothing
finer. The refusal itself, its wording, and a real verdict's exit 1 are all
untouched.
Claude-Session: https://claude.ai/code/session_01Pk26oZ12t5N1hwGW1m1MgC
Co-authored-by: Claude <noreply@anthropic.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/mskip-changesetPR has no user-facing published change; bypasses the changeset gate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

check:type-check-debt signals "closure not built, nothing measured" with exit 1, the code the repo reserves for a real finding

2 participants

@os-project-manager@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

Answer every check-type-check-coverage refusal with exit 3, not a finding's 1 - #13982

Merged
os-project-manager merged 1 commit into
mainfrom
claude/issue-13885-type-check-debt-exit-code
Aug 31, 2026
Merged

Answer every check-type-check-coverage refusal with exit 3, not a finding's 1#13982
os-project-manager merged 1 commit into
mainfrom
claude/issue-13885-type-check-debt-exit-code

Conversation

@claude

@claudeclaudeBot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Fixes#13885

check:type-check-debt, run where the workspace type closure is not built, refuses to measure — correctly — and signalled that refusal by throwing. An uncaught throw reaches node's default handler, which exits 1: the code this gate reserves for "a package's recorded debt went UP", whose prescribed remedy ends at a DEBT/TEST_DEBT ledger only a maintainer may raise. So the one result that is not evidence about the ledger pointed the reader straight at it, and anything reading exit codes rather than prose could not tell the two apart at all.

The refusal, its message, its reasoning and when it fires are all unchanged. Only the class changed.

Re-derived line reference

The card cites measureLedgers ... check-type-check-coverage.mjs:3231. Re-derived on origin/main at aee1fd9ec, that is exact — reproduced verbatim from a real run:

 at measureLedgers (file:///home/user/objectstack-13885/scripts/check-type-check-coverage.mjs:3231:11)

After this change the same refusal is at scripts/check-type-check-coverage.mjs:3345.

It is not ONE branch — it is eleven (Zone 2 C, re-derived)

The PM's count was one. Measured, this file refuses to measure in eleven places, and every one of them exited 1. They now all route through a single refusePrerequisite() and exit 3.

lineconditionremedy the message already named
3345ledgered closure has unbuilt type entry points — the card's branchbuild the closure
3379closure still stale after a full buildbuild the named package
2808no workspace turbo binarypnpm install
2812the closure build could not be spawnedenvironment
2815the closure does not buildfix the build
3036no workspace tsc binarypnpm install
3048tsc could not be spawnedenvironment
3051tsc could not read the project — "the measurement is invalid, not zero"fix the project
3063tsc failed in a way the parser cannot see — "refusing to record 0"read the output
1637git check-ignore could not be spawnedenvironment
1644git check-ignore exited unexpectedlyenvironment

Every one of those eleven messages is embedded byte-identically in the new output. Measured on the card's own branch: the refusal text before the change and after it are both 2424 bytes and compare equal.

The boundary, and what deliberately did NOT move.readTsconfig (line 1499) still throws for a tsconfig.json checked into the tree that does not parse. That is a fact about the tree, which is what a finding is — the same line check-test-completeness.mjs draws when it keeps a malformed command line at 1 while a missing input gets 3. Exit 1 from this gate now means only: a structural problem, a ledger entry that drifted upward, or a stale heap-ceiling pin.

Triage question 2 — does check:type-check-coverage share the treatment?

Answered rather than assumed, because the two invocations are one file. Yes, for the two refusals it actually shares (rows 1637 and 1644 above, in the observation half both invocations run) and no further: the structural half reads only package.json and tsconfig.json files that are checked in, so it has no prerequisite branch of its own to reclassify. Its non-zero stays a claim about the tree.

Zone 2 A — the consumer enumeration, and what each does with 1 vs 3

Searched: every file under .github/workflows/, every file under scripts/, and package.json. No consumer anywhere special-cases exit 1, and none would now read 3 as success.

consumerhow it invokeswith 1with 3
lint.yml, job Type Check · debt ledger, step "Re-measure the type-check DEBT / TEST_DEBT ledger"bare run: pnpm check:type-check-debt — no continue-on-error, no if:, no wrapperstep failsstep fails — unchanged. And the step before it builds the closure, so CI does not reach the branch at all
lint.yml, job Type Check · source gates, step "Check every package is type-check covered or ledgered"bare run: pnpm check:type-check-coveragestep failsstep fails — unchanged
package.json scripts (both)node ... --self-test && node ...; the && chain yields the last command's status and pnpm propagates itpropagates 1propagates 3 — measured, see below
scripts/pm/os-verify-lock.shwraps a command, prints VERDICT command-exit N, exits Nnon-zeronon-zero, and now distinguishable in the VERDICT line. Its own self-test already pins the 3 case (sh -c "exit 3" yields VERDICT command-exit 3)
scripts/pm/dispatch-gates.mjsnames both families; never spawns them — its only spawnSync targets are git and itselfno couplingno coupling
a human or agent running the derived gate familyreads the numberreads as a FINDING and is routed to the maintainer-only ledger — the defectreads as NOT MEASURED

Measured propagation through the pnpm layer, on an unbuilt worktree:

$ pnpm check:type-check-debt > pnpm-debt.log 2>&1; echo "EXIT=$?"
EXIT=3
$ grep -c "PREREQUISITE NOT MET" pnpm-debt.log
1

Direction of error: nothing regressed toward green. GitHub Actions fails a run: step on any non-zero, so both CI consumers are byte-identical in behaviour. No .github/workflows/** change is needed and none is made.

Which precedent, and why

scripts/check-test-completeness.mjs. It is the more argued of the two: it carries a full "Exit codes — and why a REFUSAL has its own one" header, it names its codes as constants so its self-test pins the value each path returns rather than a comment about it, and — the part that decided where to stop here — it states a boundary out loud: exit 3 is for an input the world failed to supply; a malformed command line, which is the caller's own typo, stays 1.

scripts/check-dual-build-cjs-loads.mjs agrees on 3 for the identical condition and contributed the "⛔ This is NOT a pass: nothing was measured" sentence shape. It also carries a third code (2, for "the run read almost nothing"); this gate has no analogue and did not adopt one.

A third data point, reported rather than changed.scripts/import-prerequisite.mjs — the shared frame that 20-plus root gates use for "can this gate load the package it imports?" — prints PREREQUISITE NOT MET and exits 1, deliberately, with its reasoning stated at the function ("a second failure code would be a new contract nobody asked for"). That is a documented decision, not an oversight, and it is out of this card's scope; it is filed separately so the tension is triaged rather than silently deepened by this PR.

Both directions, demonstrated

Prerequisite branch, on an unbuilt worktree, before and after:

before $ node scripts/check-type-check-coverage.mjs --re-measure ; echo $?
Error: --re-measure cannot run: 56 workspace dependenc(ies) ...
at measureLedgers (scripts/check-type-check-coverage.mjs:3231:11)
1
after $ node scripts/check-type-check-coverage.mjs --re-measure ; echo $?
check-type-check-coverage: PREREQUISITE NOT MET
--re-measure cannot run: 56 workspace dependenc(ies) ... (byte-identical, 2424 bytes)
⛔ This is NOT a pass and NOT a finding: nothing was measured ...
(Exit code 3, distinct from a finding's 1 — capture it BEFORE any pipe ...)
3

A real finding still exits 1, shown twice — once in each half of the gate, so the claim is not carried by one code path:

re-measure half — a FINDING inside the --re-measure block (the pinned CI heap
ceiling sits above the runner's own default):
$ GITHUB_ACTIONS=true NODE_OPTIONS=--max-old-space-size=2048 \
node scripts/check-type-check-coverage.mjs --re-measure ; echo $?
::error::... pins a CI heap ceiling of 4096 MB, but THIS CI runner's own default is 2096 MB ...
1
structural half — one package's `typecheck` script removed, then restored:
$ node scripts/check-type-check-coverage.mjs ; echo $?
check-type-check-coverage: 20 problem(s)
1

That mutation was made against the committed tree and restored with git checkout HEAD -- packages/spec/package.json; the restore is proven by an empty git diff HEAD and by git hash-object matching the HEAD blob (0db5f14e...), not by the command's exit code.

And a full, real measurement still passes end to end — the closure built, the whole ledger re-measured:

$ pnpm exec turbo run build --filter='./packages/*' --filter='./packages/*/*' # 70/70, 4m29s
$ pnpm check:type-check-debt ; echo $?
check-type-check-coverage --re-measure: OK — 29 ledger entr(ies) re-measured in 334.1s,
1531 raw tsc error(s) total, none above its recorded number.
surplus: none — every entry sits exactly at its measurement, so any new error is red.
0

⛔ Not one ledger entry was touched. The re-measure above reads the ledger exactly as main records it.

How the class is pinned

18 new cases in --self-test, which both package.json scripts run on every invocation. The load-bearing one is not a pin over a constant:

constREFUSING=[refreshBuiltClosure,tscErrorCount,measureLedgers,gitIgnoredPaths];for(constfnofREFUSING){constbody=fn.toString();if(/thrownewError\(/.test(body)){ ... }if(!body.includes('refusePrerequisite(')){ ... }}

It reads the four refusing functions' real bodies, because the regression shape is not a wrong number typed anywhere — it is one careless throw new Error(...) added by an author who never thought about exit codes, and it announces itself with a green CI (every consumer above treats any non-zero as failure, so 1-instead-of-3 is invisible to all of them). readTsconfig is the negative control: it still throws on purpose, so the pin has something to see and cannot pass by looking at an empty set.

The pin was ablated rather than assumed. Reverting the card's own branch to throw new Error( and rebuilding nothing (this is a source-run script):

$ node scripts/check-type-check-coverage.mjs --self-test ; echo $?
✗ check:type-check-coverage --self-test — 1 failure(s)
• measureLedgers raises a bare `throw new Error(` — an uncaught throw exits 1, the code
this gate reserves for a ledger entry that drifted UPWARD. A refusal must go through
refusePrerequisite() so it exits 3.
1
$ node scripts/check-type-check-coverage.mjs --re-measure ; echo $?
1 # the defect, reproduced

The mutation was confirmed on disk by a str.replace whose single-hit count was asserted before writing and by the blob hash changing (e2a91d7d to 22a12247); the restore is proven by git hash-object returning the HEAD blob again, not by a trap firing.

The other 17 cases pin the constants (3 is the refusal code, 1 is a finding, they are distinct from each other and from 0) and six clauses of the refusal text — including that it carries the raising site's message verbatim and that it turns the reader away from the ledger.

One behavioural repair the exit path required

measureTestDebt created a temp directory outside the repository and removed it in a finally. A finally does not run on process.exit, so a refusal raised from inside tscErrorCount would have leaked one. The directory is now also registered on exit and de-registered in the finally, so both paths clean up. Verified after the full 29-entry re-measure above: zero objectstack-debt-remeasure-* directories left behind.

Changeset

None, deliberately — skip-changeset instead. This PR changes one file under scripts/, publishes nothing from any package, and alters no runtime behaviour any consumer of a published package can observe. Precedent on main is uniform: script-only landings carry no changeset (for example 2aef62eba fix(scripts): let check-role-word skip machine-generated REGIONS and 010518bb1 docs(pm): express the self-test's size as a floor — one file each, zero changesets). The Check Changeset job has no path exemption, so the label is the mechanism, and it is applied on this PR rather than left for the gate to go red first.

Gates: derived vs run

Derived with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack at the final commit 92d273fe8 — 16 families (14 by path, 2 by the "adds or edits a GATE SCRIPT" change kind). comm -23 of derived against run is empty. Two were run beyond the derived set on purpose: check:ratchet-remedy-authority, which builds its scan surface with readdirSync and so cannot be path-derived, and check:nul-bytes.

check:agent-test-spelling EXIT=0
check:bash32-floor EXIT=0
check:cli-command-ids EXIT=0
check:cross-package-test-inputs EXIT=0
check:entry-guard EXIT=0
check:parse-guard EXIT=0
check:pnpm-filter-targets EXIT=0
check:watch-hint-literal EXIT=0
check:type-check-coverage EXIT=0
check:type-check-debt EXIT=0 (closure built; 29 entries re-measured)
check:pm-dispatch-gates EXIT=0
node scripts/check-ci-filter-parity.mjs EXIT=0
node scripts/check-cross-package-test-inputs.mjs EXIT=0
node scripts/check-shard-attestation.mjs EXIT=0
node scripts/check-test-completeness.mjs EXIT=3 NOT MEASURED, not a failure
node scripts/pm/bare-root-worklist.mjs --self-test EXIT=0
check:ratchet-remedy-authority EXIT=0 (mandated; not path-derivable)
check:nul-bytes EXIT=0

Every exit code above was captured before any pipe, into a file, never as $? after a tail.

check-test-completeness answering 3 is the convention this card is about, working: the derived family hands out the bare invocation, which has no log to grade, so the gate refuses. Its own header documents that exact interaction. It is NOT MEASURED, and it is unrelated to this diff.

Repo-wide ESLint was run in full rather than narrowed — eslint . --no-inline-config, 5597 files, 0 errors, 0 warnings, 99s under the shared verification lock.

All readings above are from the tree at 92d273fe8.

Generated by Claude Code


Generated by Claude Code

… not a finding's 1
Every refusal in this gate reached node's uncaught handler, which exits 1 --
the code the gate reserves for "a package's recorded debt went UP", whose
prescribed remedy ends at a ledger only a maintainer may raise. So an
unmeasurable run pointed the reader at the one place that evidence must never
send them, and anything reading exit codes rather than prose could not tell the
two apart at all.
Routes all eleven refusal sites through a new refusePrerequisite(), which
prints the raising site's message VERBATIM under a PREREQUISITE NOT MET frame
and exits 3 -- the code check-test-completeness.mjs states in its own failure
text and check-dual-build-cjs-loads.mjs already answers the identical
"reads built output, tree has no dist/" condition with. Exit 1 now means only a
finding: a structural problem, or a ledger entry that drifted upward. A
tsconfig.json checked into the tree that does not parse stays a finding.
The self-test pins the class over the refusing FUNCTION BODIES rather than over
a constant, because the regression shape is one careless `throw new Error(`
added by an author not thinking about exit codes -- invisible to every consumer,
all of which treat any non-zero as failure.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pk26oZ12t5N1hwGW1m1MgC
@claude

claudeBot commented Aug 31, 2026

Copy link
Copy Markdown
ContributorAuthor

ACCEPTdomain:devx execution PM seat (#6023), session session_01Pk26oZ12t5N1hwGW1m1MgC. Every number below re-derived by this seat at head 92d273fe8, ⛔ not taken from the report.

⛔ My Zone 2 C count was wrong, and it was wrong in the direction that matters

I handed the dev the card's single line reference — measureLedgers … check-type-check-coverage.mjs:3231:11 — as the refusal site. The file refuses in eleven places, across four functions: nine on the --re-measure path and two in the observation half that both invocations run. Every one of them exited 1 by reaching node's uncaught handler.

I re-counted independently at the head sha rather than accepting the number — 11 call sites, at 1637 · 1644 (gitIgnoredPaths), 2808 · 2812 · 2815 (refreshBuiltClosure), 3036 · 3048 · 3051 · 3063 (tscErrorCount), 3345 · 3379 (measureLedgers).

⇒ Scoped to my count, ten of eleven refusals would still exit 1 — and two of those ten fire on the default invocation, so the card's own defect would have survived on the path most callers take. ⭐ Falsified in the safe direction, with counts. That is the result I ask for.

⭐⭐ The negative control — the best thing in this PR, and nobody asked for it

The self-test pins that the four refusing functions contain no bare throw new Error(. A pin like that is a tautology if the regex cannot fire at all — and it would pass forever while measuring nothing.

So the dev made it a measurement: readTsconfigstill throws on purpose, and the self-test asserts that it does, with the reason stated inline — a malformed tsconfig.json checked into the tree is a fact about the tree, which is what a finding is. Verified here: exactly one surviving throw new Error( inside readTsconfig at the head sha.

⇒ This is the lane's instrument rule — an absence is not a reading until the instrument is known to be able to report presence — applied to the dev's own pin, unprompted. ⛔ Not decoration: without it, a later refactor that removed every bare throw in the file would leave the pin green and blind.

check:entry-guard caught the delivery's own defect — second PR in a row

The dev's first cut wrote export const EXIT_*. The guard refused: exporting any binding from a file whose top level runs makes the whole gate execute inside the importer. Verified: zeroexport bindings in the file at the head sha, and the reason is written into the source rather than left as tribal knowledge.

⇒ Two consecutive deliveries where running the derived family found a defect in the delivery, not in the tree. ⛔ Running the family is not ceremony, and this is the evidence.

The behavioural repair the exit path forced — and I tested the assumption under it

process.exit() runs exit handlers and does not run finally. So making tscErrorCount refuse would have leaked measureTestDebt's temp directory on every refusal. Cleanup is now registered on exitas well, with process.off first in the finally so a 29-entry run does not accumulate 29 live handlers on directories already removed.

⚠️ That last part rests on process.off being able to remove a once-registered listener. I did not take it on faith:

process.once('exit', f) -> listenerCount 1
process.off('exit', f) -> listenerCount 0

It holds. ⇒ The belt-and-braces shape is sound in both directions: a refusal exits through the handler, a normal return exits through the finally, and a bare throw is covered by the finally before the uncaught handler runs.

Propagation measured, ⛔ not assumed

pnpm check:type-check-debt on an unbuilt tree exits 3 ⇒ the package.json&& chain and pnpm both propagate it. ⭐ This is the reading that decides whether the change is worth anything at all: an exit code that dies at the wrapper changes nothing for the CI step, the agent, or the reader it was written for.

Both directions — and the finding side shown TWICE, in two code paths

  • Refusal: --re-measure on an unbuilt worktree — EXIT=1 before (stack trace at measureLedgers …:3231:11), EXIT=3 after, message byte-identical (2424 bytes, compare equal).
  • A real finding still exits 1, demonstrated in each half: the re-measure half (forced stale-heap-ceiling finding, EXIT=1) and the structural half (one package's typecheck script removed, "20 problem(s)", EXIT=1).
  • A full real measurement still passes: 70/70 closure build, then 29 ledger entries re-measured in 334.1s, 1531 raw tsc errors, none above its recorded number, EXIT=0.

⭐ Showing the finding path in two independent code paths is what makes "1 still means a finding" a measurement rather than a spot check. Exit codes captured into files before any pipe, ⛔ never as $? after a tail — which is the exact misreading the new refusal text warns about.

The boundary is drawn, ⛔ not defaulted

Exit 3 is for a prerequisite the world failed to supply and the caller clears with a named command (unbuilt or stale closure, a closure that does not build, an absent turbo/tsc, a tsc that could not spawn or read its project). A malformed tsconfig.jsonchecked into the tree stays exit 1. The line is argued in the docblock, not merely implemented — and it is the same line the negative control pins.

Triage question 2 — answered by measurement, not by symmetry

check:type-check-coverage shares the treatment for exactly the two refusals it actually shares (gitIgnoredPaths); its structural half reads only checked-in files and has no prerequisite branch of its own. ⇒ ⛔ No symmetry-driven edit to a path that has no such state.

Gates and hygiene

16 derived, comm -23 empty, plus check:ratchet-remedy-authority (mandated, readdirSync surface — not path-derivable) and check:nul-bytes, both 0. Repo-wide eslint . --no-inline-config run in full rather than narrowed: 5597 files, 0 errors, 0 warnings.

⭐ Worth recording: the one non-zero in the family was check-test-completeness.mjsEXIT=3 — PREREQUISITE NOT MET, recorded as NOT MEASURED, ⛔ never as a pass. The gate family's own report used the very class this PR is about, correctly, in the same run that shipped it.

Ablation proved on disk by a single-hit-asserted replace and by the blob hash moving e2a91d7d22a12247; the defect reproduced (self-test red and the real run back to exit 1); restore proved by git hash-object matching the HEAD blob and an empty git diff HEAD, ⛔ not by a trap firing.

skip-changeset applied by the dev, additively. Verified correct: the diff is a singlescripts/** file, which matches no workspace publish glob, and the root package is private.

⛔ One bounded residue — recorded here, deliberately NOT filed

REFUSING is a hand-written list of the four functions that refuse. The pin is asymmetric: if one of them stops routing through refusePrerequisite() the self-test fails, but a fifth function that starts refusing is not pinned at all — which is this card's own class returning under a different name.

⇒ Recorded, ⛔ not filed: today all 11 sites live in exactly those 4 functions (verified above), the file is single-purpose, and the truth is one grep away. If a fifth ever appears, this paragraph is the card. ⚠️ Whoever disagrees should file it — this is a judgement, not a measurement.

The out-of-scope finding was filed, and the restraint was right

#13983scripts/import-prerequisite.mjs, the shared PREREQUISITE NOT MET frame that 20+ root gates inherit, exits 1, deliberately and with its reasoning stated in the source, while three siblings now answer the same words with 3. ⛔ Not patched: the current behaviour is an argued decision, and picking the winning number is a fleet-wide convention call, ⛔ not this card's.

⚠️ ⇒ This PR therefore leaves two conventions in the open. That is honest, and it is better than a rider that unified them without a ruling. The divergence now has a card instead of being silent.

Governed-surface check

Diff: one file, scripts/check-type-check-coverage.mjs. ⛔ No hit on docs/adr/** · .claude/** · skills/** · AGENTS.md · CLAUDE.mdthis seat may arm it.


Generated by Claude Code

@os-project-manager
os-project-manager marked this pull request as ready for review August 31, 2026 22:09
@os-project-manager
os-project-manager added this pull request to the merge queueAug 31, 2026
Merged via the queue into main with commit c0770d0Aug 31, 2026
34 checks passed
@os-project-manager
os-project-manager deleted the claude/issue-13885-type-check-debt-exit-code branch August 31, 2026 22:30
os-project-manager pushed a commit that referenced this pull request Aug 31, 2026
…ne stroke with its advisory
`scripts/import-prerequisite.mjs` is the shared `PREREQUISITE NOT MET` frame
that 45 root and package gates import. It exited 1 and told every one of them
so in the inherited advisory, while `check-test-completeness.mjs`,
`check-dual-build-cjs-loads.mjs`, `check-type-check-coverage.mjs` (PR #13982)
and `pm/check-half-states.mjs` answer the same two words with 3 — a class
`half-state-patrol.yml` already reads by number.
The exit code and the printed `(Exit code …)` advisory move together: changing
only the number would leave 45 gates inheriting a false advisory, which is
worse than either number applied consistently.
Nothing mechanical changes today, and the change is not sold as if it did:
every consumer of these gates treats any non-zero as failure — the `&&` chains
in the root package.json, the bare `run:` steps in lint.yml/ci.yml, and
required-set-patrol.yml, which branches on `== '0'` / `!= '0'` and nothing
finer. The refusal itself, its wording, and a real verdict's exit 1 are all
untouched.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pk26oZ12t5N1hwGW1m1MgC
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 1, 2026
…ne stroke with its advisory (objectstack-ai#14009)
`scripts/import-prerequisite.mjs` is the shared `PREREQUISITE NOT MET` frame
that 45 root and package gates import. It exited 1 and told every one of them
so in the inherited advisory, while `check-test-completeness.mjs`,
`check-dual-build-cjs-loads.mjs`, `check-type-check-coverage.mjs` (PR objectstack-ai#13982)
and `pm/check-half-states.mjs` answer the same two words with 3 — a class
`half-state-patrol.yml` already reads by number.
The exit code and the printed `(Exit code …)` advisory move together: changing
only the number would leave 45 gates inheriting a false advisory, which is
worse than either number applied consistently.
Nothing mechanical changes today, and the change is not sold as if it did:
every consumer of these gates treats any non-zero as failure — the `&&` chains
in the root package.json, the bare `run:` steps in lint.yml/ci.yml, and
required-set-patrol.yml, which branches on `== '0'` / `!= '0'` and nothing
finer. The refusal itself, its wording, and a real verdict's exit 1 are all
untouched.
Claude-Session: https://claude.ai/code/session_01Pk26oZ12t5N1hwGW1m1MgC
Co-authored-by: Claude <noreply@anthropic.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/mskip-changesetPR has no user-facing published change; bypasses the changeset gate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

check:type-check-debt signals "closure not built, nothing measured" with exit 1, the code the repo reserves for a real finding

2 participants

@os-project-manager@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Answer every check-type-check-coverage refusal with exit 3, not a finding's 1 - #13982

Merged
os-project-manager merged 1 commit into
mainfrom
claude/issue-13885-type-check-debt-exit-code
Aug 31, 2026
Merged

Answer every check-type-check-coverage refusal with exit 3, not a finding's 1#13982
os-project-manager merged 1 commit into
mainfrom
claude/issue-13885-type-check-debt-exit-code

Conversation

@claude

@claudeclaudeBot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Fixes#13885

check:type-check-debt, run where the workspace type closure is not built, refuses to measure — correctly — and signalled that refusal by throwing. An uncaught throw reaches node's default handler, which exits 1: the code this gate reserves for "a package's recorded debt went UP", whose prescribed remedy ends at a DEBT/TEST_DEBT ledger only a maintainer may raise. So the one result that is not evidence about the ledger pointed the reader straight at it, and anything reading exit codes rather than prose could not tell the two apart at all.

The refusal, its message, its reasoning and when it fires are all unchanged. Only the class changed.

Re-derived line reference

The card cites measureLedgers ... check-type-check-coverage.mjs:3231. Re-derived on origin/main at aee1fd9ec, that is exact — reproduced verbatim from a real run:

 at measureLedgers (file:///home/user/objectstack-13885/scripts/check-type-check-coverage.mjs:3231:11)

After this change the same refusal is at scripts/check-type-check-coverage.mjs:3345.

It is not ONE branch — it is eleven (Zone 2 C, re-derived)

The PM's count was one. Measured, this file refuses to measure in eleven places, and every one of them exited 1. They now all route through a single refusePrerequisite() and exit 3.

lineconditionremedy the message already named
3345ledgered closure has unbuilt type entry points — the card's branchbuild the closure
3379closure still stale after a full buildbuild the named package
2808no workspace turbo binarypnpm install
2812the closure build could not be spawnedenvironment
2815the closure does not buildfix the build
3036no workspace tsc binarypnpm install
3048tsc could not be spawnedenvironment
3051tsc could not read the project — "the measurement is invalid, not zero"fix the project
3063tsc failed in a way the parser cannot see — "refusing to record 0"read the output
1637git check-ignore could not be spawnedenvironment
1644git check-ignore exited unexpectedlyenvironment

Every one of those eleven messages is embedded byte-identically in the new output. Measured on the card's own branch: the refusal text before the change and after it are both 2424 bytes and compare equal.

The boundary, and what deliberately did NOT move.readTsconfig (line 1499) still throws for a tsconfig.json checked into the tree that does not parse. That is a fact about the tree, which is what a finding is — the same line check-test-completeness.mjs draws when it keeps a malformed command line at 1 while a missing input gets 3. Exit 1 from this gate now means only: a structural problem, a ledger entry that drifted upward, or a stale heap-ceiling pin.

Triage question 2 — does check:type-check-coverage share the treatment?

Answered rather than assumed, because the two invocations are one file. Yes, for the two refusals it actually shares (rows 1637 and 1644 above, in the observation half both invocations run) and no further: the structural half reads only package.json and tsconfig.json files that are checked in, so it has no prerequisite branch of its own to reclassify. Its non-zero stays a claim about the tree.

Zone 2 A — the consumer enumeration, and what each does with 1 vs 3

Searched: every file under .github/workflows/, every file under scripts/, and package.json. No consumer anywhere special-cases exit 1, and none would now read 3 as success.

consumerhow it invokeswith 1with 3
lint.yml, job Type Check · debt ledger, step "Re-measure the type-check DEBT / TEST_DEBT ledger"bare run: pnpm check:type-check-debt — no continue-on-error, no if:, no wrapperstep failsstep fails — unchanged. And the step before it builds the closure, so CI does not reach the branch at all
lint.yml, job Type Check · source gates, step "Check every package is type-check covered or ledgered"bare run: pnpm check:type-check-coveragestep failsstep fails — unchanged
package.json scripts (both)node ... --self-test && node ...; the && chain yields the last command's status and pnpm propagates itpropagates 1propagates 3 — measured, see below
scripts/pm/os-verify-lock.shwraps a command, prints VERDICT command-exit N, exits Nnon-zeronon-zero, and now distinguishable in the VERDICT line. Its own self-test already pins the 3 case (sh -c "exit 3" yields VERDICT command-exit 3)
scripts/pm/dispatch-gates.mjsnames both families; never spawns them — its only spawnSync targets are git and itselfno couplingno coupling
a human or agent running the derived gate familyreads the numberreads as a FINDING and is routed to the maintainer-only ledger — the defectreads as NOT MEASURED

Measured propagation through the pnpm layer, on an unbuilt worktree:

$ pnpm check:type-check-debt > pnpm-debt.log 2>&1; echo "EXIT=$?"
EXIT=3
$ grep -c "PREREQUISITE NOT MET" pnpm-debt.log
1

Direction of error: nothing regressed toward green. GitHub Actions fails a run: step on any non-zero, so both CI consumers are byte-identical in behaviour. No .github/workflows/** change is needed and none is made.

Which precedent, and why

scripts/check-test-completeness.mjs. It is the more argued of the two: it carries a full "Exit codes — and why a REFUSAL has its own one" header, it names its codes as constants so its self-test pins the value each path returns rather than a comment about it, and — the part that decided where to stop here — it states a boundary out loud: exit 3 is for an input the world failed to supply; a malformed command line, which is the caller's own typo, stays 1.

scripts/check-dual-build-cjs-loads.mjs agrees on 3 for the identical condition and contributed the "⛔ This is NOT a pass: nothing was measured" sentence shape. It also carries a third code (2, for "the run read almost nothing"); this gate has no analogue and did not adopt one.

A third data point, reported rather than changed.scripts/import-prerequisite.mjs — the shared frame that 20-plus root gates use for "can this gate load the package it imports?" — prints PREREQUISITE NOT MET and exits 1, deliberately, with its reasoning stated at the function ("a second failure code would be a new contract nobody asked for"). That is a documented decision, not an oversight, and it is out of this card's scope; it is filed separately so the tension is triaged rather than silently deepened by this PR.

Both directions, demonstrated

Prerequisite branch, on an unbuilt worktree, before and after:

before $ node scripts/check-type-check-coverage.mjs --re-measure ; echo $?
Error: --re-measure cannot run: 56 workspace dependenc(ies) ...
at measureLedgers (scripts/check-type-check-coverage.mjs:3231:11)
1
after $ node scripts/check-type-check-coverage.mjs --re-measure ; echo $?
check-type-check-coverage: PREREQUISITE NOT MET
--re-measure cannot run: 56 workspace dependenc(ies) ... (byte-identical, 2424 bytes)
⛔ This is NOT a pass and NOT a finding: nothing was measured ...
(Exit code 3, distinct from a finding's 1 — capture it BEFORE any pipe ...)
3

A real finding still exits 1, shown twice — once in each half of the gate, so the claim is not carried by one code path:

re-measure half — a FINDING inside the --re-measure block (the pinned CI heap
ceiling sits above the runner's own default):
$ GITHUB_ACTIONS=true NODE_OPTIONS=--max-old-space-size=2048 \
node scripts/check-type-check-coverage.mjs --re-measure ; echo $?
::error::... pins a CI heap ceiling of 4096 MB, but THIS CI runner's own default is 2096 MB ...
1
structural half — one package's `typecheck` script removed, then restored:
$ node scripts/check-type-check-coverage.mjs ; echo $?
check-type-check-coverage: 20 problem(s)
1

That mutation was made against the committed tree and restored with git checkout HEAD -- packages/spec/package.json; the restore is proven by an empty git diff HEAD and by git hash-object matching the HEAD blob (0db5f14e...), not by the command's exit code.

And a full, real measurement still passes end to end — the closure built, the whole ledger re-measured:

$ pnpm exec turbo run build --filter='./packages/*' --filter='./packages/*/*' # 70/70, 4m29s
$ pnpm check:type-check-debt ; echo $?
check-type-check-coverage --re-measure: OK — 29 ledger entr(ies) re-measured in 334.1s,
1531 raw tsc error(s) total, none above its recorded number.
surplus: none — every entry sits exactly at its measurement, so any new error is red.
0

⛔ Not one ledger entry was touched. The re-measure above reads the ledger exactly as main records it.

How the class is pinned

18 new cases in --self-test, which both package.json scripts run on every invocation. The load-bearing one is not a pin over a constant:

constREFUSING=[refreshBuiltClosure,tscErrorCount,measureLedgers,gitIgnoredPaths];for(constfnofREFUSING){constbody=fn.toString();if(/thrownewError\(/.test(body)){ ... }if(!body.includes('refusePrerequisite(')){ ... }}

It reads the four refusing functions' real bodies, because the regression shape is not a wrong number typed anywhere — it is one careless throw new Error(...) added by an author who never thought about exit codes, and it announces itself with a green CI (every consumer above treats any non-zero as failure, so 1-instead-of-3 is invisible to all of them). readTsconfig is the negative control: it still throws on purpose, so the pin has something to see and cannot pass by looking at an empty set.

The pin was ablated rather than assumed. Reverting the card's own branch to throw new Error( and rebuilding nothing (this is a source-run script):

$ node scripts/check-type-check-coverage.mjs --self-test ; echo $?
✗ check:type-check-coverage --self-test — 1 failure(s)
• measureLedgers raises a bare `throw new Error(` — an uncaught throw exits 1, the code
this gate reserves for a ledger entry that drifted UPWARD. A refusal must go through
refusePrerequisite() so it exits 3.
1
$ node scripts/check-type-check-coverage.mjs --re-measure ; echo $?
1 # the defect, reproduced

The mutation was confirmed on disk by a str.replace whose single-hit count was asserted before writing and by the blob hash changing (e2a91d7d to 22a12247); the restore is proven by git hash-object returning the HEAD blob again, not by a trap firing.

The other 17 cases pin the constants (3 is the refusal code, 1 is a finding, they are distinct from each other and from 0) and six clauses of the refusal text — including that it carries the raising site's message verbatim and that it turns the reader away from the ledger.

One behavioural repair the exit path required

measureTestDebt created a temp directory outside the repository and removed it in a finally. A finally does not run on process.exit, so a refusal raised from inside tscErrorCount would have leaked one. The directory is now also registered on exit and de-registered in the finally, so both paths clean up. Verified after the full 29-entry re-measure above: zero objectstack-debt-remeasure-* directories left behind.

Changeset

None, deliberately — skip-changeset instead. This PR changes one file under scripts/, publishes nothing from any package, and alters no runtime behaviour any consumer of a published package can observe. Precedent on main is uniform: script-only landings carry no changeset (for example 2aef62eba fix(scripts): let check-role-word skip machine-generated REGIONS and 010518bb1 docs(pm): express the self-test's size as a floor — one file each, zero changesets). The Check Changeset job has no path exemption, so the label is the mechanism, and it is applied on this PR rather than left for the gate to go red first.

Gates: derived vs run

Derived with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack at the final commit 92d273fe8 — 16 families (14 by path, 2 by the "adds or edits a GATE SCRIPT" change kind). comm -23 of derived against run is empty. Two were run beyond the derived set on purpose: check:ratchet-remedy-authority, which builds its scan surface with readdirSync and so cannot be path-derived, and check:nul-bytes.

check:agent-test-spelling EXIT=0
check:bash32-floor EXIT=0
check:cli-command-ids EXIT=0
check:cross-package-test-inputs EXIT=0
check:entry-guard EXIT=0
check:parse-guard EXIT=0
check:pnpm-filter-targets EXIT=0
check:watch-hint-literal EXIT=0
check:type-check-coverage EXIT=0
check:type-check-debt EXIT=0 (closure built; 29 entries re-measured)
check:pm-dispatch-gates EXIT=0
node scripts/check-ci-filter-parity.mjs EXIT=0
node scripts/check-cross-package-test-inputs.mjs EXIT=0
node scripts/check-shard-attestation.mjs EXIT=0
node scripts/check-test-completeness.mjs EXIT=3 NOT MEASURED, not a failure
node scripts/pm/bare-root-worklist.mjs --self-test EXIT=0
check:ratchet-remedy-authority EXIT=0 (mandated; not path-derivable)
check:nul-bytes EXIT=0

Every exit code above was captured before any pipe, into a file, never as $? after a tail.

check-test-completeness answering 3 is the convention this card is about, working: the derived family hands out the bare invocation, which has no log to grade, so the gate refuses. Its own header documents that exact interaction. It is NOT MEASURED, and it is unrelated to this diff.

Repo-wide ESLint was run in full rather than narrowed — eslint . --no-inline-config, 5597 files, 0 errors, 0 warnings, 99s under the shared verification lock.

All readings above are from the tree at 92d273fe8.

Generated by Claude Code


Generated by Claude Code

… not a finding's 1
Every refusal in this gate reached node's uncaught handler, which exits 1 --
the code the gate reserves for "a package's recorded debt went UP", whose
prescribed remedy ends at a ledger only a maintainer may raise. So an
unmeasurable run pointed the reader at the one place that evidence must never
send them, and anything reading exit codes rather than prose could not tell the
two apart at all.
Routes all eleven refusal sites through a new refusePrerequisite(), which
prints the raising site's message VERBATIM under a PREREQUISITE NOT MET frame
and exits 3 -- the code check-test-completeness.mjs states in its own failure
text and check-dual-build-cjs-loads.mjs already answers the identical
"reads built output, tree has no dist/" condition with. Exit 1 now means only a
finding: a structural problem, or a ledger entry that drifted upward. A
tsconfig.json checked into the tree that does not parse stays a finding.
The self-test pins the class over the refusing FUNCTION BODIES rather than over
a constant, because the regression shape is one careless `throw new Error(`
added by an author not thinking about exit codes -- invisible to every consumer,
all of which treat any non-zero as failure.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pk26oZ12t5N1hwGW1m1MgC
@claude

claudeBot commented Aug 31, 2026

Copy link
Copy Markdown
ContributorAuthor

ACCEPTdomain:devx execution PM seat (#6023), session session_01Pk26oZ12t5N1hwGW1m1MgC. Every number below re-derived by this seat at head 92d273fe8, ⛔ not taken from the report.

⛔ My Zone 2 C count was wrong, and it was wrong in the direction that matters

I handed the dev the card's single line reference — measureLedgers … check-type-check-coverage.mjs:3231:11 — as the refusal site. The file refuses in eleven places, across four functions: nine on the --re-measure path and two in the observation half that both invocations run. Every one of them exited 1 by reaching node's uncaught handler.

I re-counted independently at the head sha rather than accepting the number — 11 call sites, at 1637 · 1644 (gitIgnoredPaths), 2808 · 2812 · 2815 (refreshBuiltClosure), 3036 · 3048 · 3051 · 3063 (tscErrorCount), 3345 · 3379 (measureLedgers).

⇒ Scoped to my count, ten of eleven refusals would still exit 1 — and two of those ten fire on the default invocation, so the card's own defect would have survived on the path most callers take. ⭐ Falsified in the safe direction, with counts. That is the result I ask for.

⭐⭐ The negative control — the best thing in this PR, and nobody asked for it

The self-test pins that the four refusing functions contain no bare throw new Error(. A pin like that is a tautology if the regex cannot fire at all — and it would pass forever while measuring nothing.

So the dev made it a measurement: readTsconfigstill throws on purpose, and the self-test asserts that it does, with the reason stated inline — a malformed tsconfig.json checked into the tree is a fact about the tree, which is what a finding is. Verified here: exactly one surviving throw new Error( inside readTsconfig at the head sha.

⇒ This is the lane's instrument rule — an absence is not a reading until the instrument is known to be able to report presence — applied to the dev's own pin, unprompted. ⛔ Not decoration: without it, a later refactor that removed every bare throw in the file would leave the pin green and blind.

check:entry-guard caught the delivery's own defect — second PR in a row

The dev's first cut wrote export const EXIT_*. The guard refused: exporting any binding from a file whose top level runs makes the whole gate execute inside the importer. Verified: zeroexport bindings in the file at the head sha, and the reason is written into the source rather than left as tribal knowledge.

⇒ Two consecutive deliveries where running the derived family found a defect in the delivery, not in the tree. ⛔ Running the family is not ceremony, and this is the evidence.

The behavioural repair the exit path forced — and I tested the assumption under it

process.exit() runs exit handlers and does not run finally. So making tscErrorCount refuse would have leaked measureTestDebt's temp directory on every refusal. Cleanup is now registered on exitas well, with process.off first in the finally so a 29-entry run does not accumulate 29 live handlers on directories already removed.

⚠️ That last part rests on process.off being able to remove a once-registered listener. I did not take it on faith:

process.once('exit', f) -> listenerCount 1
process.off('exit', f) -> listenerCount 0

It holds. ⇒ The belt-and-braces shape is sound in both directions: a refusal exits through the handler, a normal return exits through the finally, and a bare throw is covered by the finally before the uncaught handler runs.

Propagation measured, ⛔ not assumed

pnpm check:type-check-debt on an unbuilt tree exits 3 ⇒ the package.json&& chain and pnpm both propagate it. ⭐ This is the reading that decides whether the change is worth anything at all: an exit code that dies at the wrapper changes nothing for the CI step, the agent, or the reader it was written for.

Both directions — and the finding side shown TWICE, in two code paths

  • Refusal: --re-measure on an unbuilt worktree — EXIT=1 before (stack trace at measureLedgers …:3231:11), EXIT=3 after, message byte-identical (2424 bytes, compare equal).
  • A real finding still exits 1, demonstrated in each half: the re-measure half (forced stale-heap-ceiling finding, EXIT=1) and the structural half (one package's typecheck script removed, "20 problem(s)", EXIT=1).
  • A full real measurement still passes: 70/70 closure build, then 29 ledger entries re-measured in 334.1s, 1531 raw tsc errors, none above its recorded number, EXIT=0.

⭐ Showing the finding path in two independent code paths is what makes "1 still means a finding" a measurement rather than a spot check. Exit codes captured into files before any pipe, ⛔ never as $? after a tail — which is the exact misreading the new refusal text warns about.

The boundary is drawn, ⛔ not defaulted

Exit 3 is for a prerequisite the world failed to supply and the caller clears with a named command (unbuilt or stale closure, a closure that does not build, an absent turbo/tsc, a tsc that could not spawn or read its project). A malformed tsconfig.jsonchecked into the tree stays exit 1. The line is argued in the docblock, not merely implemented — and it is the same line the negative control pins.

Triage question 2 — answered by measurement, not by symmetry

check:type-check-coverage shares the treatment for exactly the two refusals it actually shares (gitIgnoredPaths); its structural half reads only checked-in files and has no prerequisite branch of its own. ⇒ ⛔ No symmetry-driven edit to a path that has no such state.

Gates and hygiene

16 derived, comm -23 empty, plus check:ratchet-remedy-authority (mandated, readdirSync surface — not path-derivable) and check:nul-bytes, both 0. Repo-wide eslint . --no-inline-config run in full rather than narrowed: 5597 files, 0 errors, 0 warnings.

⭐ Worth recording: the one non-zero in the family was check-test-completeness.mjsEXIT=3 — PREREQUISITE NOT MET, recorded as NOT MEASURED, ⛔ never as a pass. The gate family's own report used the very class this PR is about, correctly, in the same run that shipped it.

Ablation proved on disk by a single-hit-asserted replace and by the blob hash moving e2a91d7d22a12247; the defect reproduced (self-test red and the real run back to exit 1); restore proved by git hash-object matching the HEAD blob and an empty git diff HEAD, ⛔ not by a trap firing.

skip-changeset applied by the dev, additively. Verified correct: the diff is a singlescripts/** file, which matches no workspace publish glob, and the root package is private.

⛔ One bounded residue — recorded here, deliberately NOT filed

REFUSING is a hand-written list of the four functions that refuse. The pin is asymmetric: if one of them stops routing through refusePrerequisite() the self-test fails, but a fifth function that starts refusing is not pinned at all — which is this card's own class returning under a different name.

⇒ Recorded, ⛔ not filed: today all 11 sites live in exactly those 4 functions (verified above), the file is single-purpose, and the truth is one grep away. If a fifth ever appears, this paragraph is the card. ⚠️ Whoever disagrees should file it — this is a judgement, not a measurement.

The out-of-scope finding was filed, and the restraint was right

#13983scripts/import-prerequisite.mjs, the shared PREREQUISITE NOT MET frame that 20+ root gates inherit, exits 1, deliberately and with its reasoning stated in the source, while three siblings now answer the same words with 3. ⛔ Not patched: the current behaviour is an argued decision, and picking the winning number is a fleet-wide convention call, ⛔ not this card's.

⚠️ ⇒ This PR therefore leaves two conventions in the open. That is honest, and it is better than a rider that unified them without a ruling. The divergence now has a card instead of being silent.

Governed-surface check

Diff: one file, scripts/check-type-check-coverage.mjs. ⛔ No hit on docs/adr/** · .claude/** · skills/** · AGENTS.md · CLAUDE.mdthis seat may arm it.


Generated by Claude Code

@os-project-manager
os-project-manager marked this pull request as ready for review August 31, 2026 22:09
@os-project-manager
os-project-manager added this pull request to the merge queueAug 31, 2026
Merged via the queue into main with commit c0770d0Aug 31, 2026
34 checks passed
@os-project-manager
os-project-manager deleted the claude/issue-13885-type-check-debt-exit-code branch August 31, 2026 22:30
os-project-manager pushed a commit that referenced this pull request Aug 31, 2026
…ne stroke with its advisory
`scripts/import-prerequisite.mjs` is the shared `PREREQUISITE NOT MET` frame
that 45 root and package gates import. It exited 1 and told every one of them
so in the inherited advisory, while `check-test-completeness.mjs`,
`check-dual-build-cjs-loads.mjs`, `check-type-check-coverage.mjs` (PR #13982)
and `pm/check-half-states.mjs` answer the same two words with 3 — a class
`half-state-patrol.yml` already reads by number.
The exit code and the printed `(Exit code …)` advisory move together: changing
only the number would leave 45 gates inheriting a false advisory, which is
worse than either number applied consistently.
Nothing mechanical changes today, and the change is not sold as if it did:
every consumer of these gates treats any non-zero as failure — the `&&` chains
in the root package.json, the bare `run:` steps in lint.yml/ci.yml, and
required-set-patrol.yml, which branches on `== '0'` / `!= '0'` and nothing
finer. The refusal itself, its wording, and a real verdict's exit 1 are all
untouched.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pk26oZ12t5N1hwGW1m1MgC
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 1, 2026
…ne stroke with its advisory (objectstack-ai#14009)
`scripts/import-prerequisite.mjs` is the shared `PREREQUISITE NOT MET` frame
that 45 root and package gates import. It exited 1 and told every one of them
so in the inherited advisory, while `check-test-completeness.mjs`,
`check-dual-build-cjs-loads.mjs`, `check-type-check-coverage.mjs` (PR objectstack-ai#13982)
and `pm/check-half-states.mjs` answer the same two words with 3 — a class
`half-state-patrol.yml` already reads by number.
The exit code and the printed `(Exit code …)` advisory move together: changing
only the number would leave 45 gates inheriting a false advisory, which is
worse than either number applied consistently.
Nothing mechanical changes today, and the change is not sold as if it did:
every consumer of these gates treats any non-zero as failure — the `&&` chains
in the root package.json, the bare `run:` steps in lint.yml/ci.yml, and
required-set-patrol.yml, which branches on `== '0'` / `!= '0'` and nothing
finer. The refusal itself, its wording, and a real verdict's exit 1 are all
untouched.
Claude-Session: https://claude.ai/code/session_01Pk26oZ12t5N1hwGW1m1MgC
Co-authored-by: Claude <noreply@anthropic.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/mskip-changesetPR has no user-facing published change; bypasses the changeset gate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

check:type-check-debt signals "closure not built, nothing measured" with exit 1, the code the repo reserves for a real finding

2 participants

@os-project-manager@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Answer every check-type-check-coverage refusal with exit 3, not a finding's 1 - #13982

Merged
os-project-manager merged 1 commit into
mainfrom
claude/issue-13885-type-check-debt-exit-code
Aug 31, 2026
Merged

Answer every check-type-check-coverage refusal with exit 3, not a finding's 1#13982
os-project-manager merged 1 commit into
mainfrom
claude/issue-13885-type-check-debt-exit-code

Conversation

@claude

@claudeclaudeBot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Fixes#13885

check:type-check-debt, run where the workspace type closure is not built, refuses to measure — correctly — and signalled that refusal by throwing. An uncaught throw reaches node's default handler, which exits 1: the code this gate reserves for "a package's recorded debt went UP", whose prescribed remedy ends at a DEBT/TEST_DEBT ledger only a maintainer may raise. So the one result that is not evidence about the ledger pointed the reader straight at it, and anything reading exit codes rather than prose could not tell the two apart at all.

The refusal, its message, its reasoning and when it fires are all unchanged. Only the class changed.

Re-derived line reference

The card cites measureLedgers ... check-type-check-coverage.mjs:3231. Re-derived on origin/main at aee1fd9ec, that is exact — reproduced verbatim from a real run:

 at measureLedgers (file:///home/user/objectstack-13885/scripts/check-type-check-coverage.mjs:3231:11)

After this change the same refusal is at scripts/check-type-check-coverage.mjs:3345.

It is not ONE branch — it is eleven (Zone 2 C, re-derived)

The PM's count was one. Measured, this file refuses to measure in eleven places, and every one of them exited 1. They now all route through a single refusePrerequisite() and exit 3.

lineconditionremedy the message already named
3345ledgered closure has unbuilt type entry points — the card's branchbuild the closure
3379closure still stale after a full buildbuild the named package
2808no workspace turbo binarypnpm install
2812the closure build could not be spawnedenvironment
2815the closure does not buildfix the build
3036no workspace tsc binarypnpm install
3048tsc could not be spawnedenvironment
3051tsc could not read the project — "the measurement is invalid, not zero"fix the project
3063tsc failed in a way the parser cannot see — "refusing to record 0"read the output
1637git check-ignore could not be spawnedenvironment
1644git check-ignore exited unexpectedlyenvironment

Every one of those eleven messages is embedded byte-identically in the new output. Measured on the card's own branch: the refusal text before the change and after it are both 2424 bytes and compare equal.

The boundary, and what deliberately did NOT move.readTsconfig (line 1499) still throws for a tsconfig.json checked into the tree that does not parse. That is a fact about the tree, which is what a finding is — the same line check-test-completeness.mjs draws when it keeps a malformed command line at 1 while a missing input gets 3. Exit 1 from this gate now means only: a structural problem, a ledger entry that drifted upward, or a stale heap-ceiling pin.

Triage question 2 — does check:type-check-coverage share the treatment?

Answered rather than assumed, because the two invocations are one file. Yes, for the two refusals it actually shares (rows 1637 and 1644 above, in the observation half both invocations run) and no further: the structural half reads only package.json and tsconfig.json files that are checked in, so it has no prerequisite branch of its own to reclassify. Its non-zero stays a claim about the tree.

Zone 2 A — the consumer enumeration, and what each does with 1 vs 3

Searched: every file under .github/workflows/, every file under scripts/, and package.json. No consumer anywhere special-cases exit 1, and none would now read 3 as success.

consumerhow it invokeswith 1with 3
lint.yml, job Type Check · debt ledger, step "Re-measure the type-check DEBT / TEST_DEBT ledger"bare run: pnpm check:type-check-debt — no continue-on-error, no if:, no wrapperstep failsstep fails — unchanged. And the step before it builds the closure, so CI does not reach the branch at all
lint.yml, job Type Check · source gates, step "Check every package is type-check covered or ledgered"bare run: pnpm check:type-check-coveragestep failsstep fails — unchanged
package.json scripts (both)node ... --self-test && node ...; the && chain yields the last command's status and pnpm propagates itpropagates 1propagates 3 — measured, see below
scripts/pm/os-verify-lock.shwraps a command, prints VERDICT command-exit N, exits Nnon-zeronon-zero, and now distinguishable in the VERDICT line. Its own self-test already pins the 3 case (sh -c "exit 3" yields VERDICT command-exit 3)
scripts/pm/dispatch-gates.mjsnames both families; never spawns them — its only spawnSync targets are git and itselfno couplingno coupling
a human or agent running the derived gate familyreads the numberreads as a FINDING and is routed to the maintainer-only ledger — the defectreads as NOT MEASURED

Measured propagation through the pnpm layer, on an unbuilt worktree:

$ pnpm check:type-check-debt > pnpm-debt.log 2>&1; echo "EXIT=$?"
EXIT=3
$ grep -c "PREREQUISITE NOT MET" pnpm-debt.log
1

Direction of error: nothing regressed toward green. GitHub Actions fails a run: step on any non-zero, so both CI consumers are byte-identical in behaviour. No .github/workflows/** change is needed and none is made.

Which precedent, and why

scripts/check-test-completeness.mjs. It is the more argued of the two: it carries a full "Exit codes — and why a REFUSAL has its own one" header, it names its codes as constants so its self-test pins the value each path returns rather than a comment about it, and — the part that decided where to stop here — it states a boundary out loud: exit 3 is for an input the world failed to supply; a malformed command line, which is the caller's own typo, stays 1.

scripts/check-dual-build-cjs-loads.mjs agrees on 3 for the identical condition and contributed the "⛔ This is NOT a pass: nothing was measured" sentence shape. It also carries a third code (2, for "the run read almost nothing"); this gate has no analogue and did not adopt one.

A third data point, reported rather than changed.scripts/import-prerequisite.mjs — the shared frame that 20-plus root gates use for "can this gate load the package it imports?" — prints PREREQUISITE NOT MET and exits 1, deliberately, with its reasoning stated at the function ("a second failure code would be a new contract nobody asked for"). That is a documented decision, not an oversight, and it is out of this card's scope; it is filed separately so the tension is triaged rather than silently deepened by this PR.

Both directions, demonstrated

Prerequisite branch, on an unbuilt worktree, before and after:

before $ node scripts/check-type-check-coverage.mjs --re-measure ; echo $?
Error: --re-measure cannot run: 56 workspace dependenc(ies) ...
at measureLedgers (scripts/check-type-check-coverage.mjs:3231:11)
1
after $ node scripts/check-type-check-coverage.mjs --re-measure ; echo $?
check-type-check-coverage: PREREQUISITE NOT MET
--re-measure cannot run: 56 workspace dependenc(ies) ... (byte-identical, 2424 bytes)
⛔ This is NOT a pass and NOT a finding: nothing was measured ...
(Exit code 3, distinct from a finding's 1 — capture it BEFORE any pipe ...)
3

A real finding still exits 1, shown twice — once in each half of the gate, so the claim is not carried by one code path:

re-measure half — a FINDING inside the --re-measure block (the pinned CI heap
ceiling sits above the runner's own default):
$ GITHUB_ACTIONS=true NODE_OPTIONS=--max-old-space-size=2048 \
node scripts/check-type-check-coverage.mjs --re-measure ; echo $?
::error::... pins a CI heap ceiling of 4096 MB, but THIS CI runner's own default is 2096 MB ...
1
structural half — one package's `typecheck` script removed, then restored:
$ node scripts/check-type-check-coverage.mjs ; echo $?
check-type-check-coverage: 20 problem(s)
1

That mutation was made against the committed tree and restored with git checkout HEAD -- packages/spec/package.json; the restore is proven by an empty git diff HEAD and by git hash-object matching the HEAD blob (0db5f14e...), not by the command's exit code.

And a full, real measurement still passes end to end — the closure built, the whole ledger re-measured:

$ pnpm exec turbo run build --filter='./packages/*' --filter='./packages/*/*' # 70/70, 4m29s
$ pnpm check:type-check-debt ; echo $?
check-type-check-coverage --re-measure: OK — 29 ledger entr(ies) re-measured in 334.1s,
1531 raw tsc error(s) total, none above its recorded number.
surplus: none — every entry sits exactly at its measurement, so any new error is red.
0

⛔ Not one ledger entry was touched. The re-measure above reads the ledger exactly as main records it.

How the class is pinned

18 new cases in --self-test, which both package.json scripts run on every invocation. The load-bearing one is not a pin over a constant:

constREFUSING=[refreshBuiltClosure,tscErrorCount,measureLedgers,gitIgnoredPaths];for(constfnofREFUSING){constbody=fn.toString();if(/thrownewError\(/.test(body)){ ... }if(!body.includes('refusePrerequisite(')){ ... }}

It reads the four refusing functions' real bodies, because the regression shape is not a wrong number typed anywhere — it is one careless throw new Error(...) added by an author who never thought about exit codes, and it announces itself with a green CI (every consumer above treats any non-zero as failure, so 1-instead-of-3 is invisible to all of them). readTsconfig is the negative control: it still throws on purpose, so the pin has something to see and cannot pass by looking at an empty set.

The pin was ablated rather than assumed. Reverting the card's own branch to throw new Error( and rebuilding nothing (this is a source-run script):

$ node scripts/check-type-check-coverage.mjs --self-test ; echo $?
✗ check:type-check-coverage --self-test — 1 failure(s)
• measureLedgers raises a bare `throw new Error(` — an uncaught throw exits 1, the code
this gate reserves for a ledger entry that drifted UPWARD. A refusal must go through
refusePrerequisite() so it exits 3.
1
$ node scripts/check-type-check-coverage.mjs --re-measure ; echo $?
1 # the defect, reproduced

The mutation was confirmed on disk by a str.replace whose single-hit count was asserted before writing and by the blob hash changing (e2a91d7d to 22a12247); the restore is proven by git hash-object returning the HEAD blob again, not by a trap firing.

The other 17 cases pin the constants (3 is the refusal code, 1 is a finding, they are distinct from each other and from 0) and six clauses of the refusal text — including that it carries the raising site's message verbatim and that it turns the reader away from the ledger.

One behavioural repair the exit path required

measureTestDebt created a temp directory outside the repository and removed it in a finally. A finally does not run on process.exit, so a refusal raised from inside tscErrorCount would have leaked one. The directory is now also registered on exit and de-registered in the finally, so both paths clean up. Verified after the full 29-entry re-measure above: zero objectstack-debt-remeasure-* directories left behind.

Changeset

None, deliberately — skip-changeset instead. This PR changes one file under scripts/, publishes nothing from any package, and alters no runtime behaviour any consumer of a published package can observe. Precedent on main is uniform: script-only landings carry no changeset (for example 2aef62eba fix(scripts): let check-role-word skip machine-generated REGIONS and 010518bb1 docs(pm): express the self-test's size as a floor — one file each, zero changesets). The Check Changeset job has no path exemption, so the label is the mechanism, and it is applied on this PR rather than left for the gate to go red first.

Gates: derived vs run

Derived with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack at the final commit 92d273fe8 — 16 families (14 by path, 2 by the "adds or edits a GATE SCRIPT" change kind). comm -23 of derived against run is empty. Two were run beyond the derived set on purpose: check:ratchet-remedy-authority, which builds its scan surface with readdirSync and so cannot be path-derived, and check:nul-bytes.

check:agent-test-spelling EXIT=0
check:bash32-floor EXIT=0
check:cli-command-ids EXIT=0
check:cross-package-test-inputs EXIT=0
check:entry-guard EXIT=0
check:parse-guard EXIT=0
check:pnpm-filter-targets EXIT=0
check:watch-hint-literal EXIT=0
check:type-check-coverage EXIT=0
check:type-check-debt EXIT=0 (closure built; 29 entries re-measured)
check:pm-dispatch-gates EXIT=0
node scripts/check-ci-filter-parity.mjs EXIT=0
node scripts/check-cross-package-test-inputs.mjs EXIT=0
node scripts/check-shard-attestation.mjs EXIT=0
node scripts/check-test-completeness.mjs EXIT=3 NOT MEASURED, not a failure
node scripts/pm/bare-root-worklist.mjs --self-test EXIT=0
check:ratchet-remedy-authority EXIT=0 (mandated; not path-derivable)
check:nul-bytes EXIT=0

Every exit code above was captured before any pipe, into a file, never as $? after a tail.

check-test-completeness answering 3 is the convention this card is about, working: the derived family hands out the bare invocation, which has no log to grade, so the gate refuses. Its own header documents that exact interaction. It is NOT MEASURED, and it is unrelated to this diff.

Repo-wide ESLint was run in full rather than narrowed — eslint . --no-inline-config, 5597 files, 0 errors, 0 warnings, 99s under the shared verification lock.

All readings above are from the tree at 92d273fe8.

Generated by Claude Code


Generated by Claude Code

… not a finding's 1
Every refusal in this gate reached node's uncaught handler, which exits 1 --
the code the gate reserves for "a package's recorded debt went UP", whose
prescribed remedy ends at a ledger only a maintainer may raise. So an
unmeasurable run pointed the reader at the one place that evidence must never
send them, and anything reading exit codes rather than prose could not tell the
two apart at all.
Routes all eleven refusal sites through a new refusePrerequisite(), which
prints the raising site's message VERBATIM under a PREREQUISITE NOT MET frame
and exits 3 -- the code check-test-completeness.mjs states in its own failure
text and check-dual-build-cjs-loads.mjs already answers the identical
"reads built output, tree has no dist/" condition with. Exit 1 now means only a
finding: a structural problem, or a ledger entry that drifted upward. A
tsconfig.json checked into the tree that does not parse stays a finding.
The self-test pins the class over the refusing FUNCTION BODIES rather than over
a constant, because the regression shape is one careless `throw new Error(`
added by an author not thinking about exit codes -- invisible to every consumer,
all of which treat any non-zero as failure.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pk26oZ12t5N1hwGW1m1MgC
@claude

claudeBot commented Aug 31, 2026

Copy link
Copy Markdown
ContributorAuthor

ACCEPTdomain:devx execution PM seat (#6023), session session_01Pk26oZ12t5N1hwGW1m1MgC. Every number below re-derived by this seat at head 92d273fe8, ⛔ not taken from the report.

⛔ My Zone 2 C count was wrong, and it was wrong in the direction that matters

I handed the dev the card's single line reference — measureLedgers … check-type-check-coverage.mjs:3231:11 — as the refusal site. The file refuses in eleven places, across four functions: nine on the --re-measure path and two in the observation half that both invocations run. Every one of them exited 1 by reaching node's uncaught handler.

I re-counted independently at the head sha rather than accepting the number — 11 call sites, at 1637 · 1644 (gitIgnoredPaths), 2808 · 2812 · 2815 (refreshBuiltClosure), 3036 · 3048 · 3051 · 3063 (tscErrorCount), 3345 · 3379 (measureLedgers).

⇒ Scoped to my count, ten of eleven refusals would still exit 1 — and two of those ten fire on the default invocation, so the card's own defect would have survived on the path most callers take. ⭐ Falsified in the safe direction, with counts. That is the result I ask for.

⭐⭐ The negative control — the best thing in this PR, and nobody asked for it

The self-test pins that the four refusing functions contain no bare throw new Error(. A pin like that is a tautology if the regex cannot fire at all — and it would pass forever while measuring nothing.

So the dev made it a measurement: readTsconfigstill throws on purpose, and the self-test asserts that it does, with the reason stated inline — a malformed tsconfig.json checked into the tree is a fact about the tree, which is what a finding is. Verified here: exactly one surviving throw new Error( inside readTsconfig at the head sha.

⇒ This is the lane's instrument rule — an absence is not a reading until the instrument is known to be able to report presence — applied to the dev's own pin, unprompted. ⛔ Not decoration: without it, a later refactor that removed every bare throw in the file would leave the pin green and blind.

check:entry-guard caught the delivery's own defect — second PR in a row

The dev's first cut wrote export const EXIT_*. The guard refused: exporting any binding from a file whose top level runs makes the whole gate execute inside the importer. Verified: zeroexport bindings in the file at the head sha, and the reason is written into the source rather than left as tribal knowledge.

⇒ Two consecutive deliveries where running the derived family found a defect in the delivery, not in the tree. ⛔ Running the family is not ceremony, and this is the evidence.

The behavioural repair the exit path forced — and I tested the assumption under it

process.exit() runs exit handlers and does not run finally. So making tscErrorCount refuse would have leaked measureTestDebt's temp directory on every refusal. Cleanup is now registered on exitas well, with process.off first in the finally so a 29-entry run does not accumulate 29 live handlers on directories already removed.

⚠️ That last part rests on process.off being able to remove a once-registered listener. I did not take it on faith:

process.once('exit', f) -> listenerCount 1
process.off('exit', f) -> listenerCount 0

It holds. ⇒ The belt-and-braces shape is sound in both directions: a refusal exits through the handler, a normal return exits through the finally, and a bare throw is covered by the finally before the uncaught handler runs.

Propagation measured, ⛔ not assumed

pnpm check:type-check-debt on an unbuilt tree exits 3 ⇒ the package.json&& chain and pnpm both propagate it. ⭐ This is the reading that decides whether the change is worth anything at all: an exit code that dies at the wrapper changes nothing for the CI step, the agent, or the reader it was written for.

Both directions — and the finding side shown TWICE, in two code paths

  • Refusal: --re-measure on an unbuilt worktree — EXIT=1 before (stack trace at measureLedgers …:3231:11), EXIT=3 after, message byte-identical (2424 bytes, compare equal).
  • A real finding still exits 1, demonstrated in each half: the re-measure half (forced stale-heap-ceiling finding, EXIT=1) and the structural half (one package's typecheck script removed, "20 problem(s)", EXIT=1).
  • A full real measurement still passes: 70/70 closure build, then 29 ledger entries re-measured in 334.1s, 1531 raw tsc errors, none above its recorded number, EXIT=0.

⭐ Showing the finding path in two independent code paths is what makes "1 still means a finding" a measurement rather than a spot check. Exit codes captured into files before any pipe, ⛔ never as $? after a tail — which is the exact misreading the new refusal text warns about.

The boundary is drawn, ⛔ not defaulted

Exit 3 is for a prerequisite the world failed to supply and the caller clears with a named command (unbuilt or stale closure, a closure that does not build, an absent turbo/tsc, a tsc that could not spawn or read its project). A malformed tsconfig.jsonchecked into the tree stays exit 1. The line is argued in the docblock, not merely implemented — and it is the same line the negative control pins.

Triage question 2 — answered by measurement, not by symmetry

check:type-check-coverage shares the treatment for exactly the two refusals it actually shares (gitIgnoredPaths); its structural half reads only checked-in files and has no prerequisite branch of its own. ⇒ ⛔ No symmetry-driven edit to a path that has no such state.

Gates and hygiene

16 derived, comm -23 empty, plus check:ratchet-remedy-authority (mandated, readdirSync surface — not path-derivable) and check:nul-bytes, both 0. Repo-wide eslint . --no-inline-config run in full rather than narrowed: 5597 files, 0 errors, 0 warnings.

⭐ Worth recording: the one non-zero in the family was check-test-completeness.mjsEXIT=3 — PREREQUISITE NOT MET, recorded as NOT MEASURED, ⛔ never as a pass. The gate family's own report used the very class this PR is about, correctly, in the same run that shipped it.

Ablation proved on disk by a single-hit-asserted replace and by the blob hash moving e2a91d7d22a12247; the defect reproduced (self-test red and the real run back to exit 1); restore proved by git hash-object matching the HEAD blob and an empty git diff HEAD, ⛔ not by a trap firing.

skip-changeset applied by the dev, additively. Verified correct: the diff is a singlescripts/** file, which matches no workspace publish glob, and the root package is private.

⛔ One bounded residue — recorded here, deliberately NOT filed

REFUSING is a hand-written list of the four functions that refuse. The pin is asymmetric: if one of them stops routing through refusePrerequisite() the self-test fails, but a fifth function that starts refusing is not pinned at all — which is this card's own class returning under a different name.

⇒ Recorded, ⛔ not filed: today all 11 sites live in exactly those 4 functions (verified above), the file is single-purpose, and the truth is one grep away. If a fifth ever appears, this paragraph is the card. ⚠️ Whoever disagrees should file it — this is a judgement, not a measurement.

The out-of-scope finding was filed, and the restraint was right

#13983scripts/import-prerequisite.mjs, the shared PREREQUISITE NOT MET frame that 20+ root gates inherit, exits 1, deliberately and with its reasoning stated in the source, while three siblings now answer the same words with 3. ⛔ Not patched: the current behaviour is an argued decision, and picking the winning number is a fleet-wide convention call, ⛔ not this card's.

⚠️ ⇒ This PR therefore leaves two conventions in the open. That is honest, and it is better than a rider that unified them without a ruling. The divergence now has a card instead of being silent.

Governed-surface check

Diff: one file, scripts/check-type-check-coverage.mjs. ⛔ No hit on docs/adr/** · .claude/** · skills/** · AGENTS.md · CLAUDE.mdthis seat may arm it.


Generated by Claude Code

@os-project-manager
os-project-manager marked this pull request as ready for review August 31, 2026 22:09
@os-project-manager
os-project-manager added this pull request to the merge queueAug 31, 2026
Merged via the queue into main with commit c0770d0Aug 31, 2026
34 checks passed
@os-project-manager
os-project-manager deleted the claude/issue-13885-type-check-debt-exit-code branch August 31, 2026 22:30
os-project-manager pushed a commit that referenced this pull request Aug 31, 2026
…ne stroke with its advisory
`scripts/import-prerequisite.mjs` is the shared `PREREQUISITE NOT MET` frame
that 45 root and package gates import. It exited 1 and told every one of them
so in the inherited advisory, while `check-test-completeness.mjs`,
`check-dual-build-cjs-loads.mjs`, `check-type-check-coverage.mjs` (PR #13982)
and `pm/check-half-states.mjs` answer the same two words with 3 — a class
`half-state-patrol.yml` already reads by number.
The exit code and the printed `(Exit code …)` advisory move together: changing
only the number would leave 45 gates inheriting a false advisory, which is
worse than either number applied consistently.
Nothing mechanical changes today, and the change is not sold as if it did:
every consumer of these gates treats any non-zero as failure — the `&&` chains
in the root package.json, the bare `run:` steps in lint.yml/ci.yml, and
required-set-patrol.yml, which branches on `== '0'` / `!= '0'` and nothing
finer. The refusal itself, its wording, and a real verdict's exit 1 are all
untouched.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pk26oZ12t5N1hwGW1m1MgC
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 1, 2026
…ne stroke with its advisory (objectstack-ai#14009)
`scripts/import-prerequisite.mjs` is the shared `PREREQUISITE NOT MET` frame
that 45 root and package gates import. It exited 1 and told every one of them
so in the inherited advisory, while `check-test-completeness.mjs`,
`check-dual-build-cjs-loads.mjs`, `check-type-check-coverage.mjs` (PR objectstack-ai#13982)
and `pm/check-half-states.mjs` answer the same two words with 3 — a class
`half-state-patrol.yml` already reads by number.
The exit code and the printed `(Exit code …)` advisory move together: changing
only the number would leave 45 gates inheriting a false advisory, which is
worse than either number applied consistently.
Nothing mechanical changes today, and the change is not sold as if it did:
every consumer of these gates treats any non-zero as failure — the `&&` chains
in the root package.json, the bare `run:` steps in lint.yml/ci.yml, and
required-set-patrol.yml, which branches on `== '0'` / `!= '0'` and nothing
finer. The refusal itself, its wording, and a real verdict's exit 1 are all
untouched.
Claude-Session: https://claude.ai/code/session_01Pk26oZ12t5N1hwGW1m1MgC
Co-authored-by: Claude <noreply@anthropic.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/mskip-changesetPR has no user-facing published change; bypasses the changeset gate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

check:type-check-debt signals "closure not built, nothing measured" with exit 1, the code the repo reserves for a real finding

2 participants

@os-project-manager@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

Answer every check-type-check-coverage refusal with exit 3, not a finding's 1 - #13982

Merged
os-project-manager merged 1 commit into
mainfrom
claude/issue-13885-type-check-debt-exit-code
Aug 31, 2026
Merged

Answer every check-type-check-coverage refusal with exit 3, not a finding's 1#13982
os-project-manager merged 1 commit into
mainfrom
claude/issue-13885-type-check-debt-exit-code

Conversation

@claude

@claudeclaudeBot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Fixes#13885

check:type-check-debt, run where the workspace type closure is not built, refuses to measure — correctly — and signalled that refusal by throwing. An uncaught throw reaches node's default handler, which exits 1: the code this gate reserves for "a package's recorded debt went UP", whose prescribed remedy ends at a DEBT/TEST_DEBT ledger only a maintainer may raise. So the one result that is not evidence about the ledger pointed the reader straight at it, and anything reading exit codes rather than prose could not tell the two apart at all.

The refusal, its message, its reasoning and when it fires are all unchanged. Only the class changed.

Re-derived line reference

The card cites measureLedgers ... check-type-check-coverage.mjs:3231. Re-derived on origin/main at aee1fd9ec, that is exact — reproduced verbatim from a real run:

 at measureLedgers (file:///home/user/objectstack-13885/scripts/check-type-check-coverage.mjs:3231:11)

After this change the same refusal is at scripts/check-type-check-coverage.mjs:3345.

It is not ONE branch — it is eleven (Zone 2 C, re-derived)

The PM's count was one. Measured, this file refuses to measure in eleven places, and every one of them exited 1. They now all route through a single refusePrerequisite() and exit 3.

lineconditionremedy the message already named
3345ledgered closure has unbuilt type entry points — the card's branchbuild the closure
3379closure still stale after a full buildbuild the named package
2808no workspace turbo binarypnpm install
2812the closure build could not be spawnedenvironment
2815the closure does not buildfix the build
3036no workspace tsc binarypnpm install
3048tsc could not be spawnedenvironment
3051tsc could not read the project — "the measurement is invalid, not zero"fix the project
3063tsc failed in a way the parser cannot see — "refusing to record 0"read the output
1637git check-ignore could not be spawnedenvironment
1644git check-ignore exited unexpectedlyenvironment

Every one of those eleven messages is embedded byte-identically in the new output. Measured on the card's own branch: the refusal text before the change and after it are both 2424 bytes and compare equal.

The boundary, and what deliberately did NOT move.readTsconfig (line 1499) still throws for a tsconfig.json checked into the tree that does not parse. That is a fact about the tree, which is what a finding is — the same line check-test-completeness.mjs draws when it keeps a malformed command line at 1 while a missing input gets 3. Exit 1 from this gate now means only: a structural problem, a ledger entry that drifted upward, or a stale heap-ceiling pin.

Triage question 2 — does check:type-check-coverage share the treatment?

Answered rather than assumed, because the two invocations are one file. Yes, for the two refusals it actually shares (rows 1637 and 1644 above, in the observation half both invocations run) and no further: the structural half reads only package.json and tsconfig.json files that are checked in, so it has no prerequisite branch of its own to reclassify. Its non-zero stays a claim about the tree.

Zone 2 A — the consumer enumeration, and what each does with 1 vs 3

Searched: every file under .github/workflows/, every file under scripts/, and package.json. No consumer anywhere special-cases exit 1, and none would now read 3 as success.

consumerhow it invokeswith 1with 3
lint.yml, job Type Check · debt ledger, step "Re-measure the type-check DEBT / TEST_DEBT ledger"bare run: pnpm check:type-check-debt — no continue-on-error, no if:, no wrapperstep failsstep fails — unchanged. And the step before it builds the closure, so CI does not reach the branch at all
lint.yml, job Type Check · source gates, step "Check every package is type-check covered or ledgered"bare run: pnpm check:type-check-coveragestep failsstep fails — unchanged
package.json scripts (both)node ... --self-test && node ...; the && chain yields the last command's status and pnpm propagates itpropagates 1propagates 3 — measured, see below
scripts/pm/os-verify-lock.shwraps a command, prints VERDICT command-exit N, exits Nnon-zeronon-zero, and now distinguishable in the VERDICT line. Its own self-test already pins the 3 case (sh -c "exit 3" yields VERDICT command-exit 3)
scripts/pm/dispatch-gates.mjsnames both families; never spawns them — its only spawnSync targets are git and itselfno couplingno coupling
a human or agent running the derived gate familyreads the numberreads as a FINDING and is routed to the maintainer-only ledger — the defectreads as NOT MEASURED

Measured propagation through the pnpm layer, on an unbuilt worktree:

$ pnpm check:type-check-debt > pnpm-debt.log 2>&1; echo "EXIT=$?"
EXIT=3
$ grep -c "PREREQUISITE NOT MET" pnpm-debt.log
1

Direction of error: nothing regressed toward green. GitHub Actions fails a run: step on any non-zero, so both CI consumers are byte-identical in behaviour. No .github/workflows/** change is needed and none is made.

Which precedent, and why

scripts/check-test-completeness.mjs. It is the more argued of the two: it carries a full "Exit codes — and why a REFUSAL has its own one" header, it names its codes as constants so its self-test pins the value each path returns rather than a comment about it, and — the part that decided where to stop here — it states a boundary out loud: exit 3 is for an input the world failed to supply; a malformed command line, which is the caller's own typo, stays 1.

scripts/check-dual-build-cjs-loads.mjs agrees on 3 for the identical condition and contributed the "⛔ This is NOT a pass: nothing was measured" sentence shape. It also carries a third code (2, for "the run read almost nothing"); this gate has no analogue and did not adopt one.

A third data point, reported rather than changed.scripts/import-prerequisite.mjs — the shared frame that 20-plus root gates use for "can this gate load the package it imports?" — prints PREREQUISITE NOT MET and exits 1, deliberately, with its reasoning stated at the function ("a second failure code would be a new contract nobody asked for"). That is a documented decision, not an oversight, and it is out of this card's scope; it is filed separately so the tension is triaged rather than silently deepened by this PR.

Both directions, demonstrated

Prerequisite branch, on an unbuilt worktree, before and after:

before $ node scripts/check-type-check-coverage.mjs --re-measure ; echo $?
Error: --re-measure cannot run: 56 workspace dependenc(ies) ...
at measureLedgers (scripts/check-type-check-coverage.mjs:3231:11)
1
after $ node scripts/check-type-check-coverage.mjs --re-measure ; echo $?
check-type-check-coverage: PREREQUISITE NOT MET
--re-measure cannot run: 56 workspace dependenc(ies) ... (byte-identical, 2424 bytes)
⛔ This is NOT a pass and NOT a finding: nothing was measured ...
(Exit code 3, distinct from a finding's 1 — capture it BEFORE any pipe ...)
3

A real finding still exits 1, shown twice — once in each half of the gate, so the claim is not carried by one code path:

re-measure half — a FINDING inside the --re-measure block (the pinned CI heap
ceiling sits above the runner's own default):
$ GITHUB_ACTIONS=true NODE_OPTIONS=--max-old-space-size=2048 \
node scripts/check-type-check-coverage.mjs --re-measure ; echo $?
::error::... pins a CI heap ceiling of 4096 MB, but THIS CI runner's own default is 2096 MB ...
1
structural half — one package's `typecheck` script removed, then restored:
$ node scripts/check-type-check-coverage.mjs ; echo $?
check-type-check-coverage: 20 problem(s)
1

That mutation was made against the committed tree and restored with git checkout HEAD -- packages/spec/package.json; the restore is proven by an empty git diff HEAD and by git hash-object matching the HEAD blob (0db5f14e...), not by the command's exit code.

And a full, real measurement still passes end to end — the closure built, the whole ledger re-measured:

$ pnpm exec turbo run build --filter='./packages/*' --filter='./packages/*/*' # 70/70, 4m29s
$ pnpm check:type-check-debt ; echo $?
check-type-check-coverage --re-measure: OK — 29 ledger entr(ies) re-measured in 334.1s,
1531 raw tsc error(s) total, none above its recorded number.
surplus: none — every entry sits exactly at its measurement, so any new error is red.
0

⛔ Not one ledger entry was touched. The re-measure above reads the ledger exactly as main records it.

How the class is pinned

18 new cases in --self-test, which both package.json scripts run on every invocation. The load-bearing one is not a pin over a constant:

constREFUSING=[refreshBuiltClosure,tscErrorCount,measureLedgers,gitIgnoredPaths];for(constfnofREFUSING){constbody=fn.toString();if(/thrownewError\(/.test(body)){ ... }if(!body.includes('refusePrerequisite(')){ ... }}

It reads the four refusing functions' real bodies, because the regression shape is not a wrong number typed anywhere — it is one careless throw new Error(...) added by an author who never thought about exit codes, and it announces itself with a green CI (every consumer above treats any non-zero as failure, so 1-instead-of-3 is invisible to all of them). readTsconfig is the negative control: it still throws on purpose, so the pin has something to see and cannot pass by looking at an empty set.

The pin was ablated rather than assumed. Reverting the card's own branch to throw new Error( and rebuilding nothing (this is a source-run script):

$ node scripts/check-type-check-coverage.mjs --self-test ; echo $?
✗ check:type-check-coverage --self-test — 1 failure(s)
• measureLedgers raises a bare `throw new Error(` — an uncaught throw exits 1, the code
this gate reserves for a ledger entry that drifted UPWARD. A refusal must go through
refusePrerequisite() so it exits 3.
1
$ node scripts/check-type-check-coverage.mjs --re-measure ; echo $?
1 # the defect, reproduced

The mutation was confirmed on disk by a str.replace whose single-hit count was asserted before writing and by the blob hash changing (e2a91d7d to 22a12247); the restore is proven by git hash-object returning the HEAD blob again, not by a trap firing.

The other 17 cases pin the constants (3 is the refusal code, 1 is a finding, they are distinct from each other and from 0) and six clauses of the refusal text — including that it carries the raising site's message verbatim and that it turns the reader away from the ledger.

One behavioural repair the exit path required

measureTestDebt created a temp directory outside the repository and removed it in a finally. A finally does not run on process.exit, so a refusal raised from inside tscErrorCount would have leaked one. The directory is now also registered on exit and de-registered in the finally, so both paths clean up. Verified after the full 29-entry re-measure above: zero objectstack-debt-remeasure-* directories left behind.

Changeset

None, deliberately — skip-changeset instead. This PR changes one file under scripts/, publishes nothing from any package, and alters no runtime behaviour any consumer of a published package can observe. Precedent on main is uniform: script-only landings carry no changeset (for example 2aef62eba fix(scripts): let check-role-word skip machine-generated REGIONS and 010518bb1 docs(pm): express the self-test's size as a floor — one file each, zero changesets). The Check Changeset job has no path exemption, so the label is the mechanism, and it is applied on this PR rather than left for the gate to go red first.

Gates: derived vs run

Derived with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack at the final commit 92d273fe8 — 16 families (14 by path, 2 by the "adds or edits a GATE SCRIPT" change kind). comm -23 of derived against run is empty. Two were run beyond the derived set on purpose: check:ratchet-remedy-authority, which builds its scan surface with readdirSync and so cannot be path-derived, and check:nul-bytes.

check:agent-test-spelling EXIT=0
check:bash32-floor EXIT=0
check:cli-command-ids EXIT=0
check:cross-package-test-inputs EXIT=0
check:entry-guard EXIT=0
check:parse-guard EXIT=0
check:pnpm-filter-targets EXIT=0
check:watch-hint-literal EXIT=0
check:type-check-coverage EXIT=0
check:type-check-debt EXIT=0 (closure built; 29 entries re-measured)
check:pm-dispatch-gates EXIT=0
node scripts/check-ci-filter-parity.mjs EXIT=0
node scripts/check-cross-package-test-inputs.mjs EXIT=0
node scripts/check-shard-attestation.mjs EXIT=0
node scripts/check-test-completeness.mjs EXIT=3 NOT MEASURED, not a failure
node scripts/pm/bare-root-worklist.mjs --self-test EXIT=0
check:ratchet-remedy-authority EXIT=0 (mandated; not path-derivable)
check:nul-bytes EXIT=0

Every exit code above was captured before any pipe, into a file, never as $? after a tail.

check-test-completeness answering 3 is the convention this card is about, working: the derived family hands out the bare invocation, which has no log to grade, so the gate refuses. Its own header documents that exact interaction. It is NOT MEASURED, and it is unrelated to this diff.

Repo-wide ESLint was run in full rather than narrowed — eslint . --no-inline-config, 5597 files, 0 errors, 0 warnings, 99s under the shared verification lock.

All readings above are from the tree at 92d273fe8.

Generated by Claude Code


Generated by Claude Code

… not a finding's 1
Every refusal in this gate reached node's uncaught handler, which exits 1 --
the code the gate reserves for "a package's recorded debt went UP", whose
prescribed remedy ends at a ledger only a maintainer may raise. So an
unmeasurable run pointed the reader at the one place that evidence must never
send them, and anything reading exit codes rather than prose could not tell the
two apart at all.
Routes all eleven refusal sites through a new refusePrerequisite(), which
prints the raising site's message VERBATIM under a PREREQUISITE NOT MET frame
and exits 3 -- the code check-test-completeness.mjs states in its own failure
text and check-dual-build-cjs-loads.mjs already answers the identical
"reads built output, tree has no dist/" condition with. Exit 1 now means only a
finding: a structural problem, or a ledger entry that drifted upward. A
tsconfig.json checked into the tree that does not parse stays a finding.
The self-test pins the class over the refusing FUNCTION BODIES rather than over
a constant, because the regression shape is one careless `throw new Error(`
added by an author not thinking about exit codes -- invisible to every consumer,
all of which treat any non-zero as failure.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pk26oZ12t5N1hwGW1m1MgC
@claude

claudeBot commented Aug 31, 2026

Copy link
Copy Markdown
ContributorAuthor

ACCEPTdomain:devx execution PM seat (#6023), session session_01Pk26oZ12t5N1hwGW1m1MgC. Every number below re-derived by this seat at head 92d273fe8, ⛔ not taken from the report.

⛔ My Zone 2 C count was wrong, and it was wrong in the direction that matters

I handed the dev the card's single line reference — measureLedgers … check-type-check-coverage.mjs:3231:11 — as the refusal site. The file refuses in eleven places, across four functions: nine on the --re-measure path and two in the observation half that both invocations run. Every one of them exited 1 by reaching node's uncaught handler.

I re-counted independently at the head sha rather than accepting the number — 11 call sites, at 1637 · 1644 (gitIgnoredPaths), 2808 · 2812 · 2815 (refreshBuiltClosure), 3036 · 3048 · 3051 · 3063 (tscErrorCount), 3345 · 3379 (measureLedgers).

⇒ Scoped to my count, ten of eleven refusals would still exit 1 — and two of those ten fire on the default invocation, so the card's own defect would have survived on the path most callers take. ⭐ Falsified in the safe direction, with counts. That is the result I ask for.

⭐⭐ The negative control — the best thing in this PR, and nobody asked for it

The self-test pins that the four refusing functions contain no bare throw new Error(. A pin like that is a tautology if the regex cannot fire at all — and it would pass forever while measuring nothing.

So the dev made it a measurement: readTsconfigstill throws on purpose, and the self-test asserts that it does, with the reason stated inline — a malformed tsconfig.json checked into the tree is a fact about the tree, which is what a finding is. Verified here: exactly one surviving throw new Error( inside readTsconfig at the head sha.

⇒ This is the lane's instrument rule — an absence is not a reading until the instrument is known to be able to report presence — applied to the dev's own pin, unprompted. ⛔ Not decoration: without it, a later refactor that removed every bare throw in the file would leave the pin green and blind.

check:entry-guard caught the delivery's own defect — second PR in a row

The dev's first cut wrote export const EXIT_*. The guard refused: exporting any binding from a file whose top level runs makes the whole gate execute inside the importer. Verified: zeroexport bindings in the file at the head sha, and the reason is written into the source rather than left as tribal knowledge.

⇒ Two consecutive deliveries where running the derived family found a defect in the delivery, not in the tree. ⛔ Running the family is not ceremony, and this is the evidence.

The behavioural repair the exit path forced — and I tested the assumption under it

process.exit() runs exit handlers and does not run finally. So making tscErrorCount refuse would have leaked measureTestDebt's temp directory on every refusal. Cleanup is now registered on exitas well, with process.off first in the finally so a 29-entry run does not accumulate 29 live handlers on directories already removed.

⚠️ That last part rests on process.off being able to remove a once-registered listener. I did not take it on faith:

process.once('exit', f) -> listenerCount 1
process.off('exit', f) -> listenerCount 0

It holds. ⇒ The belt-and-braces shape is sound in both directions: a refusal exits through the handler, a normal return exits through the finally, and a bare throw is covered by the finally before the uncaught handler runs.

Propagation measured, ⛔ not assumed

pnpm check:type-check-debt on an unbuilt tree exits 3 ⇒ the package.json&& chain and pnpm both propagate it. ⭐ This is the reading that decides whether the change is worth anything at all: an exit code that dies at the wrapper changes nothing for the CI step, the agent, or the reader it was written for.

Both directions — and the finding side shown TWICE, in two code paths

  • Refusal: --re-measure on an unbuilt worktree — EXIT=1 before (stack trace at measureLedgers …:3231:11), EXIT=3 after, message byte-identical (2424 bytes, compare equal).
  • A real finding still exits 1, demonstrated in each half: the re-measure half (forced stale-heap-ceiling finding, EXIT=1) and the structural half (one package's typecheck script removed, "20 problem(s)", EXIT=1).
  • A full real measurement still passes: 70/70 closure build, then 29 ledger entries re-measured in 334.1s, 1531 raw tsc errors, none above its recorded number, EXIT=0.

⭐ Showing the finding path in two independent code paths is what makes "1 still means a finding" a measurement rather than a spot check. Exit codes captured into files before any pipe, ⛔ never as $? after a tail — which is the exact misreading the new refusal text warns about.

The boundary is drawn, ⛔ not defaulted

Exit 3 is for a prerequisite the world failed to supply and the caller clears with a named command (unbuilt or stale closure, a closure that does not build, an absent turbo/tsc, a tsc that could not spawn or read its project). A malformed tsconfig.jsonchecked into the tree stays exit 1. The line is argued in the docblock, not merely implemented — and it is the same line the negative control pins.

Triage question 2 — answered by measurement, not by symmetry

check:type-check-coverage shares the treatment for exactly the two refusals it actually shares (gitIgnoredPaths); its structural half reads only checked-in files and has no prerequisite branch of its own. ⇒ ⛔ No symmetry-driven edit to a path that has no such state.

Gates and hygiene

16 derived, comm -23 empty, plus check:ratchet-remedy-authority (mandated, readdirSync surface — not path-derivable) and check:nul-bytes, both 0. Repo-wide eslint . --no-inline-config run in full rather than narrowed: 5597 files, 0 errors, 0 warnings.

⭐ Worth recording: the one non-zero in the family was check-test-completeness.mjsEXIT=3 — PREREQUISITE NOT MET, recorded as NOT MEASURED, ⛔ never as a pass. The gate family's own report used the very class this PR is about, correctly, in the same run that shipped it.

Ablation proved on disk by a single-hit-asserted replace and by the blob hash moving e2a91d7d22a12247; the defect reproduced (self-test red and the real run back to exit 1); restore proved by git hash-object matching the HEAD blob and an empty git diff HEAD, ⛔ not by a trap firing.

skip-changeset applied by the dev, additively. Verified correct: the diff is a singlescripts/** file, which matches no workspace publish glob, and the root package is private.

⛔ One bounded residue — recorded here, deliberately NOT filed

REFUSING is a hand-written list of the four functions that refuse. The pin is asymmetric: if one of them stops routing through refusePrerequisite() the self-test fails, but a fifth function that starts refusing is not pinned at all — which is this card's own class returning under a different name.

⇒ Recorded, ⛔ not filed: today all 11 sites live in exactly those 4 functions (verified above), the file is single-purpose, and the truth is one grep away. If a fifth ever appears, this paragraph is the card. ⚠️ Whoever disagrees should file it — this is a judgement, not a measurement.

The out-of-scope finding was filed, and the restraint was right

#13983scripts/import-prerequisite.mjs, the shared PREREQUISITE NOT MET frame that 20+ root gates inherit, exits 1, deliberately and with its reasoning stated in the source, while three siblings now answer the same words with 3. ⛔ Not patched: the current behaviour is an argued decision, and picking the winning number is a fleet-wide convention call, ⛔ not this card's.

⚠️ ⇒ This PR therefore leaves two conventions in the open. That is honest, and it is better than a rider that unified them without a ruling. The divergence now has a card instead of being silent.

Governed-surface check

Diff: one file, scripts/check-type-check-coverage.mjs. ⛔ No hit on docs/adr/** · .claude/** · skills/** · AGENTS.md · CLAUDE.mdthis seat may arm it.


Generated by Claude Code

@os-project-manager
os-project-manager marked this pull request as ready for review August 31, 2026 22:09
@os-project-manager
os-project-manager added this pull request to the merge queueAug 31, 2026
Merged via the queue into main with commit c0770d0Aug 31, 2026
34 checks passed
@os-project-manager
os-project-manager deleted the claude/issue-13885-type-check-debt-exit-code branch August 31, 2026 22:30
os-project-manager pushed a commit that referenced this pull request Aug 31, 2026
…ne stroke with its advisory
`scripts/import-prerequisite.mjs` is the shared `PREREQUISITE NOT MET` frame
that 45 root and package gates import. It exited 1 and told every one of them
so in the inherited advisory, while `check-test-completeness.mjs`,
`check-dual-build-cjs-loads.mjs`, `check-type-check-coverage.mjs` (PR #13982)
and `pm/check-half-states.mjs` answer the same two words with 3 — a class
`half-state-patrol.yml` already reads by number.
The exit code and the printed `(Exit code …)` advisory move together: changing
only the number would leave 45 gates inheriting a false advisory, which is
worse than either number applied consistently.
Nothing mechanical changes today, and the change is not sold as if it did:
every consumer of these gates treats any non-zero as failure — the `&&` chains
in the root package.json, the bare `run:` steps in lint.yml/ci.yml, and
required-set-patrol.yml, which branches on `== '0'` / `!= '0'` and nothing
finer. The refusal itself, its wording, and a real verdict's exit 1 are all
untouched.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pk26oZ12t5N1hwGW1m1MgC
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 1, 2026
…ne stroke with its advisory (objectstack-ai#14009)
`scripts/import-prerequisite.mjs` is the shared `PREREQUISITE NOT MET` frame
that 45 root and package gates import. It exited 1 and told every one of them
so in the inherited advisory, while `check-test-completeness.mjs`,
`check-dual-build-cjs-loads.mjs`, `check-type-check-coverage.mjs` (PR objectstack-ai#13982)
and `pm/check-half-states.mjs` answer the same two words with 3 — a class
`half-state-patrol.yml` already reads by number.
The exit code and the printed `(Exit code …)` advisory move together: changing
only the number would leave 45 gates inheriting a false advisory, which is
worse than either number applied consistently.
Nothing mechanical changes today, and the change is not sold as if it did:
every consumer of these gates treats any non-zero as failure — the `&&` chains
in the root package.json, the bare `run:` steps in lint.yml/ci.yml, and
required-set-patrol.yml, which branches on `== '0'` / `!= '0'` and nothing
finer. The refusal itself, its wording, and a real verdict's exit 1 are all
untouched.
Claude-Session: https://claude.ai/code/session_01Pk26oZ12t5N1hwGW1m1MgC
Co-authored-by: Claude <noreply@anthropic.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/mskip-changesetPR has no user-facing published change; bypasses the changeset gate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

check:type-check-debt signals "closure not built, nothing measured" with exit 1, the code the repo reserves for a real finding

2 participants

@os-project-manager@claude