Skip to content

fix(spec): six dist-reading gates answer an unmet build prerequisite with exit 3, not a finding's 1 - #19463

Merged
os-warren merged 4 commits into
mainfrom
claude/issue-19227-spec-gate-prerequisite-exit
Sep 21, 2026
Merged

os-warren merged 4 commits into
mainfrom
claude/issue-19227-spec-gate-prerequisite-exit

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Fixes #19227
Refs #19450

Clause-②: no

Six packages/spec gates refused an unmet build prerequisite with process.exit(1) — the
code a real finding uses — while scripts/import-prerequisite.mjs:250 declares
EXIT_PREREQUISITE_NOT_MET = 3 for exactly those two words and argues the case in its own
docblock. This lands the six on that vocabulary. ⛔ Nothing about what a real finding returns
moves: every fail(), refuse(), self-test, ratchet and diagnostics verdict still exits 1.

Why it is not cosmetic — reproduced in this round

scripts/pm/dispatch-gates.mjs --ran derives its NOT-MEASURED class from the recorded exit
code
(dispatch-gates.mjs:13932) and counts every other non-kill code as a family that RAN.
Driven twice over the same derivation of this very branch, same tool, same tree, only the six
recorded codes differing:

the six recorded as reconciliation
exit 1 (before this PR) 65 derived, 65 run, 0 NOT-MEASURED, 0 UNRUN — and the tool calls it "a DERIVED zero — all 65 recorded an exit code and none of them is 3"
exit 3 (after this PR) 65 derived, 59 run, 6 NOT-MEASURED, 0 UNRUN, each of the six named with "recorded exit 3 … PREREQUISITE NOT MET"

So the card's sharpest claim — filed as UNREPRODUCED by both the filing seat and the PM — holds:
a gate that refused before reading a single declaration was reconciled as coverage the round did
not have. That is a reconciliation instrument reporting coverage it does not have, not a code
mismatch.

The true population — six gates, seven sites

The card verified four files with grep -c 'process.exit(1)' and could not locate
check-api-surface / check-api-surface-declarations "under that spelling". Measured here
against origin/main @ 8e368dc, the population is the set of guarded refusals reached through
lib/dist-freshness.ts:

gate script site the card's reading
build-api-surface.ts stale/missing dist this is check:api-surface — spelled build-* because one file serves gen: and check:
check-browser-reachable-entries.ts stale/missing bundles not in the card at all; it already PRINTED NOT MEASURED and then exited 1
check-dual-source-exports.ts stale/missing dist, both modes exit(1) x6
check-entry-nameability.ts stale/missing dist exit(1) x0 — the instrument was blind: this gate returns its code to process.exit(main())
check-exported-any.ts stale/missing dist exit(1) x5
check-skill-examples.ts stale dist and a self-package with no declarations (was fail()) exit(1) x6; the refusal text the card could not find is at check-skill-examples.ts:3219

check-api-surface-declarations does not exist as a separate gate under any spelling. The nearest
candidate, build-declaration-map.ts (check:declaration-map), reads no dist and has no
prerequisite refusal; build-export-origins.ts says in its own header that it reads src/, never
the built dist. Recorded as: not present.

#19450 is the same defect measured independently on check-skill-examples.ts (the refusal fires
when packages/client-react/dist holds no declarations; the built closure returns a true exit 0).
Its site is covered here. ⛔ Refs, not Fixes — the dedupe/close call is triage's.

Dispositioned, not changed — with readings

  • check-entry-nameability.ts's dead-canary, foreign-diagnostic and zero-probe refusals keep
    return 1. The gate says so itself: "Both are findings; neither is a pass."
  • check-skill-examples.ts's refuse() keeps exit 1 — a surface that never reached tsc is the
    gate's own invariant breaking, not an unbuilt workspace.
  • Every --self-test failure, battery floor and verdict handshake keeps exit 1: a broken detector
    is a finding about the instrument.
  • check-dual-source-exports.ts's missing tracked baseline keeps exit 1 — a checked-in file
    that is absent is a repo-state finding, not a build-environment prerequisite.
  • check-generated.ts:751 refuses a readsDist generator on a stale dist, but folds it into
    failed and exits failed ? 1 : 0 across every sub-gate. There is no per-gate exit to move
    without changing what the aggregate returns for a real failure. Left alone, recorded here.

What the change is

