diff --git a/.github/workflows/pr-automation.yml b/.github/workflows/pr-automation.yml index a8d4d8b2e6b..3e235ae0d21 100644 --- a/.github/workflows/pr-automation.yml +++ b/.github/workflows/pr-automation.yml @@ -751,46 +751,68 @@ jobs: 1. It releases something -> run 'pnpm changeset' and name the packages it releases. - WHICH LEVEL: A purely additive widening of a published package's - public surface (a new exported symbol on an `index`, a new accepted - key or value) takes at least `minor`. The commit type may raise a - bump but never lower it below what the act requires; a `fix(` that - widens an index is therefore `minor`, and a `fix(` that changes no - public surface stays `patch`. During the launch window `major` stays - refused by `check-changeset-no-major` and breaking-ness is carried by - the BREAKING banner plus the ADR-0087 disposition, not by the level. - Ruled by the maintainer on 2026-09-04 (decision batch #35) on #15294, - which recorded two contract reviews reading the repo's own history to - OPPOSITE bumps for the same additive act. The commit type is not the - discriminator it looked like: it correlates with the act, and when - they disagree the act wins. The 64 historical `patch` precedents are - pre-rule and nothing is retro-fixed. + WHICH LEVEL: the level follows the METADATA PROTOCOL alone -- the + change to what a metadata author can write -- and the commit type + never decides it (maintainer ruling A, 2026-10-21, on #25105; it + amends the 2026-09-04 rule of decision batch #35 on #15294, under + which nearly every release was a minor: of 95 pending changesets + about half were `minor` and about a dozen moved the protocol). + Declare the kind on a line of its own, in the PR body AND in the + changeset body, beside the `Clause-②:` line: + + Metadata-protocol: added a new authorable key, a new accepted + value or enum member, a new metadata + type -> `minor` + Metadata-protocol: narrowed a key removed or tombstoned, a value + refused, a requirement tightened + (declare the `(narrowing)` arm too) + -> `minor` + Metadata-protocol: semantic the meaning or default of an + authorable key changes -> `minor` + Metadata-protocol: none everything else: description or + error text, non-authorable internal + types, REST, drivers, services, the + console, `feat:` and `fix!:` alike + -> `patch` + + `added` takes `minor` because spec schemas are not `.strict()`: an + older runtime silently strips a key it does not know, so an app + that writes a new key must be able to name a minor floor. Where + several kinds apply, name the one highest in breaking-ness: + `narrowed` over `semantic` over `added`. A non-protocol breaking + change ships `patch` and KEEPS its carriers: the BREAKING banner + and the ADR-0087 disposition are unchanged. `major` stays refused + by `check-changeset-no-major` during the launch window. The + GitHub Release body is grouped from these declarations by what the + reader must do: 需手动处理 / 自动迁移 / 新增能力 / 语义变化 / 其他修复. PART OF THIS IS A GATE, and which part is the difference between grading well and being refused. `check-changeset-no-major.mjs` - refuses `major` and says why in its header. Since #16055 the SAME - script also runs a LEVEL AXIS over the rule above, and that - script's header states its scope: "a PR that DECLARES clause ② (a - new key on a published payload) must grade AT LEAST ONE package - whose published source it moves `minor` or above". It mechanizes - this ruling "for exactly the PRs where the widening is already - DECLARED, and for no others", and its `judgeLevel` verdict table - spells both directions: `not-declared`, "the declaration reads - `no`", exits 0; `enforce`, "declared `yes`, moved packages graded - `patch` and NONE of them graded `minor` or above", exits 1. So on - a PR that declares clause ②, grading `patch` across the board is - refused; on every other PR the axis stands down and the two levels - below `major` are yours, as they always were. + refuses `major` and says why in its header. The SAME script runs + the LEVEL AXIS over the rule above, reading both lines from the PR + body on every `opened`, `synchronize` and `edited` event, and its + `judgeLevel` verdict table spells every direction: a declared kind + must grade at least ONE package whose published source the diff + moves `minor` or above (`enforce`, exit 1 otherwise); a declared + `none` must grade every entry it introduces `patch` (`none-minor`, + exit 1) and may not move a `packages/spec/authorable-surface/` or + `authorable-defaults/` shard (`none-baseline-moved`, exit 1); + `added` beside `Clause-②: no`, or `narrowed` beside a line without + the `(narrowing)` arm, is a `contradiction` (exit 1); and a PR that + introduces a changeset entry with no readable `Metadata-protocol:` + line is refused as NOT MEASURED (exit 1) rather than ticked -- the + body is re-read on `edited`, so that red clears with no push. WHAT THE AXIS DOES NOT JUDGE, in its own words: "WHICH of the packages above received that widening is NOT something this gate - can read, and it does not claim to. Clause ② is declared ONCE, FOR - THE PR" -- the declaration line in the PR body names no package. - The axis cross-checks a declaration - against the levels; it never reads the diff to find the act. - Judging the act is still yours, this paragraph is still where the - rule lives, and everything in it the axis does not reach is still - prose. + can read, and it does not claim to. The kind is declared ONCE, FOR + THE PR" -- the declaration lines in the PR body name no package. A + refused value, a narrowed enum or a changed meaning is invisible to + the baselines, so the declaration, not the diff, is the carrier. + The axis cross-checks the declarations against the levels; it never + reads the diff to find the act. Judging the act is still yours, + this paragraph is still where the rule lives, and everything in it + the axis does not reach is still prose. 2. It releases nothing (.github/, .claude/, docs/, content/, examples/, tests-only, and the like) diff --git a/AGENTS.md b/AGENTS.md index ca57aaf3f4a..7b49ba6b547 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1081,24 +1081,24 @@ Both non-handshake shapes, and how to classify and probe your own: surface** (Prime Directive #14 names them — more than ADRs): push it, open the PR, and stop there; landing waits for its tier's record — the maintainer's word, or a seat's contract-tier review. For that class, a finished task = a PR left visibly awaiting that record. -3. **Add a changeset for anything that publishes.** Feature, functional improvement or fix — run `pnpm changeset` - (or add a `.changeset/*.md` entry) describing it before committing. A bug fix in a released package takes a - **`patch`** changeset — never none, and ⛔ never `skip-changeset`: that label is for a diff that publishes - nothing from any released package. The declaration is `Clause-②: yes|no` plus at most one arm from the closed pair - `(widening)`/`(narrowing)`: `yes` takes at least **`minor`**, `(narrowing)` is BREAKING, `no (widening)` malformed. - **Breaking changesets must carry their migration.** If the change removes or renames anything an author can write (a - spec key, an export, a config field), the changeset body must state the FROM → TO mapping and the one-line fix — - this text ships to consumers as `CHANGELOG.md` inside the npm package and is what an upgrading agent greps after the - tombstone error. Removing an authorable spec key also requires a tombstone so the rejection itself carries the - prescription — `retiredKey()` (`packages/spec/src/shared/retired-key.ts`) on the schema whether or not it is - `.strict()`, and an entry in the shape's `*_RETIRED_KEY_GUIDANCE` map (see `data/mapping.zod.ts`) only for a - spelling the shape never declared, such as the retired key's old alias, where a tombstone has no property to - replace. The changeset is one of fourteen surfaces a retirement touches — follow the - `spec-property-retirement` skill (`.claude/skills/`) rather than reconstructing the kit, and note that a tombstone - keeps its liveness-ledger row while a key deleted without one loses it. - **A breaking changeset must also state its ADR-0087 disposition, in writing** — exactly one marker in the changeset - body, which also carries the PR's `Clause-②` line: `pnpm check:adr-0087-registration` reads the arm there. ⛔ The - categories are NOT copied here — the gate prints the full set when it fails. +3. **Add a changeset for anything that publishes** (`pnpm changeset` before committing). A bug fix in a released + package takes a **`patch`** changeset — never none, and ⛔ never `skip-changeset`: that label is for a diff that + publishes nothing from any released package. Two line-initial declarations, in the PR body and the changeset body: + `Clause-②: yes|no` plus at most one arm from the closed pair `(widening)`/`(narrowing)` (`(narrowing)` is + BREAKING, `no (widening)` malformed), and `Metadata-protocol: added|narrowed|semantic|none`, the change to what a + metadata author can write — a new key, accepted value or metadata type is `added`; a key removed or tombstoned, a + value refused or a requirement tightened is `narrowed`; a changed meaning or default is `semantic`; all else is + `none`; several ⇒ `narrowed` over `semantic` over `added`. The level follows the kind alone: a kind takes at least + **`minor`** on a package the diff moves, `none` takes **`patch`**; a non-protocol change, widening or breaking, + ships `patch` and keeps its BREAKING banner and ADR-0087 disposition. **Breaking changesets must carry their + migration**: removing or renaming anything an author can write (a spec key, an export, a config field) puts the FROM + → TO mapping and the one-line fix in the body, which ships as `CHANGELOG.md` in the npm package. Removing an + authorable spec key also needs a tombstone (`retiredKey()`, `packages/spec/src/shared/retired-key.ts`) so the + rejection carries the prescription — one of fourteen retirement surfaces; follow the `spec-property-retirement` + skill (`.claude/skills/`), never reconstruct the kit. **A breaking changeset must also state its ADR-0087 + disposition, in writing** — exactly one marker in the changeset body, which also carries both declaration lines + (`pnpm check:adr-0087-registration` reads the arm and the kind there). ⛔ The categories are NOT copied here — + the gate prints the full set when it fails. 4. **A removal that breaks the pinned sibling checkout ships together with the sibling fix and the pin bump — or it does not ship.** The full battery's `Console Pin Gate` builds objectui at the pinned `.objectui-sha` against **current** `main`, so such a removal is a main-red the moment it merges; its ruling therefore authorizes the diff --git a/docs/releases-maintenance.md b/docs/releases-maintenance.md index a2b11361f00..a8567baf091 100644 --- a/docs/releases-maintenance.md +++ b/docs/releases-maintenance.md @@ -39,9 +39,12 @@ repo and bundled into `@objectstack/console` at a pinned commit recorded in [`.objectui-sha`](../.objectui-sha). A SHA bump alone used to leave **no trace** in the history. `scripts/bump-objectui.sh` now closes that gap: on every bump it emits a `@objectstack/console` changeset summarising the objectui `OLD_SHA..NEW_SHA` -commit range (auto-typed `minor` if the range contains any `feat`, else `patch`). -So frontend deltas flow through the *same* pipeline as the backend — they land in -`@objectstack/console`'s changelog and roll up into the platform version. +commit range, graded `patch`: the platform's version line follows the metadata +protocol, which the console never changes, so the file carries +`Metadata-protocol: none` and `CONSOLE_BUMP` is the only thing that grades it +otherwise. So frontend deltas flow through the *same* pipeline as the backend — +they land in `@objectstack/console`'s changelog and roll up into the platform +version. ```bash scripts/bump-objectui.sh # bump to ../objectui HEAD, write the changeset, commit @@ -167,6 +170,25 @@ platform version maps to. page under a "What's new in N.x" running section, or leave them to the generated per-package changelogs. +### Release notes are grouped by what the reader must do + +The GitHub Release body is not the raw CHANGELOG section. `scripts/release-github-releases.mjs` +regroups that section's entries — byte for byte, none dropped — under five headings, in this +order, each read from the declarations the changeset already carries (`Metadata-protocol:`, the +`Clause-②:` arm, the BREAKING banner, the ADR-0087 disposition marker): + +| Section | What places an entry there | +|:--|:--| +| 需手动处理 | breaking (`Metadata-protocol: narrowed`, a `(narrowing)` arm, a BREAKING banner or a `!` summary) with no `registered` ADR-0087 conversion | +| 自动迁移 | breaking, with a `registered` conversion that `objectstack migrate meta` applies | +| 新增能力 | `Metadata-protocol: added`, or a non-breaking `Clause-②: yes` widening | +| 语义变化 | `Metadata-protocol: semantic` | +| 其他修复 | everything else — `none`, dependency bumps, and entries written before the declaration existed | + +A section with nothing in it is omitted. A curated `vN.mdx` section for the same release uses the +same five headings in the same order, so the hand-written page and the generated body agree on +what a reader must do; the per-package `CHANGELOG.md` keeps changesets' own level grouping. + ### Sourcing the Console section for a release Backend content comes from the spec/package changesets. Frontend content comes from diff --git a/scripts/check-adr-0087-registration.mjs b/scripts/check-adr-0087-registration.mjs index 322b6f8728c..e0d430f6efc 100644 --- a/scripts/check-adr-0087-registration.mjs +++ b/scripts/check-adr-0087-registration.mjs @@ -382,7 +382,7 @@ import { maskComments, maskCommentsAndLiterals } from './js-comment-mask.mjs'; // #16421 — the DIRECTION ARM, read through the fleet's one declaration reader. // ⛔ Not re-implemented here: the ruling's condition on the arm is that it has a // single legal spelling read in a single place, and every reader imports it. -import { readClause2Line } from './pm/clause2-line.mjs'; +import { readClause2Line, readMetadataProtocolLine } from './pm/clause2-line.mjs'; // #16421 — the staging manifest for this gate's own I1/I2 fixture, derived from // the module graph rather than typed out. Shared with the OTHER site that copies // this gate into a sandbox (`objectui-changeset-digest.mjs`), because two hand @@ -621,7 +621,7 @@ export function parseChangeset(text) { /** * Does this changeset DECLARE a breaking change? * - * FOUR spellings are in live use in this repo and all four count, because the + * FIVE spellings are in live use in this repo and all five count, because the * gate's subject is the author's own declaration and an author who used any of * them has declared it: * @@ -629,6 +629,7 @@ export function parseChangeset(text) { * 2. a `**BREAKING` marker in the body (52) * 3. a conventional-commit `!` in the summary line (175) * 4. a `Clause-②` declaration whose ARM reads `narrowing` (#16421) + * 5. a `Metadata-protocol: narrowed` declaration (ruling A, #25105) * * The union of the first three is 213. Narrowing to any one of them would drop * real declarations: #6048's changeset used (1) and (2) and NOT (3), while the @@ -667,6 +668,21 @@ export function parseChangeset(text) { * refused since it was written. Signal (4) is the carrier that does not depend on * prose; (2) stays the one that already works where it was used. * + * ## Signal (5): the metadata-protocol kind (maintainer ruling A, 2026-10-21) + * + * Ruling A moved the changeset LEVEL onto a second declaration line beside the + * arm — `Metadata-protocol: added | narrowed | semantic | none` — read by the + * same module. Of the four kinds exactly one is a break by definition: + * `narrowed` is "a key removed or tombstoned, a value refused, a requirement + * tightened", an accept-set narrowing of what an author writes against, and + * ruling A keeps the BREAKING carriers on it unchanged. So a changeset that + * declares `narrowed` has declared breaking, and this gate asks it for its + * ADR-0087 disposition exactly as it asks a changeset carrying the arm — the + * level gate refuses `narrowed` beside a line with no `(narrowing)` arm, so the + * two signals ordinarily travel together; this one closes the day they do not. + * `added`, `semantic` and `none` add NO signal: a new key, a changed default and + * a non-protocol change are not breaks, and the self-test pins all three. + * * @param {ReturnType} parsed * @returns {{ breaking: boolean, signals: string[] }} */ @@ -682,6 +698,11 @@ export function breakingDeclaration(parsed) { // the moment the claim carrying it is written. const decl = readClause2Line(parsed.body); if (decl?.kind === 'declared' && decl.arm === 'narrowing') signals.push('clause-②-narrowing'); + // (5) The metadata-protocol kind, same reader module, same calibration: only + // a DECLARED `narrowed` is a signal; a malformed or near-miss line declares + // nothing here, and the level gate is what refuses it on the PR. + const kind = readMetadataProtocolLine(parsed.body); + if (kind?.kind === 'declared' && kind.value === 'narrowed') signals.push('metadata-protocol-narrowed'); return { breaking: signals.length > 0, signals }; } @@ -2462,6 +2483,10 @@ export function assertInputs({ cwd, head, witness = generatedWitness }) { // signals are all absent from it on purpose. ['the `narrowing` ARM with no banner, no `major` and no `!` (#16421)', "---\n'@objectstack/spec': minor\n---\n\na summary\n\nClause-②: no (narrowing)\n"], ['the `narrowing` arm beside a `yes` value — a diff that widens AND narrows', "---\n'@objectstack/spec': minor\n---\n\na summary\n\nClause-②: yes (narrowing)\n"], + // Ruling A, signal (5): the kind alone, with no arm, no banner, no `major` + // and no `!` — the shape the level gate refuses on the PR body and this + // gate must not wave through on the changeset body. + ['the `Metadata-protocol: narrowed` kind with no arm, no banner, no `major` and no `!` (ruling A)', "---\n'@objectstack/spec': minor\n---\n\na summary\n\nMetadata-protocol: narrowed\n"], ]; const MUST_NOT_MATCH_BREAKING = [ ['a plain `patch` changeset', "---\n'@objectstack/spec': patch\n---\n\nfix a typo\n"], @@ -2471,6 +2496,14 @@ export function assertInputs({ cwd, head, witness = generatedWitness }) { ['a bare `Clause-②: no` — the shape every declaration on the board carries', "---\n'@objectstack/spec': patch\n---\n\na summary\n\nClause-②: no\n"], ['a bare `Clause-②: yes` — a widening declared through the value alone', "---\n'@objectstack/spec': minor\n---\n\na summary\n\nClause-②: yes\n"], ['prose merely NAMING the arm — the reader takes a token, never a phrase', "---\n'@objectstack/spec': minor\n---\n\nthis is a narrowing of nothing at all\n"], + // The OTHER three kinds of signal (5): a new key, a changed default and a + // non-protocol change are not breaks. Without these rows a reader that + // classified every `Metadata-protocol:` line as breaking would satisfy the + // list above while telling a consumer nothing. + ['`Metadata-protocol: added` — a new key is not a break (ruling A)', "---\n'@objectstack/spec': minor\n---\n\na summary\n\nClause-②: yes\n\nMetadata-protocol: added\n"], + ['`Metadata-protocol: semantic` — a changed default is not a break (ruling A)', "---\n'@objectstack/spec': minor\n---\n\na summary\n\nClause-②: no\n\nMetadata-protocol: semantic\n"], + ['`Metadata-protocol: none` — a non-protocol change at `patch` (ruling A)', "---\n'@objectstack/rest': patch\n---\n\na summary\n\nClause-②: yes\n\nMetadata-protocol: none\n"], + ['a MALFORMED kind declares nothing here — the level gate refuses it on the PR', "---\n'@objectstack/spec': minor\n---\n\na summary\n\nMetadata-protocol: narrowed|added\n"], ]; for (const [label, text] of MUST_MATCH_BREAKING) { if (!breakingDeclaration(parseChangeset(text)).breaking) { diff --git a/scripts/check-changeset-no-major.mjs b/scripts/check-changeset-no-major.mjs index 839d35afb8c..0eb10f518d3 100644 --- a/scripts/check-changeset-no-major.mjs +++ b/scripts/check-changeset-no-major.mjs @@ -4,16 +4,22 @@ * * 1. LAUNCH-WINDOW GUARD — a PR may not introduce a changeset that declares a * `major` bump. Everything above "The LEVEL axis" below is this. - * 2. THE LEVEL AXIS (#16055) — a PR that DECLARES clause ② (a new key on a - * published payload) must grade AT LEAST ONE package whose published - * source it moves `minor` or above; and (#16776) a PR that - * grades none of them that way must not leave the declaration UNREADABLE: - * where the missing reading is what decides the verdict, this refuses - * rather than exiting 0 into a check run that concludes `success`. The - * "at least one" is #16361 — the declaration is PR-scoped and names no - * package, so a PR-scoped predicate is the whole of what it entails. Read - * the block headed "The LEVEL axis" for what it cross-checks, where the - * declaration comes from, and the residual it records. + * 2. THE LEVEL AXIS (#16055; re-ruled by maintainer ruling A, 2026-10-21, on + * #25105) — the level follows the METADATA PROTOCOL alone. A PR declares + * the kind of protocol change it makes on a `Metadata-protocol:` line + * beside its `Clause-②:` line (`added` / `narrowed` / `semantic` / `none`, + * read through `clause2-line.mjs`): a declared kind must grade AT LEAST + * ONE package whose published source it moves `minor` or above; a declared + * `none` must grade every entry it introduces `patch`, and may not move an + * `authorable-surface/` or `authorable-defaults/` baseline; `added` beside + * `Clause-②: no`, or `narrowed` beside a line without the `(narrowing)` + * arm, is a contradiction. And (#16776) a PR must not leave a reading that + * decides the verdict UNREADABLE: that refuses rather than exiting 0 into + * a check run that concludes `success`. The "at least one" is #16361 — the + * declaration is PR-scoped and names no package, so a PR-scoped predicate + * is the whole of what it entails. Read the block headed "The LEVEL axis" + * for what it cross-checks, where the declarations come from, and the + * residual it records. * 3. THE PROTOCOL LOCKSTEP EVIDENCE (ruling records 6049734955 and * 6056808625) — the one question here about the TREE rather than the * diff. `PROTOCOL_VERSION` moves to the next major in an ordinary pull @@ -84,12 +90,14 @@ * change that states neither). They are not documentation niceties — during the * window they are the only signal there is. * - * CHOOSING BETWEEN THE TWO LEVELS THIS GUARD LEAVES: a purely additive widening - * of a published package's public surface takes at least `minor`, and the commit - * type may raise a bump but never lower it — the rule is written out in full in - * the `Check Changeset` step's prose in `.github/workflows/pr-automation.yml` + * CHOOSING BETWEEN THE TWO LEVELS THIS GUARD LEAVES: the level follows the + * metadata protocol alone — `minor` when the protocol an author writes against + * is added to, narrowed or changed in meaning, `patch` for everything else, the + * commit type never deciding it — the rule is written out in full in the + * `Check Changeset` step's prose in `.github/workflows/pr-automation.yml` * ("WHICH LEVEL"), where the author who is told a changeset is missing reads it - * (maintainer ruling, 2026-09-04 decision batch #35, on #15294). + * (maintainer ruling A, 2026-10-21, on #25105, amending the 2026-09-04 rule of + * decision batch #35 on #15294). * * ⇒ **THIS GUARD IS WHAT GETS DISARMED AT GA.** The end condition lives in the * file that enforces the convention, rather than in a prose home elsewhere, so @@ -401,7 +409,7 @@ import { isEntrypoint } from './invoked-as.mjs'; // here would be one that can drift from the gate the PM protocol actually // runs. The module imports nothing, so this file still runs before // `pnpm install`. -import { readClause2Line } from './pm/clause2-line.mjs'; +import { readClause2Line, readMetadataProtocolLine } from './pm/clause2-line.mjs'; const __dirname = dirname(fileURLToPath(import.meta.url)); const REPO_ROOT = resolve(__dirname, '..'); @@ -888,6 +896,53 @@ export function render(result) { // tree — a control that would have caught #16044 rather than one that grades // the checker's own fixtures. // +// ## Ruling A (maintainer, 2026-10-21, on #25105): the level follows the protocol +// +// The rule this axis mechanizes was re-ruled. Until then `Clause-②: yes` (or the +// `(narrowing)` arm) coupled to `minor`, so nearly every release was a minor — +// of 95 pending changesets about half were `minor` and about a dozen of those +// moved the metadata protocol; the version number did not say whether the +// protocol had moved. Under ruling A the level is decided by ONE question, the +// kind of change to what a metadata author can write, declared on the +// `Metadata-protocol:` line (`clause2-line.mjs`, the one reader): +// +// `added` / `narrowed` / `semantic` -> at least one moved package `minor`+ +// `none` -> every introduced entry `patch` +// +// Three consequences, each a lane below: +// +// * `Clause-②` keeps its MEANING (does this diff widen or narrow a published +// accept set or public surface) and its readers — the contract-review face, +// the ADR-0087 breaking signal from the `(narrowing)` arm — and loses its +// LEVEL COUPLING here. A non-protocol widening (REST) or narrowing (a +// driver, a posture) ships `patch` and keeps its BREAKING banner and its +// ADR-0087 disposition unchanged. The two lines are cross-checked for the +// two contradictions that are contradictions by definition: `added` widens +// a published accept set, so it cannot sit under `Clause-②: no`; `narrowed` +// narrows one, so it cannot sit beside a line that declares no `(narrowing)` +// arm (that arm is what the ADR-0087 gate and the queue guard read). +// * `none` beside a `minor` is the half of the ruling with teeth — it is what +// stops a `feat:` from lifting 73 packages a minor — and it is refused +// whatever the package, moved or not: a `minor` the diff did not earn on a +// protocol change is the same error wherever the entry sits. +// * Where the existing ratchets can SEE a protocol change, a declared `none` +// over it is refused: a changed `packages/spec/authorable-surface/` shard is +// a key added or removed, a changed `authorable-defaults/` shard is a default +// changed. A refused value, a narrowed enum or a changed meaning is invisible +// to them, so the declaration — not the diff — is still the carrier. +// +// The materiality rule (#16776) is kept and widened to the second line: a +// `Metadata-protocol:` line that cannot be read is MATERIAL whenever the PR +// introduces an entry a kind or a `none` would grade differently — a `patch` on +// a moved package (a kind refuses), any `minor` (a `none` refuses) or a moved +// baseline — and MOOT otherwise. The `Clause-②` materiality is exactly what it +// was: unreadable over a moved package graded `patch` with no raise refuses. +// Nothing below this paragraph loosens a verdict it did not re-rule: the grain +// (#16361), the depth (#16713), the root (#16692), the copy (#15204) and the +// reporting (#18263, #19008) are inherited unchanged and their batteries drive +// the kind lanes with `Metadata-protocol: added` where they drove `Clause-②: +// yes` before. +// // ## What is cross-checked, and why nothing new is asked of an author // // Two declarations about the same PR already exist and were never compared: @@ -1528,6 +1583,50 @@ export function packagesTouched({ cwd, from, head }) { return { packages: [...packages].sort(), unreadable: [...unreadable].sort() }; } +/** + * The generated baselines that make one class of metadata-protocol change + * VISIBLE to this gate (ruling A, the third consequence): the authorable-key + * ratchet, sharded per category, which a normal `packages/spec` build rewrites + * when an authorable key is added or removed; and the default-value ratchet, + * rewritten when an authorable key's default changes. Directory prefixes, read + * with a trailing slash so `authorable-surface.base.json` — the hand-off file + * only `gen:authorable-surface-base` writes — is not one of them. + * + * ⛔ This is a reading of what the ratchets ALREADY record, never a parse of the + * shard content: whether a changed shard means `added`, `narrowed` or + * `semantic` is the author's declaration to make, and the refusal names the + * three. What the baselines cannot see (a refused value, a narrowed enum, a + * changed meaning) is stated in the header and is why the line, not the diff, + * stays the carrier. + */ +export const PROTOCOL_BASELINE_ROOTS = Object.freeze([ + 'packages/spec/authorable-surface/', + 'packages/spec/authorable-defaults/', +]); + +/** + * The protocol baselines this diff moves — the shard files under + * {@link PROTOCOL_BASELINE_ROOTS} that differ between the branch point and + * `head`. Same diff, same branch point as `packagesTouched`, so the two legs + * can never read different sides of the fork. + * + * @param {{ cwd: string, from: string, head: string }} opts + * @returns {{ moved: string[] }} + */ +export function baselinesTouched({ cwd, from, head }) { + let out = ''; + try { + out = git(['diff', '--name-only', from, head, '--', ...PROTOCOL_BASELINE_ROOTS], cwd); + } catch { + return { moved: [] }; + } + const moved = out + .split('\n') + .map((line) => line.trim()) + .filter((line) => line.endsWith('.json') && PROTOCOL_BASELINE_ROOTS.some((root) => line.startsWith(root))); + return { moved: [...new Set(moved)].sort() }; +} + // ── The near-miss remedy vocabulary (#18263) ──────────────────────────────── // // `readClause2Line` returns a REASON with every near miss, and this gate used @@ -1611,6 +1710,66 @@ export function nearMissReadings(reason, line) { ]; } +/** + * The same three remedies for the sibling `Metadata-protocol:` line (ruling A). + * The reader is shared with the `Clause-②:` line and so is its decoration + * tolerance, so the sentences differ only in the key and the closed value set; + * the miss path is identical and identically loud. + */ +const METADATA_PROTOCOL_NEAR_MISS_REMEDIES = Object.freeze({ + spelling: + 'the line does not carry `Metadata-protocol:` in the fixed spelling at the start of a line (case, a space or an ' + + 'underscore in the key, or a markdown heading in front of it). The reader tolerates a `- `, `* `, `> ` or `**` ' + + 'prefix and NOTHING else. Write it bare, on a line of its own: `Metadata-protocol: added`, `narrowed`, `semantic` ' + + 'or `none`.', + 'inline-key': + 'the key is spelled exactly right but sits INSIDE a line, after text that no line-start decoration explains. The ' + + 'POSITION is what is wrong. Give the declaration a line of its own.', + describing: + 'the line carries the key in the fixed spelling at the start of a line, but it QUOTES the spelling instead of ' + + 'declaring a value: it either names `Metadata-protocol:` twice on one line, or opens a backtick span at the key ' + + 'and continues past the closing tick. ADD the declaration on a line of its own, bare, with nothing following it.', +}); + +/** + * The remedy sentence for one `Metadata-protocol:` near-miss reason — or a loud + * miss, exactly as {@link clause2NearMissRemedy}. + * + * @param {string|null|undefined} reason + * @param {string} line + * @returns {string} + */ +export function metadataProtocolNearMissRemedy(reason, line) { + if (typeof reason === 'string' && Object.prototype.hasOwnProperty.call(METADATA_PROTOCOL_NEAR_MISS_REMEDIES, reason)) { + return METADATA_PROTOCOL_NEAR_MISS_REMEDIES[reason]; + } + return ( + `this gate carries no remedy sentence for a near miss of reason ${JSON.stringify(reason ?? null)} — ` + + '`readMetadataProtocolLine` (scripts/pm/clause2-line.mjs) reports a reason this file has not been taught, ' + + `and the reason plus the line are printed rather than swallowed. The offending line is: ${line}. Whatever the ` + + 'reason, a declaration is `Metadata-protocol: added`, `narrowed`, `semantic` or `none`, bare, at the start of a ' + + 'line of its own. Add the sentence for this reason to METADATA_PROTOCOL_NEAR_MISS_REMEDIES in ' + + 'scripts/check-changeset-no-major.mjs.' + ); +} + +/** + * The TWO readings a `Metadata-protocol:` near miss contributes — the sibling of + * {@link nearMissReadings}, keyed so a log reader can tell which line is being + * talked about. + * + * @param {string|null|undefined} reason + * @param {string} line + * @returns {string[]} + */ +export function protocolNearMissReadings(reason, line) { + const named = typeof reason === 'string' && reason ? reason : '(unnamed)'; + return [ + `metadata-protocol line: a near miss, not a declaration — reason \`${named}\` — ${line}`, + `remedy for \`${named}\` (Metadata-protocol): ${metadataProtocolNearMissRemedy(reason, line)}`, + ]; +} + /** * The clause-② declaration this PR carries, read from the event payload alone. * @@ -1635,8 +1794,16 @@ export function nearMissReadings(reason, line) { * and a PR that declares `no (narrowing)` must not be reported as having * declared `yes`. * + * ## The METADATA-PROTOCOL kind (ruling A) + * + * `protocol` is the sibling line's value, from `readMetadataProtocolLine` in the + * same module, and `null` when the line is absent, malformed or a near miss — + * the readings say which. It is the value the level lanes decide on; `value` + * and `arm` stay beside it, read and printed, for the two contradictions + * `judgeLevel` refuses and for the review face the arm routes elsewhere. + * * @param {{ body?: string }|null} pr - * @returns {{ value: 'yes'|'no'|null, arm: 'widening'|'narrowing'|null, payload: boolean, readings: string[] }} + * @returns {{ value: 'yes'|'no'|null, arm: 'widening'|'narrowing'|null, protocol: 'added'|'narrowed'|'semantic'|'none'|null, payload: boolean, readings: string[] }} */ export function declarationFromPullRequest(pr) { const readings = []; @@ -1645,7 +1812,7 @@ export function declarationFromPullRequest(pr) { // "no pull request to read" and "a pull request that declared nothing" are // different facts about different runs, and #16776 is the card about two // facts sharing one exit code. `judgeLevel` routes on this flag. - return { value: null, arm: null, payload: false, readings: ['no `pull_request` payload was available to read a declaration from'] }; + return { value: null, arm: null, protocol: null, payload: false, readings: ['no `pull_request` payload was available to read a declaration from'] }; } const line = readClause2Line(pr.body ?? ''); @@ -1670,9 +1837,20 @@ export function declarationFromPullRequest(pr) { : `direction arm: \`${arm}\`${arm === 'narrowing' ? ' — a BREAKING change; during the launch window it ships `minor`' : ''}`); } - if (line?.kind === 'declared' && line.value === 'yes') return { value: 'yes', arm, payload: true, readings }; - if (line?.kind === 'declared' && line.value === 'no') return { value: 'no', arm, payload: true, readings }; - return { value: null, arm, payload: true, readings }; + // Ruling A: the sibling line, read from the same body by the same module, and + // reported on its own line whichever way it reads — absent included, because + // under this ruling an absent kind is the reading that decides the level. + const protocolLine = readMetadataProtocolLine(pr.body ?? ''); + if (protocolLine?.kind === 'declared') readings.push(`metadata-protocol line: \`${protocolLine.line}\``); + else if (protocolLine?.kind === 'malformed') { + readings.push(`metadata-protocol line: MALFORMED, not a declaration — ${protocolLine.line} (the closed set is \`added\`, \`narrowed\`, \`semantic\`, \`none\`)`); + } else if (protocolLine?.kind === 'near-miss') readings.push(...protocolNearMissReadings(protocolLine.reason, protocolLine.line)); + else readings.push('metadata-protocol line: the PR body carries no `Metadata-protocol:` line'); + const protocol = protocolLine?.kind === 'declared' ? protocolLine.value : null; + + if (line?.kind === 'declared' && line.value === 'yes') return { value: 'yes', arm, protocol, payload: true, readings }; + if (line?.kind === 'declared' && line.value === 'no') return { value: 'no', arm, protocol, payload: true, readings }; + return { value: null, arm, protocol, payload: true, readings }; } /** @@ -1682,20 +1860,34 @@ export function declarationFromPullRequest(pr) { * unreadable-diff the diff could not be computed -> exit 1 (#4690) * payload-unreadable a `pull_request` run whose payload would not read -> exit 1 (#4690) * no-pull-request not a PR run at all (RC cut, local run) -> exit 0 - * not-measured-moot no declaration, and nothing a `yes` could have refused -> exit 0 - * not-measured-material no declaration, and a `yes` WOULD have refused -> exit 1 - * not-declared the declaration reads `no` and names no - * `narrowing` arm (#16421) -> exit 0 - * clean the axis is CARRIED (`yes`, or `no (narrowing)` — the - * arm carries it too, #16421), and no moved package is - * graded `patch` -> exit 0 - * discharged the axis is CARRIED, a moved package IS graded `minor`+, - * and others are graded `patch` -> exit 0 - * enforce the axis is CARRIED, moved packages graded `patch` and + * not-measured-material a reading that DECIDES this verdict is unreadable: + * the `Clause-②:` line over a moved package graded + * `patch` with no raise (#16776, unchanged), or the + * `Metadata-protocol:` line over any introduced entry a + * kind or a `none` would grade differently — a `patch` + * on a moved package, any `minor`+, a moved baseline + * -> exit 1 + * not-measured-moot the kind is unreadable and could not have changed + * the verdict: nothing a kind or a `none` refuses -> exit 0 + * contradiction `added` under `Clause-②: no`, or `narrowed` beside a + * `Clause-②:` line with no `(narrowing)` arm -> exit 1 + * none-baseline-moved `none` declared while the diff moves an + * `authorable-surface/` or `authorable-defaults/` + * shard — a protocol change the ratchet can see -> exit 1 + * none-minor `none` declared while an introduced entry is graded + * `minor` or above, on any package -> exit 1 + * none-clean `none` declared, no introduced entry above `patch`, + * no baseline moved — a non-protocol change at its + * level, BREAKING carriers untouched -> exit 0 + * clean a kind is declared and no moved package is graded + * `patch` -> exit 0 + * discharged a kind is declared, a moved package IS graded `minor`+, + * and others are graded `patch` (#16361) -> exit 0 + * enforce a kind is declared, moved packages graded `patch` and * NONE of them graded `minor` or above -> exit 1 * - * Nine verdicts and no two of them collapse, because every collapse in this - * family has been a defect. `not-measured-*` and `not-declared` are a missing + * Twelve verdicts and no two of them collapse, because every collapse in this + * family has been a defect. `not-measured-*` and `none-clean` are a missing * reading and a decision (#16055). The two `not-measured-*` are a missing * reading that could not have mattered and one that decided the verdict * (#16776) — sharing exit 0 is what let a gate that judged nothing conclude @@ -1703,30 +1895,42 @@ export function declarationFromPullRequest(pr) { * `discharged` are #16361's: "no moved package is graded `patch`" and "some are, * and this gate is deliberately not refusing them" are different facts, and the * second one carries a residual that must be printed rather than implied by a - * tick. + * tick. The three `none-*` and `contradiction` are ruling A's: a `none` that is + * refused by a `minor` it carries, by a baseline it moves, or by the other + * line it contradicts owes a different sentence each, and an author reading + * one must not be sent to fix another. + * + * `not-declared` (the old "a bare `no` stands the axis down") is GONE, not + * renamed: under ruling A the `Clause-②` value never stands the axis down — + * the kind does, and a bare `no` is judged on its `Metadata-protocol:` line like + * every other declaration. * * @param {{ * levels: { file: string, entries: { pkg: string, bump: string }[] }[] | null, * touched: { packages: string[], unreadable: string[] }, - * declaration: { value: 'yes'|'no'|null, arm?: 'widening'|'narrowing'|null, readings: string[], payload?: boolean }, + * declaration: { value: 'yes'|'no'|null, arm?: 'widening'|'narrowing'|null, protocol?: 'added'|'narrowed'|'semantic'|'none'|null, readings: string[], payload?: boolean }, * prEvent?: boolean, + * baselines?: { moved: string[] }, * }} input */ -export function judgeLevel({ levels, touched, declaration, prEvent = false }) { +export function judgeLevel({ levels, touched, declaration, prEvent = false, baselines = { moved: [] } }) { const readings = declaration?.readings ?? []; // ⭐ #19008. The DECLARATION ITSELF travels beside the readings, for the same // reason `arm` does: `renderLevel` must not - // assert a declaration it did not read. Past the `not-declared` lane below, - // this verdict is decided by `refusable` and `offenders.length` ALONE — - // `value` plays no further part — so `clean`, `discharged` and `enforce` are - // every one of them reachable on a `no (narrowing)`. Each of their headlines - // used to state `yes` as a LITERAL, which made the arm docblock's own rule - // ("a PR that declares `no (narrowing)` must not be reported as having - // declared `yes`") false in the one line a reader quotes. ⛔ The repair is - // this field and the derivation in `renderLevel` — it is ⛔ NOT a reworded - // branch: a literal in any headline is the same defect one branch along. - const declared = { value: declaration?.value ?? null, arm: declaration?.arm ?? null }; - if (!levels) return { verdict: 'unreadable-diff', offenders: [], raised: [], declared, readings, unreadable: [] }; + // assert a declaration it did not read. Past the contradiction and `none` + // lanes below, this verdict is decided by `refusable` and `offenders.length` + // ALONE — `value` plays no further part — so `clean`, `discharged` and + // `enforce` are every one of them reachable on a `no (narrowing)`. Each of + // their headlines used to state `yes` as a LITERAL, which made the arm + // docblock's own rule ("a PR that declares `no (narrowing)` must not be + // reported as having declared `yes`") false in the one line a reader quotes. + // ⛔ The repair is this field and the derivation in `renderLevel` — it is ⛔ + // NOT a reworded branch: a literal in any headline is the same defect one + // branch along. `protocol` travels the same way, for the same reason. + const declared = { value: declaration?.value ?? null, arm: declaration?.arm ?? null, protocol: declaration?.protocol ?? null }; + const moved = Array.isArray(baselines?.moved) ? [...baselines.moved] : []; + const empty = { offenders: [], raised: [], minors: [], declared, readings, unreadable: [], missing: [], baselines: moved }; + if (!levels) return { verdict: 'unreadable-diff', ...empty }; const unreadable = touched?.unreadable ?? []; // NO PR TO READ A DECLARATION FROM. This is a different fact from "a PR that @@ -1751,8 +1955,8 @@ export function judgeLevel({ levels, touched, declaration, prEvent = false }) { // this file takes everywhere else. if (declaration?.payload === false) { return prEvent - ? { verdict: 'payload-unreadable', offenders: [], raised: [], declared, readings, unreadable } - : { verdict: 'no-pull-request', offenders: [], raised: [], declared, readings, unreadable }; + ? { verdict: 'payload-unreadable', ...empty, unreadable } + : { verdict: 'no-pull-request', ...empty, unreadable }; } // The offenders are computed BEFORE the declaration is consulted, because @@ -1772,59 +1976,96 @@ export function judgeLevel({ levels, touched, declaration, prEvent = false }) { const grown = new Set(touched?.packages ?? []); const offenders = []; const raised = []; + // Ruling A: EVERY introduced entry graded `minor` or above, on any package — + // the set a declared `none` refuses. `raised` below is its subset on moved + // packages, which is what discharges a declared kind (#16361's grain). + const minors = []; for (const { file, entries } of levels) { const bad = entries.filter((entry) => entry.bump === 'patch' && grown.has(entry.pkg)).map((entry) => entry.pkg); if (bad.length) offenders.push({ file, packages: bad }); for (const entry of entries) { - if (grown.has(entry.pkg) && (entry.bump === 'minor' || entry.bump === 'major')) raised.push({ file, pkg: entry.pkg, bump: entry.bump }); + if (entry.bump !== 'minor' && entry.bump !== 'major') continue; + minors.push({ file, pkg: entry.pkg, bump: entry.bump }); + if (grown.has(entry.pkg)) raised.push({ file, pkg: entry.pkg, bump: entry.bump }); } } - // The one condition a `yes` refuses. Written once and read by both the + // The one condition a KIND refuses. Written once and read by both the // declared lane and the NOT MEASURED lane, so materiality cannot drift from // enforcement — two copies of this predicate is how the two would come to // disagree about whether an unread declaration mattered. const refusable = offenders.length > 0 && raised.length === 0; + const result = { offenders, raised, minors, declared, readings, unreadable, baselines: moved }; - if (declaration?.value === null || declaration?.value === undefined) { - // ⭐ #16776. `NOT MEASURED` used to be one verdict at exit 0, and the check - // run therefore concluded `success` whether the reading was IMMATERIAL or - // whether it was the one thing the gate needed. Those are the two halves - // split here, and only the second one fails: - // - // * MOOT — no candidate offender exists, so `yes` and `no` reach the same - // verdict. The exit 0 is a DECIDED one: the missing input could not have - // moved it, and the reader is told exactly that rather than being handed - // a tick that means nothing. - // * MATERIAL — a `patch` sits on a package this PR grew, so the declaration - // is the difference between `clean` and `enforce`, and it was not - // readable. The gate refuses. Not because the level is wrong — it may - // well be right — but because nobody can tell, and a reading that did - // not happen must not be indistinguishable from one that passed at the - // only layer anything downstream reads (#4690). - return refusable - ? { verdict: 'not-measured-material', offenders, raised, declared, readings, unreadable } - : { verdict: 'not-measured-moot', offenders, raised, declared, readings, unreadable }; + // ⭐ #16776, kept and widened to the second line. `NOT MEASURED` used to be + // one verdict at exit 0, and the check run therefore concluded `success` + // whether the reading was IMMATERIAL or whether it was the one thing the + // gate needed. Those are the two halves split here, and only the second one + // fails: + // + // * The `Clause-②:` line is MATERIAL exactly where it was: unreadable over a + // `patch` on a package this PR grew with nothing raised. Ruling A moved + // the level coupling off this line, and the obligation to write it stays + // where it was — this verdict is not loosened. + // * The `Metadata-protocol:` line is MATERIAL wherever a kind and a `none` + // would reach different verdicts: a `patch` on a moved package (a kind + // refuses it), any `minor`+ anywhere (a `none` refuses it), or a moved + // protocol baseline (a `none` refuses it). An introduced entry therefore + // almost always makes the kind material, and that is the ruling's own + // sentence — every PR and changeset states its kind. + // * MOOT — neither reading could have moved the verdict. The exit 0 is a + // DECIDED one, and the reader is told exactly that rather than being + // handed a tick that means nothing. + // + // The gate refuses the material case not because the level is wrong — it may + // well be right — but because nobody can tell, and a reading that did not + // happen must not be indistinguishable from one that passed at the only + // layer anything downstream reads (#4690). + const clause2Material = declared.value === null && refusable; + const protocolMaterial = declared.protocol === null && (refusable || minors.length > 0 || moved.length > 0); + const missing = [ + ...(declared.value === null ? ['Clause-②'] : []), + ...(declared.protocol === null ? ['Metadata-protocol'] : []), + ]; + if (clause2Material || protocolMaterial) return { verdict: 'not-measured-material', ...result, missing }; + if (declared.protocol === null) return { verdict: 'not-measured-moot', ...result, missing }; + + // The kind is read. The two contradictions are refused before any level is + // judged, because each is a declaration pair nobody can grade: `added` widens + // a published accept set, so it cannot sit under a `Clause-②: no`; `narrowed` + // narrows one, so it cannot sit beside a `Clause-②:` line that declares no + // `(narrowing)` arm — that arm is the breaking signal `check-adr-0087- + // registration.mjs` and the queue guard read from the changeset. ⛔ Neither + // is inferred the other way: a `Clause-②: yes` does not become `added`, and a + // `(narrowing)` arm does not become `narrowed` — a REST widening and a driver + // posture narrowing are exactly the non-protocol changes ruling A grades + // `patch`. With no `Clause-②:` line read at all (immaterial here, above), + // there is nothing to contradict and the kind is judged alone. + if (declared.protocol === 'added' && declared.value === 'no') { + return { verdict: 'contradiction', reason: 'added-under-no', ...result, missing }; } - // #16421. A `no` stands the axis down — UNLESS it carries the narrowing arm. - // `no (narrowing)` is a truthful `no` to the widening question and a breaking - // change at the same time, and the grade a breaking change owes inside the - // launch window is the grade this axis enforces. ⛔ The arm is read, never - // inferred from the value: `no` alone keeps standing the axis down, which is - // what every declaration written before the arm existed says. - if (declaration.value === 'no' && declaration.arm !== 'narrowing') { - return { verdict: 'not-declared', offenders: [], raised: [], declared, readings, unreadable }; + if (declared.protocol === 'narrowed' && declared.value !== null && declared.arm !== 'narrowing') { + return { verdict: 'contradiction', reason: 'narrowed-without-arm', ...result, missing }; } - // An unread manifest can only ever hide an offender, so it cannot be reported - // under a tick: every green below states it, and the reader is told what was - // not named. - if (refusable) return { verdict: 'enforce', offenders, raised, declared, readings, unreadable }; + // `none`: the level is `patch`, full stop. Three answers, the two refusals + // first because each names a different remedy. + if (declared.protocol === 'none') { + if (moved.length) return { verdict: 'none-baseline-moved', ...result, missing }; + if (minors.length) return { verdict: 'none-minor', ...result, missing }; + return { verdict: 'none-clean', ...result, missing }; + } + + // A kind: the inherited predicate at the inherited grain (#16361). An unread + // manifest can only ever hide an offender, so it cannot be reported under a + // tick: every green below states it, and the reader is told what was not + // named. + if (refusable) return { verdict: 'enforce', ...result, missing }; // #16361. A `patch` on a moved package that this gate is NOT refusing is a // reading it made and set aside, not an absence — it gets its own verdict so // the residual is printed rather than folded into a tick that means "nothing // to see". - if (offenders.length) return { verdict: 'discharged', offenders, raised, declared, readings, unreadable }; - return { verdict: 'clean', offenders: [], raised, declared, readings, unreadable }; + if (offenders.length) return { verdict: 'discharged', ...result, missing }; + return { verdict: 'clean', ...result, offenders: [], missing }; } /** @@ -1853,6 +2094,19 @@ export function declaredClause2(declared) { return arm === null ? `\`${value}\`` : `\`${value} (${arm})\``; } +/** + * The metadata-protocol kind a level verdict was reached on, spelled for a + * reader — the sibling of {@link declaredClause2}, with the same `NOT MEASURED` + * for a kind that was not read (ruling A). + * + * @param {{ protocol?: 'added'|'narrowed'|'semantic'|'none'|null }|null|undefined} declared + * @returns {string} + */ +export function declaredProtocol(declared) { + const protocol = declared?.protocol ?? null; + return protocol === null ? 'NOT MEASURED' : `\`${protocol}\``; +} + /** * Render the level verdict. Separated from `judgeLevel` and from `console` for * the same reason `render` is: the self-test asserts the MESSAGE, and "exits 1" @@ -1872,6 +2126,7 @@ export function renderLevel(result) { const readingsBlock = (result?.readings ?? []).map((r) => `· ${r}`).join('\n'); const offenderList = (offenders) => (offenders ?? []).flatMap(({ file, packages }) => (packages ?? []).map((pkg) => `${pkg}: patch (${file})`)).join('; '); + const minorList = (minors) => (minors ?? []).map(({ file, pkg, bump }) => `${pkg}: ${bump} (${file})`).join('; '); // The `patch` lines, listed WITHOUT a per-package claim about what the diff // did to each one. The old rendering appended "← this PR moves 's // packages/*/src/**" to every line, which is true, directly under a headline @@ -1889,6 +2144,7 @@ export function renderLevel(result) { return lines; }; const raisedLines = (raised) => (raised ?? []).map(({ file, pkg, bump }) => ` - ${pkg}: ${bump} (${file})`); + const baselineLines = (baselines) => (baselines ?? []).map((path) => ` - ${path}`); const unreadableNote = (result?.unreadable ?? []).length > 0 ? [` ⚠️ ${result.unreadable.length} touched package dir(s) could not be named: ${result.unreadable.join(', ')} — an offender there could not be seen.`] @@ -1897,12 +2153,50 @@ export function renderLevel(result) { // the `readings` lines below it come from — never a literal. Three of the // lanes below are reachable on a `no (narrowing)` (see `declared` in // `judgeLevel`), and a literal `yes` in any of them reports a declaration the - // PR did not make while the rows underneath print the true one. + // PR did not make while the rows underneath print the true one. The kind + // (ruling A) is derived the same way, beside it. const declaredValue = declaredClause2(result?.declared); + const declaredKind = declaredProtocol(result?.declared); + const declares = `clause-② ${declaredValue} and metadata-protocol ${declaredKind}`; // And the ACT that declaration names, for the one headline that names it. A // declared narrowing is accounted for by the same grade, but calling it a - // widening is the same misreport one noun along. - const declaredAct = result?.declared?.arm === 'narrowing' ? 'narrowing' : 'widening'; + // widening is the same misreport one noun along; a `semantic` change under a + // bare `no` is neither. + const declaredAct = + result?.declared?.arm === 'narrowing' || result?.declared?.protocol === 'narrowed' + ? 'narrowing' + : result?.declared?.value === 'yes' || result?.declared?.protocol === 'added' + ? 'widening' + : 'protocol change'; + const PUBLISHED = '`src/**`, a packed `bin` target, or a repo file its build copies in'; + const RULING = + 'The level follows the METADATA PROTOCOL alone: `added`, `narrowed` or `semantic` takes AT LEAST `minor` on\n' + + 'a package the diff moves, `none` takes `patch`, and the commit type never decides it (maintainer ruling A,\n' + + '2026-10-21, on #25105, amending the 2026-09-04 rule of decision batch #35 on #15294 — written out in full\n' + + 'under "WHICH LEVEL" in the `Check Changeset` step of .github/workflows/pr-automation.yml).'; + const DECLARE_BLOCK = + 'DECLARE IT. Two lines, each at the START of a line in the PR BODY (a `- `, `> ` or `**` prefix is read too):\n' + + '\n' + + ' Clause-②: no — this PR widens no published accept set or public surface; add `(narrowing)` if it\n' + + ' narrows one (a BREAKING change, whatever its level).\n' + + ' Clause-②: yes — it widens one. Neither value decides the level any more; the next line does.\n' + + ' Metadata-protocol: none — no change to what a metadata author can write: the level is `patch`,\n' + + ' REST, drivers, services, the console, `feat:` and `fix!:` alike.\n' + + ' Metadata-protocol: added — a new authorable key, accepted value or enum member, or metadata type\n' + + ' (takes `minor`: an older runtime silently strips a key it does not know).\n' + + ' Metadata-protocol: narrowed — a key removed or tombstoned, a value refused, a requirement tightened\n' + + ' (takes `minor`; also declare the `(narrowing)` arm above).\n' + + ' Metadata-protocol: semantic — the meaning or default of an authorable key changes (takes `minor`).\n' + + '\n' + + '⛔ The remedy is the declaration, never the deletion: dropping the changeset, or regrading the package to\n' + + 'dodge this message, changes what ships in order to quiet a gate. And the lines are read from the body on the\n' + + 'next `edited` event (pr-automation.yml subscribes to it), so this red clears with no push and no re-run.'; + const KIND_REMEDY = + 'A `minor` is earned by a change to the metadata protocol and by nothing else. If this diff makes one, declare\n' + + 'its kind — `Metadata-protocol: added`, `narrowed` or `semantic` — and the `minor` is accounted for; if it does\n' + + 'not, grade the entry `patch`. A non-protocol change keeps its BREAKING banner and its ADR-0087 disposition\n' + + 'at `patch`: the level stopped carrying breaking-ness when the launch window opened, and ruling A does not\n' + + 'hand it back.'; switch (result?.verdict) { case 'unreadable-diff': @@ -1949,46 +2243,47 @@ export function renderLevel(result) { case 'not-measured-moot': stdout.push( - 'ℹ️ LEVEL AXIS: NOT MEASURED, and it could not have changed this verdict — no clause-② declaration was ' + - 'readable for this PR, AND there is nothing here a `yes` would have refused: either no changeset grades ' + - '`patch` a package whose published source (`src/**`, a packed `bin` target, or a repo file its build copies in) this PR moves, or one of the packages it moves is already ' + - 'graded `minor` or above and carries the level for the PR (#16361). ' + - '`yes` and `no` reach the same answer on this diff, so this exit 0 is a decided one rather than an unread one (#16776).', + 'ℹ️ LEVEL AXIS: NOT MEASURED, and it could not have changed this verdict — the metadata-protocol kind was ' + + 'not readable for this PR, AND there is nothing here a kind or a `none` would have graded differently: ' + + `no changeset grades \`patch\` a package whose published source (${PUBLISHED}) this PR moves, no introduced ` + + 'entry is graded `minor` or above, and no `authorable-surface/` or `authorable-defaults/` baseline moved. ' + + 'Every kind reaches the same answer on this diff, so this exit 0 is a decided one rather than an unread one (#16776).', ...readings, ...unreadableNote, ); return { exitCode: 0, stdout, stderr }; - case 'not-measured-material': - stderr.push('⛔ LEVEL AXIS: NOT MEASURED, and it is the one reading this PR needed.\n'); - stderr.push(' The packages whose published source (`src/**`, a packed `bin` target, or a repo file its build copies in) this PR moves, and the level each is graded:'); - stderr.push(...patchLines(result.offenders)); - stderr.push(' ⇒ none of them is graded `minor` or above, so a `yes` here would REFUSE (#16361).\n'); + case 'not-measured-material': { + const missing = (result?.missing ?? []).map((k) => `\`${k}:\``).join(' and '); + stderr.push(`⛔ LEVEL AXIS: NOT MEASURED, and it is the reading this PR needed — the ${missing} line was not readable.\n`); + stderr.push(` The packages whose published source (${PUBLISHED}) this PR moves, and the level each is graded:`); + stderr.push(...(result.offenders?.length ? patchLines(result.offenders) : [' (none graded `patch`)'])); + if (result?.minors?.length) { + stderr.push(' The changeset entries this PR introduces at `minor` or above:'); + stderr.push(...raisedLines(result.minors)); + } + if (result?.baselines?.length) { + stderr.push(' The metadata-protocol baselines this PR moves:'); + stderr.push(...baselineLines(result.baselines)); + } + stderr.push( + ' ⇒ a declared kind and a declared `none` would reach DIFFERENT verdicts here — a `patch` on a moved package\n' + + ' is refused under a kind, a `minor` or a moved baseline is refused under `none` — so the reading that was\n' + + ' not made is the one that decides (#16361, #16776).\n', + ); stderr.push( - 'No clause-② declaration was readable, so whether this PR widened a published surface at all was not judged:\n' + + 'What was read:\n' + `${(result.readings ?? []).map((r) => ` · ${r}`).join('\n')}\n` + '\n' + 'This is a REFUSAL rather than the tick it used to be, and the reason is the layer above this log. A check run\n' + 'concludes `success` or `failure`; it has no third word for "did not judge". Exiting 0 published the same\n' + 'conclusion for a reading that passed and a reading that never happened, on every surface that reads\n' + 'conclusions rather than step logs (#16776, and #4690: a reading that cannot fail is indistinguishable from\n' + - 'one that passed). Where the declaration could not have mattered this gate still exits 0 and says so — it is\n' + - 'refusing HERE because every package whose published source (`src/**`, a packed `bin` target, or a repo file its build copies in) this diff moves is graded `patch`, which is\n' + - 'exactly the shape a `yes` refuses (#16361).\n' + - '\n' + - 'DECLARE IT. One line, at the START of a line in the PR BODY (a `- `, `> ` or `**` prefix is read too):\n' + + 'one that passed). Where the declaration could not have mattered this gate still exits 0 and says so.\n' + '\n' + - ' Clause-②: no — this PR puts no new key on a published payload. The axis stands down and the `patch`\n' + - ' above is yours to keep. Say it in the line, not only in the prose around it.\n' + - ' Clause-②: yes — it does. Then the level rule applies, and ONE of the packages listed above — the one\n' + - ' that actually grew — must be graded at least `minor`. This gate cannot read which of\n' + - ' them that is, so it asks only that one of them carries it (maintainer ruling\n' + - ' 2026-09-04, decision batch #35, on #15294 — written out under "WHICH LEVEL" in the\n' + - ' `Check Changeset` step of pr-automation.yml).\n' + + `${RULING}\n` + '\n' + - '⛔ The remedy is the declaration, never the deletion: dropping the changeset, or regrading the package to\n' + - 'dodge this message, changes what ships in order to quiet a gate. And the line is read from the body on the\n' + - 'next `edited` event (pr-automation.yml subscribes to it), so this red clears with no push and no re-run.', + `${DECLARE_BLOCK}`, ); // #18263. The discriminator, on the ONE channel the check run exposes. // This title and the `Require a changeset` step's own `::error::` are what @@ -1996,27 +2291,118 @@ export function renderLevel(result) { // the two refusals this card measured as one event from outside. stdout.push( errorAnnotation({ - title: 'Check Changeset (level axis): no readable `Clause-②:` declaration, and it is the reading this PR needed', + title: `Check Changeset (level axis): no readable ${missing} declaration, and it is the reading this PR needed`, message: - `Every package whose published source (\`src/**\`, a packed \`bin\` target, or a repo file its build copies in) this diff moves is graded \`patch\` (${offenderList(result.offenders)}), ` + - 'which is exactly the shape a `yes` refuses (#16361) — so the declaration that was not readable is what ' + + `Moved and graded \`patch\`: ${offenderList(result.offenders) || '(none)'}; introduced at \`minor\` or above: ` + + `${minorList(result.minors) || '(none)'}; protocol baselines moved: ${(result.baselines ?? []).join(', ') || '(none)'}. ` + + 'A kind and a `none` would grade this differently, so the declaration that was not readable is what ' + `decides this verdict.\n${readingsBlock}\n` + - 'DECLARE IT: one line in the PR BODY, bare and at the START of a line of its own — `Clause-②: no` (this PR ' + - 'puts no new key on a published payload) or `Clause-②: yes` plus at least `minor` on the package that grew. ' + + 'DECLARE IT: two lines in the PR BODY, each bare at the START of a line of its own — `Clause-②: yes` or `no` ' + + '(plus `(narrowing)` if it narrows a published surface), and `Metadata-protocol: added`, `narrowed`, `semantic` ' + + 'or `none` (the kind of change to what a metadata author can write; `none` is `patch`, a kind is `minor`). ' + 'A `- `, `> ` or `**` prefix is read too; a `## ` heading and a backtick span that runs past the key are ' + 'not. ⛔ The remedy is the declaration, never dropping the changeset. The body is re-read on the next ' + '`edited` event, so this red clears with no push and no re-run. Full text in this step\'s log.', }), ); return { exitCode: 1, stdout, stderr }; + } + + case 'contradiction': { + const sentence = + result?.reason === 'added-under-no' + ? '`Metadata-protocol: added` says a new authorable key, value or type enters a published payload — a widening ' + + 'of a published accept set by definition — while `Clause-②: no` says nothing published widens.' + : '`Metadata-protocol: narrowed` says a key, value or requirement leaves the accept set an author writes ' + + 'against — a BREAKING narrowing by definition — while the `Clause-②:` line declares no `(narrowing)` arm, ' + + 'which is the signal `check-adr-0087-registration.mjs` and the queue guard read for exactly that.'; + const remedy = + result?.reason === 'added-under-no' + ? 'Correct the one that is wrong: `Clause-②: yes` (plus `(widening)` if you like) beside `added`, or a kind ' + + 'that is not `added` beside `no`.' + : 'Correct the one that is wrong: add the arm — `Clause-②: no (narrowing)` or `yes (narrowing)` — beside ' + + '`narrowed`, or a kind that is not `narrowed` beside the line as written.'; + stderr.push( + `⛔ This PR declares ${declares}, and the two lines contradict each other.\n`, + ` ${sentence}\n`, + `${(result.readings ?? []).map((r) => ` · ${r}`).join('\n')}\n`, + ` ${remedy}\n` + + ' ⛔ Neither line is inferred from the other: a `Clause-②: yes` is not read as `added` (a REST widening is\n' + + ' `none`), and a `(narrowing)` arm is not read as `narrowed` (a driver posture narrowing is `none`). The\n' + + ' lines are re-read from the body on the next `edited` event, so this clears with no push.', + ); + stdout.push( + errorAnnotation({ + title: `Check Changeset (level axis): metadata-protocol ${declaredKind} contradicts clause-② ${declaredValue}`, + message: `${sentence}\n${readingsBlock}\n${remedy} Full text in this step's log.`, + }), + ); + return { exitCode: 1, stdout, stderr }; + } - case 'not-declared': - stdout.push(`✓ LEVEL AXIS: this PR declares clause-② ${declaredValue}, so no package here is declared to have grown a published surface.`, ...readings, ...unreadableNote); + case 'none-clean': + stdout.push( + `✓ LEVEL AXIS: this PR declares ${declares}, and it introduces no changeset entry graded \`minor\` or above ` + + 'and moves no metadata-protocol baseline — a non-protocol change at `patch`, its BREAKING banner and ' + + 'ADR-0087 disposition (if any) untouched by the level.', + ...readings, + ...unreadableNote, + ); return { exitCode: 0, stdout, stderr }; + case 'none-minor': + stderr.push( + `⛔ This PR declares ${declares}, and it introduces a changeset entry graded \`minor\` or above:\n`, + ...raisedLines(result.minors), + '', + `${(result.readings ?? []).map((r) => ` · ${r}`).join('\n')}\n`, + `${RULING}\n`, + `${KIND_REMEDY}\n`, + 'Only what THIS diff introduces is listed above — an entry the branch point already carried at the same bump\n' + + 'is not this PR\'s to answer for (#7005). The line is re-read from the body on the next `edited` event.', + ); + stdout.push( + errorAnnotation({ + title: `Check Changeset (level axis): metadata-protocol declares \`none\` while a changeset entry is graded \`minor\` or above`, + message: + `Introduced at \`minor\` or above: ${minorList(result.minors)}. Under \`none\` the level is \`patch\`.\n${readingsBlock}\n` + + 'Grade the entry `patch`, or — if this diff changes the metadata protocol — declare `Metadata-protocol: ' + + 'added`, `narrowed` or `semantic`. A non-protocol breaking change keeps its BREAKING banner and ADR-0087 ' + + 'disposition at `patch`. Full text in this step\'s log.', + }), + ); + return { exitCode: 1, stdout, stderr }; + + case 'none-baseline-moved': + stderr.push( + `⛔ This PR declares ${declares}, and it moves a metadata-protocol baseline the ratchets record:\n`, + ...baselineLines(result.baselines), + '', + `${(result.readings ?? []).map((r) => ` · ${r}`).join('\n')}\n`, + ' A changed `packages/spec/authorable-surface/` shard is an authorable key added or removed; a changed\n' + + ' `packages/spec/authorable-defaults/` shard is an authorable key\'s default changed. Either is a change to\n' + + ' the metadata protocol, and `none` says there is none.\n', + `${RULING}\n`, + 'Declare the kind the shard records — `Metadata-protocol: added` (a key entered), `narrowed` (a key left or\n' + + 'was tombstoned; declare the `(narrowing)` arm too), `semantic` (a default moved) — and grade the package\n' + + 'that carries it at least `minor`. ⛔ Not the reverse: a baseline is never edited to quiet this gate, and\n' + + '`authorable-surface.base.json` is rewritten only by `gen:authorable-surface-base`.', + ); + stdout.push( + errorAnnotation({ + title: 'Check Changeset (level axis): metadata-protocol declares `none` while an authorable-surface or authorable-defaults baseline moved', + message: + `Moved: ${(result.baselines ?? []).join(', ')}. A changed authorable-surface shard is a key added or removed; ` + + `a changed authorable-defaults shard is a default changed — a metadata-protocol change the ratchet can see.\n${readingsBlock}\n` + + 'Declare `Metadata-protocol: added`, `narrowed` (with the `(narrowing)` arm) or `semantic` and grade the ' + + 'package at least `minor`. Full text in this step\'s log.', + }), + ); + return { exitCode: 1, stdout, stderr }; + case 'clean': stdout.push( - `✓ LEVEL AXIS: this PR declares clause-② ${declaredValue}, and no package whose published source (\`src/**\`, a packed \`bin\` target, or a repo file its build copies in) it moves is graded \`patch\`.`, + `✓ LEVEL AXIS: this PR declares ${declares}, and no package whose published source (${PUBLISHED}) it moves is graded \`patch\`.`, ...readings, ...unreadableNote, ); @@ -2029,14 +2415,14 @@ export function renderLevel(result) { // mode the filing card is about, one layer along. case 'discharged': stdout.push( - `✓ LEVEL AXIS: this PR declares clause-② ${declaredValue}, and it grades a package whose published source (\`src/**\`, a packed \`bin\` target, or a repo file its build copies in) ` + + `✓ LEVEL AXIS: this PR declares ${declares}, and it grades a package whose published source (${PUBLISHED}) ` + `it moves at \`minor\` or above — the declared ${declaredAct} is accounted for:`, ...raisedLines(result.raised), '', ' These packages the diff also moves are graded `patch`, and are NOT refused:', ...patchLines(result.offenders), '', - ' ⚠️ Because clause ② is declared once FOR THE PR and names no package, this gate cannot read WHICH ' + + ' ⚠️ Because the kind is declared once FOR THE PR and names no package, this gate cannot read WHICH ' + 'package the act landed in. It therefore does not ask every moved package to carry the level — it asks ' + 'that ONE of them does (#16361). The residual, named rather than left silent: a SECOND widening in this ' + 'PR, graded `patch` beside the `minor` above, would not be seen here. The contract review of record is ' + @@ -2048,48 +2434,45 @@ export function renderLevel(result) { case 'enforce': stderr.push( - `⛔ This PR declares clause-② ${declaredValue}, and it grades NO package whose published source (\`src/**\`, a packed \`bin\` target, or a repo file its build copies in) it moves\n` + + `⛔ This PR declares ${declares}, and it grades NO package whose published source (${PUBLISHED}) it moves\n` + ' at `minor` or above.\n', ); - stderr.push(' The packages whose published source (`src/**`, a packed `bin` target, or a repo file its build copies in) this PR moves, and the level each is graded:'); + stderr.push(` The packages whose published source (${PUBLISHED}) this PR moves, and the level each is graded:`); stderr.push(...patchLines(result.offenders)); stderr.push(' ⇒ none of them is graded `minor` or above.\n'); stderr.push( - 'The clause-② declaration and the changeset level disagree, inside one PR:\n' + + 'The metadata-protocol declaration and the changeset level disagree, inside one PR:\n' + `${(result.readings ?? []).map((r) => ` · ${r}`).join('\n')}\n` + '\n' + - 'A purely additive widening of a published package\'s public surface takes AT LEAST `minor`;\n' + - 'the commit type may raise a bump but never lower it below what the act requires (maintainer\n' + - 'ruling 2026-09-04, decision batch #35, on #15294 — written out in full under "WHICH LEVEL" in\n' + - 'the `Check Changeset` step of .github/workflows/pr-automation.yml).\n' + + `${RULING}\n` + '\n' + - '⚠️ WHICH of the packages above received that widening is NOT something this gate can read, and it\n' + - 'does not claim to. Clause ② is declared ONCE, FOR THE PR — the `Clause-②:` line is a PR-body\n' + + `⚠️ WHICH of the packages above received that ${declaredAct} is NOT something this gate can read, and it\n` + + 'does not claim to. The kind is declared ONCE, FOR THE PR — the `Metadata-protocol:` line is a PR-body\n' + 'line, and it names no package. So\n' + - 'the finding above is not "each of these was widened"; it is the whole of what a PR-scoped\n' + + 'the finding above is not "each of these was changed"; it is the whole of what a PR-scoped\n' + 'declaration entails: THE WIDENED PACKAGE IS ONE OF THEM, AND NONE OF THEM CARRIES THE LEVEL.\n' + 'Raise the one that actually grew. Raising a package that only received a comment is not asked\n' + 'for here, and one `minor` on a package this diff moved clears this red for the PR (#16361).\n' + '\n' + 'TWO ways forward, and they are not interchangeable:\n' + - ' 1. The declaration is right and the level is wrong -> raise the widened package to `minor`.\n' + + ' 1. The declaration is right and the level is wrong -> raise the package that carries the protocol change to `minor`.\n' + ' This is the ordinary case; #16044 is the measured one, one word in one changeset.\n' + - ' 2. The level is right and the `Clause-②:` LINE is wrong -> correct the line at the producer;\n' + - ' it is the claim\'s. ⛔ Do not add a tolerance here to route around a declaration that says\n' + - ' something its author did not mean.\n' + + ' 2. The level is right and the `Metadata-protocol:` LINE is wrong -> correct the line at the producer:\n' + + ' a change that adds, narrows or re-means nothing an author writes is `none`, at `patch`. ⛔ Do not add\n' + + ' a tolerance here to route around a declaration that says something its author did not mean.\n' + '\n' + 'Only what THIS diff introduces is listed above — an entry the branch point already carried at\n' + 'the same bump is not this PR\'s to answer for (#7005).', ); stdout.push( errorAnnotation({ - title: `Check Changeset (level axis): clause-② declares ${declaredValue} while no moved package is graded \`minor\` or above`, + title: `Check Changeset (level axis): metadata-protocol declares ${declaredKind} under clause-② ${declaredValue} while no moved package is graded \`minor\` or above`, message: `Moved and graded \`patch\`: ${offenderList(result.offenders)}. None of them is graded \`minor\` or above.\n` + `${readingsBlock}\n` + - 'A purely additive widening of a published package\'s public surface takes AT LEAST `minor` (maintainer ' + - 'ruling 2026-09-04, decision batch #35, on #15294). Raise the ONE package that actually grew, or correct ' + - 'the declaration at its producer. Full text in this step\'s log.', + 'A change to the metadata protocol takes AT LEAST `minor` (maintainer ruling A, 2026-10-21, on #25105). ' + + 'Raise the ONE package that carries it, or correct the declaration at its producer (`none` is `patch`). ' + + 'Full text in this step\'s log.', }), ); return { exitCode: 1, stdout, stderr }; @@ -2471,6 +2854,10 @@ function main(argv) { const levelResult = judgeLevel({ levels: scanned?.levels ?? null, touched: scanned ? packagesTouched({ cwd: REPO_ROOT, from: scanned.base, head }) : { packages: [], unreadable: [] }, + // Ruling A: the protocol baselines this diff moves, read off the same + // branch point, so a declared `none` over a visible protocol change is + // refused by name. + baselines: scanned ? baselinesTouched({ cwd: REPO_ROOT, from: scanned.base, head }) : { moved: [] }, declaration: declarationFromPullRequest(readEventPullRequest(eventPath)), prEvent: eventName === 'pull_request' || eventName === 'pull_request_target', }); @@ -2535,6 +2922,7 @@ const SELF_TEST_BATTERIES = Object.freeze({ '#18263: the refusal says its reason, and says it where the API can read it': 35, '#19008: the level headline is the parsed declaration, not a literal': 30, 'The protocol lockstep evidence, in BOTH directions (ruling 6056808625)': 39, + 'Ruling A: the metadata-protocol kind is the level axis (#25105)': 30, }); // DELETING an entry silences that battery's floor exactly as effectively as @@ -2542,8 +2930,9 @@ const SELF_TEST_BATTERIES = Object.freeze({ // carrier-axis battery left with the gate label it read (ruling record // 5770886272 on #19061): the ordinary direction, one battery, one row. Raised // 19 → 20 with the protocol lockstep battery (ruling record 6056808625). Raised -// 20 → 21 with the copy-leg battery (#15204). -const SELF_TEST_BATTERY_FLOOR = 21; +// 20 → 21 with the copy-leg battery (#15204). Raised 21 → 22 with the ruling A +// battery (maintainer ruling A, 2026-10-21, on #25105). +const SELF_TEST_BATTERY_FLOOR = 22; // The key an assertion is filed under when no battery is open. It is not a // declared battery, so it reds by the same set difference rather than silently @@ -3211,7 +3600,10 @@ function selfTest() { const MINOR_HEAD = `---\n"@objectstack/cli": minor${LEVEL_BODY}`; const CHANGESET = '.changeset/lint-eval-generator-load-envelope.md'; const CLI = '@objectstack/cli'; - const declaredYes = { value: 'yes', readings: ['declaration line: `Clause-②: yes`'] }; + // Ruling A: the kind lanes are driven by `Metadata-protocol: added` where + // `Clause-②: yes` alone drove them before — the `yes` stays, read and + // printed, and no longer decides the level. + const declaredYes = { value: 'yes', protocol: 'added', readings: ['declaration line: `Clause-②: yes`', 'metadata-protocol line: `Metadata-protocol: added`'] }; const touchedCli = { packages: [CLI], unreadable: [] }; const levelsFor = (text) => [{ file: CHANGESET, entries: entriesIn(text) }]; @@ -3243,30 +3635,42 @@ function selfTest() { // here is about the declaration and cannot be about the changeset. // // ⭐ #16776 splits the old single `not-measured` in two, and the pair below - // is the whole of it: the SAME missing declaration, over two trees that - // differ by exactly the bump word, must reach two different EXIT CODES. - // The old verdict exited 0 on both, so the check run concluded `success` - // whether the unread declaration was immaterial or whether it was the one - // input that decided the answer — indistinguishable at every surface that - // reads a conclusion rather than a step log. - const noDeclaration = { value: null, payload: true, readings: ['declaration line: the PR body carries no `Clause-②:` line'] }; + // is the whole of it: the SAME missing declaration must reach two + // different EXIT CODES depending on whether it could have moved the + // verdict. The old verdict exited 0 on both, so the check run concluded + // `success` whether the unread declaration was immaterial or whether it + // was the one input that decided the answer — indistinguishable at every + // surface that reads a conclusion rather than a step log. + // + // Ruling A re-cuts the pair. The unread kind is material over the `patch` + // head (a kind refuses it) AND over the `minor` head (a `none` refuses + // it): the two heads are no longer the material/moot pair, they are two + // material readings with different reasons. Moot is the tree where no + // introduced entry sits on a moved package and nothing is raised — the + // SAME changeset bytes with the package not grown. + const noDeclaration = { value: null, protocol: null, payload: true, readings: ['declaration line: the PR body carries no `Clause-②:` line', 'metadata-protocol line: the PR body carries no `Metadata-protocol:` line'] }; const notMeasuredMaterial = judgeLevel({ levels: levelsFor(PATCH_HEAD), touched: touchedCli, declaration: noDeclaration }); - const notMeasuredMoot = judgeLevel({ levels: levelsFor(MINOR_HEAD), touched: touchedCli, declaration: noDeclaration }); + const notMeasuredMinor = judgeLevel({ levels: levelsFor(MINOR_HEAD), touched: touchedCli, declaration: noDeclaration }); + const notMeasuredMoot = judgeLevel({ levels: levelsFor(PATCH_HEAD), touched: { packages: ['@objectstack/spec'], unreadable: [] }, declaration: noDeclaration }); assert( notMeasuredMaterial.verdict === 'not-measured-material', `an unread declaration over a \`patch\` on a package this diff grew is MATERIAL — got ${notMeasuredMaterial.verdict}`, ); + assert( + notMeasuredMinor.verdict === 'not-measured-material' && notMeasuredMinor.missing.join() === 'Clause-②,Metadata-protocol', + `ruling A: an unread kind over a \`minor\` is MATERIAL too — a \`none\` would refuse it — and both missing lines are named — got ${notMeasuredMinor.verdict} / ${notMeasuredMinor.missing}`, + ); assert( notMeasuredMoot.verdict === 'not-measured-moot', `an unread declaration that could not have changed the verdict is MOOT — got ${notMeasuredMoot.verdict}`, ); assert( - renderLevel(notMeasuredMaterial).exitCode === 1 && renderLevel(notMeasuredMoot).exitCode === 0, - 'the two must differ in EXIT CODE, not merely in verdict name — the exit code is what becomes the check-run conclusion, and that conclusion is the whole of #16776', + renderLevel(notMeasuredMaterial).exitCode === 1 && renderLevel(notMeasuredMinor).exitCode === 1 && renderLevel(notMeasuredMoot).exitCode === 0, + 'the material and moot readings must differ in EXIT CODE, not merely in verdict name — the exit code is what becomes the check-run conclusion, and that conclusion is the whole of #16776', ); assert( PATCH_HEAD.replace('patch', 'minor') === MINOR_HEAD, - 'control: the material/moot pair must differ by exactly the bump word, and by nothing about the declaration — both are judged on the same `noDeclaration` reading', + 'control: the two material heads must differ by exactly the bump word, and by nothing about the declaration — both are judged on the same `noDeclaration` reading, and the moot tree is the patch head with the package not grown', ); const materialText = renderLevel(notMeasuredMaterial).stderr.join('\n'); assert(materialText.includes('NOT MEASURED'), 'the refusal must still SAY it did not measure — #16055 bought that honesty and #16776 does not spend it'); @@ -3278,6 +3682,14 @@ function selfTest() { materialText.includes('Clause-②: no') && materialText.includes('Clause-②: yes'), 'the refusal must spell BOTH declarations — the way out of this red is a declaration, and a message that names only the `yes` reads as a demand to raise the level', ); + assert( + ['Metadata-protocol: none', 'Metadata-protocol: added', 'Metadata-protocol: narrowed', 'Metadata-protocol: semantic'].every((s) => materialText.includes(s)), + 'ruling A: and it spells all FOUR kinds — an author who is told a line is missing must be able to copy the one that is true', + ); + assert( + renderLevel(notMeasuredMinor).stderr.join('\n').includes(`${CLI}: minor`), + 'ruling A: the material refusal over a `minor` NAMES the raised entry — that is the line a `none` would refuse, and the author must see which one', + ); assert( renderLevel(notMeasuredMoot).stdout.join('\n').includes('could not have changed this verdict'), 'the moot green must say WHY it is green — an exit 0 that means "the missing input could not have moved this" is a different claim from a tick, and #16776 is what happens when they print alike', @@ -3286,35 +3698,71 @@ function selfTest() { renderLevel(notMeasuredMaterial).stderr.join('\n') !== renderLevel(judgeLevel({ levels: levelsFor(PATCH_HEAD), touched: touchedCli, declaration: declaredYes })).stderr.join('\n'), 'a missing reading and a self-contradiction must not print the same refusal: one asks for a declaration, the other says the declaration and the level disagree', ); - const declaredNo = judgeLevel({ levels: levelsFor(PATCH_HEAD), touched: touchedCli, declaration: { value: 'no', payload: true, readings: [] } }); - assert(declaredNo.verdict === 'not-declared', `a declaration of \`no\` is a DECISION, distinct from an unread one — got ${declaredNo.verdict}`); + // The decision that stands the kind lanes down is `none`, and only `none` + // — a bare `no` decides nothing about the level any more (ruling A). + const declaredNo = judgeLevel({ levels: levelsFor(PATCH_HEAD), touched: touchedCli, declaration: { value: 'no', protocol: 'none', payload: true, readings: [] } }); + assert(declaredNo.verdict === 'none-clean', `a declaration of \`none\` is a DECISION, distinct from an unread one — got ${declaredNo.verdict}`); assert( renderLevel(declaredNo).exitCode === 0, - 'the explicit `no` is the opt-out this refusal is built around: it must stay a PASS on the very tree the unread reading refuses, or #16776 has been closed by making the gate uncloseable', + 'the explicit `none` is the opt-out this refusal is built around: it must stay a PASS on the very tree the unread reading refuses, or #16776 has been closed by making the gate uncloseable', ); assert( renderLevel(declaredNo).stdout.join('\n') !== renderLevel(notMeasuredMoot).stdout.join('\n'), 'a decision and a missing reading must not print the same thing — collapsing them is the defect #16055 records', ); + assert( + judgeLevel({ levels: levelsFor(PATCH_HEAD), touched: touchedCli, declaration: { value: 'no', protocol: null, payload: true, readings: [] } }).verdict === 'not-measured-material', + '⛔ ruling A: a bare `no` with NO kind read is not a decision about the level — the kind is the reading this tree needed, and it is refused as unread rather than waved through as the old `not-declared`', + ); - // ── The DIRECTION ARM on the level axis (#16421) ────────────────────── + // ── The DIRECTION ARM on the level axis (#16421), under ruling A ─────── // // ⭐ BOTH arms, and the arm-less `no` beside them. One direction alone // would be satisfied by a reader that answered `narrowing` to everything: // the widening row and the bare-`no` row are what make the narrowing row // a reading. Same tree, same levels, same `touched` — only the declaration // moves, so the verdicts differ for exactly one reason. + // + // Ruling A: the arm no longer carries the level — the KIND does. The same + // `no (narrowing)` reaches `enforce` with `narrowed` beside it and + // `none-clean` with `none` beside it: a protocol narrowing owes `minor`, a + // non-protocol narrowing (a driver posture, a REST refusal) ships `patch` + // and keeps its BREAKING banner and ADR-0087 disposition. That pair is the + // ruling, and the widening pair beside it is its control. const armDecl = (body) => declarationFromPullRequest({ labels: [], body }); const armVerdict = (body) => judgeLevel({ levels: levelsFor(PATCH_HEAD), touched: touchedCli, declaration: armDecl(body) }).verdict; - assert(armDecl('Clause-②: no (narrowing)\n').arm === 'narrowing' && armDecl('Clause-②: no (narrowing)\n').value === 'no', 'the arm travels beside the value and does NOT overwrite it — `no (narrowing)` is still a truthful `no`'); - assert(armVerdict('Clause-②: no (narrowing)\n') === 'enforce', `a declared narrowing is breaking, so it owes the same grade a widening owes — got ${armVerdict('Clause-②: no (narrowing)\n')}`); - assert(armVerdict('Clause-②: yes (widening)\n') === 'enforce', 'the widening arm is read and the value still carries the axis'); - assert(armVerdict('Clause-②: no\n') === 'not-declared', '⛔ CONTROL: a bare `no` still stands the axis down — every declaration on the board the day this landed is this shape'); - assert(armDecl('Clause-②: no\n').arm === null, '⛔ CONTROL: a bare `no` names NO arm, and says so rather than defaulting to one'); - assert(armVerdict('Clause-②: no (nothing published moves)\n') === 'not-declared', '⛔ CONTROL: ordinary parenthetical reasoning is not an arm'); - assert(armDecl('Clause-②: no (narrowing)\n').readings.some((r) => /direction arm: `narrowing`/.test(r)), 'the arm is PRINTED, so a reader of the log can see which fact moved the verdict'); - assert(armDecl('Clause-②: no\n').readings.some((r) => /direction arm: none declared/.test(r)), 'and an absent arm is printed too — a decision and a silence must not look alike here either'); - assert(judgeLevel({ levels: levelsFor(MINOR_HEAD), touched: touchedCli, declaration: armDecl('Clause-②: no (narrowing)\n') }).verdict === 'clean', 'a declared narrowing graded `minor` is clean — the arm asks for the grade, it does not refuse the PR'); + assert(armDecl('Clause-②: no (narrowing)\nMetadata-protocol: narrowed\n').arm === 'narrowing' && armDecl('Clause-②: no (narrowing)\nMetadata-protocol: narrowed\n').value === 'no', 'the arm travels beside the value and does NOT overwrite it — `no (narrowing)` is still a truthful `no`'); + assert(armVerdict('Clause-②: no (narrowing)\nMetadata-protocol: narrowed\n') === 'enforce', `a declared PROTOCOL narrowing owes \`minor\` — got ${armVerdict('Clause-②: no (narrowing)\nMetadata-protocol: narrowed\n')}`); + assert(armVerdict('Clause-②: no (narrowing)\nMetadata-protocol: none\n') === 'none-clean', `⭐ THE RULING: the same \`no (narrowing)\` with \`none\` beside it ships \`patch\` — a non-protocol narrowing keeps its BREAKING carriers and loses the level — got ${armVerdict('Clause-②: no (narrowing)\nMetadata-protocol: none\n')}`); + assert(armVerdict('Clause-②: yes (widening)\nMetadata-protocol: added\n') === 'enforce', 'the widening arm is read and the KIND carries the axis'); + assert(armVerdict('Clause-②: yes (widening)\nMetadata-protocol: none\n') === 'none-clean', `⭐ THE RULING, other direction: a \`yes (widening)\` with \`none\` beside it (a REST widening) ships \`patch\` — got ${armVerdict('Clause-②: yes (widening)\nMetadata-protocol: none\n')}`); + assert(armVerdict('Clause-②: no\nMetadata-protocol: none\n') === 'none-clean', '⛔ CONTROL: a bare `no` with `none` stands the axis down — every declaration on the board the day the arm landed is this shape, plus the one line ruling A adds'); + assert(armDecl('Clause-②: no\nMetadata-protocol: none\n').arm === null, '⛔ CONTROL: a bare `no` names NO arm, and says so rather than defaulting to one'); + assert(armVerdict('Clause-②: no (nothing published moves)\nMetadata-protocol: none\n') === 'none-clean', '⛔ CONTROL: ordinary parenthetical reasoning is not an arm'); + assert(armDecl('Clause-②: no (narrowing)\nMetadata-protocol: narrowed\n').readings.some((r) => /direction arm: `narrowing`/.test(r)), 'the arm is PRINTED, so a reader of the log can see which fact moved the verdict'); + assert(armDecl('Clause-②: no\nMetadata-protocol: none\n').readings.some((r) => /direction arm: none declared/.test(r)), 'and an absent arm is printed too — a decision and a silence must not look alike here either'); + assert(armDecl('Clause-②: no\nMetadata-protocol: none\n').readings.some((r) => /metadata-protocol line: `Metadata-protocol: none`/.test(r)), 'ruling A: the kind is PRINTED beside the arm, from the same reader'); + assert(judgeLevel({ levels: levelsFor(MINOR_HEAD), touched: touchedCli, declaration: armDecl('Clause-②: no (narrowing)\nMetadata-protocol: narrowed\n') }).verdict === 'clean', 'a declared protocol narrowing graded `minor` is clean — the kind asks for the grade, it does not refuse the PR'); + assert(judgeLevel({ levels: levelsFor(MINOR_HEAD), touched: touchedCli, declaration: armDecl('Clause-②: no (narrowing)\nMetadata-protocol: none\n') }).verdict === 'none-minor', '⭐ THE TEETH: a non-protocol narrowing graded `minor` under `none` is REFUSED — this is the half of ruling A that stops every release being a minor'); + // `semantic`, the third kind: a bare `no` beside it is coherent (a changed + // default widens nothing and narrows nothing), and it owes `minor`. + assert(armVerdict('Clause-②: no\nMetadata-protocol: semantic\n') === 'enforce', `\`semantic\` under a bare \`no\` is a kind like the others and owes \`minor\` — got ${armVerdict('Clause-②: no\nMetadata-protocol: semantic\n')}`); + assert(judgeLevel({ levels: levelsFor(MINOR_HEAD), touched: touchedCli, declaration: armDecl('Clause-②: no\nMetadata-protocol: semantic\n') }).verdict === 'clean', 'and graded `minor` it is clean'); + // The two contradictions, and the two non-inferences that bound them. + assert(armVerdict('Clause-②: no\nMetadata-protocol: added\n') === 'contradiction', `\`added\` under \`Clause-②: no\` is a CONTRADICTION — a new authorable key widens a published accept set by definition — got ${armVerdict('Clause-②: no\nMetadata-protocol: added\n')}`); + assert(armVerdict('Clause-②: no\nMetadata-protocol: narrowed\n') === 'contradiction' && armVerdict('Clause-②: yes\nMetadata-protocol: narrowed\n') === 'contradiction', '`narrowed` beside a `Clause-②:` line with no `(narrowing)` arm is a CONTRADICTION under `no` and under `yes` alike — the arm is the breaking signal the ADR-0087 gate reads'); + assert(armVerdict('Clause-②: yes (narrowing)\nMetadata-protocol: narrowed\n') === 'enforce', 'control: `narrowed` beside `yes (narrowing)` is coherent — a diff that widens and narrows — and is judged on its level'); + assert( + judgeLevel({ levels: levelsFor(PATCH_HEAD), touched: touchedCli, declaration: armDecl('Clause-②: no\nMetadata-protocol: added\n') }).reason === 'added-under-no' && + judgeLevel({ levels: levelsFor(PATCH_HEAD), touched: touchedCli, declaration: armDecl('Clause-②: no\nMetadata-protocol: narrowed\n') }).reason === 'narrowed-without-arm', + 'the two contradictions carry different REASONS, because they owe different remedies', + ); + const contradictionOut = renderLevel(judgeLevel({ levels: levelsFor(PATCH_HEAD), touched: touchedCli, declaration: armDecl('Clause-②: no\nMetadata-protocol: added\n') })); + assert(contradictionOut.exitCode === 1 && contradictionOut.stdout.filter((l) => l.startsWith('::error ')).length === 1, 'a contradiction exits 1 and annotates exactly once'); + assert( + contradictionOut.stderr.join('\n').includes('Neither line is inferred from the other'), + 'and the refusal states the non-inference out loud: a `yes` is not read as `added` and a `(narrowing)` arm is not read as `narrowed` — the REST widening and the driver narrowing are exactly what ruling A grades `patch`', + ); // The two contexts that are NOT a pull request, and the one that only // looks like it. `cut-rc.yml` runs this script on a `workflow_dispatch` @@ -3484,6 +3932,122 @@ function selfTest() { ); } + // ── Ruling A: the metadata-protocol kind is the level axis (#25105) ────── + // + // The lanes the ruling adds, each driven through the REAL reader + // (`declarationFromPullRequest` over a body) and the real `baselinesTouched` + // over temp repositories, and each paired with the control that says the + // red is about the declaration and not about the diff. + battery('Ruling A: the metadata-protocol kind is the level axis (#25105)'); + { + const SPEC = '@objectstack/spec'; + const CLI = '@objectstack/cli'; + const CS = '.changeset/ruling-a.md'; + const decl = (body) => declarationFromPullRequest({ labels: [], body }); + const cs = (entries) => `---\n${entries.map(([p, b]) => `"${p}": ${b}`).join('\n')}\n---\n\nbody\n`; + const SHARD = 'packages/spec/authorable-surface/ui.json'; + const DEFAULTS = 'packages/spec/authorable-defaults/data.json'; + const BASE_FILE = 'packages/spec/authorable-surface.base.json'; + const SRC = 'packages/spec/src/ui/view.zod.ts'; + const specRepo = (head) => + makeRepo( + { + 'packages/spec/package.json': JSON.stringify({ name: SPEC, version: '0.0.0' }), + [SRC]: 'export const a = 1;\n', + [SHARD]: '{"keys":["a"]}\n', + [DEFAULTS]: '{"defaults":["a = 1"]}\n', + [BASE_FILE]: '{"base":1}\n', + }, + head, + ); + const levelOf = ({ dir, base }, body) => { + const scanned = scan({ cwd: dir, base }); + const touched = packagesTouched({ cwd: dir, from: scanned.base, head: 'HEAD' }); + const baselines = baselinesTouched({ cwd: dir, from: scanned.base, head: 'HEAD' }); + const result = judgeLevel({ levels: scanned.levels, touched, baselines, declaration: decl(body), prEvent: true }); + return { touched, baselines, result, out: renderLevel(result) }; + }; + const KEY_ADDED = { [SRC]: 'export const a = 1;\nexport const b = 2;\n', [SHARD]: '{"keys":["a","b"]}\n' }; + + // ── The baseline reader, on a real repository ───────────────────────── + const r1 = levelOf(specRepo({ ...KEY_ADDED, [CS]: cs([[SPEC, 'minor']]) }), 'Clause-②: yes\nMetadata-protocol: added\n'); + assert(r1.baselines.moved.join() === SHARD, `baselinesTouched names the moved authorable-surface shard — got ${JSON.stringify(r1.baselines)}`); + const defaultsMoved = levelOf(specRepo({ [DEFAULTS]: '{"defaults":["a = 2"]}\n', [SRC]: 'export const a = 2;\n', [CS]: cs([[SPEC, 'minor']]) }), 'Clause-②: no\nMetadata-protocol: semantic\n'); + assert(defaultsMoved.baselines.moved.join() === DEFAULTS && defaultsMoved.result.verdict === 'clean', `and a moved authorable-defaults shard, clean under \`semantic\` + \`minor\` — got ${JSON.stringify(defaultsMoved.baselines)} / ${defaultsMoved.result.verdict}`); + assert(levelOf(specRepo({ [BASE_FILE]: '{"base":2}\n', [CS]: cs([[SPEC, 'patch']]) }), 'Clause-②: no\nMetadata-protocol: none\n').baselines.moved.length === 0, 'control: `authorable-surface.base.json` — the gen-only hand-off, not a shard — is NOT a baseline this leg reads'); + assert(levelOf(specRepo({ [SRC]: 'export const a = 2;\n', [CS]: cs([[SPEC, 'patch']]) }), 'Clause-②: no\nMetadata-protocol: none\n').baselines.moved.length === 0, 'control: a src-only diff moves no baseline'); + + // ── `none` over a visible protocol change is refused by name ─────────── + const noneOverShard = levelOf(specRepo({ ...KEY_ADDED, [CS]: cs([[SPEC, 'patch']]) }), 'Clause-②: no\nMetadata-protocol: none\n'); + assert(noneOverShard.result.verdict === 'none-baseline-moved' && noneOverShard.out.exitCode === 1, `\`none\` over a moved authorable-surface shard is REFUSED — got ${noneOverShard.result.verdict}`); + assert(noneOverShard.out.stderr.join('\n').includes(SHARD) && noneOverShard.out.stderr.join('\n').includes('`added`'), 'and the refusal names the shard and the kinds that would be true'); + assert(noneOverShard.out.stdout.filter((l) => l.startsWith('::error ')).length === 1, 'and annotates exactly once'); + assert(r1.result.verdict === 'clean' && r1.out.exitCode === 0, `control: the same shard move declared \`added\` and graded \`minor\` is CLEAN — the red above is about the declaration — got ${r1.result.verdict}`); + const noneOverShardMinor = levelOf(specRepo({ ...KEY_ADDED, [CS]: cs([[SPEC, 'minor']]) }), 'Clause-②: yes\nMetadata-protocol: none\n'); + assert(noneOverShardMinor.result.verdict === 'none-baseline-moved', `\`none\` + \`minor\` + a moved shard is refused for the BASELINE first — the author declared no protocol change and the tree shows one — got ${noneOverShardMinor.result.verdict}`); + const unreadOverShard = levelOf(specRepo({ ...KEY_ADDED, [CS]: cs([[SPEC, 'minor']]) }), 'Clause-②: yes\n'); + assert(unreadOverShard.result.verdict === 'not-measured-material' && unreadOverShard.result.missing.join() === 'Metadata-protocol', `an unread kind over a moved shard is MATERIAL, naming the one missing line — got ${unreadOverShard.result.verdict} / ${unreadOverShard.result.missing}`); + assert(unreadOverShard.out.stderr.join('\n').includes(SHARD), 'and the material refusal lists the baseline that made it material'); + + // ── `none` + `minor`: refused on ANY package, moved or not ───────────── + const noneMinorOffDiff = judgeLevel({ levels: [{ file: CS, entries: [{ pkg: CLI, bump: 'minor' }] }], touched: { packages: [SPEC], unreadable: [] }, declaration: decl('Clause-②: no\nMetadata-protocol: none\n'), prEvent: true }); + assert(noneMinorOffDiff.verdict === 'none-minor' && noneMinorOffDiff.minors[0].pkg === CLI, `\`none\` with a \`minor\` on a package the diff does not even move is refused — a \`minor\` nothing earned is the same error wherever it sits — got ${noneMinorOffDiff.verdict}`); + const noneMinorOut = renderLevel(noneMinorOffDiff); + assert(noneMinorOut.exitCode === 1 && noneMinorOut.stdout.filter((l) => l.startsWith('::error ')).length === 1, 'none-minor exits 1 and annotates once'); + assert(noneMinorOut.stderr.join('\n').includes(`${CLI}: minor`) && noneMinorOut.stderr.join('\n').includes('grade the entry `patch`'), 'and names the entry and both ways out'); + const nonePatchOffDiff = judgeLevel({ levels: [{ file: CS, entries: [{ pkg: CLI, bump: 'patch' }] }], touched: { packages: [SPEC], unreadable: [] }, declaration: decl('Clause-②: no\nMetadata-protocol: none\n'), prEvent: true }); + assert(nonePatchOffDiff.verdict === 'none-clean' && renderLevel(nonePatchOffDiff).exitCode === 0, 'control: the same entry at `patch` is clean under `none`'); + assert(renderLevel(nonePatchOffDiff).stdout.filter((l) => l.startsWith('::error ')).length === 0, 'and a green `none` annotates nothing'); + assert( + judgeLevel({ levels: [{ file: CS, entries: [{ pkg: CLI, bump: 'major' }] }], touched: { packages: [CLI], unreadable: [] }, declaration: decl('Clause-②: no\nMetadata-protocol: none\n'), prEvent: true }).verdict === 'none-minor', + 'a `major` under `none` is refused on this axis too (the launch-window guard refuses it on its own)', + ); + assert( + renderLevel(judgeLevel({ levels: [{ file: CS, entries: [{ pkg: CLI, bump: 'patch' }] }], touched: { packages: [CLI], unreadable: [] }, declaration: decl('Clause-②: no (narrowing)\nMetadata-protocol: none\n'), prEvent: true })).stdout.join('\n').includes('BREAKING banner'), + 'the `none-clean` green for a declared non-protocol narrowing says the BREAKING carriers are untouched — the level stopped carrying breaking-ness when the window opened and ruling A does not hand it back', + ); + + // ── The materiality matrix, under the one predicate ─────────────────── + const matrix = (levels, touched, baselines, body) => judgeLevel({ levels, touched, baselines, declaration: decl(body), prEvent: true }).verdict; + const patchOnGrown = [{ file: CS, entries: [{ pkg: SPEC, bump: 'patch' }] }]; + const minorOnGrown = [{ file: CS, entries: [{ pkg: SPEC, bump: 'minor' }] }]; + const grownSpec = { packages: [SPEC], unreadable: [] }; + const notGrown = { packages: [], unreadable: [] }; + const noBaseline = { moved: [] }; + assert(matrix(patchOnGrown, grownSpec, noBaseline, 'Clause-②: no\n') === 'not-measured-material', 'kind unread + patch on a moved package: material'); + assert(matrix(minorOnGrown, notGrown, noBaseline, 'Clause-②: no\n') === 'not-measured-material', 'kind unread + a minor anywhere: material'); + assert(matrix([], notGrown, { moved: [SHARD] }, 'Clause-②: no\n') === 'not-measured-material', 'kind unread + a moved baseline and no entry at all: material'); + assert(matrix(patchOnGrown, notGrown, noBaseline, 'Clause-②: no\n') === 'not-measured-moot', 'kind unread + a patch on a package the diff does not move: moot'); + assert(matrix([], notGrown, noBaseline, 'Clause-②: no\n') === 'not-measured-moot', 'kind unread + nothing introduced: moot'); + assert(matrix(patchOnGrown, grownSpec, noBaseline, 'Metadata-protocol: none\n') === 'not-measured-material', '⛔ NOT LOOSENED: the `Clause-②:` line unread over a patch on a moved package is still material, kind or no kind'); + assert(matrix(minorOnGrown, grownSpec, noBaseline, 'Metadata-protocol: none\n') === 'none-minor', 'the `Clause-②:` line unread where it is immaterial does not stop the kind from deciding: `none` + `minor` is refused'); + assert(matrix(minorOnGrown, grownSpec, noBaseline, 'Metadata-protocol: added\n') === 'clean', 'and `added` + `minor` with no `Clause-②:` line is clean — nothing to contradict, the kind is judged alone'); + assert(matrix([], notGrown, noBaseline, 'Clause-②: no\nMetadata-protocol: none\n') === 'none-clean', 'both read, nothing introduced: none-clean'); + + // ── The readings and the remedies, for the second line ──────────────── + const malformed = decl('Clause-②: no\nMetadata-protocol: minor\n'); + assert(malformed.protocol === null && malformed.readings.some((r) => /metadata-protocol line: MALFORMED/.test(r) && /`added`, `narrowed`, `semantic`, `none`/.test(r)), 'a malformed kind is NOT a declaration and the reading spells the closed set'); + const misspelled = decl('Clause-②: no\nmetadata protocol: added\n'); + assert(misspelled.protocol === null && misspelled.readings.some((r) => /remedy for `spelling` \(Metadata-protocol\)/.test(r)), 'a misspelled key is a near miss whose remedy names the line it is about'); + assert(decl('Clause-②: no\nDomain: x · Metadata-protocol: none\n').readings.some((r) => /remedy for `inline-key` \(Metadata-protocol\)/.test(r)), 'the inline-key near miss reaches its own remedy'); + assert(decl('Clause-②: no\n`Metadata-protocol: none` · skip-changeset\n').readings.some((r) => /remedy for `describing` \(Metadata-protocol\)/.test(r)), 'and the describing one'); + assert(new Set(['spelling', 'inline-key', 'describing'].map((r) => metadataProtocolNearMissRemedy(r, 'x'))).size === 3, 'the three remedies are three different sentences'); + const invented = protocolNearMissReadings('never-taught', '## Metadata-protocol: none'); + assert(invented.length === 2 && invented.every((r) => r.includes('never-taught')) && invented[1].includes('## Metadata-protocol: none'), 'an unknown reason names itself and quotes the line — the loud path, exactly as the Clause-② remedy'); + assert(metadataProtocolNearMissRemedy(null, 'x').length > 0, 'a null reason still returns a sentence'); + assert(declaredProtocol({ protocol: 'added' }) === '`added`' && declaredProtocol({ protocol: null }) === 'NOT MEASURED' && declaredProtocol(undefined) === 'NOT MEASURED', 'the headline spelling of the kind, and NOT MEASURED for a kind not read'); + const headline = renderLevel(judgeLevel({ levels: minorOnGrown, touched: grownSpec, declaration: decl('Clause-②: yes (widening)\nMetadata-protocol: added\n'), prEvent: true })).stdout.join('\n'); + assert(headline.includes('clause-② `yes (widening)` and metadata-protocol `added`'), 'the green headline carries BOTH declarations as written'); + const titles = [ + noneMinorOut, + noneOverShard.out, + renderLevel(judgeLevel({ levels: patchOnGrown, touched: grownSpec, declaration: decl('Clause-②: no\nMetadata-protocol: added\n'), prEvent: true })), + renderLevel(judgeLevel({ levels: patchOnGrown, touched: grownSpec, declaration: decl('Clause-②: yes\nMetadata-protocol: added\n'), prEvent: true })), + renderLevel(judgeLevel({ levels: patchOnGrown, touched: grownSpec, declaration: decl('Clause-②: yes\n'), prEvent: true })), + ].map((o) => o.stdout.find((l) => l.startsWith('::error ')) ?? ''); + assert(titles.every((t) => t.length > 0 && !t.includes('\n')) && new Set(titles.map((t) => t.split('::')[1])).size === titles.length, 'none-minor, none-baseline-moved, contradiction, enforce and not-measured-material carry five DIFFERENT one-line annotation titles'); + } + // ── The HEADLINE is the DECLARATION, never a literal (#19008) ──────────── // // ⭐ BOTH DIRECTIONS, on ALL THREE carried lanes. Past `not-declared` the @@ -3503,10 +4067,14 @@ function selfTest() { const CLI = '@objectstack/cli'; const OTHER = '@objectstack/spec'; const CS = '.changeset/headline.md'; - const NARROWING = 'Clause-②: no (narrowing)\n'; - const YES = 'Clause-②: yes\n'; - const WIDENING = 'Clause-②: yes (widening)\n'; - const BARE_NO = 'Clause-②: no\n'; + // Ruling A: the kind drives the lanes, so every body carries one — the + // narrowing rows `narrowed`, the yes rows `added`, the bare `no` `none`. + // The headline pins below are about the Clause-② half of the headline, + // which the kind sits beside and must not displace. + const NARROWING = 'Clause-②: no (narrowing)\nMetadata-protocol: narrowed\n'; + const YES = 'Clause-②: yes\nMetadata-protocol: added\n'; + const WIDENING = 'Clause-②: yes (widening)\nMetadata-protocol: added\n'; + const BARE_NO = 'Clause-②: no\nMetadata-protocol: none\n'; // The REAL reader, so every row below is judged on a declaration this // repo's parser actually returns rather than on one assembled here. const decl = (body) => declarationFromPullRequest({ labels: ['domain:devx'], body }); @@ -3560,7 +4128,7 @@ function selfTest() { const annotation = renderLevel(judged('enforce', NARROWING)).stdout.filter((l) => l.startsWith('::error ')); assert(annotation.length === 1, `the \`enforce\` lane still annotates EXACTLY once — the derivation is in the title, not in the count (got ${annotation.length})`); assert( - annotation[0].includes('clause-② declares `no (narrowing)` while no moved package is graded'), + annotation[0].includes('metadata-protocol declares `narrowed` under clause-② `no (narrowing)` while no moved package is graded'), 'the CHECK-RUN annotation title carries the true declaration too — it is the one line a reader outside the run sees, and it used to read `declares YES` for a PR that declared the opposite', ); assert(!annotation[0].includes('\n'), 'and it is still ONE line — a workflow command carrying a raw newline is truncated at it, and the diagnosis would be lost in the channel that exists to carry it'); @@ -3579,8 +4147,8 @@ function selfTest() { // it was: this row is what says nothing widened while the headline was fixed. const bareNo = judgeLevel({ levels: LANES.enforce.levels, touched: LANES.enforce.touched, declaration: decl(BARE_NO), prEvent: true }); assert( - bareNo.verdict === 'not-declared' && renderLevel(bareNo).exitCode === 0, - `⛔ CONTROL: a bare \`no\` still stands the axis down at exit 0 on the very tree the narrowing refuses — the ARM is what carries the axis, and this row says the derivation did not widen the lane (got ${bareNo.verdict})`, + bareNo.verdict === 'none-clean' && renderLevel(bareNo).exitCode === 0, + `⛔ CONTROL: a bare \`no\` with \`none\` still stands the axis down at exit 0 on the very tree the narrowing refuses — the KIND is what carries the axis (ruling A), and this row says the derivation did not widen the lane (got ${bareNo.verdict})`, ); assert( renderLevel(bareNo).stdout.join('\n').includes('declares clause-② `no`'), @@ -3621,8 +4189,8 @@ function selfTest() { const SPEC = '@objectstack/spec'; const RUNTIME = '@objectstack/runtime'; const LINT = '@objectstack/lint'; - const yes = { value: 'yes', payload: true, readings: ['declaration line: `Clause-②: yes`'] }; - const unread = { value: null, payload: true, readings: ['declaration line: the PR body carries no `Clause-②:` line'] }; + const yes = { value: 'yes', protocol: 'added', payload: true, readings: ['declaration line: `Clause-②: yes`', 'metadata-protocol line: `Metadata-protocol: added`'] }; + const unread = { value: null, protocol: null, payload: true, readings: ['declaration line: the PR body carries no `Clause-②:` line', 'metadata-protocol line: the PR body carries no `Metadata-protocol:` line'] }; const cs = (file, entries) => ({ file, entries }); // ---- #16342: every moved package graded `patch` -> REFUSED ------------ @@ -3744,24 +4312,35 @@ function selfTest() { // lane cannot drift from the enforcing lane. assert( judgeLevel({ ...p16342, declaration: unread }).verdict === 'not-measured-material', - "#16342's shape with no declaration is MATERIAL — a `yes` would have refused it, so the missing reading decided the verdict", + "#16342's shape with no declaration is MATERIAL — a kind would have refused it, so the missing reading decided the verdict", + ); + // Ruling A: #16347's shape with no declaration is material TOO, for the + // other reason — its lint `minor` is what a `none` refuses. Moot is the + // spec TSDoc `patch` alone, on a diff that moved only the lint package: + // no entry on a moved package, nothing raised, nothing a kind or a `none` + // would grade differently. + assert( + judgeLevel({ ...p16347, declaration: unread }).verdict === 'not-measured-material', + "#16347's shape with no declaration is MATERIAL under ruling A — a `none` would refuse its lint `minor`, so the unread kind decided the verdict", ); + const p16347moot = { levels: [cs('.changeset/spec-preset-comparand-message-tsdoc.md', [{ pkg: SPEC, bump: 'patch' }])], touched: { packages: [LINT], unreadable: [] } }; assert( - judgeLevel({ ...p16347, declaration: unread }).verdict === 'not-measured-moot', - "#16347's shape with no declaration is MOOT — `yes` and `no` reach the same answer once a moved package carries the level, and refusing here would re-open this card through the NOT MEASURED lane", + judgeLevel({ ...p16347moot, declaration: unread }).verdict === 'not-measured-moot', + 'a `patch` on a package the diff does not move, with nothing raised, is MOOT — every kind and `none` reach the same answer, and refusing here would re-open this card through the NOT MEASURED lane', ); assert( renderLevel(judgeLevel({ ...p16342, declaration: unread })).exitCode === 1 && - renderLevel(judgeLevel({ ...p16347, declaration: unread })).exitCode === 0, + renderLevel(judgeLevel({ ...p16347moot, declaration: unread })).exitCode === 0, 'and the two differ in EXIT CODE — #16776 bought that split and #16361 must not spend it', ); + const mootText = renderLevel(judgeLevel({ ...p16347moot, declaration: unread })).stdout.join('\n'); assert( - renderLevel(judgeLevel({ ...p16347, declaration: unread })).stdout.join('\n').includes('carries the level for the PR'), - 'the moot green must say WHICH of the two reasons made it moot — "no `patch` at all" and "a raise already carries it" are different facts about the diff', + mootText.includes('no changeset grades `patch` a package whose published source') && mootText.includes('no introduced entry is graded `minor` or above') && mootText.includes('baseline moved'), + 'the moot green must say WHY it is moot — the three facts that together make the unread kind immaterial are different facts about the diff, and all three are printed', ); assert( - judgeLevel({ ...p16342, declaration: { value: 'no', payload: true, readings: [] } }).verdict === 'not-declared', - 'control: an explicit `no` is still a DECISION on the very tree the unread reading refuses — the opt-out survives the regrain', + judgeLevel({ ...p16342, declaration: { value: 'no', protocol: 'none', payload: true, readings: [] } }).verdict === 'none-clean', + 'control: an explicit `none` is still a DECISION on the very tree the unread reading refuses — the opt-out survives the regrain', ); // ---- end to end, on a real temp git repository ------------------------ @@ -3842,7 +4421,7 @@ function selfTest() { // green here cannot be coming from the other leg. const cliManifest = { name: '@objectstack/cli', version: '0.0.0', files: ['dist'], bin: { os: './bin/os.mjs' } }; const owners = (p) => JSON.stringify(publishedSourceOwners(p, (dir) => (dir === 'packages/cli' ? cliManifest : null))); - const declaredYes = { value: 'yes', payload: true, readings: ['declaration line: `Clause-②: yes`'] }; + const declaredYes = { value: 'yes', protocol: 'added', payload: true, readings: ['declaration line: `Clause-②: yes`', 'metadata-protocol line: `Metadata-protocol: added`'] }; // The shape reading, at three depths and its controls. Depth-agnostic is // the whole point: a repair that merely allowed ONE extra segment passes @@ -4011,7 +4590,7 @@ function selfTest() { // everything now" produce the same red. battery('THE ROOT: a packed `bin` target is a published surface the axis can refuse (#16692)'); { - const declaredYes = { value: 'yes', payload: true, readings: ['declaration line: `Clause-②: yes`'] }; + const declaredYes = { value: 'yes', protocol: 'added', payload: true, readings: ['declaration line: `Clause-②: yes`', 'metadata-protocol line: `Metadata-protocol: added`'] }; const CLI = '@objectstack/cli'; const CS = '.changeset/root-leg.md'; @@ -4238,8 +4817,8 @@ function selfTest() { { const CONSOLE = '@objectstack/console'; const SPEC = '@objectstack/spec'; - const yesWidening = { value: 'yes', arm: 'widening', payload: true, readings: ['declaration line: `Clause-②: yes (widening)`'] }; - const declaredNo = { value: 'no', arm: null, payload: true, readings: ['declaration line: `Clause-②: no`'] }; + const yesWidening = { value: 'yes', arm: 'widening', protocol: 'added', payload: true, readings: ['declaration line: `Clause-②: yes (widening)`', 'metadata-protocol line: `Metadata-protocol: added`'] }; + const declaredNo = { value: 'no', arm: null, protocol: 'none', payload: true, readings: ['declaration line: `Clause-②: no`', 'metadata-protocol line: `Metadata-protocol: none`'] }; const ROOT_LINE = 'FRAMEWORK_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"'; const BUILD = [ '#!/usr/bin/env bash', @@ -4363,9 +4942,10 @@ function selfTest() { `the manifest moved with no console changeset at all (spec \`patch\` only) stays red — got ${ungraded.result.verdict}`, ); - // `Clause-②: no` is unaffected: the axis stands down whatever it sees. + // `Clause-②: no` + `Metadata-protocol: none` is unaffected: the kind + // stands the axis down whatever the copy leg sees (ruling A). const no = levelOf(copyRepo({ head: { ...PR22812, '.changeset/console-bbbb.md': changeset([[CONSOLE, 'patch']]) } }), declaredNo); - assert(no.result.verdict === 'not-declared' && no.exitCode === 0, `the manifest moved under \`Clause-②: no\` is unaffected — got ${no.result.verdict} / ${no.exitCode}`); + assert(no.result.verdict === 'none-clean' && no.exitCode === 0, `the manifest moved under \`Clause-②: no\` + \`none\` is unaffected — got ${no.result.verdict} / ${no.exitCode}`); // The record moves with every regeneration but ships nothing. const recordOnly = levelOf( @@ -4669,7 +5249,7 @@ function selfTest() { judgeLevel({ levels: materialLevels, touched: materialTouched, - declaration: declarationFromPullRequest({ labels: [], body: 'Clause-②: yes\nFixes #1\n' }), + declaration: declarationFromPullRequest({ labels: [], body: 'Clause-②: yes\nMetadata-protocol: added\nFixes #1\n' }), prEvent: true, }), ); @@ -4682,7 +5262,7 @@ function selfTest() { // Every refusing lane annotates EXACTLY ONCE, and every green annotates not // at all. The cap is ten per level and the runner already spends one. const lane = (result) => annotationsOf(renderLevel(result)).length; - const declaredNoDecl = declarationFromPullRequest({ labels: [{ name: 'domain:devx' }], body: 'Clause-②: no\n' }); + const declaredNoDecl = declarationFromPullRequest({ labels: [{ name: 'domain:devx' }], body: 'Clause-②: no\nMetadata-protocol: none\n' }); assert( lane({ verdict: 'unreadable-diff', offenders: [], readings: [], unreadable: [] }) === 1 && lane({ verdict: 'payload-unreadable', offenders: [], readings: [], unreadable: [] }) === 1 && diff --git a/scripts/objectui-changeset-digest.mjs b/scripts/objectui-changeset-digest.mjs index 2e67cb6c284..404172eb4a0 100644 --- a/scripts/objectui-changeset-digest.mjs +++ b/scripts/objectui-changeset-digest.mjs @@ -1118,12 +1118,18 @@ export function buildDigest({ const breakingByLevel = breakingEntries.filter((r) => r.breakingSource === 'declared-major').length; const breakingByAnnotation = breaking - breakingByLevel; - let bump = bumpOverride || declaredLevel || 'patch'; - let downgradedMajor = false; - if (!bumpOverride && bump === 'major' && !inPreMode(frameworkRoot)) { - bump = 'minor'; - downgradedMajor = true; - } + // Ruling A (maintainer, 2026-10-21, objectstack#25105): the platform's version + // line follows the METADATA PROTOCOL alone, and the console never changes it, + // so the refresh changeset is `patch` whatever objectui declared over the + // range — in RC pre-mode too, which is why `inPreMode` no longer exempts a + // declared `major` here. `declaredLevel` is still derived and reported (the + // entry lines carry it), and `CONSOLE_BUMP` (`bumpOverride`) still wins, so an + // operator who knows better says so out loud. `downgradedMajor` keeps its name + // and its meaning — a declared `major` recorded lower — and `recordedBelow` is + // the wider fact: ANY declared level recorded below itself, `minor` included. + const bump = bumpOverride || 'patch'; + const recordedBelow = !bumpOverride && declaredLevel !== null && LEVEL_RANK[declaredLevel] > LEVEL_RANK[bump]; + const downgradedMajor = recordedBelow && declaredLevel === 'major'; const shown = releasing.slice(0, Math.max(0, max)); const hidden = releasing.length - shown.length; @@ -1182,10 +1188,10 @@ export function buildDigest({ notes.push( `⚠️ ${breaking} of these ${breaking === 1 ? 'carries' : 'carry'} a breaking change: ${source}. ` + `Each is marked ${BREAKING_MARKER} in the list above — read them before compiling the release record` + - (downgradedMajor - ? `. The declared \`major\` is recorded here as \`minor\` because the launch-window convention ` + - `ships breaking as minor (\`scripts/check-changeset-no-major.mjs\`) — the breaking entries are ` + - `listed above, unabridged.` + (recordedBelow + ? `. The declared \`${declaredLevel}\` is recorded here as \`${bump}\`: the platform's version line follows ` + + `the metadata protocol, which the console never changes (\`scripts/check-changeset-no-major.mjs\`) — ` + + `the breaking entries are listed above, unabridged, and keep their marker.` : '.'), ); } @@ -1271,6 +1277,7 @@ export function buildDigest({ absentAtTo, totalCommits, downgradedMajor, + recordedBelow, adr0087Scaffold, body, }; @@ -1375,10 +1382,16 @@ function main(argv) { const short = to.slice(0, 12); const rangeLabel = `${from.slice(0, 12)}...${to.slice(0, 12)}`; + // The declaration line every changeset carries (ruling A): the console refresh + // changes nothing a metadata author writes, so its kind is `none` — the + // release-notes grouping files it under 其他修复, and the level gate reads it + // as the `patch` it is. Written by the generator because a generated file is + // regenerated: a line a human added would last exactly one pin bump. const file = `---\n"@objectstack/console": ${digest.bump}\n---\n\n` + `Console (objectui) refreshed to \`${short}\`. Frontend changes in this range:\n\n` + `${digest.body}\n\n` + + `Metadata-protocol: none (the console refresh changes no key, value or type a metadata author writes)\n\n` + `objectui range: \`${rangeLabel}\`\n`; console.error( @@ -1386,7 +1399,9 @@ function main(argv) { `${digest.breaking} breaking (${digest.breakingByLevel} declared major, ` + `${digest.breakingByAnnotation} annotated), ` + `${digest.releaseNothing} release-nothing, ${digest.noChangeset} commit(s) without a changeset` + - (digest.downgradedMajor ? ' — declared major recorded as minor (launch window)' : ''), + (digest.recordedBelow + ? ` — objectui declared \`${digest.declaredLevel}\`, recorded as \`${digest.bump}\` (the console never changes the metadata protocol)` + : ''), ); // #6175: the one sentence that replaces the screenful of git `fatal:` lines. @@ -1586,7 +1601,11 @@ function selfTest() { digest.body.includes('- **major** — **BREAKING** — Remove `PageNodeRenderer`'), digest.body, ); - check('in RC pre-mode a declared major stays major', digest.bump === 'major'); + check( + 'a declared major records as patch, in RC pre-mode too — the console never moves the version line (ruling A)', + digest.bump === 'patch' && digest.declaredLevel === 'major' && digest.downgradedMajor === true && digest.recordedBelow === true, + `bump=${digest.bump} declared=${digest.declaredLevel}`, + ); const plain = buildDigest({ objectuiRoot: ui, @@ -1595,8 +1614,9 @@ function selfTest() { to: head, }); check( - 'outside pre-mode a declared major records as minor — AUDIBLY', - plain.bump === 'minor' && plain.downgradedMajor && plain.body.includes('launch-window'), + 'outside pre-mode a declared major records as patch — AUDIBLY, naming the rule and the declared level', + plain.bump === 'patch' && plain.downgradedMajor && plain.body.includes('metadata protocol') && plain.body.includes('The declared `major` is recorded here as `patch`'), + plain.body, ); const overridden = buildDigest({ @@ -1824,9 +1844,10 @@ function selfTest() { annotated.body, ); check( - '#6099 nothing was downgraded — there was no major to downgrade', - annotated.bump === 'minor' && annotated.downgradedMajor === false, - `bump=${annotated.bump} downgraded=${annotated.downgradedMajor}`, + '#6099 no major was downgraded — there was none to downgrade — and the declared `minor` is recorded as `patch` (ruling A), said in the body', + annotated.bump === 'patch' && annotated.downgradedMajor === false && annotated.recordedBelow === true && annotated.declaredLevel === 'minor' && + annotated.body.includes('The declared `minor` is recorded here as `patch`'), + `bump=${annotated.bump} downgraded=${annotated.downgradedMajor} recordedBelow=${annotated.recordedBelow}`, ); // --- #23112: a DECORATED annotation — a pictograph before the word ------- @@ -2209,10 +2230,15 @@ function selfTest() { const body = existsSync(written) ? readFileSync(written, 'utf8') : ''; check('bump-objectui.sh writes the digest-derived changeset', body.length > 0); check( - 'the emitted changeset declares the derived bump', - body.startsWith('---\n"@objectstack/console": major\n---'), + 'the emitted changeset declares the derived bump — `patch`, whatever objectui declared (ruling A)', + body.startsWith('---\n"@objectstack/console": patch\n---'), body.slice(0, 60), ); + check( + 'the emitted changeset carries its `Metadata-protocol: none` line, at the start of a line, for the gate and the release notes', + /^Metadata-protocol: none\b/m.test(body), + body, + ); check( 'the emitted changeset carries the breaking entry and not the ci noise', body.includes('PageNodeRenderer') && !body.includes('cross-repo token'), @@ -2685,7 +2711,7 @@ function selfTest() { // unbuilt body would satisfy "no marker" just as well). allDeclared.releasing.length === 2 && allDeclared.breaking === 0 && - allDeclared.bump === 'minor' && + allDeclared.bump === 'patch' && allDeclared.adr0087Scaffold === false && markersIn(allDeclared.body).length === 0, allDeclared.body, @@ -3408,15 +3434,17 @@ function selfTest() { ? readFileSync(join(fwDeepen, '.changeset', csName), 'utf8') : ''; check( - '#14178 S6 an absent endpoint now DEEPENS and re-asks — the shallow checkout derives the same record as a full clone, `minor` not the `patch` default', + '#14178 S6 an absent endpoint now DEEPENS and re-asks — the shallow checkout derives the same COMPLETE record as a full clone: the breaking entry, the ADR-0087 prompt, no degraded list', // The measured before/after, in one assertion. Before this card the same - // input wrote a one-line degraded entry declaring `patch`, exited 0, and - // never printed the ADR-0087 prompt (which keys on a declared-breaking - // changeset, and the degraded one declared none). + // input wrote a one-line degraded entry, exited 0, and never printed the + // ADR-0087 prompt (which keys on a declared-breaking changeset, and the + // degraded one declared none). The level is `patch` on both sides now + // (ruling A: the console never moves the version line), so the + // discriminator is the RECORD — the entries and the prompt — not the bump. deepenRun.status === 0 && deepenRun.stdout.includes('deepening') && deepenRun.stdout.includes('the range walks completely now') && - deepenCs.includes('"@objectstack/console": minor') && + deepenCs.includes('"@objectstack/console": patch') && deepenCs.includes('**BREAKING**') && deepenCs.includes('adr-0087: TODO') && !deepenCs.includes('Degraded list') && diff --git a/scripts/pm/clause2-line.mjs b/scripts/pm/clause2-line.mjs index ce61f4f96e6..26bd1c57aa4 100644 --- a/scripts/pm/clause2-line.mjs +++ b/scripts/pm/clause2-line.mjs @@ -3,7 +3,27 @@ /** * clause2-line — the fleet's ONE reader of the `Clause-②: yes | no` - * declaration line, and nothing else. + * declaration line and of its sibling, the `Metadata-protocol:` line, and + * nothing else. + * + * ## The sibling line (maintainer ruling A, 2026-10-21, on #25105) + * + * The level of a changeset follows the METADATA PROTOCOL alone: `minor` when + * the protocol an author writes against is added to, narrowed or changed in + * meaning, `patch` otherwise. That kind is declared on a second line beside the + * `Clause-②:` line, in the PR body and in the changeset body alike: + * + * Metadata-protocol: added | narrowed | semantic | none + * + * It is a SIBLING LINE rather than a third arm on the `Clause-②:` line, and the + * choice is structural: every declaration written before it — `Clause-②: no`, + * `yes (widening)`, `no (narrowing)` — keeps reading byte-identically through + * `readClause2Line`, whose predicate, value class and arm reader are untouched. + * `readMetadataProtocolLine` below reuses the same key-line shape, the same + * tolerated decoration, the same value-token calibration and the same + * describing/inline/spelling near-miss triage, parameterised on the key and the + * closed value set, so the two lines cannot drift in what they accept. It has + * no arm: the kind IS the whole value. * * ## Why this file exists, and what it deliberately does NOT carry * @@ -170,16 +190,20 @@ const CLAUSE2_LINE_START_DECORATION = /^[ \t>\-*#`]*$/; * Does this line carry the fixed key, followed by its colon, at a position no * line-start decoration can explain? * + * `keyText` defaults to the `Clause-②` key; the sibling reader passes its own. + * The predicate is otherwise identical for both lines. + * * @param {string} line + * @param {string} [keyText] * @returns {boolean} */ -function hasInlineClause2Key(line) { +function hasInlineClause2Key(line, keyText = CLAUSE2_KEY_TEXT) { const s = String(line ?? ''); let from = 0; for (;;) { - const at = s.indexOf(CLAUSE2_KEY_TEXT, from); + const at = s.indexOf(keyText, from); if (at < 0) return false; - from = at + CLAUSE2_KEY_TEXT.length; + from = at + keyText.length; // The key alone is not the shape; it is the key AND its colon, so a bare // mention of `Clause-②` in a sentence is left to the pattern above. if (!CLAUSE2_KEY_COLON.test(s.slice(from))) continue; @@ -219,11 +243,12 @@ function hasInlineClause2Key(line) { * allows may contain a pipe anywhere later — a table column, a shell * pipeline — and is untouched. */ -function matchValueToken(raw) { +function matchValueToken(raw, values = CLAUSE2_VALUES) { const rest = String(raw ?? '').replace(/^[ \t]+/, ''); // Built from CLAUSE2_VALUES so the closed set is declared once: adding a - // third reading would have to be a deliberate edit to that constant. - const token = new RegExp(`^(?:\\*\\*)?(?:\`)?[ \\t]*(${CLAUSE2_VALUES.join('|')})(?![A-Za-z0-9_])(?![ \\t]*\\|)`); + // third reading would have to be a deliberate edit to that constant. The + // sibling line hands in its own closed set; the token rule is the same. + const token = new RegExp(`^(?:\\*\\*)?(?:\`)?[ \\t]*(${values.join('|')})(?![A-Za-z0-9_])(?![ \\t]*\\|)`); const m = token.exec(rest); // `after` is the REST OF THE LINE, handed on so the arm is read from the same // single pass. ⛔ Not a second parser: the arm reader below never sees the key, @@ -356,11 +381,11 @@ function readArmToken(after) { * @param {RegExpExecArray} m — this line's `CLAUSE2_KEY_LINE` match. * @returns {boolean} */ -function clause2LineDescribes(line, m) { +function clause2LineDescribes(line, m, keyText = CLAUSE2_KEY_TEXT) { const s = String(line ?? ''); // TWICE-NAMED. `indexOf` from the last hit, so an overlap cannot double-count. let seen = 0; - for (let at = s.indexOf(CLAUSE2_KEY_TEXT); at >= 0; at = s.indexOf(CLAUSE2_KEY_TEXT, at + CLAUSE2_KEY_TEXT.length)) { + for (let at = s.indexOf(keyText); at >= 0; at = s.indexOf(keyText, at + keyText.length)) { if (++seen > 1) return true; } // QUOTED-AND-CONTINUED. The span is open at the colon exactly when the key's @@ -463,3 +488,98 @@ export function readClause2Line(text) { if (inlineKey !== null) return { kind: 'near-miss', reason: 'inline-key', line: inlineKey }; return nearMiss === null ? null : { kind: 'near-miss', reason: 'spelling', line: nearMiss }; } + +// --------------------------------------------------------------------------- +// The sibling line — `Metadata-protocol: added | narrowed | semantic | none` +// --------------------------------------------------------------------------- + +/** + * The four kinds of change to the metadata protocol a declaration may name — + * the closed set the maintainer's ruling A table spells, in its order: + * + * `added` a new authorable key, a new accepted value or enum member, a + * new metadata type -> `minor` + * `narrowed` a key removed or tombstoned, a value refused, a requirement + * tightened -> `minor` + * `semantic` the meaning or default of an authorable key changes -> `minor` + * `none` everything else — description or error text, non-authorable + * internal types and interfaces, and every non-protocol change + * (REST, drivers, services, console, `feat:` and `fix!:` alike) + * -> `patch` + * + * One token per line. Where more than one applies, the author names the one + * highest in that order's breaking-ness: `narrowed` over `semantic` over + * `added` — the gates that read the line enforce the level the token owes, and + * the release notes group the entry under the section the token names. + */ +export const METADATA_PROTOCOL_KINDS = Object.freeze(['added', 'narrowed', 'semantic', 'none']); + +const METADATA_PROTOCOL_KEY_TEXT = 'Metadata-protocol'; + +/** + * The key line, with EXACTLY the decoration `CLAUSE2_KEY_LINE` tolerates — an + * optional blockquote `>`, an optional list bullet, backtick or bold wrapping + * on the key — and nothing else. Same three capture groups, for the same + * describing test. ⛔ A different key, a different case (`metadata-protocol`), + * a space (`Metadata protocol:`) or a full-width colon is a near miss, reported + * below and never read. + */ +const METADATA_PROTOCOL_KEY_LINE = /^[ \t]*(?:>[ \t]*)?(?:[-*][ \t]+)?(?:\*\*)?(`?)Metadata-protocol(`?)(?:\*\*)?[ \t]*:(.*)$/; + +/** + * A line that MENTIONS the key without being the machine declaration — the + * vocabulary near miss, quoted back so a "no reading" row is actionable. It + * admits the case, space and underscore misspellings an author plausibly + * types; like its Clause-② sibling it never produces a verdict. + */ +const METADATA_PROTOCOL_NEAR_MISS_LINE = /^[ \t]*(?:>[ \t]*)?(?:[-*#][ \t]*)*(?:\*\*)?`?\s*Metadata[ \t_-]*protocol(?![\w]).*$/i; + +/** + * Read the metadata-protocol kind out of ONE comment or body. + * + * @param {string} text + * @returns {{ kind: 'declared', value: 'added'|'narrowed'|'semantic'|'none', line: string } + * | { kind: 'malformed', value: string, line: string } + * | { kind: 'near-miss', reason: 'describing'|'inline-key'|'spelling', line: string } + * | null} + * + * Four-valued exactly as `readClause2Line` is, and for the same reason: a line + * that IS the key with an ungradeable value (`Metadata-protocol: minor`, + * `Metadata-protocol: `, an empty value, the + * unfilled alternation) is `malformed`; a line that nearly is the key is a + * `near-miss` with its reason; a body with neither reads `null`. ⛔ None of + * those three is a declaration of `none`: an absent kind is a reading that did + * not happen, and the gate that reads it decides what that costs. + * + * Trailing text after the token is the author's reasoning and is not read — + * `Metadata-protocol: added (the `confirm` slot on bulk actions)` declares + * `added`. There is no arm. + */ +export function readMetadataProtocolLine(text) { + const lines = String(text ?? '').split(/\r?\n/); + let read = null; + let describing = null; + let nearMiss = null; + let inlineKey = null; + for (const line of lines) { + const m = METADATA_PROTOCOL_KEY_LINE.exec(line); + if (m) { + if (clause2LineDescribes(line, m, METADATA_PROTOCOL_KEY_TEXT)) { + if (describing === null) describing = quoteLine(line); + continue; + } + if (read !== null) continue; + const hit = matchValueToken(m[3], METADATA_PROTOCOL_KINDS); + read = hit !== null + ? { kind: 'declared', value: hit.value, line: quoteLine(line) } + : { kind: 'malformed', value: quoteLine(m[3], 60), line: quoteLine(line) }; + continue; + } + if (inlineKey === null && hasInlineClause2Key(line, METADATA_PROTOCOL_KEY_TEXT)) inlineKey = quoteLine(line); + if (nearMiss === null && METADATA_PROTOCOL_NEAR_MISS_LINE.test(line)) nearMiss = quoteLine(line); + } + if (read !== null) return read; + if (describing !== null) return { kind: 'near-miss', reason: 'describing', line: describing }; + if (inlineKey !== null) return { kind: 'near-miss', reason: 'inline-key', line: inlineKey }; + return nearMiss === null ? null : { kind: 'near-miss', reason: 'spelling', line: nearMiss }; +} diff --git a/scripts/release-github-releases.mjs b/scripts/release-github-releases.mjs index 6874d1dc851..2f891b1345c 100644 --- a/scripts/release-github-releases.mjs +++ b/scripts/release-github-releases.mjs @@ -103,6 +103,7 @@ import { dirname, join, relative, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { isEntrypoint } from './invoked-as.mjs'; import { workspacePackages } from './workspace-enumerator.mjs'; +import { readClause2Line, readMetadataProtocolLine } from './pm/clause2-line.mjs'; // ── The self-test's own battery roster and floor (#13489) ────────────────── // @@ -135,11 +136,13 @@ const SELF_TEST_BATTERIES = Object.freeze({ '12. Two concurrent invocations leave exactly one release per tag': 6, '13. Any other 422 still fails; a racer the read cannot see fails loudly, bounded': 8, '14. Duplicate releases for one tag are reported, never deleted': 14, + '15. The Release body is grouped by what the reader must do (ruling A)': 14, }); // DELETING an entry silences that battery's floor exactly as effectively as -// zeroing it, so the roster's own size is pinned too. -const SELF_TEST_BATTERY_FLOOR = 14; +// zeroing it, so the roster's own size is pinned too. Raised 14 → 15 with the +// grouping battery (maintainer ruling A, 2026-10-21, objectstack#25105). +const SELF_TEST_BATTERY_FLOOR = 15; // The key an assertion is filed under when no battery is open. It is not a // declared battery, so it reds by the same set difference rather than silently @@ -353,6 +356,158 @@ export function buildReleaseBody({ entry, tagName, changelogLabel, changelogHref return { body, truncated: true, originalLength }; } +// ───────────────────────────────────────────────────────────────────────────── +// Release notes grouped by what the reader must do (ruling A) +// ───────────────────────────────────────────────────────────────────────────── +// +// Maintainer ruling A (2026-10-21, objectstack#25105): the GitHub Release body +// is grouped by what the reader must do, and each section is read from the +// declarations each changeset already carries — the `Metadata-protocol:` line, +// the `Clause-②:` arm, the BREAKING banner, the ADR-0087 disposition marker. +// `changeset version` compiles those bodies into CHANGELOG.md under changesets' +// own `### Minor Changes` / `### Patch Changes` headings, and the per-package +// CHANGELOG stays exactly as changesets writes it (RELEASE-OWNED, never +// hand-edited). What changes is the Release body built FROM that section here: +// the same bullets, byte for byte, re-grouped under five headings in a fixed +// order; a section with nothing in it is omitted; a bullet that is not a +// bullet (prose above the first one) is kept ahead of the sections. +// +// The declarations are read with the fleet's one reader (`pm/clause2-line.mjs`, +// import-free) and the breaking signals MIRROR `check-adr-0087-registration.mjs` +// rather than importing it: the release lane must not sit one rename of an +// 8,000-line gate away from failing, and the agreement is pinned in the +// self-test below. A bullet with no declaration at all — the pre-rule stock — +// lands where its remaining signals put it, 其他修复 last. + +/** + * The five sections, in the order the ruling lists them. The Chinese names are + * the ruling's own words and stay untranslated; the gloss after the dash is for + * a reader of the GitHub Release. + */ +export const RELEASE_NOTE_SECTIONS = Object.freeze([ + Object.freeze({ id: 'manual', heading: '需手动处理 — action required: narrowed, with no ADR-0087 conversion' }), + Object.freeze({ id: 'migrated', heading: '自动迁移 — migrated for you: narrowed, with a conversion `objectstack migrate meta` applies' }), + Object.freeze({ id: 'added', heading: '新增能力 — new capabilities' }), + Object.freeze({ id: 'semantic', heading: '语义变化 — semantic changes: the meaning or default of an authorable key moved' }), + Object.freeze({ id: 'other', heading: '其他修复 — other fixes' }), +]); + +const RELEASE_NOTE_LEAD = + '_Sections follow what the reader must do; each entry is placed by its own declarations ' + + '(`Metadata-protocol:`, the `Clause-②:` arm, the BREAKING banner, the ADR-0087 disposition). ' + + 'The package CHANGELOG keeps the level grouping._'; + +/** + * Split one CHANGELOG version section into its top-level bullets, fence-aware. + * + * A bullet starts at a column-0 `- ` outside a code fence and runs until the + * next such bullet or a column-0 heading; changesets indents every continuation + * line by two spaces, so a `- ` or a `##` INSIDE an entry (its own FROM → TO + * table, a nested dependency list, a fenced example) never opens a new one. + * Column-0 headings — changesets' level headings — are dropped: they are what + * this grouping replaces. Anything else at column 0 before the first bullet is + * `stray` and is kept ahead of the sections. + * + * @param {string} entry + * @returns {{ bullets: { text: string }[], stray: string }} + */ +export function splitChangelogEntry(entry) { + const bullets = []; + const stray = []; + let current = null; + let fence = null; + for (const line of String(entry ?? '').split('\n')) { + const mark = /^ {0,3}(`{3,}|~{3,})/.exec(line); + if (fence !== null) { + if (mark && mark[1][0] === fence[0] && mark[1].length >= fence.length) fence = null; + } else if (mark) { + fence = mark[1]; + } else if (/^#{1,6}\s/.test(line)) { + current = null; + continue; + } else if (/^- /.test(line)) { + current = { lines: [line] }; + bullets.push(current); + continue; + } + if (current) current.lines.push(line); + else if (line.trim() !== '') stray.push(line); + } + return { + bullets: bullets.map((b) => ({ text: b.lines.join('\n').replace(/\s+$/, '') })), + stray: stray.join('\n').trim(), + }; +} + +/** + * Which section one bullet belongs to, from the declarations it carries. + * + * manual breaking, and no `registered` ADR-0087 disposition + * migrated breaking, with a `registered` disposition (a conversion + * `objectstack migrate meta` applies) + * added not breaking; `Metadata-protocol: added`, or a `Clause-②: yes` + * widening with no protocol kind — new for the reader either way + * semantic not breaking; `Metadata-protocol: semantic` + * other everything else, dependency bumps and the undeclared stock included + * + * "Breaking" is the union `check-adr-0087-registration.mjs` reads, mirrored + * here signal for signal: a `**BREAKING` banner or a `BREAKING CHANGE:` line, + * a conventional-commit `!` on the summary, the `(narrowing)` arm, and + * `Metadata-protocol: narrowed`. (The `major` bump is a frontmatter fact the + * CHANGELOG no longer carries; its entries sit under `### Major Changes` and + * carry the banner, which is read.) Breaking wins over the kind: a semantic + * change that also breaks is action required. + * + * @param {string} text one bullet, as `splitChangelogEntry` returns it + * @returns {{ section: 'manual'|'migrated'|'added'|'semantic'|'other', kind: string|null, value: string|null, arm: string|null, breaking: boolean, registered: boolean }} + */ +export function classifyReleaseBullet(text) { + const body = String(text ?? '') + .split('\n') + .map((line, i) => (i === 0 ? line.replace(/^- /, '') : line.replace(/^ {0,2}/, ''))) + .join('\n'); + const summary = (body.split('\n')[0] ?? '').replace(/^[0-9a-f]{7,40}: /, '').replace(/^\*\*|^#+\s*/, ''); + const protocol = readMetadataProtocolLine(body); + const kind = protocol?.kind === 'declared' ? protocol.value : null; + const clause = readClause2Line(body); + const value = clause?.kind === 'declared' ? clause.value : null; + const arm = clause?.kind === 'declared' ? clause.arm : null; + const breaking = + kind === 'narrowed' || + arm === 'narrowing' || + /\*\*BREAKING/i.test(body) || + /^\s*BREAKING[ -]CHANGE/im.test(body) || + /^[a-z]+(\([^)]*\))?!:/.test(summary); + const registered = /', + migrated: + '- bbbbbbb: feat(spec)!: a key moves\n \n Metadata-protocol: narrowed\n \n Clause-②: no (narrowing)\n \n' + + ' \n \n ```ts\n - not a bullet\n ### not a heading\n ```', + added: '- ccccccc: feat(spec): a new key\n \n Clause-②: yes (widening)\n \n Metadata-protocol: added', + semantic: '- ddddddd: fix(spec): a default moves\n \n Clause-②: no\n \n Metadata-protocol: semantic', + other: '- eeeeeee: fix(rest): a typo\n \n Clause-②: no\n \n Metadata-protocol: none', + rest: '- fffffff: feat(rest): a new endpoint\n \n Clause-②: yes\n \n Metadata-protocol: none', + stock: '- 0000000: fix(core): a pre-rule entry with no declaration at all', + deps: '- Updated dependencies [1234567]\n - @objectstack/spec@18.1.0', + }; + const entry = ['### Minor Changes', '', B.manual, '', B.added, '', B.migrated, '', B.semantic, '', '### Patch Changes', '', B.other, '', B.rest, '', B.stock, '', B.deps].join('\n'); + const grouped = groupReleaseEntry(entry); + const headingsIn = (t) => t.split('\n').filter((l) => /^### /.test(l)); + assert( + headingsIn(grouped).length === 5 && RELEASE_NOTE_SECTIONS.every((s, i) => headingsIn(grouped)[i] === `### ${s.heading}`), + `all five sections render, in the ruling's order — got ${JSON.stringify(headingsIn(grouped))}`, + ); + assert(!grouped.includes('### Minor Changes') && !grouped.includes('### Patch Changes'), 'the level headings are gone from the Release body (the CHANGELOG keeps them)'); + const section = (id) => { + const start = grouped.indexOf(`### ${RELEASE_NOTE_SECTIONS.find((s) => s.id === id).heading}`); + const next = grouped.indexOf('\n### ', start + 1); + return grouped.slice(start, next < 0 ? undefined : next); + }; + assert(section('manual').includes(B.manual) && !section('manual').includes('bbbbbbb'), '需手动处理 holds the narrowing with no conversion, and not the registered one'); + assert(section('migrated').includes(B.migrated), '自动迁移 holds the narrowing with a registered conversion — byte for byte, fence included'); + assert(section('added').includes(B.added) && section('added').includes(B.rest), '新增能力 holds `added` and a non-protocol `Clause-②: yes` widening alike — both are new for the reader'); + assert(section('semantic').includes(B.semantic), '语义变化 holds `semantic`'); + assert( + section('other').includes(B.other) && section('other').includes(B.stock) && section('other').includes(B.deps) && + section('other').indexOf(B.other) < section('other').indexOf(B.stock) && section('other').indexOf(B.stock) < section('other').indexOf(B.deps), + '其他修复 holds `none`, the undeclared pre-rule stock and the dependency bumps, in CHANGELOG order', + ); + const bulletsOf = (t) => splitChangelogEntry(t).bullets.map((b) => b.text); + assert( + bulletsOf(grouped).length === bulletsOf(entry).length && bulletsOf(grouped).every((b) => bulletsOf(entry).includes(b)), + 'every bullet appears exactly once, unchanged — grouping is presentation, never a filter', + ); + assert( + classifyReleaseBullet(B.manual).section === 'manual' && + classifyReleaseBullet('- 1234567: fix(a)!: bang only').section === 'manual' && + classifyReleaseBullet('- fix(a)!: bang only, no commit prefix').section === 'manual' && + classifyReleaseBullet('- x: a\n \n BREAKING CHANGE: y').section === 'manual' && + classifyReleaseBullet('- x: a\n \n Clause-②: no (narrowing)').section === 'manual' && + classifyReleaseBullet('- x: a\n \n Metadata-protocol: narrowed').section === 'manual', + 'every breaking spelling the ADR-0087 gate reads lands under 需手动处理 — the banner, the bang (with and without the commit prefix changesets writes), the arm, the kind', + ); + assert( + classifyReleaseBullet('- 1234567: fix(a): no bang, `!` only in prose!: here').section === 'other', + 'control: a `!` that is not on the summary\'s type is not the bang', + ); + assert( + classifyReleaseBullet('- x: a\n \n Metadata-protocol: narrowed\n \n ').section === 'migrated' && + classifyReleaseBullet('- x: a\n \n Metadata-protocol: narrowed\n \n ').section === 'manual', + 'only a REGISTERED disposition is an automatic migration; `not-required` leaves the reader to act', + ); + assert(classifyReleaseBullet('- x: a\n \n Metadata-protocol: semantic\n \n **BREAKING**: y').section === 'manual', 'breaking wins over the kind: a semantic change that also breaks is action required'); + assert( + classifyReleaseBullet('- x: a\n \n this is a narrowing of nothing\n \n Metadata-protocol: narrowed|added').section === 'other', + 'prose naming the arm and a malformed kind declare nothing — the reader takes a token, never a phrase', + ); + assert(groupReleaseEntry('no bullets here\n\njust prose') === 'no bullets here\n\njust prose' && groupReleaseEntry('') === '', 'an entry with no bullets round-trips untouched, the empty one included'); + assert(grouped.startsWith(RELEASE_NOTE_LEAD), 'the body leads with the one-line key to the sections'); + // The real spec CHANGELOG: a real section regroups without losing, splitting + // or inventing a bullet, and `planRelease` posts the grouped body. + const real = getChangelogEntry(specChangelog, '16.1.0') ?? ''; + assert( + bulletsOf(real).length > 0 && bulletsOf(groupReleaseEntry(real)).length === bulletsOf(real).length, + `the real 16.1.0 section regroups without losing or splitting a bullet (${bulletsOf(real).length} bullets)`, + ); + const realPlan = planRelease({ target: { name: '@objectstack/spec', version: '16.1.0', dir: join(REPO_ROOT, 'packages/spec') }, ...CTX }); + assert( + 'body' in realPlan && realPlan.body === buildReleaseBody({ entry: groupReleaseEntry(real), tagName: '@objectstack/spec@16.1.0', changelogLabel: specLabel, changelogHref: specHref }).body, + 'planRelease posts the GROUPED section, through the same truncation path as before', + ); + assert( + 'body' in realPlan && headingsIn(realPlan.body).length >= 1 && headingsIn(realPlan.body).every((h) => RELEASE_NOTE_SECTIONS.some((s) => `### ${s.heading}` === h)), + 'and every heading in the posted body is one of the five', + ); + } + // ── The floor: every declared battery RAN, and ran its cases (#13489) ─── // // Evaluated after every battery has had its chance and BEFORE the verdict, so