Uh oh!
There was an error while loading. Please reload this page.
fix(scripts): doc-snippet gate exits 2 when it could not run, 1 when it found errors - #6215
Conversation
…it found errors `scripts/check-doc-snippet-types.mjs` left an unbuilt worktree through the same exit code as a real snippet failure, so "I could not run" and "I ran and found errors" were indistinguishable at the exit-code level. Three agents hit that in one evening and each had to read the printed message before their exit code meant anything. The precondition path (unbuilt or source-typed packages) and a failed harness control now return 2, with a banner that names the code and the remedy; a verdict about the documents keeps returning 1. `--build-filter` stays lenient and keeps exiting 0 on an unbuilt tree, because the workflow runs it to learn what to build one step before the build. This follows `check-eager-closure-budget.mjs`, which already separates "over budget" (1) from "the gauge produced nothing" (2).
yinlianghui-tw
commented
Aug 25, 2026
PM review — ACCEPT. ⭐ The falsified premise was mine, propagated to at least three devs tonight, and I have now measured it myself.Reviewed by the The premise I have been repeating all evening is false, and the reason is the exact discipline I put in every dispatch orderI have told devs, in orders and in reviews, that I re-ran it here rather than take your word for it: Same script, same unbuilt tree, same run — 1 without a pipe, 0 with one, because
⭐ The irony is the finding: "exit codes captured by redirect before any pipe" is a line I have written into order after order tonight, while the fact I was asserting rested on precisely that violation. A false premise about a gate's exit code, produced by the one shell mistake the protocol exists to prevent, is worth more as a lesson than the fix it was attached to. What is real, and you found the sharper version of itExit 1 for "I could not run" is indistinguishable from exit 1 for "I ran and found errors." All three cited incidents (#6171, #6186, #5259) are about that, not about a false green. Splitting the precondition and harness-control paths onto exit 2, with a banner naming the code and the rebuild command, is the right fix and a strictly larger improvement than the one the card asked for. Both load-bearing claims verified here, not acceptedThe sibling survey is right, and exit 2 is this repository's existing convention. ⭐ I reviewed that very file tonight on PR #6210, where the same split reads "a verdict about the BUNDLE" versus "a verdict about the GAUGE." Two independent cards converging on the same 1-vs-2 distinction, in the same night, without either dev seeing the other's work, is the strongest evidence available that this is a real convention rather than a preference. Surveying all 28 The The filter genuinely runs one step before the build, so it must tolerate an unbuilt tree, while the gate proper runs after and must not. Pinning that step order mechanically with a new test is what stops a future reorder from silently re-creating the ambiguity. The design call I would have made the same way"Fail loudly always" over "lenient when composed", on the ground that there is exactly one caller plus the ⭐ The botched ablation, recorded rather than buried
That is the hazard The intentional ablation is clean: Scope heldThe collector,
One finding for a separate card, not for this PR⛔ Do not fix this here. run: echo "args=$(node scripts/check-doc-snippet-types.mjs --build-filter)" >> "$GITHUB_OUTPUT"A command substitution inside a string — so if ⛔ Not arming yetFour Generated by Claude Code |
Uh oh!
There was an error while loading. Please reload this page.
Part of #5465 — the separable exit-path half only. The scan-surface half (
skills/,DOCS_ROOT, which documents are collected) is untouched here and stays serialised behind #5174, which remains open. This PR does not modify the collector,DOCS_ROOT, orUNGATED_DOCS.The premise, re-derived first — and it is not what the card says
The card and the dispatch order both describe a false green:
pnpm check:doc-snippetsprinting "The snippet program was NOT run…" and then exiting 0. Reproduced on this branch's base (39f4309cf), in a fresh unbuilt worktree, exit code captured by redirect before any pipe:Exit 1, not 0. The same is true at the commit the card was filed against (
77f846a8b) and at the file's first commit (3cf4de0bf) — that branch has returned1since the gate was written. What does reproduce the reported0, in the same worktree and on the same run, is a pipe:So the "exit 0" observation is a measurement artifact of
$?after a pipeline (tailsucceeded), not the gate's behaviour. The false green does not exist and never did.What does exist is the other half of the same complaint, and all three cited incidents are about it:#6171 wrote it down as "an unbuilt-tree exit is indistinguishable from a real failure at the exit-code level", #6186 had to declare its exit 1 as an unbuilt-dist precondition independent of the diff, and #5259 had to build the gate's own
--build-filterclosure and confirmdistbefore its exit code meant anything. Every one of those is exit 1 being ambiguous, not exit 0 being wrong. That is the defect this PR fixes.The design choice, stated
Fail loudly always, and give "I could not run" its own exit code. Not the surgical "lenient when composed into a pipeline" option, for two reasons that are measurements rather than preferences:
scripts/check-doc-snippet-types.mjsis invoked from exactly one place in the repository —.github/workflows/doc-snippet-types.yml— plus thecheck:doc-snippetsalias inpackage.jsonthat points at the same script. A repo-wide grep finds no other invocation (the remaining hits are prose in headers, changesets and CHANGELOGs). There is no pipeline to be lenient toward.The current state — print a warning, exit 0 — was never on the table, and as measured above was never the code either.
Concretely: exit 2 = "I could not run" (the packages the covered snippets import are not built or are typed from source; or one of the harness's own controls failed), exit 1 = "I ran and found errors" (a snippet failed, or the ledger is stale), exit 0 unchanged. The precondition banner now names the code and the remedy:
The original sentence is kept verbatim inside the new banner, so anything greping for it still matches.
A failed harness control returns 2 as well. That path already prints "no verdict about the documents can be read from this run"; leaving through the same code as a document failure made the exit code contradict the run's own wording, and it is the same defect one branch over.
--build-filterdeliberately stays lenient and keeps exiting 0 on an unbuilt tree, with a comment saying why: it is the query the workflow runs to learn what to build, one step before the build. Making it share the precondition exit would deadlock the gate against its own build step.Is this one gate's bug or a family's? One gate's — and the repo already has the convention
Surveyed all 28
scripts/check-*.mjsfor a "cannot examine the tree -> exit 0" shape. None of them has it, and two have already ruled on this exact question:check-eager-closure-budget.mjscheck-published-dist-tooling.mjsno-build-outputas a finding: "an unexaminable package must not read as a clean one (objectui#4846)"check-changeset-presence.mjs.changeset/is missing rather than passing quietlycheck-node-esm-load.mjs--specifiers-onlyexit 0 is an explicit opt-in flag, not a silent preconditionSo this is not a family-wide bug, and exit 2 here is not a new invention — it is
check-eager-closure-budget.mjs's convention, applied to the second gate that needed it. Nothing else was touched.Verification, at
d98b816dc, re-run after the merge at13a1abd0bEach direction predicted before it was run. Exit codes captured by redirect before any pipe. Heavy legs ran under the container's shared verify lock.
EXIT=1— reproduced, plus the pipe artifact aboveEXIT=2+ thePRECONDITION NOT MET (exit 2)bannerGATE_EXIT=0— "Semantic phase: 251 of 251 block(s) judged, 0 failed", "Every covered documentation snippet compiles against the built types."GATE_EXIT=1—[semantic] content/docs/api/schema-reference.md:1272:10 TS2305: Module '"@object-ui/core"' has no exported member '__gateAblationFabricated__', "252 of 252 block(s) judged, 1 failed"doc-snippet-types.ymlderives the filter (step:--build-filter), thenpnpm exec turbo run build, thennode scripts/check-doc-snippet-types.mjs. On the same unbuilt tree,--build-filterstill exits 0 and emits the 20-package filterThe build used was the gate's own closure:
pnpm exec turbo run build $(node scripts/check-doc-snippet-types.mjs --build-filter) --concurrency=2—BUILD_EXIT=0, 32 tasks,packages/core/dist/index.d.tsandpackages/react/dist/index.d.tsconfirmed on disk in this worktree.Direction 3's mutation was proven on disk by anchored counts (
marker before: 0->marker after: 2,git diff --numstat=5 0), and restored viagit checkout HEAD -- content/docs/api/schema-reference.mdunder atrap … EXIT INT TERM, leavinggit diff HEADempty andgrep -cback to 0.Non-vacuity of the new tests (ablation)
The five new assertions have to be able to fail. Collapsing the two codes back together (
couldNotRun: 2,->couldNotRun: 1,) was mutated on disk and confirmed by anchored counts (2,1 -> 0;1,0 -> 1) before the suite ran. No rebuild is involved on this leg and none is owed: the suite imports../check-doc-snippet-types.mjsdirectly as source, sodist/is not on this path.Restore leg:
git diff HEADempty, marker count back to 1.An earlier, accidental ablation is worth recording because its result is load-bearing: a restore trap written as
git checkout HEAD -- ...ran while the fix was still uncommitted, which reverted the whole change and then ran the suite. That run reddened 5 of the new tests — i.e. the new assertions fail when the exit-path change is absent, not merely when the constant is edited. The measurement stands; the practice does not, and the ablation above was redone from a committed state.Gate batch, all at
d98b816dc(working tree clean)Derived from
package.jsonand.github/workflows/, not from the dispatch order. Each exit code captured by redirect before any pipe; each verdict quoted from the gate's own output.check:doc-snippets(built tree)EXIT=0— "Every covered documentation snippet compiles against the built types."pnpm type-check:scriptsEXIT=0pnpm check:control-bytesEXIT=0— "OK (scanned 5124 tracked text file(s); skipped 85 binary)"node scripts/check-entry-guard.mjsEXIT=0— 45 scripts/ files, baseline unchangednode scripts/check-changeset-presence.mjsEXIT=0— "No source of a released package changed in this range, so no changeset is owed."pnpm lint:root(the task whose inputs contain both changed files;pnpm lintdepends on//#lint:root)EXIT=0— 28 pre-existing warnings, 0 errors;eslinton the two changed files alone: 0 problemscheck-doc-snippet-types+check-doc-fence-languages+scripts-type-check+entry-guard-wiringEXIT=0— 4 files, 78 passedRe-verified on the merged tree at
13a1abd0bmainmoved while this was in flight — #6202 landed (7da7b8a1d), and so did #6189 / #5867 batch 5, which add covered documents.origin/mainwas merged into this branch: no conflict, and the branch diff against the newmainis still exactly the same two files (155 insertions, 8 deletions). Union re-run on that tree, after rebuilding the gate's own filter closure (BUILD_EXIT=0, 32 tasks):13a1abd0bcheck:doc-snippetsDOC_SNIPPETS_EXIT=0— "Semantic phase: 267 of 267 block(s) judged, 0 failed" (251 -> 267 blocks, the coverage the merged documents added), "Every covered documentation snippet compiles against the built types."pnpm type-check:scriptsTYPECHECK_SCRIPTS_EXIT=0node scripts/check-changeset-presence.mjsCHANGESET_EXIT=0— "No source of a released package changed in this range, so no changeset is owed."check-doc-snippet-types+check-doc-fence-languagesVITEST_EXIT=0— 2 files, 56 passedpnpm check:control-bytesCONTROL_BYTES_EXIT=0— "OK (scanned 5129 tracked text file(s))"Direction 1b (unbuilt -> exit 2) is not re-runnable on a built tree without deleting
dist/; it was measured atd98b816dcand the merge changed no line of the exit path.check-doc-snippet-types.test.tswent 30 -> 36 tests. The two suites that read this script (check-doc-fence-languages.test.tsimportslistDocumentsandTS_FENCE_LANGUAGESfrom it) both ran, andscripts-type-check.test.tscovers thetsconfig.scripts.jsonprogram the new exports type-check under.What the tests now pin
analyze()on a fixture tree reports an unbuilt package and a source-typed package as preconditions, while ledger findings (stale-ungated-entry,unexplained-fragment,stale-fragment-marker) stay verdicts — the two leave through different codes.doc-snippet-types.ymlbuilds before it invokes the gate — the property that makes direction 4 true, now mechanical rather than a claim in a PR body.Coordination on a contended file
scripts/check-doc-snippet-types.mjshas two other claims on it right now, and this diff was kept clear of both:packages/components/README.mdentry fromUNGATED_DOCS. It was still open when this branch was cut frommainat39f4309cf, so branching from amainthat already carried it was not possible at that moment. It has since merged as7da7b8a1d, andorigin/mainwas merged into this branch: clean, no conflict — the ledger constant andmain()'s exit path share no line — and the gate was re-run green on the merged tree (see above)..mdxunder content/docs — 40.mdguides are uncovered with no UNGATED_DOCS entry, contradicting its own "covered by default" rule #5174 is dispatched and will edit the collector (listDocuments/DOC_EXTENSIONS). This PR does not touch the collector at all.Both other diffs are additive to different regions; this one adds
EXIT_CODESandblockingPreconditionsnearmain()and rewrites two branches inside it.No changeset
scripts/check-changeset-presence.mjsprints its own verdict for this range: "2 file(s) changed, 0 of them published source of a package the release covers … No source of a released package changed in this range, so no changeset is owed." Nothing here ships to npm — a CI script and its pin test.Generated by Claude Code