lib/dist-freshness.ts is already this package's one home for the precondition ("they live in ONE
home, this file, so the next bundle-reading gate finds them together"), so it gains
EXIT_PREREQUISITE_NOT_MET, EXIT_FINDINGS and prerequisiteNotMetText(); the seven call sites
use them. The verdict's own message — cause, damage, two-line build remedy — is passed through
byte for byte; the wrapper adds the fleet phrase GATE: PREREQUISITE NOT MET — … that
check-dts-closure, check-dual-build-cjs-loads, check-i18n-bundles, check-i18n-coverage and
check-closing-target-claim all already print, plus the "Nothing was measured" paragraph that
names the code. A number with no sentence beside it leaves the reader where they were.

Route (the brief offered both). The shared scripts/import-prerequisite.mjs frame is not
adopted: it is a dependency LOADER (requireDependency / requireDefaultExport) answering "can
this gate import the package it names?", which is a different prerequisite reached a different
way. The constant is hand-carried with the citation, because scripts/import-prerequisite.d.mts
is partial by design — its own header records that the exit-code constants are deliberately left
out — so importing the name is TS2305 until that root-tree mirror is extended, and extending it is
outside this card's surface. Drift is closed by a pin on each side instead: that module's
--self-test asserts === 3 there, and dist-freshness.test.ts asserts the same here and
compares against the value read from that module itself.

Verification

origin/main (8f6d831) is merged; it touched none of packages/spec/scripts/,
scripts/import-prerequisite.mjs or scripts/pm/dispatch-gates.mjs. Everything below is measured
after that merge, on c28fe19, with the closure rebuilt on the merged sources (turbo run build over spec + client + client-react, 34 tasks, exit 0) — each exit captured before any pipe:

pnpm --filter @objectstack/spec run check:api-surface                :: exit 0
pnpm --filter @objectstack/spec run check:browser-reachable-entries  :: exit 0
pnpm --filter @objectstack/spec run check:dual-source-exports        :: exit 0
pnpm --filter @objectstack/spec run check:entry-nameability          :: exit 0
pnpm --filter @objectstack/spec run check:exported-any               :: exit 0
pnpm --filter @objectstack/spec run check:skill-examples             :: exit 0
pnpm --filter @objectstack/spec run check:generated                  :: exit 0

Six green is the positive control: the change refuses nothing CI is legitimately in.

