Skip to content

feat(devx): the changeset gate answers whether the LEVEL fits the surface, not only whether it is major - #16264

Merged
baozhoutao merged 1 commit into
mainfrom
claude/issue-16055-changeset-level-vs-clause2
Sep 6, 2026
Merged

feat(devx): the changeset gate answers whether the LEVEL fits the surface, not only whether it is major#16264
baozhoutao merged 1 commit into
mainfrom
claude/issue-16055-changeset-level-vs-clause2

Conversation

@claude

@claude claude Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Fixes #16055

Check Changeset was green on patch and green on minor for the same diff. Measured on PR #16044's two real heads — e0938d3fdce grading @objectstack/cli patch, 98179cae022 grading it minorgit diff between the two commits returns one changeset path and nothing else, and inside it one word. Every level-sensitive gate passed both. On that axis the job's green could not fail, so it carried no information about the level while looking exactly like a green that does.

The change — one axis added to scripts/check-changeset-no-major.mjs, no new script, no workflow edit

The gate now answers a second question beside "is this bump major": when this PR DECLARES clause ② — a new key on a published payload — is it grading a package it grew patch? If so it refuses, naming the changeset file, the package, and the level to raise to.

  • The declaration is read, not inferred. The carrier label needs:contract-review, and the fixed Clause-②: yes / no line — read through check-clause2-carriers.mjs's own exported readClause2Line, and CONTRACT_REVIEW_LABEL imported from check-half-states.mjs, so there is no second spelling here that can drift from the gate the PM protocol runs. That module is read as a library and is not edited.
  • "The package that grew the surface" is any workspace package whose packages/*/src/** this diff moves, resolved to its name from package.json in the HEAD tree so the reading works on commits that are not checked out.
  • Three answers, never two. No readable declaration prints LEVEL AXIS: NOT MEASURED and exits 0 — neither a pass nor a failure. A declaration that reads no is a separate verdict with separate text, because a decision and a missing reading must not look the same; that confusion is the class this card is about.
  • Nothing new is asked of an author. Both declarations already existed in the same PR and were never compared. Sketch 3 of the card (a precedent lookup, a new obligation on every PR) is deliberately not here.
  • The refusal adds no new blocking state. The clause-② carrier is what already holds a PR outside the merge queue until the contract review clears it, so this can only ever fire inside a window the PR is already waiting out. It turns a silent wrong level into a loud one.

No .github/workflows/** file changes. The real scan is already wired per PR: pr-automation.yml's Check Changeset job runs node scripts/check-changeset-no-major.mjs --base "$MERGE_BASE" as its last step. The declaration comes from the pull_request payload the job already has on disk at $GITHUB_EVENT_PATHno API call, no token, no network. --event FILE names that payload for an offline run and makes it the whole declaration input.

The PM's mechanism assumption A1 is falsified, and that is what made this reachable

The dispatch recorded that check-changeset-no-major.mjs / check-empty-changeset.mjs / check-adr-0087-registration.mjs run per PR only as --self-test, and for real only in cut-rc.yml. Measured on this tree instead:

script per-PR REAL run where
check-changeset-no-major.mjs yes, --base "$MERGE_BASE" pr-automation.yml, Check Changeset job (last step)
check-empty-changeset.mjs yes, --base "$MERGE_BASE" same job
check-adr-0087-registration.mjs yes, --base "$MERGE_BASE" same job
all three --self-test yes lint.yml, via pnpm check:changeset-gate-self-tests
all three real RC time only cut-rc.yml

So placement (b) needed no lint.yml edit and no new script, and the needs_decision fork the dispatch offered was not taken.

A2's question, answered by measurement rather than assumed

The dispatch asked which declaration the #16044 heads actually carried. Both were checked:

That is why the carrier is read, and why the reader is a disjunction over both carriers rather than the line alone.

Both precedent citations in the card were re-checked at source before being relied on

Both hold. The card's premise stands.

Acceptance — driven on #16044's two real heads, not only on fixtures

A scratch clone fetched the two commits plus their branch point 2024eca4ff2, checked each head out, overlaid this branch's gate, and ran it with the event payload built from the label state measured above. Exit codes captured before any pipe.

head changeset declaration exit verdict
e0938d3fdce @objectstack/cli: patch carrier on 1 refused, naming the file and the package
98179cae022 @objectstack/cli: minor carrier on 0 LEVEL AXIS: … no package … graded patch
e0938d3fdce @objectstack/cli: patch carrier stripped 0 LEVEL AXIS: NOT MEASURED

Row 1 against row 2 is the card's hard condition: byte-identical source tree, one word apart, opposite verdicts. Row 3 is the control on the other axis — the same offending tree goes quiet when the declaration is not readable, so row 1's red is about the level and the declaration, not about the diff.

The refusal, verbatim from row 1:

⛔ This PR declares clause-② YES and grades a package it grew `patch`.

   .changeset/lint-eval-generator-load-envelope.md
     - @objectstack/cli: patch   ← this PR moves @objectstack/cli's packages/*/src/**

The two declarations disagree, inside one PR:
   · carrier: `needs:contract-review` IS on this PR
   · declaration line: a near miss, not a declaration — ## Clause ② — declared per limb, from the delivered diff

Tests

--self-test gains one battery, The LEVEL axis: #16044's two heads, one word apart (#16055), 41 assertions, registered in SELF_TEST_BATTERIES with the roster floor raised 13 → 14. Total 116 → 157 assertions. The two head fixtures are built from one shared body constant with the bump word substituted, so they are byte-identical by construction — a typo cannot make the negative pass for the wrong reason. It covers the pure judgement, the declaration reader against #16044's real PR-body prose, the event-payload reader, and six end-to-end cases over real temp git repositories (including the #7005 exemption on this axis and the downgrade direction that must still fire).

Reverse verification — two ablation legs, each proven on disk and restored. Mutation confirmed by removed-text and injected-text counts plus a git hash-object that moved off the HEAD blob; restore confirmed by an empty git diff HEAD and the hash back to b9c431b736e750490ed0f6bf74e1fcd7acd3649a. A trap … EXIT INT TERM with absolute paths carried the restore.

  • Leg 1, the refusal removed (entry.bump === 'patch' → a bump word nothing declares): self-test exits 1 with 7 failures, and they are exactly the seven positive assertions —
  • #16044's patch head must be REFUSED — got clean
  • the two heads must differ in EXIT CODE, not merely in verdict name — that is what CI reads
  • the refusal must NAME the file and the package — got []
  • the refusal MESSAGE must name the package and the changeset, not merely exit 1
  • the refusal message must name the level to raise to — an author reading it must know which word to change
  • end to end: a real diff that moves packages/cli/src/** and grades it `patch` under a `yes` declaration is REFUSED
  • a DOWNGRADE of an existing entry to `patch` is introduced by this PR and is refused — the control for the exemption above

Every negative and control stayed green — the discriminating direction, not merely "it goes red".

  • Leg 2, the declaration axis collapsed to always-yes: self-test exits 1 with 4 failures, the NOT MEASURED / no / near-miss controls. So the declaration reading is load-bearing too.

Gates

Union derived at 07103b4f9 with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --changed --commands, reconciled with --ran: 33 derived, 33 run, 0 NOT-MEASURED, 0 UNRUN — all exit 0. pnpm check:pm-dispatch-gates ran under scripts/pm/os-verify-lock.sh (VERDICT command-exit 0, held 677s, waited 469s). The two imported modules' own self-tests are green as well (check-clause2-carriers 190 cases, check-half-states 2535 cases), neither file edited.

Two readings that are NOT MEASURED rather than green, stated so they are not mistaken for coverage:

  • No *.test.ts anywhere names check-changeset-no-major.mjsgit grep -l over the test globs returns nothing. The script's --self-test is its suite, and it is wired in lint.yml.
  • Repo-wide pnpm lint is CI's. Locally eslint ran over the one changed file as a declared narrowing: population read from eslint's own resolution (--print-config exits 0, so the file is not ignored), file count read from --format json (1 file, 0 errors, 0 warnings), and eslint.config.mjs states in its own text that it "never enables type-aware linting (no parserOptions.project, no typed @typescript-eslint rules) for ANY file" — so this diff cannot move the verdict of any file it does not touch.

grep -naP '[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]' over the changed file returns nothing, beside a green check:nul-bytes.

Residuals, recorded in the file's own header rather than implied

  1. The payload label set is a snapshot. A carrier applied after the event fired is invisible to that run — the same stale cell this job documents at length for skip-changeset and allow-major, closed the same way: this workflow triggers on labeled/unlabeled, so hanging the carrier fires a run that does see it. On fix(cli): carry the ADR-0112 error carriers on os lint --eval --json's generator-load exit #16044 the opened run at 21:40Z would have read NOT MEASURED and the labeled run three minutes later refuses the patch.
  2. The carrier is stripped at review PASS, so a run after the PASS reads NOT MEASURED and this axis stands down. That is the intended order — on fix(cli): carry the ADR-0112 error carriers on os lint --eval --json's generator-load exit #16044 the review's own verdict comment concurred with the minor grading explicitly — but it makes this a pre-review reading, never a landing-time one.
  3. The allow-major label skips the whole step, this axis included, because the step it lives in is the launch-window major guard. A PR granted a whole-stack major that also grades a clause-②-declared package patch is not caught. Two conditions with no motive; the alternative — a second step — is refused by check-empty-changeset.mjs's pin on this job's failable step count.

Scope

This card only. No out-of-scope defects were found, so no issues were filed. Sketch 2 (binding the level to an ADR-0087 disposition) did not fall out for free and was not attempted; sketch 3 stays refused for this card. .github/workflows/** is untouched, so PRs #15331 / #15392 are not contended.

Publishes nothing — the whole diff is one file under scripts/ — so skip-changeset is applied.

Opened as a draft; not flipped ready, auto-merge not armed.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Vbw3RPgdtqesx4azk9SbW8


Generated by Claude Code

…face, not only whether it is `major`

`Check Changeset` was green on `patch` and green on `minor` for the same diff:
measured on PR #16044, heads `e0938d3fdce` and `98179cae022`, a source tree that
`git diff` reports as differing in exactly one changeset path and, inside it,
one word. Every level-sensitive gate passed both. On that axis the job's green
could not fail, so it carried no information about the level while looking
exactly like a green that does.

`check-changeset-no-major.mjs` now reads a second axis beside the major guard:
when the PR DECLARES clause ② -- the `needs:contract-review` carrier, or the
fixed `Clause-②: yes` line, read through `check-clause2-carriers.mjs`'s own
`readClause2Line` rather than a second spelling -- a `patch` entry for any
package whose `packages/*/src/**` the diff moves is refused. Nothing new is
asked of an author: both declarations already existed in the same PR and were
never compared, and the refusal can only fire while the carrier already holds
the PR outside the merge queue.

The declaration is read from the `pull_request` payload CI already has on disk,
so the gate makes no API call and needs no token; `--event FILE` names that
payload for an offline run. No workflow file changes: the real scan is already
wired per PR in pr-automation.yml's `Check Changeset` job.

Driven on the two real heads, not only on fixtures: `patch` exits 1 naming
`@objectstack/cli` and the changeset, `minor` exits 0, and the same `patch` head
with the carrier stripped reads NOT MEASURED -- a missing reading, never a pass.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vbw3RPgdtqesx4azk9SbW8
@claude claude Bot added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Sep 6, 2026
@github-actions github-actions Bot added the size/l label Sep 6, 2026
@baozhoutao
baozhoutao marked this pull request as ready for review September 6, 2026 08:56
@baozhoutao
baozhoutao enabled auto-merge September 6, 2026 08:56
@baozhoutao
baozhoutao added this pull request to the merge queue Sep 6, 2026
Merged via the queue into main with commit c3b63f7 Sep 6, 2026
36 checks passed
@baozhoutao
baozhoutao deleted the claude/issue-16055-changeset-level-vs-clause2 branch September 6, 2026 09:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[finding] No gate answers whether a changeset's LEVEL fits the surface — Check Changeset is green on patch and on minor for the same diff

2 participants