From 4e3cbe10412ac271dd6bcb19d1f7792a6deb4d66 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 19 Sep 2026 02:01:50 +0000 Subject: [PATCH] fix(scripts): derive check-changeset-no-major's level headline from the parsed declaration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Past the `not-declared` lane the level verdict is decided by `refusable` and `offenders.length` alone — `declaration.value` plays no further part — so `clean`, `discharged` and `enforce` are each reachable on a `Clause-②: no (narrowing)` declaration. All three stated `yes` as a LITERAL, and `enforce` did it twice: once on stderr and once in the check-run annotation title, which is the only one of the three that crosses out of the step log. The rows underneath printed the true value and arm the whole time. That made the arm docblock's own rule false in the line a reader quotes: "a PR that declares `no (narrowing)` must not be reported as having declared `yes`". The parsed declaration now travels on the verdict as `declared`, beside `carrier` and for the same reason, and one exported speller (`declaredClause2`) renders all four headlines plus the `not-declared` one from it. The arm is carried through, so the headline is the declaration as written and a reader can grep the PR body for it. The `discharged` sentence derives its noun too — a declared narrowing is no longer "accounted for" as a widening. ⛔ No verdict and no exit code moves: 30 new pins assert the grade unchanged in both directions on all three lanes, with the `yes` and `yes (widening)` rows as the controls that matter — they are every other PR in the repo. Claude-Session: https://claude.ai/code/session_017ef78bLdybu3AffehKkhfk Co-authored-by: Claude --- scripts/check-changeset-no-major.mjs | 209 ++++++++++++++++++++++++--- 1 file changed, 190 insertions(+), 19 deletions(-) diff --git a/scripts/check-changeset-no-major.mjs b/scripts/check-changeset-no-major.mjs index af36b25de8..4d72a0f731 100644 --- a/scripts/check-changeset-no-major.mjs +++ b/scripts/check-changeset-no-major.mjs @@ -1580,10 +1580,12 @@ export function declarationFromPullRequest(pr) { * 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 declared `yes`, no moved package graded `patch` -> exit 0 - * discharged declared `yes`, a moved package IS graded `minor`+, + * 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 declared `yes`, moved packages graded `patch` and + * enforce the axis is CARRIED, 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 @@ -1612,7 +1614,19 @@ export function judgeLevel({ levels, touched, declaration, prEvent = false }) { // when a caller hands in a declaration that carries no axis at all, which // keeps the pre-#17229 rendering for every shape that never had one. const carrier = declaration?.carrier ?? null; - if (!levels) return { verdict: 'unreadable-diff', offenders: [], raised: [], carrier, readings, unreadable: [] }; + // ⭐ #19008. The DECLARATION ITSELF travels beside the readings, for the third + // time and the same reason `carrier` and `arm` do: `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: [], carrier, declared, readings, unreadable: [] }; const unreadable = touched?.unreadable ?? []; // NO PR TO READ A DECLARATION FROM. This is a different fact from "a PR that @@ -1637,8 +1651,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: [], carrier, readings, unreadable } - : { verdict: 'no-pull-request', offenders: [], raised: [], carrier, readings, unreadable }; + ? { verdict: 'payload-unreadable', offenders: [], raised: [], carrier, declared, readings, unreadable } + : { verdict: 'no-pull-request', offenders: [], raised: [], carrier, declared, readings, unreadable }; } // The offenders are computed BEFORE the declaration is consulted, because @@ -1688,8 +1702,8 @@ export function judgeLevel({ levels, touched, declaration, prEvent = false }) { // 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, carrier, readings, unreadable } - : { verdict: 'not-measured-moot', offenders, raised, carrier, readings, unreadable }; + ? { verdict: 'not-measured-material', offenders, raised, carrier, declared, readings, unreadable } + : { verdict: 'not-measured-moot', offenders, raised, carrier, declared, readings, unreadable }; } // #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 @@ -1698,19 +1712,45 @@ export function judgeLevel({ levels, touched, declaration, prEvent = false }) { // 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: [], carrier, readings, unreadable }; + return { verdict: 'not-declared', offenders: [], raised: [], carrier, declared, readings, unreadable }; } // 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, carrier, readings, unreadable }; + if (refusable) return { verdict: 'enforce', offenders, raised, carrier, declared, readings, unreadable }; // #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, carrier, readings, unreadable }; - return { verdict: 'clean', offenders: [], raised, carrier, readings, unreadable }; + if (offenders.length) return { verdict: 'discharged', offenders, raised, carrier, declared, readings, unreadable }; + return { verdict: 'clean', offenders: [], raised, carrier, declared, readings, unreadable }; +} + +/** + * The clause-② declaration a level verdict was reached on, spelled for a reader + * — the ONE sentence any headline in `renderLevel` is allowed to make about it. + * + * It is the DECLARATION AS WRITTEN, value and arm together, so the headline and + * the `Clause-②:` line in the PR body are the same string and a reader can grep + * one for the other. The arm is not dropped on the way up: `yes (widening)` and + * a bare `yes` are different declarations, and the headline that flattened them + * is the one that also flattened `no (narrowing)` into `yes`. + * + * `value: null` is spelled `NOT MEASURED` rather than guessed at. The three + * carried lanes cannot be reached with a null value — `judgeLevel` returns a + * `not-measured-*` verdict first — so a result that arrives here without one is + * a caller that dropped the axis, and #4690's direction is that such a run says + * so rather than printing a reading it does not have. + * + * @param {{ value: 'yes'|'no'|null, arm: 'widening'|'narrowing'|null }|null|undefined} declared + * @returns {string} + */ +export function declaredClause2(declared) { + const value = declared?.value ?? null; + if (value !== 'yes' && value !== 'no') return 'NOT MEASURED'; + const arm = declared?.arm ?? null; + return arm === null ? `\`${value}\`` : `\`${value} (${arm})\``; } /** @@ -1766,6 +1806,16 @@ export function renderLevel(result) { // interchangeable. `null` (a caller that hands in no axis) keeps the // pre-#17229 rendering, so only a POSITIVE `not-measured` softens anything. const carrierUnread = result?.carrier === 'not-measured'; + // ⭐ #19008. The headline's subject, DERIVED from the same parsed declaration + // 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. + const declaredValue = declaredClause2(result?.declared); + // 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'; switch (result?.verdict) { case 'unreadable-diff': @@ -1876,12 +1926,12 @@ export function renderLevel(result) { return { exitCode: 1, stdout, stderr }; case 'not-declared': - stdout.push('✓ LEVEL AXIS: this PR declares clause-② `no`, so no package here is declared to have grown a published surface.', ...readings, ...unreadableNote); + stdout.push(`✓ LEVEL AXIS: this PR declares clause-② ${declaredValue}, so no package here is declared to have grown a published surface.`, ...readings, ...unreadableNote); return { exitCode: 0, stdout, stderr }; case 'clean': stdout.push( - '✓ LEVEL AXIS: this PR declares clause-② `yes`, and no package whose `packages/**/src/**` it moves is graded `patch`.', + `✓ LEVEL AXIS: this PR declares clause-② ${declaredValue}, and no package whose \`packages/**/src/**\` it moves is graded \`patch\`.`, ...readings, ...unreadableNote, ); @@ -1894,8 +1944,8 @@ export function renderLevel(result) { // mode the filing card is about, one layer along. case 'discharged': stdout.push( - '✓ LEVEL AXIS: this PR declares clause-② `yes`, and it grades a package whose `packages/**/src/**` ' + - 'it moves at `minor` or above — the declared widening is accounted for:', + `✓ LEVEL AXIS: this PR declares clause-② ${declaredValue}, and it grades a package whose \`packages/**/src/**\` ` + + `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:', @@ -1913,7 +1963,7 @@ export function renderLevel(result) { case 'enforce': stderr.push( - '⛔ This PR declares clause-② YES, and it grades NO package whose `packages/**/src/**` it moves\n' + + `⛔ This PR declares clause-② ${declaredValue}, and it grades NO package whose \`packages/**/src/**\` it moves\n` + ' at `minor` or above.\n', ); stderr.push(' The packages this PR moves `packages/**/src/**` of, and the level each is graded:'); @@ -1962,7 +2012,7 @@ export function renderLevel(result) { ); stdout.push( errorAnnotation({ - title: 'Check Changeset (level axis): clause-② declares YES while no moved package is graded `minor` or above', + title: `Check Changeset (level axis): clause-② declares ${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` + @@ -2233,11 +2283,12 @@ const SELF_TEST_BATTERIES = Object.freeze({ 'THE ROOT: a packed `bin` target is a published surface the axis can refuse (#16692)': 37, '#17229: an UNREAD carrier is NOT MEASURED, never an absent one': 25, '#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, }); // 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 = 19; +const SELF_TEST_BATTERY_FLOOR = 20; // 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 @@ -3182,6 +3233,126 @@ function selfTest() { ); } + // ── The HEADLINE is the DECLARATION, never a literal (#19008) ──────────── + // + // ⭐ BOTH DIRECTIONS, on ALL THREE carried lanes. Past `not-declared` the + // verdict is decided by `refusable` and `offenders.length` alone, so + // `clean`, `discharged` and `enforce` are each reachable on a + // `no (narrowing)` — and each of their headlines used to state `yes` as a + // LITERAL, the `enforce` one twice over (stderr, and the check-run + // annotation title that outlives the step log). The `yes` rows here are not + // decoration: they are every other PR in the repo, and a derivation pinned + // only on the narrowing side would have broken them silently. + // + // ⛔ The verdict and the exit code are pinned UNCHANGED beside every + // headline. This card is a REPORTING defect; a repair that moved a grade + // would be a different and worse one. + battery('#19008: the level headline is the parsed declaration, not a literal'); + { + 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'; + // 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 }); + // One levels/touched pair per lane, named by the verdict it must produce. + const LANES = { + clean: { levels: [{ file: CS, entries: [{ pkg: CLI, bump: 'minor' }] }], touched: { packages: [CLI], unreadable: [] }, exit: 0 }, + discharged: { + levels: [{ file: CS, entries: [{ pkg: CLI, bump: 'patch' }, { pkg: OTHER, bump: 'minor' }] }], + touched: { packages: [CLI, OTHER], unreadable: [] }, + exit: 0, + }, + enforce: { levels: [{ file: CS, entries: [{ pkg: CLI, bump: 'patch' }] }], touched: { packages: [CLI], unreadable: [] }, exit: 1 }, + }; + const judged = (lane, body) => + judgeLevel({ levels: LANES[lane].levels, touched: LANES[lane].touched, declaration: decl(body), prEvent: true }); + const textOf = (lane, body) => { + const out = renderLevel(judged(lane, body)); + return { text: [...out.stdout, ...out.stderr].join('\n'), exitCode: out.exitCode }; + }; + + for (const lane of ['clean', 'discharged', 'enforce']) { + assert( + judged(lane, NARROWING).verdict === lane && judged(lane, YES).verdict === lane, + `LANE IDENTITY: a headline pin is worthless if the row it reads is not the row it names — the \`${lane}\` fixtures must reach \`${lane}\` on BOTH declarations, and a \`no (narrowing)\` reaches it because past \`not-declared\` only \`refusable\` and \`offenders.length\` decide (got ${judged(lane, NARROWING).verdict} / ${judged(lane, YES).verdict})`, + ); + assert( + textOf(lane, NARROWING).exitCode === LANES[lane].exit && textOf(lane, YES).exitCode === LANES[lane].exit, + `⛔ GRADING IS UNTOUCHED on \`${lane}\`: exit ${LANES[lane].exit} in both directions. This card is a reporting defect, and a repair that moved an exit code would be a worse card than the one it closes`, + ); + assert( + textOf(lane, NARROWING).text.includes('clause-② `no (narrowing)`'), + `the \`${lane}\` lane reports the declaration the PR ACTUALLY made — the arm docblock's rule, verbatim: "a PR that declares \`no (narrowing)\` must not be reported as having declared \`yes\`"`, + ); + assert( + !/clause-② `yes`|clause-② YES/.test(textOf(lane, NARROWING).text), + `⛔ and the \`${lane}\` lane claims \`yes\` NOWHERE on a narrowing declaration — stdout, the stderr refusal and the check-run annotation title alike; one corrected branch beside an uncorrected sibling is the same defect one lane along`, + ); + assert( + textOf(lane, YES).text.includes('clause-② `yes`'), + `⛔ CONTROL: a bare \`yes\` still renders \`yes\` on \`${lane}\` — that is every other PR in this repo, and a derivation verified only on the narrowing side breaks it silently`, + ); + assert( + textOf(lane, WIDENING).text.includes('clause-② `yes (widening)`'), + `⛔ CONTROL: the ARM reaches the headline too on \`${lane}\` — \`yes (widening)\` and a bare \`yes\` are different declarations, and the flattening that lost that difference is the flattening that printed \`yes\` for a narrowing`, + ); + } + + // The channel that crosses OUT of the step log. A literal here outlives + // the readings that would have corrected it, so it is pinned separately + // from the stderr prose it sits beside. + 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'), + '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'); + + // The ACT the declaration names, in the one headline that names it. + assert( + textOf('discharged', NARROWING).text.includes('the declared narrowing is accounted for'), + 'the declared ACT is derived as well: calling a declared narrowing a widening is the same misreport one noun along, in the same sentence', + ); + assert( + textOf('discharged', YES).text.includes('the declared widening is accounted for'), + '⛔ CONTROL: a declared `yes` is still a widening in that sentence — the noun is read, not swapped', + ); + + // The lane a bare `no` takes, which the derivation must leave exactly where + // 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})`, + ); + assert( + renderLevel(bareNo).stdout.join('\n').includes('declares clause-② `no`'), + 'and that lane, derived like the rest, prints the `no` it always printed — a derivation that rewrote a correct sentence would be a regression dressed as a repair', + ); + + // The axis itself, and the spelling function the four headlines share. + assert( + judged('clean', NARROWING).declared.value === 'no' && judged('clean', NARROWING).declared.arm === 'narrowing', + 'the parsed declaration TRAVELS on the verdict, beside `carrier` and for the same reason — `renderLevel` derives from what it was handed and must never re-parse a body it was never given', + ); + assert(declaredClause2({ value: 'yes', arm: null }) === '`yes`', 'the spelling of a bare `yes`'); + assert( + declaredClause2({ value: 'no', arm: 'narrowing' }) === '`no (narrowing)`', + 'and of a narrowing — it is the declaration AS WRITTEN, so a reader can grep the PR body for the string the headline printed', + ); + assert( + declaredClause2({ value: null, arm: null }) === 'NOT MEASURED', + '⛔ an absent value reads NOT MEASURED, never a guessed `yes`: the three carried lanes cannot be reached with one, so a result that arrives without it is a caller that dropped the axis, and #4690 says such a run states that rather than printing a reading it does not have', + ); + assert(declaredClause2(undefined) === 'NOT MEASURED', 'control: no axis at all reads the same as a null value — both are a reading that did not happen'); + } + // ── The GRAIN: a PR-scoped declaration judged at PR scope (#16361) ─────── // // The fixtures are the two PRs of one dispatch round, at the heads that