Ablation. The refusal in check-exported-any.ts was put back to console.error(freshness.message); process.exit(1); through scripts/ablation-replace.mjs (anchor hit x1, blob
8e3498ca72bf -> 391020853d3a — the mutation is proved on disk, not by an editor's exit code).
dist-freshness-adoption.test.ts then read 1 failed | 9 passed, the one failure being
check:exported-any … AssertionError: expected 1 to be 3. Nine cases stayed green, so the pin is
targeted rather than blanket. Restored through the same tool: blob back to 8e3498ca72bf, equal to
HEAD, git diff HEAD empty. No dist preflight applies — these gates run from source under tsx
and the sandbox copies packages/spec/scripts, which is precisely why the mutation reached the run.

Tests and gates, all exit 0: check:scripts-typecheck · @objectstack/spec typecheck ·
check:cross-package-test-inputs · check:nul-bytes · check:test-source-alias ·
check:issue-citations · check:pm-dispatch-gates.

Changeset: skip-changeset, measured. packages/spec's files[] is
dist json-schema liveness prompts llms.txt README.md src/**/*.zod.ts CHANGELOG.md api-surface spec-changes.jsonscripts/ is not on it. After the build, prerequisiteNotMetText,
EXIT_PREREQUISITE_NOT_MET and inspectDistFreshness each grep to 0 files under those paths,
while the positive control (QueryAST 55, ObjectSchema 59) fires. Nothing here publishes.

Governed surfaces: none, and none falsified

The landing-path question, re-measured rather than inherited. The instrument reaches governed prose
(control: 8 exit hits in AGENTS.md). Across docs/adr/**, docs/NORTH-STAR.md, .claude/**,
skills/**, AGENTS.md and CLAUDE.md, no file pins a number to these gates' prerequisite
state.
The gate NAMES appear (AGENTS.md:731,736,743,
.claude/skills/spec-property-retirement/SKILL.md:285,321-322) and carry no exit code.
.claude/skills/pm-dispatch/references/lanes/ — the surface #14008 had to repair — holds exactly
one exit mention today, director.md:66, about a different tool. AGENTS.md:736 ("a failure
there is a real finding to fix") reads MORE true once a prerequisite stops being spelled as a
finding. ⇒ no governed hunk is pulled in, and this stays an ordinary queue landing.

Acceptance notes

Found while sweeping for the true population; ⛔ not fixed here, to be filed rather than ridden in:

  1. build-docs.ts:103-119 is a seventh site of the same class. It refuses a stale or missing
    json-schema tree — a different artefact reached through a different predicate
    (schemaTreeIsStale, not distIsStale), outside the "six gates" this card rules on — and it,
    too, answers exit 1. Same remedy shape ("Generate it first"), same reader confusion.
  2. Four unguarded throw backstops reach the shell as exit 1 with a node stack trace for the
    same event: build-api-surface.ts:145, check-exported-any.ts:143,
    check-dual-source-exports.ts:125 ("Is the package built?") and check-entry-nameability.ts:311
    ("Build first"). These are exceptions, not refusals with a chosen code, so converting them is a
    behaviour change rather than a constant swap — which is why they are named here instead.
  3. Noted, not filed: AGENTS.md:735-737 calls check:skill-examples, check:exported-any and
    check:dual-source-exports "pure source audits", yet all three read the built dist and refuse
    on it. Pre-existing prose imprecision, untouched by this diff and carried by no gate. Successor:
    the next PR that edits that paragraph for another reason.

🤖 Generated with Claude Code

https://claude.ai/code/session_01UDXER3sdqfeVYpEWZs5mZx


Generated by Claude Code

…with exit 3

The five gates that adopted the #7122 dist precondition, plus the bundle
reader next door, all spelled their refusal `process.exit(1)` -- the code a
real finding uses. `scripts/import-prerequisite.mjs:250` declares
`EXIT_PREREQUISITE_NOT_MET = 3` for exactly these two words and argues the
case, so the repo's vocabulary was being contradicted from the one place a
caller looks.

The bite is not cosmetic: `scripts/pm/dispatch-gates.mjs --ran` derives its
NOT-MEASURED class from the recorded exit code and counts every other
non-kill code as a family that RAN, so a gate that refused before reading a
single declaration was reconciled as coverage the round did not have.

`lib/dist-freshness.ts` -- already the one home for this precondition --
gains the constant and the fleet-shaped refusal text; the six call sites use
them. What a real finding returns is untouched: every `fail()`, `refuse()`,
self-test and ratchet verdict still exits 1.

Claude-Session: https://claude.ai/code/session_01UDXER3sdqfeVYpEWZs5mZx
Co-authored-by: Claude <noreply@anthropic.com>
…o-end legs

`expectRefusal` in the adoption test pinned `status` to 1, so the roster moves
with the gate in the same PR. It now pins `EXIT_PREREQUISITE_NOT_MET` and the
two sentences that make the number legible.

`dist-freshness.test.ts` gains the spec-side half of the one-vocabulary pin:
the constant is 3, it is distinct from a finding's 1, it agrees with the root
frame read from `scripts/import-prerequisite.mjs` itself (inside the
`scripts/**` radius `packages/spec` already declares), and the refusal text
carries the fleet phrase plus the "Nothing was measured" paragraph.

Claude-Session: https://claude.ai/code/session_01UDXER3sdqfeVYpEWZs5mZx
Co-authored-by: Claude <noreply@anthropic.com>
…ence

`scripts/import-prerequisite.d.mts` is partial by design and declares only the
two loaders, so tsc sees a namespace without the exit-code constants and
refused the direct cast (TS2352) under `check:scripts-typecheck`. The cast now
goes through `unknown`, and the two `typeof` assertions are what stop a
vanished export from reading as `undefined` on both sides of a comparison.

Claude-Session: https://claude.ai/code/session_01UDXER3sdqfeVYpEWZs5mZx
Co-authored-by: Claude <noreply@anthropic.com>
@os-warren os-warren added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Sep 21, 2026 — with Claude
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

Nothing in this diff resolved to a documentable surface (no symbol, route or SDK anchor derived from 0 changed package(s)), so this run has no opinion about the docs.

What this run could not see

Coarse fallback — 0 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 8f6d83147b2575cad02d336df3fcb9aaea3084f2packageMentionDocs.

Copy link
Copy Markdown
Collaborator Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: c28fe1918f30e403f73b758fc5ba9a314d1e08a6

Read first-hand from a worktree pinned to that sha (base 8f6d831, the PR's merge base; origin/main is 6 commits ahead at fbc12be and none of them touches a file this review read), the card #19227 body, comments 5748777680 / 5753470159 / 5754123048, the PR body and the full 9-file diff. Neither the dev report nor the dispatch brief was adopted as a reading.

① Derived judgments

Accept/reject behaviour. The diff moves exactly seven refusal sites in six gates from exit 1 to EXIT_PREREQUISITE_NOT_MET = 3, and nothing else. A base-vs-head enumeration of every exit site per file (process.exit, return N, fail(, refuse() shows identical site counts with only these seven changed: build-api-surface.ts:88→91, check-browser-reachable-entries.ts:522→529, check-dual-source-exports.ts:400→408 (both modes), check-entry-nameability.ts:587→597 (a return into process.exit(main())), check-exported-any.ts:451→454, and in check-skill-examples.ts the staleness guard 3255→3282 plus the unbuilt-self-package fail() at 3259→3293 (fail( call sites 9→8, refuse( 2→2, prerequisiteNotMet( 2). Every other exit-1 site is byte-identical to base: the --self-test batteries and handshakes at check-exported-any.ts:265/399/427, check-dual-source-exports.ts:230/351/379, check-browser-reachable-entries.ts:674/693/705/1271/1297; the finding return 1s in check-entry-nameability.ts:615/657/694/738/755; fail()/refuse() in check-skill-examples.ts:1460/1476; the missing-tracked-baseline exit at check-dual-source-exports.ts:438; and the final process.exit(1) in each gate. EXIT_PREREQUISITE_NOT_MET is referenced at those seven sites and nowhere else. Claim 1 holds: no real-finding path became exit 3. Each of the seven guards a predicate that is build state by construction: inspectDistFreshness / inspectBundleFreshness (distIsStale / bundlesAreStale) for six, and for the seventh packagePaths()'s declared root .d.ts absent on disk, which that function's own docblock reads as "the package was not built (or built with OS_SKIP_DTS)".

Text. prerequisiteNotMetText() passes refusal.message through verbatim for the six inspect*Freshness sites (detail = refusal.message; the new test pins text.toContain(verdict.message)); the headline is new and chosen from state: 'missing' | 'stale', the only two states the type declares. The seventh site is caller-composed: its first line moves from [name] not built — no declarations to check examples against: to the fleet headline; the missing list and the build-first remedy pass through byte for byte. That rewording is declared in the PR body and the docblock, and no test, doc or workflow pins either old string (repo grep 0); the old ❌ check:browser-reachable-entries — NOT MEASURED. prefix is likewise unpinned.

Population. check:api-surface is tsx scripts/build-api-surface.ts --check (packages/spec/package.json), so the identification holds. check-api-surface-declarations is absent from this tree under every spelling as a script, artefact or producer (0 scripts, 0 directories, no producer of declaration text changed); firing control check:api-surface 29 files. One correction to "does not exist under any spelling": it DID exist. check:api-surface-declarations and the api-surface-declarations/ artefact were removed by the revert 2277d1f (#19024, 2026-09-20), and four .changeset/*.md files still name it in prose. The card's source named a gate reverted the same day; "not present" is correct for this tree and it is not a missed site.

Constant. Hand-carried, as claimed. scripts/import-prerequisite.d.mts says in its own header that the exit-code constants are deliberately omitted, so a typed import is TS2305. Both pins verified: import-prerequisite.mjs:1482 asserts === 3 (--self-test run here: 76 cases pass, exit 0), and dist-freshness.test.ts asserts 3, asserts !== EXIT_FINDINGS, and compares both constants against the root module read at runtime with typeof guards so a vanished export cannot compare undefined to undefined. That test and the adoption test are both listed in packages/spec/vitest.repo-tests.json (the repo project, whose inputs include the declared scripts/** cross-package glob), and CI runs turbo run test test:repo, so the comparison is not turbo-cache-blind to a root-side change. Judgement on the two-implementations shape: the VALUE is declared twice, but a runtime comparison closes the drift in both directions rather than describing it. The TEXT helper is a separate observation: prerequisiteNotMetText already exists as a local copy in nine root scripts (check-i18n-bundles, check-dts-closure, check-type-check-coverage, check-i18n-coverage, check-i18n-walk-parity, check-published-readme-exports, check-sourcemap-no-sources-content, pm/post-stamped, import-prerequisite), so the spec copy is the eleventh instance of an established per-gate pattern, not a new fork. The pins cover the number, not the prose, and the "Nothing was measured" sentence already varies between copies. Noted, not a defect.

Ablation. Reproduced on disk without running it: restoring console.error(freshness.message); process.exit(1); in check-exported-any.ts with the new comment kept (the one-anchor shape) hashes to 391020853d3acd9a6784a3e3ffd69960ae80a9ba, the blob the dev reported; head is 8e3498ca72bf4778a437ddfd4868d30abd05b87c. The adoption test has 10 it cases and exactly one (check:exported-any … refuses the audit on a stale dist) asserts that gate's refusal code, so 1 failed / 9 passed is the targeted shape, and expectRefusal pins toBe(3), not "non-zero". The test run itself is NOT MEASURED here (no node_modules); CI Test Core is green on this head, which is the positive half.

Clause-②: no is CORRECT. The mechanical floor (a new exported symbol or a new key on a published payload forces yes) is not met: the new exports live in packages/spec/scripts/lib/, which is not in files[], is not a tsup entry, and greps to 0 under every tracked published path (src/**/*.zod.ts, json-schema, liveness, prompts, llms.txt, README.md, api-surface, spec-changes.json; control QueryAST 30 files, ObjectSchema 84). 0 of 9 paths are under packages/spec/src/**. check-widening-tells --declaration no --diff: 0 tells, 9 files NOT MEASURED (no declared surface covers packages/spec/scripts/), recorded as what it is rather than as a clean reading.

② Semver level

skip-changeset is the correct declaration, measured: packages/spec files[] is dist json-schema liveness prompts llms.txt README.md src/**/*.zod.ts CHANGELOG.md api-surface spec-changes.json, and scripts/ is not on it; no package.json and no .changeset/ path is in the diff (0 of 9). The dist half of the symbol grep is NOT MEASURED here (no build); the tracked-path half reads 0 with a firing control, and scripts/ is not a tsup entry, so nothing in dist can carry the symbols. Ruling batch #158 item 1 (card #18375, comment 5729188290) — 「⛔ skip-changeset is not applied to a PR that edits an existing changeset」 — does not reach this PR: it edits no .changeset/ file, so the label is the ordinary case, not the forbidden one. Check Changeset is green on the head.

③ Boundary flags

  • Security / permission boundaries: none touched.
  • Gate weakening, argued rather than assumed: moving a refusal from 1 to 3 removes no block anywhere. Every CI consumer treats any non-zero as failure: lint.yml runs the six gates as bare run: steps (:6257/6297/6310/6350/6384/6397); check-generated.ts run() is ok only on exit 0 (execSync throws on 3 exactly as on 1); build-api-surface.ts in gen: mode still fails its && chain. The one consumer that reads 3 by value is dispatch-gates --ran (:13932), whose ok is unrun.length === 0: NOT MEASURED never blocked, and an exit-1 refusal never blocked either — it was counted as RAN, which is the defect. The direction is toward a truer reading, and no finding is reclassified (①). shard-timings-refresh.yml:302 branches on 3 for a different tool. Not a weakening.
  • Published-contract change: none; nothing published moves (②).
  • New required gate / hook / ratchet: none. Five new test cases in dist-freshness.test.ts; no workflow, hook or baseline added. The exit-code pins moved with the gate in the same PR and each asserts an exact 3.
  • New runtime third-party dependency: none; node built-ins and a local lib only.
  • Governed surfaces: 0 of 9 paths hit the GOVERNED_SURFACES register (docs/adr/**, .claude/**, skills/**, AGENTS.md, CLAUDE.md, docs/NORTH-STAR.md); no lane doc pins an exit number to these gates (references/lanes/ holds one exit mention, director.md:66, about another tool). Ordinary queue landing.
  • Dev flags, answered: the sites the dev dispositioned rather than changed (build-docs.ts:103 schemaTreeIsStale still exits 1; the four unguarded throw … Is the package built? backstops; the check-generated.ts:751 aggregate fold) are confirmed untouched by this diff and correctly left to filing. One residual worth its own line: the reclassified check:skill-examples site would also answer 3 to a package whose exports map names a types path the build does not emit, a manifest defect rather than build state; that class landed in the same site before (as fail()), belongs to the exports / published-files gates, and only the code moved.
  • NOT MEASURED in this review: any test or gate execution against this tree (no node_modules; the CI check-runs on the head are the evidence, all seven required contexts success), the dist grep, and the dev's dynamic --ran 65 / 59 / 6 reproduction (the classification rule was read first-hand instead).

Implemented-by: claude/issue-19227-spec-gate-prerequisite-exit
Reviewed-by: session_01UDXER3sdqfeVYpEWZs5mZx

VERDICT: PASS


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

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

Projects

None yet

2 participants