From 1d576b6c57935948d09d5fc478b95352b74032c7 Mon Sep 17 00:00:00 2001 From: "claude[bot]" Date: Wed, 16 Sep 2026 00:57:34 +0000 Subject: [PATCH 1/4] =?UTF-8?q?feat(devx):=20the=20clause-=E2=91=A1=20decl?= =?UTF-8?q?aration=20grows=20a=20direction=20arm,=20and=20three=20gates=20?= =?UTF-8?q?read=20it?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A narrowing — "these spellings stop being accepted" — could ship to consumers with every gate green. `check-adr-0087-registration.mjs` decided breaking-ness from a hand-written `**BREAKING**` prose banner, so an author who did not type it was classified non-breaking and owed no ADR-0087 disposition. Measured on #16296 (and #14238 one surface earlier), both of which shipped that way. The declaration now carries a closed direction arm — `(widening)` or `(narrowing)` — read once, in `readClause2Line`, and imported by every reader: * `scripts/pm/check-clause2-carriers.mjs` — `CLAUSE2_ARMS`, the arm reader, and the two shapes that must fail closed: a near-arm spelling (`(narrowed)`, the unfilled `(widening|narrowing)` menu) and the self-contradiction `no (widening)`. Both read `malformed`, never as an absent arm. * `scripts/check-adr-0087-registration.mjs` — signal (4). `narrowing` declares breaking; `widening` declares nothing. The `**BREAKING**` banner is KEPT: 52 changesets in stock carry it and nothing else. * `scripts/check-changeset-no-major.mjs` — a declared narrowing owes the same grade a declared widening owes, because inside the launch window a breaking change ships `minor`. The arm is OPTIONAL, and that is a measurement: all five declarations on the open board read `Clause-②: no` with no parenthetical, and all five read byte-identically after this change. AGENTS.md carries the format at net zero lines — its ratchet has no headroom — paid for by deleting the widening-rationale clause and the CI step name the gate prints for itself. Maintainer ruling: director summon #17, decision batch #2 item 1, option B, verbatim 「同意」. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_017ef78bLdybu3AffehKkhfk --- AGENTS.md | 10 +- scripts/check-adr-0087-registration.mjs | 115 +++++++++++++++--- scripts/check-changeset-no-major.mjs | 66 +++++++++-- scripts/pm/check-clause2-carriers.mjs | 150 +++++++++++++++++++++++- 4 files changed, 308 insertions(+), 33 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 7c7a5eeeb3c..01fef8940eb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1040,8 +1040,8 @@ registry? Add it to `OPEN_CAPABILITY_REGISTRIES` in the same PR that fixes it. 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. A PR that declares `Clause-②: yes` takes at least **`minor`** instead — - the widening it declares is what makes it more than a patch, whatever else the diff fixes. + 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 @@ -1051,9 +1051,9 @@ registry? Add it to `OPEN_CAPABILITY_REGISTRIES` in the same PR that fixes it. schema is `.strict()`. 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 the two routes imply **opposite** liveness-ledger dispositions. - **A breaking changeset must also state its ADR-0087 disposition, in writing** — exactly one marker in the - changeset body, enforced by `pnpm check:adr-0087-registration` (CI step *Require an ADR-0087 disposition on a - declared-breaking changeset*). ⛔ The categories are NOT copied here — the gate prints the full set when it fails. + **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. 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 `Console Pin Gate` job builds objectui at the pinned `.objectui-sha` against **current** `main`, so a removal or rename the pinned sibling still imports turns `main` red for every PR in the repo the moment it diff --git a/scripts/check-adr-0087-registration.mjs b/scripts/check-adr-0087-registration.mjs index 68879be77bf..35ab1b2c94c 100644 --- a/scripts/check-adr-0087-registration.mjs +++ b/scripts/check-adr-0087-registration.mjs @@ -361,6 +361,10 @@ import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { isEntrypoint } from './invoked-as.mjs'; 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/check-clause2-carriers.mjs'; // ── The self-test's own battery roster and floor (#13489) ────────────────── // @@ -553,18 +557,51 @@ export function parseChangeset(text) { /** * Does this changeset DECLARE a breaking change? * - * Three spellings are in live use in this repo and all three count, because the + * FOUR spellings are in live use in this repo and all four count, because the * gate's subject is the author's own declaration and an author who used any of * them has declared it: * * 1. a `major` bump in the frontmatter (118 of 1304 in stock) * 2. a `**BREAKING` marker in the body (52) * 3. a conventional-commit `!` in the summary line (175) - * - * The union is 213. Narrowing to any one of them would drop real declarations: - * #6048's changeset used (1) and (2) and NOT (3), while the launch-window guard - * `check-changeset-no-major.mjs` pushes breaking changes to `minor` outside - * pre-mode, which would leave (2)/(3) carrying the signal alone. + * 4. a `Clause-②` declaration whose ARM reads `narrowing` (#16421) + * + * 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 + * launch-window guard `check-changeset-no-major.mjs` pushes breaking changes to + * `minor` outside pre-mode, which would leave (2)/(3) carrying the signal alone. + * + * ## Signal (4), and the hole it closes (#16421) + * + * Signals (1)–(3) are all things an author REMEMBERS to type, and (2) — the one + * that carries breaking-ness during the launch window, when (1) is forbidden + * outright — is FREE-FORM PROSE. Measured: **#16296** narrowed + * `sys_job.timezone` / `sys_report_schedule.timezone` to the IANA value domain + * and shipped to consumers with no banner, no `major` and no `!`, so this gate + * classified it non-breaking, asked for no ADR-0087 disposition, and every gate + * in the repo was green. **#14238** did the same one surface earlier. An + * accept-set narrowing is exactly the change a consumer needs told about, and it + * was the change with the weakest carrier. + * + * Signal (4) replaces "remember the banner" with the declaration the PM protocol + * already requires on every card and PR: the direction ARM. The author copies + * one line they have already written into the changeset body, and the gate reads + * a CLOSED TOKEN rather than a phrase. + * + * ⛔ The arm is read through `readClause2Line`, IMPORTED — there is no second + * parser here, and the ruling made that a condition rather than a preference: + * one legal spelling, read in one place, by every reader. + * + * ⚠️ `widening` adds NO signal, and that is the half of the arm this gate has to + * get right. A widening is not a break; classifying both arms as breaking would + * make the reading indistinguishable from a constant, and the self-test pins + * both directions for exactly that reason. + * + * ⛔ Signal (2) is KEPT, not replaced. Dropping the banner while 52 changesets in + * stock carry it and nothing else would un-declare every one of them — the same + * "narrowing to any one of them drops real declarations" this docblock has + * 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. * * @param {ReturnType} parsed * @returns {{ breaking: boolean, signals: string[] }} @@ -575,6 +612,12 @@ export function breakingDeclaration(parsed) { if (/\*\*BREAKING/i.test(parsed.body) || /^\s*BREAKING[ -]CHANGE/mi.test(parsed.body)) signals.push('BREAKING'); const summary = (parsed.body.trim().split(/\n/)[0] || '').replace(/^\*\*|^#+\s*/, ''); if (/^[a-z]+(\([^)]*\))?!:/.test(summary)) signals.push('bang'); + // (4) The direction arm. A `malformed` or `near-miss` reading declares + // nothing HERE and must not: this gate judges changesets, and the states that + // send a seat to fix an unreadable declaration are `check-clause2-carriers`'s + // rows, on the carriers that own them. + const decl = readClause2Line(parsed.body); + if (decl?.kind === 'declared' && decl.arm === 'narrowing') signals.push('clause-②-narrowing'); return { breaking: signals.length > 0, signals }; } @@ -1860,10 +1903,24 @@ export function assertInputs({ cwd, head }) { ['a `**BREAKING**` body marker', "---\n'@objectstack/spec': minor\n---\n\na summary\n\n**BREAKING**: something changed\n"], ['a `BREAKING CHANGE:` body line', "---\n'@objectstack/spec': minor\n---\n\na summary\n\nBREAKING CHANGE: something changed\n"], ['a conventional-commit `!` summary', "---\n'@objectstack/spec': patch\n---\n\nfeat(spec)!: drop a key\n"], + // #16421, signal (4) — the ARM, pinned in BOTH directions across these two + // lists. `narrowing` here, `widening` in the inverted list below: an arm + // reading that classified both as breaking would satisfy this list alone + // while telling a consumer nothing, so neither fixture means anything + // without the other. The `minor` bump and the banner-free body are the + // point — this is the exact shape #16296 shipped in, and the three older + // 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"], ]; const MUST_NOT_MATCH_BREAKING = [ ['a plain `patch` changeset', "---\n'@objectstack/spec': patch\n---\n\nfix a typo\n"], ['a `minor` changeset whose prose merely contains the word breaking', "---\n'@objectstack/spec': minor\n---\n\nnothing groundbreaking here\n"], + // The OTHER direction of signal (4). A widening is not a break. + ['the `widening` ARM — the arm reads, and reads as NOT breaking (#16421)', "---\n'@objectstack/spec': minor\n---\n\na summary\n\nClause-②: yes (widening)\n"], + ['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"], ]; for (const [label, text] of MUST_MATCH_BREAKING) { if (!breakingDeclaration(parseChangeset(text)).breaking) { @@ -6186,14 +6243,44 @@ function selfTest() { }; const copy = 'scripts/check-adr-0087-registration.mjs'; w(copy, readFileSync(fileURLToPath(import.meta.url), 'utf8')); - // EVERY `./`-relative sibling this file imports travels with the copy, or the - // fixture dies on ERR_MODULE_NOT_FOUND -- which reads as "I1 is broken" rather - // than "the fixture is incomplete". Adding an import to this gate means adding - // it here, and `js-comment-mask.mjs` (#12881) is the case that proved the list - // has to be a list: it arrived after `invoked-as.mjs` and took both I-cases red - // on a fixture problem that had nothing to do with what they assert. - for (const sibling of ['invoked-as.mjs', 'js-comment-mask.mjs']) { - w(`scripts/${sibling}`, readFileSync(new URL(`./${sibling}`, import.meta.url), 'utf8')); + // EVERY first-party module this file imports, TRANSITIVELY, travels with the + // copy, or the fixture dies on ERR_MODULE_NOT_FOUND -- which reads as "I1 is + // broken" rather than "the fixture is incomplete". `js-comment-mask.mjs` + // (#12881) is the case that proved the closure has to be computed: it + // arrived after `invoked-as.mjs` and took both I-cases red on a fixture + // problem that had nothing to do with what they assert. + // + // ⭐ DERIVED, not listed (#16421). It was a two-name list while this gate's + // imports were two leaves. Signal (4) added an edge to + // `pm/check-clause2-carriers.mjs`, whose own closure is nine modules deep and + // includes `pm/dispatch-gates.mjs` -- a file under constant change. A hand + // list against that graph is a trap that springs on whoever adds an import + // over there, in a gate over here, with an error message about neither. The + // walk reads the same edges Node will resolve, so the fixture cannot drift + // from the real module graph by construction. + // + // ⛔ Statement-shaped matches ONLY -- the two spellings that reach a + // line-start `from`, a single-line `import … from '…'` and the closing brace + // of a multi-line one. A bare regex over the source harvests every specifier + // sitting inside a STRING in a self-test fixture, which is how a walk of this + // tree ends up chasing `./does-not-exist.mjs`. + const EDGE_PATTERNS = [ + /^[ \t]*(?:import|export)[^'"\n]*from[ \t]*['"](\.[^'"\n]+)['"]/gm, + /^[ \t]*\}[ \t]*from[ \t]*['"](\.[^'"\n]+)['"]/gm, + ]; + const staged = new Set([copy]); + const stage = (rel) => { + if (staged.has(rel)) return; + staged.add(rel); + const src = readFileSync(join(REPO_ROOT, rel), 'utf8'); + w(rel, src); + for (const pattern of EDGE_PATTERNS) { + pattern.lastIndex = 0; + for (const m of src.matchAll(pattern)) stage(join(dirname(rel), m[1])); + } + }; + for (const m of readFileSync(fileURLToPath(import.meta.url), 'utf8').matchAll(EDGE_PATTERNS[0])) { + stage(join('scripts', m[1])); } w( 'importer.mjs', diff --git a/scripts/check-changeset-no-major.mjs b/scripts/check-changeset-no-major.mjs index 09a7d6f9dc4..05475f0c591 100644 --- a/scripts/check-changeset-no-major.mjs +++ b/scripts/check-changeset-no-major.mjs @@ -1268,8 +1268,19 @@ export function packagesTouched({ cwd, from, head }) { * declaration line — so a seat that hangs the gate without writing the line, * which is what #16044 did, is still read. * + * ## The DIRECTION ARM (#16421) + * + * `arm` travels beside the value, from the same `readClause2Line`, and is `null` + * for every declaration written before the arm existed. The level axis reads it + * for one reason: a declared `narrowing` is a BREAKING change, and during the + * launch window a breaking change ships `minor` — the same grade a declared + * widening owes, for a different reason. ⛔ The arm is NOT folded into `value`: + * "this widens" and "this narrows" are different facts, the readings quote both, + * and a PR that declares `no (narrowing)` must not be reported as having + * declared `yes`. + * * @param {{ labels?: ({ name?: string }|string)[], body?: string }|null} pr - * @returns {{ value: 'yes'|'no'|null, payload: boolean, readings: string[] }} + * @returns {{ value: 'yes'|'no'|null, arm: 'widening'|'narrowing'|null, payload: boolean, readings: string[] }} */ export function declarationFromPullRequest(pr) { const readings = []; @@ -1278,7 +1289,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, payload: false, readings: ['no `pull_request` payload was available to read a declaration from'] }; + return { value: null, arm: null, payload: false, readings: ['no `pull_request` payload was available to read a declaration from'] }; } const labels = Array.isArray(pr.labels) @@ -1300,9 +1311,20 @@ export function declarationFromPullRequest(pr) { else if (line?.kind === 'near-miss') readings.push(`declaration line: a near miss, not a declaration — ${line.line}`); else readings.push('declaration line: the PR body carries no `Clause-②:` line'); - if (carrier || (line?.kind === 'declared' && line.value === 'yes')) return { value: 'yes', payload: true, readings }; - if (line?.kind === 'declared' && line.value === 'no') return { value: 'no', payload: true, readings }; - return { value: null, payload: true, readings }; + // #16421. The arm is reported on its own line whichever way it reads, INCLUDING + // its absence on a declaration that carries one: "this PR declared no + // direction" and "nothing about direction was read" are the same distinction + // the value axis makes above, and the reader is told which one happened. + const arm = line?.kind === 'declared' ? (line.arm ?? null) : null; + if (line?.kind === 'declared') { + readings.push(arm === null + ? 'direction arm: none declared — the declaration names no widening and no narrowing' + : `direction arm: \`${arm}\`${arm === 'narrowing' ? ' — a BREAKING change; during the launch window it ships `minor`' : ''}`); + } + + if (carrier || (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 }; } /** @@ -1314,7 +1336,8 @@ export function declarationFromPullRequest(pr) { * 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` -> exit 0 + * 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`+, * and others are graded `patch` -> exit 0 @@ -1335,7 +1358,7 @@ export function declarationFromPullRequest(pr) { * @param {{ * levels: { file: string, entries: { pkg: string, bump: string }[] }[] | null, * touched: { packages: string[], unreadable: string[] }, - * declaration: { value: 'yes'|'no'|null, readings: string[], payload?: boolean }, + * declaration: { value: 'yes'|'no'|null, arm?: 'widening'|'narrowing'|null, readings: string[], payload?: boolean }, * prEvent?: boolean, * }} input */ @@ -1420,7 +1443,15 @@ export function judgeLevel({ levels, touched, declaration, prEvent = false }) { ? { verdict: 'not-measured-material', offenders, raised, readings, unreadable } : { verdict: 'not-measured-moot', offenders, raised, readings, unreadable }; } - if (declaration.value === 'no') return { verdict: 'not-declared', offenders: [], raised: [], 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 + // 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: [], 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 @@ -2609,6 +2640,25 @@ function selfTest() { 'a decision and a missing reading must not print the same thing — collapsing them is the defect #16055 records', ); + // ── The DIRECTION ARM on the level axis (#16421) ────────────────────── + // + // ⭐ 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. + 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'); + // 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` // with no `--event` at all, over a whole RC snapshot range that certainly diff --git a/scripts/pm/check-clause2-carriers.mjs b/scripts/pm/check-clause2-carriers.mjs index af1a124a285..bd9e31b0a17 100644 --- a/scripts/pm/check-clause2-carriers.mjs +++ b/scripts/pm/check-clause2-carriers.mjs @@ -755,6 +755,43 @@ export const EXIT_PAIR_ADVERSE = 4; */ export const CLAUSE2_VALUES = Object.freeze(['yes', 'no']); +/** + * The DIRECTION ARM — the closed pair a declaration may name after its value. + * + * ## Why an arm exists at all (#16421) + * + * The value answers ONE question: 「本卡放宽接受集或扩大公开面吗」. A diff that + * NARROWS a published accept set answers it `no` truthfully — and a narrowing is + * a breaking change. So `no` was carrying two facts that need opposite handling, + * and the one needing the most was the one nothing could see: measured on + * #16296, a value-domain narrowing shipped to consumers with every gate green, + * because `check-adr-0087-registration.mjs` read breaking-ness out of a + * `**BREAKING**` PROSE BANNER the author simply did not type. Maintainer ruling, + * director summon #17, decision batch #2 item 1, option B, verbatim 「同意」. + * + * ## The arm is OPTIONAL, and that is a measurement, not a kindness + * + * Every declaration on the board the day this landed reads `Clause-②: no` with + * no parenthetical arm (5 of 13 open PRs carry a declaration; all five read + * `no`, and #18268's carries trailing em-dash reasoning and still no paren). A + * mandatory arm would have invalidated all five overnight. An ABSENT arm + * therefore declares NO DIRECTION — the reading a body written before this + * change gets, byte-identically to what it got before it existed. + * + * ## The four combinations, and the one that is refused + * + * `yes` / `yes (widening)` — a widening. The second spelling is the first, + * said out loud; both take at least `minor`. + * `yes (narrowing)` — a diff that widens one surface and narrows + * another. Both facts are true and both are read. + * `no (narrowing)` — NOT a widening, but breaking. This is the whole + * point of the arm. + * `no (widening)` — ⛔ MALFORMED. The value says "this does not + * widen" and the arm says it does; a reader that + * picked either one of the two would be guessing. + */ +export const CLAUSE2_ARMS = Object.freeze(['widening', 'narrowing']); + /** * The key, and the decoration tolerated around it. * @@ -874,13 +911,77 @@ function hasInlineClause2Key(line) { * allows may contain a pipe anywhere later — a table column, a shell * pipeline — and is untouched. */ -function readValueToken(raw) { +function matchValueToken(raw) { 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]*\\|)`); const m = token.exec(rest); - return m ? m[1] : null; + // `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, + // the colon or the value — only what this match did not consume. + return m ? { value: m[1], after: rest.slice(m[0].length) } : null; +} + +function readValueToken(raw) { + return matchValueToken(raw)?.value ?? null; +} + +/** + * The ARM token, read immediately after the value. (#16421) + * + * ## The shape, and the one calibration it inherits + * + * The arm is a PARENTHETICAL opened as the next non-blank thing after the value + * — `Clause-②: no (narrowing)` — and the arm word is the FIRST token inside it. + * That is `readValueToken`'s own calibration, one slot along: the token comes + * first and what follows it is the seat's argument, which this file does not + * read. So `no (narrowing — the IANA zone domain)` reads the arm and keeps the + * reason, exactly as `no — …` keeps trailing reasoning today. + * + * ⚠️ The closing decoration is stripped first, and that is not cosmetic: + * `**\`no\`** (narrowing)` closes the backtick and the bold AFTER the value, so + * a reader that looked for `(` at position 0 would miss the arm on the exact + * spelling this file's own remedy sentence teaches. + * + * ## Three outcomes, because a near miss must not read as an absence + * + * `{ arm: 'widening'|'narrowing' }` — the fixed spelling, exactly. + * `{ arm: null }` — no parenthetical, or one that is plainly + * reasoning (`no (nothing published + * moves)`). The overwhelming live shape. + * `{ bad: }` — ⛔ the parenthetical OPENS with a word of + * the arm family and is not one of the two + * spellings: `(narrowed)`, `(Narrowing)`, + * `(widen)`, and the unfilled template + * `(widening|narrowing)`. Read as ABSENT + * these fail OPEN — a declared narrowing + * silently stops being declared, which is + * the defect the arm exists to remove. The + * caller turns this into `malformed`, the + * state this file already owns for "the + * slot holds something ungradeable". + * + * ⛔ The alternation refusal is `readValueToken`'s, for `readValueToken`'s + * reason: `(widening|narrowing)` is a MENU, and a seat that pasted the template + * without choosing has not declared a direction. + * + * @param {string} after — the line remainder `matchValueToken` did not consume. + * @returns {{ arm: 'widening'|'narrowing'|null, bad?: string }} + */ +function readArmToken(after) { + // Closers come off in the mirror order the value's openers went on: the value + // pattern consumed `**` then a backtick, so a decorated value closes backtick + // then `**`. + const rest = String(after ?? '').replace(/^`?(?:\*\*)?[ \t]*/, ''); + if (!rest.startsWith('(')) return { arm: null }; + const exact = new RegExp(`^\\([ \\t]*(${CLAUSE2_ARMS.join('|')})(?![A-Za-z0-9_])(?![ \\t]*\\|)`); + const hit = exact.exec(rest); + if (hit) return { arm: hit[1] }; + // Not the fixed spelling. Only a word of the arm FAMILY is a near miss; any + // other parenthetical is ordinary reasoning and is left alone. + const near = /^\([ \t]*(?:\*\*)?`?[ \t]*([A-Za-z|]+)/.exec(rest); + return near && /widen|narrow/i.test(near[1]) ? { arm: null, bad: near[1] } : { arm: null }; } /** @@ -973,7 +1074,7 @@ function quoteLine(line, cap = 160) { * Read the declaration limb out of ONE comment or body. * * @param {string} text - * @returns {{ kind: 'declared', value: 'yes'|'no', line: string } + * @returns {{ kind: 'declared', value: 'yes'|'no', arm: 'widening'|'narrowing'|null, line: string } * | { kind: 'malformed', value: string, line: string } * | { kind: 'near-miss', reason: 'describing'|'inline-key'|'spelling', line: string } * | null} @@ -982,6 +1083,13 @@ function quoteLine(line, cap = 160) { * a line that IS the key; `near-miss` is a fact about a line that is not. Any * collapse of these into "no" is the defect #13914 filed. * + * ⭐ `arm` (#16421) is the DIRECTION the declaration names, from + * {@link CLAUSE2_ARMS}, and `null` when it names none — which is what every + * declaration written before the arm existed says, and says unchanged. It is the + * ONE spelling of the direction in this fleet: `check-adr-0087-registration.mjs` + * and `check-changeset-no-major.mjs` import this reader rather than growing a + * parser each, which is the ruling's own condition on the change. + * * The near miss carries a REASON because the shapes owe different remedies: * `spelling` is a line that does not carry the fixed key at all; `inline-key` * is a line that carries it exactly right but not at the start of a line; and @@ -1017,9 +1125,19 @@ export function readClause2Line(text) { continue; } if (read !== null) continue; - const value = readValueToken(m[3]); - read = value !== null - ? { kind: 'declared', value, line: quoteLine(line) } + const hit = matchValueToken(m[3]); + // #16421. The arm is read in the SAME pass, from what the value match did + // not consume, and two shapes collapse into the `malformed` this file + // already owns rather than growing a state each: + // * a near-arm spelling (`readArmToken`'s `bad`), and + // * the CONTRADICTION `no (widening)` — "does not widen" beside "widens". + // Both are a value slot nobody can grade, which is what `malformed` means + // here, and both fail CLOSED. ⛔ Neither may read as an absent arm: that is + // the direction a declared narrowing disappears in. + const armRead = hit === null ? { arm: null } : readArmToken(hit.after); + const contradiction = hit?.value === 'no' && armRead.arm === 'widening'; + read = hit !== null && armRead.bad === undefined && !contradiction + ? { kind: 'declared', value: hit.value, arm: armRead.arm, line: quoteLine(line) } : { kind: 'malformed', value: quoteLine(m[3], 60), line: quoteLine(line) }; continue; } @@ -4218,6 +4336,26 @@ export function selfTest() { t('…including the bold-wrapped, parenthesised form seats actually write', readClause2Line('**Clause-②: no**(仅移动 import/注释)')?.value === 'no'); t('⛔ but a word merely STARTING with the token is not the token', readClause2Line('Clause-②: nope')?.kind === 'malformed' && readClause2Line('Clause-②: not applicable')?.kind === 'malformed'); t('the closed set is read from CLAUSE2_VALUES, so a third reading needs an edit there', CLAUSE2_VALUES.length === 2 && CLAUSE2_VALUES.every((v) => readClause2Line(`Clause-②: ${v}`)?.value === v)); + // -- the DIRECTION ARM (#16421) — both arms, both directions ----------------- + // + // ⭐ Both directions are pinned for each arm, because one direction alone + // cannot tell a reading from a constant: `narrowing` must READ, and `widening` + // must NOT read as a narrowing — a gate that classified both as breaking would + // pass an arm test that only ever asked "did something come back?". + t('ARM: `no (narrowing)` reads the arm — the shape the whole card exists for', readClause2Line('Clause-②: no (narrowing)')?.arm === 'narrowing'); + t('ARM: `yes (widening)` reads the OTHER arm, and is not a narrowing', readClause2Line('Clause-②: yes (widening)')?.arm === 'widening'); + t('ARM: `yes (narrowing)` — a diff may widen one surface and narrow another', readClause2Line('Clause-②: yes (narrowing)')?.value === 'yes' && readClause2Line('Clause-②: yes (narrowing)')?.arm === 'narrowing'); + t('ARM: ⛔ `no (widening)` CONTRADICTS itself and is malformed, never a silent pick', readClause2Line('Clause-②: no (widening)')?.kind === 'malformed'); + t('ARM: a near-arm spelling is malformed, ⛔ never an absent arm — that direction fails OPEN', ['(narrowed)', '(Narrowing)', '(widen)', '(narrowings)'].every((p) => readClause2Line(`Clause-②: no ${p}`)?.kind === 'malformed')); + t('ARM: the unfilled template `(widening|narrowing)` is a MENU, not a choice', readClause2Line('Clause-②: no (widening|narrowing)')?.kind === 'malformed'); + t('ARM: decoration closes AFTER the value, so the taught spelling still carries an arm', readClause2Line('- **`Clause-②`**: **`no`** (narrowing)')?.arm === 'narrowing'); + t('ARM: the arm keeps its reasoning, the same calibration the value has', readClause2Line('Clause-②: no (narrowing — the IANA zone domain)')?.arm === 'narrowing'); + t('ARM: the closed pair is read from CLAUSE2_ARMS, so a third arm needs an edit there', CLAUSE2_ARMS.length === 2 && CLAUSE2_ARMS.every((a) => readClause2Line(`Clause-②: yes (${a})`)?.arm === a)); + // ⛔ CONTROLS. The arm is OPTIONAL and every declaration on the board the day + // this landed had none; if these flip, five in-flight PRs lost their reading. + t('⛔ CONTROL: the two bare spellings are byte-identical reads carrying NO arm', CLAUSE2_VALUES.every((v) => readClause2Line(`Clause-②: ${v}`)?.value === v && readClause2Line(`Clause-②: ${v}`)?.arm === null)); + t('⛔ CONTROL: an ordinary parenthetical is reasoning, not a malformed arm', readClause2Line('Clause-②: no (nothing published moves)')?.value === 'no' && readClause2Line('Clause-②: no (nothing published moves)')?.arm === null); + t('⛔ CONTROL: #18268\'s live em-dash reasoning still reads `no` with no arm', readClause2Line('Clause-②: no — this diff adds an optional field (`CloudConfig`) and a flag fallback.')?.value === 'no'); t('a very long claim line is quoted back CAPPED, so one row cannot swamp the report', (readClause2Line(`Clause-②: maybe ${'x'.repeat(400)}`)?.line ?? '').length < 200); t('a card that never mentions the clause reads null', readClause2Line('Claim: whatever\nBranch: x') === null); t('⛔ the reader never invents a value from an adjacent word', readClause2Line('this card is clause 2 yes in substance')?.kind !== 'declared'); From 2f5f1f0b25a8fb842e41d47314ce4228e982db13 Mon Sep 17 00:00:00 2001 From: "claude[bot]" Date: Wed, 16 Sep 2026 01:07:22 +0000 Subject: [PATCH 2/4] fix(spec): record the shipped IANA time-zone narrowing in the ADR-0087 ledger MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `#16296` gave `sys_job.timezone` and `sys_report_schedule.timezone` the `valueDomain: 'iana_time_zone'` declaration — a write-time narrowing that shipped with no breaking-change marker, so the repo's detector classified it non-breaking and asked for no ADR-0087 disposition. The ledger now carries a `semantic` entry for it, protocol 18, stating what narrowed and the one-line fix per offending row. Nothing is re-released and nothing is ratified in silence. ⚠️ The ruling named TWO narrowings. The second, `#14238`, is NOT one: its diff is 535 insertions and 0 deletions across 11 files — two nullable columns ADDED, carrying the value domain from their first release — and its own changeset says so in writing ("A NON-BREAKING ADDITION ... ADR-0087's registries have no additive entry kind"). A row claiming it narrowed something would be false data in the one ledger this mechanism keeps true, which ADR-0087 refuses by name. The finding is reported to the seat rather than written into the ledger. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_017ef78bLdybu3AffehKkhfk --- .changeset/16421-clause2-direction-arm.md | 30 ++++++++++ ...rm-timezone-columns-iana-domain-refused.ts | 59 +++++++++++++++++++ packages/spec/src/migrations/registry.ts | 55 +++++++++++++++++ 3 files changed, 144 insertions(+) create mode 100644 .changeset/16421-clause2-direction-arm.md create mode 100644 packages/spec/src/migrations/entries/semantic/18.platform-timezone-columns-iana-domain-refused.ts diff --git a/.changeset/16421-clause2-direction-arm.md b/.changeset/16421-clause2-direction-arm.md new file mode 100644 index 00000000000..8cb02169c30 --- /dev/null +++ b/.changeset/16421-clause2-direction-arm.md @@ -0,0 +1,30 @@ +--- +'@objectstack/spec': patch +--- + +fix(spec): record the shipped `sys_job` / `sys_report_schedule` IANA narrowing in the ADR-0087 ledger (#16421) + +Clause-②: no + +`#16296` gave `sys_job.timezone` and `sys_report_schedule.timezone` the +`valueDomain: 'iana_time_zone'` declaration. That is a write-time narrowing — a +string these columns used to accept is now refused with the ADR-0114 field code +`value_domain` — and it shipped with no breaking-change marker at all, so the +repo's own detector classified it non-breaking and asked for no ADR-0087 +disposition. Measured on the shipped changeset, not inferred. + +The ledger now carries a `semantic` entry for it +(`platform-timezone-columns-iana-domain-refused`, protocol 18). Nothing is +re-released and nothing is ratified in silence: the entry states what narrowed, +the one-line fix per offending row (write the canonical zone id, or clear the +column), and the fact that a stored non-member is still readable and still +returned unchanged — it fails only on the row's next write. For +`sys_report_schedule` that refusal is the point: a non-member zone was silently +discarding the cron expression and falling back to `interval_minutes` forever. + +No authorable key, export, config field or stored shape moves, and no DDL is +planned — this is a record of a change that already shipped, published so that +`objectstack migrate meta`'s consumers can read it. + +Maintainer ruling, director summon #17, decision batch #2 item 1, option B, +quoted verbatim and untranslated: 「同意」. diff --git a/packages/spec/src/migrations/entries/semantic/18.platform-timezone-columns-iana-domain-refused.ts b/packages/spec/src/migrations/entries/semantic/18.platform-timezone-columns-iana-domain-refused.ts new file mode 100644 index 00000000000..b4fad423ca7 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.platform-timezone-columns-iana-domain-refused.ts @@ -0,0 +1,59 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'platform-timezone-columns-iana-domain-refused', + surface: + 'The two platform audit time-zone columns — `sys_job.timezone` and ' + + '`sys_report_schedule.timezone` — carrying a string that is not a member of the ' + + 'IANA time-zone database (`Asia/Shangai`, `Europe/Munich`, `UTC+8`, `PST`).', + replacement: + 'The canonical IANA zone id the deployment meant, written in the spelling the tzdb ' + + 'uses: `Asia/Shanghai`, `Europe/Berlin`, `America/Los_Angeles`. `UTC` is a member ' + + 'and is admitted — membership is the shared `Intl.DateTimeFormat` probe, never the ' + + '`Intl.supportedValuesOf(\'timeZone\')` enumeration, which omits `UTC` and would ' + + 'refuse the one fallback this contract names. ⚠️ A non-member is RE-AUTHORED, never ' + + 'repaired on the deployment\'s behalf: the correct zone behind a typo is a fact only ' + + 'the deployment holds, which is what makes this entry semantic rather than a D2 ' + + 'conversion.', + reason: + '#16296 gave both columns `valueDomain: \'iana_time_zone\'`, which had been declared ' + + 'on `sys_business_unit.timezone` / `sys_organization.timezone` since #14238. It is a ' + + 'WRITE-TIME narrowing of the `min`/`max`/`maxLength` transition-gate class: a value ' + + 'already stored outside the domain is never re-read against it, no DDL is planned, ' + + 'and `objectstack migrate meta` has nothing to rewrite — the changeset that shipped ' + + 'it says so in those words, and this entry does not contradict it. What the ' + + 'changeset had no way to carry is that a deployment holding such a value now has ' + + 'WORK TO DO: the next write of that row is refused with the ADR-0114 field code ' + + '`value_domain`, and until then `sys_report_schedule.timezone` keeps doing the thing ' + + 'the narrowing exists to stop — `ReportService.nextRunAt` hands a non-member zone to ' + + 'croner, whose throw was caught and turned into a silent fall back to ' + + '`interval_minutes`, so "every weekday 09:00 Asia/Shanghai" became "every 1440 ' + + 'minutes, forever". Not a throw and not a fall back to UTC: the wrong instant, ' + + 'permanently. ⛔ It went out with NO `**BREAKING**` marker, so the repo\'s own ' + + 'breaking-change detector classified it non-breaking and asked for no ADR-0087 ' + + 'disposition at all — measured on the shipped changeset. #16421 closed that hole ' + + '(the declaration now carries a `(narrowing)` arm the gate reads instead of a prose ' + + 'banner) and this row is the other half of the same ruling: the narrowing that ' + + 'already shipped is RECORDED, ⛔ not re-released and ⛔ not ratified in silence. ' + + 'Maintainer ruling, director summon #17, decision batch #2 item 1, option B, ' + + 'verbatim and untranslated: 「同意」. The direct precedents for registering a change ' + + 'no transform can apply are `schedule-flow-acting-organization-required` (protocol ' + + '18) and `rest-requireauth-default-flip` (protocol 12) — behaviour-only, a ' + + 'deployment judgement, registered anyway because the prescription is real.', + acceptanceCriteria: + 'Every `sys_job.timezone` and `sys_report_schedule.timezone` value stored in the ' + + 'deployment is an IANA member. The one-line fix per offending row: write the ' + + 'canonical zone id (`UPDATE … SET timezone = \'Asia/Shanghai\'`), or clear the ' + + 'column — `sys_report_schedule` documents a `UTC` default and `sys_job` has no ' + + 'reader at all. Rows already holding a member parse and behave byte-identically to ' + + 'before; rows holding none are readable, are returned unchanged, and fail only on ' + + 'their next WRITE. A report schedule that was silently running on ' + + '`interval_minutes` resumes its cron cadence once its zone is a member — that ' + + 'resumption, not the absence of an error, is how the fix is verified. ⚠️ The two ' + + 'columns\' `maxLength` (100 vs 64) and defaults (none vs `UTC`) are deliberately ' + + 'still unconverged and are NOT part of this entry; no member is longer than 32 ' + + 'characters on the current Node baseline, so neither bound admits anything the ' + + 'domain does not.', +}; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index d9fe3f30089..202f91f6dc1 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -9676,6 +9676,61 @@ const step18: MigrationStep = { + 'who was previously outside an `assignedProfiles` list and could nonetheless open the page ' + 'is the pre-existing state, not a regression introduced by the removal.', }, + { + id: 'platform-timezone-columns-iana-domain-refused', + surface: + 'The two platform audit time-zone columns — `sys_job.timezone` and ' + + '`sys_report_schedule.timezone` — carrying a string that is not a member of the ' + + 'IANA time-zone database (`Asia/Shangai`, `Europe/Munich`, `UTC+8`, `PST`).', + replacement: + 'The canonical IANA zone id the deployment meant, written in the spelling the tzdb ' + + 'uses: `Asia/Shanghai`, `Europe/Berlin`, `America/Los_Angeles`. `UTC` is a member ' + + 'and is admitted — membership is the shared `Intl.DateTimeFormat` probe, never the ' + + '`Intl.supportedValuesOf(\'timeZone\')` enumeration, which omits `UTC` and would ' + + 'refuse the one fallback this contract names. ⚠️ A non-member is RE-AUTHORED, never ' + + 'repaired on the deployment\'s behalf: the correct zone behind a typo is a fact only ' + + 'the deployment holds, which is what makes this entry semantic rather than a D2 ' + + 'conversion.', + reason: + '#16296 gave both columns `valueDomain: \'iana_time_zone\'`, which had been declared ' + + 'on `sys_business_unit.timezone` / `sys_organization.timezone` since #14238. It is a ' + + 'WRITE-TIME narrowing of the `min`/`max`/`maxLength` transition-gate class: a value ' + + 'already stored outside the domain is never re-read against it, no DDL is planned, ' + + 'and `objectstack migrate meta` has nothing to rewrite — the changeset that shipped ' + + 'it says so in those words, and this entry does not contradict it. What the ' + + 'changeset had no way to carry is that a deployment holding such a value now has ' + + 'WORK TO DO: the next write of that row is refused with the ADR-0114 field code ' + + '`value_domain`, and until then `sys_report_schedule.timezone` keeps doing the thing ' + + 'the narrowing exists to stop — `ReportService.nextRunAt` hands a non-member zone to ' + + 'croner, whose throw was caught and turned into a silent fall back to ' + + '`interval_minutes`, so "every weekday 09:00 Asia/Shanghai" became "every 1440 ' + + 'minutes, forever". Not a throw and not a fall back to UTC: the wrong instant, ' + + 'permanently. ⛔ It went out with NO `**BREAKING**` marker, so the repo\'s own ' + + 'breaking-change detector classified it non-breaking and asked for no ADR-0087 ' + + 'disposition at all — measured on the shipped changeset. #16421 closed that hole ' + + '(the declaration now carries a `(narrowing)` arm the gate reads instead of a prose ' + + 'banner) and this row is the other half of the same ruling: the narrowing that ' + + 'already shipped is RECORDED, ⛔ not re-released and ⛔ not ratified in silence. ' + + 'Maintainer ruling, director summon #17, decision batch #2 item 1, option B, ' + + 'verbatim and untranslated: 「同意」. The direct precedents for registering a change ' + + 'no transform can apply are `schedule-flow-acting-organization-required` (protocol ' + + '18) and `rest-requireauth-default-flip` (protocol 12) — behaviour-only, a ' + + 'deployment judgement, registered anyway because the prescription is real.', + acceptanceCriteria: + 'Every `sys_job.timezone` and `sys_report_schedule.timezone` value stored in the ' + + 'deployment is an IANA member. The one-line fix per offending row: write the ' + + 'canonical zone id (`UPDATE … SET timezone = \'Asia/Shanghai\'`), or clear the ' + + 'column — `sys_report_schedule` documents a `UTC` default and `sys_job` has no ' + + 'reader at all. Rows already holding a member parse and behave byte-identically to ' + + 'before; rows holding none are readable, are returned unchanged, and fail only on ' + + 'their next WRITE. A report schedule that was silently running on ' + + '`interval_minutes` resumes its cron cadence once its zone is a member — that ' + + 'resumption, not the absence of an error, is how the fix is verified. ⚠️ The two ' + + 'columns\' `maxLength` (100 vs 64) and defaults (none vs `UTC`) are deliberately ' + + 'still unconverged and are NOT part of this entry; no member is longer than 32 ' + + 'characters on the current Node baseline, so neither bound admits anything the ' + + 'domain does not.', + }, { id: 'plugin-auto-restart-never-reinitialised', surface: From 6b6d610273b41e832ef2608bfdadbfad45c1d4b2 Mon Sep 17 00:00:00 2001 From: "claude[bot]" Date: Wed, 16 Sep 2026 02:31:19 +0000 Subject: [PATCH 3/4] fix(devx): sandbox staging manifests are derived from the module graph, not typed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `check:objectui-changeset` went red on this branch, and the error named neither cause: `ERR_MODULE_NOT_FOUND` for `fw-gate/scripts/pm/check-clause2-carriers.mjs`. `objectui-changeset-digest.mjs`'s self-test copies `check-adr-0087-registration.mjs` into a sandbox and runs it for real; that gate gained one import in this branch, its OWN staging fixture was updated in the same edit, and this second one — a hand-written two-name manifest in another file — was not. ⛔ The import is not removed: the ruling makes one parser a condition ("the arm is added there once, and every reader imports it; no second parser"). What is repaired is the copy surface. `scripts/first-party-closure.mjs` derives a module's transitive first-party closure from the same statement-shaped edges Node resolves, and every staging site now calls it: * `check-adr-0087-registration.mjs`'s own I1/I2 fixture; * `objectui-changeset-digest.mjs`'s `fw-gate` sandbox; * the SEVEN identical three-name manifests in that file's bump-driver sandboxes, now one `stageBumpDriver()`; * `bump-objectui.selftest.sh`, a THIRD site the sweep found — its failure mode was worse than a missing module: the digest died as a subprocess and the driver reported the objectui RANGE as unwalkable, a true sentence about the wrong thing. `bump-objectui.sh` stays named in the shell site: no import statement reaches a shell script, so no module walk can find it. What is derived is what a walk can answer. Ruling citation corrected to #16421 comment `5572145955` (read back HTTP 200, hotlong, 2026-09-07T14:32:32Z) in the ledger entry, the declaration reader's docblock and the changeset. The id the dispatch carried answered 404. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_017ef78bLdybu3AffehKkhfk --- .changeset/16421-clause2-direction-arm.md | 4 +- ...rm-timezone-columns-iana-domain-refused.ts | 4 +- packages/spec/src/migrations/registry.ts | 4 +- scripts/bump-objectui.selftest.sh | 65 +++++++-- scripts/check-adr-0087-registration.mjs | 57 +++----- scripts/first-party-closure.mjs | 137 ++++++++++++++++++ scripts/objectui-changeset-digest.mjs | 99 ++++++++----- scripts/pm/check-clause2-carriers.mjs | 3 +- 8 files changed, 288 insertions(+), 85 deletions(-) create mode 100644 scripts/first-party-closure.mjs diff --git a/.changeset/16421-clause2-direction-arm.md b/.changeset/16421-clause2-direction-arm.md index 8cb02169c30..881f2c4630d 100644 --- a/.changeset/16421-clause2-direction-arm.md +++ b/.changeset/16421-clause2-direction-arm.md @@ -26,5 +26,5 @@ No authorable key, export, config field or stored shape moves, and no DDL is planned — this is a record of a change that already shipped, published so that `objectstack migrate meta`'s consumers can read it. -Maintainer ruling, director summon #17, decision batch #2 item 1, option B, -quoted verbatim and untranslated: 「同意」. +Maintainer ruling, director summon #17, decision batch #2 item 1, option B +(#16421 comment 5572145955, 2026-09-07), quoted verbatim and untranslated: 「同意」. diff --git a/packages/spec/src/migrations/entries/semantic/18.platform-timezone-columns-iana-domain-refused.ts b/packages/spec/src/migrations/entries/semantic/18.platform-timezone-columns-iana-domain-refused.ts index b4fad423ca7..04bced465db 100644 --- a/packages/spec/src/migrations/entries/semantic/18.platform-timezone-columns-iana-domain-refused.ts +++ b/packages/spec/src/migrations/entries/semantic/18.platform-timezone-columns-iana-domain-refused.ts @@ -37,8 +37,8 @@ export const entry: SemanticMigration = { + '(the declaration now carries a `(narrowing)` arm the gate reads instead of a prose ' + 'banner) and this row is the other half of the same ruling: the narrowing that ' + 'already shipped is RECORDED, ⛔ not re-released and ⛔ not ratified in silence. ' - + 'Maintainer ruling, director summon #17, decision batch #2 item 1, option B, ' - + 'verbatim and untranslated: 「同意」. The direct precedents for registering a change ' + + 'Maintainer ruling, director summon #17, decision batch #2 item 1, option B ' + + '(objectstack#16421 comment 5572145955, 2026-09-07), verbatim and untranslated: 「同意」. The direct precedents for registering a change ' + 'no transform can apply are `schedule-flow-acting-organization-required` (protocol ' + '18) and `rest-requireauth-default-flip` (protocol 12) — behaviour-only, a ' + 'deployment judgement, registered anyway because the prescription is real.', diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 202f91f6dc1..f6ce2499a53 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -9711,8 +9711,8 @@ const step18: MigrationStep = { + '(the declaration now carries a `(narrowing)` arm the gate reads instead of a prose ' + 'banner) and this row is the other half of the same ruling: the narrowing that ' + 'already shipped is RECORDED, ⛔ not re-released and ⛔ not ratified in silence. ' - + 'Maintainer ruling, director summon #17, decision batch #2 item 1, option B, ' - + 'verbatim and untranslated: 「同意」. The direct precedents for registering a change ' + + 'Maintainer ruling, director summon #17, decision batch #2 item 1, option B ' + + '(objectstack#16421 comment 5572145955, 2026-09-07), verbatim and untranslated: 「同意」. The direct precedents for registering a change ' + 'no transform can apply are `schedule-flow-acting-organization-required` (protocol ' + '18) and `rest-requireauth-default-flip` (protocol 12) — behaviour-only, a ' + 'deployment judgement, registered anyway because the prescription is real.', diff --git a/scripts/bump-objectui.selftest.sh b/scripts/bump-objectui.selftest.sh index 3285ea441e6..9d0c72a6b13 100755 --- a/scripts/bump-objectui.selftest.sh +++ b/scripts/bump-objectui.selftest.sh @@ -77,11 +77,23 @@ bad() { echo " ✗ $*" >&2; FAILED=$((FAILED + 1)); } case_begin() { CASE="$1"; echo " • ${CASE}"; } # case_5 additionally needs a copy of the digest script `bump-objectui.sh` -# calls and the `isEntrypoint` helper it imports, alongside the script under -# test — mirroring `objectui-changeset-digest.mjs`'s own self-test fixtures -# (which copy the same trio for the same reason). -DIGEST_SCRIPT="${SCRIPT_DIR}/objectui-changeset-digest.mjs" -INVOKED_AS_SCRIPT="${SCRIPT_DIR}/invoked-as.mjs" +# calls, plus every first-party module that script imports, alongside the script +# under test — mirroring `objectui-changeset-digest.mjs`'s own self-test +# fixtures, which stage the same closure for the same reason. +# +# ⭐ The module list is DERIVED, not typed (#16421). It was two names here +# (`objectui-changeset-digest.mjs`, `invoked-as.mjs`) and the same two names in +# three other places; when the digest script gained one import, this file was the +# THIRD staging site to go red, and the symptom it produced was not an +# `ERR_MODULE_NOT_FOUND` a reader could act on — the digest died as a subprocess +# and `bump-objectui.sh` reported the objectui RANGE as unwalkable, which is a +# true sentence about the wrong thing. The derivation lives in +# `first-party-closure.mjs` and is shared with the JS staging sites. +DIGEST_ENTRY='scripts/objectui-changeset-digest.mjs' +REPO_ROOT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" +# The same script, absolute, for the two cases that run it from THIS checkout +# rather than from a sandbox — those need no staging at all. +DIGEST_SCRIPT="${REPO_ROOT_DIR}/${DIGEST_ENTRY}" # Case 5's fixture changeset BASENAME — interpolated into a changeset path # everywhere it is used, never spelled as one bare path literal: that path @@ -123,13 +135,48 @@ new_framework() { } # Same, plus a byte copy of the digest script `bump-objectui.sh` shells out to -# and the `invoked-as.mjs` helper it imports — needed only by cases that do NOT -# pass `--no-changeset` and so actually reach the changeset section. +# and every first-party module that script imports — needed only by cases that +# do NOT pass `--no-changeset` and so actually reach the changeset section. +# +# ⛔ The closure is written to a file and its exit code read on its own line: a +# `node ... | while read` pipeline would hand this function the exit code of +# `while`, so a derivation that died would stage NOTHING and look like it worked, +# which is the failure shape this whole change exists to remove. new_framework_with_digest() { local d="$1" pin="${2-}" new_framework "$d" "$pin" - cp "$DIGEST_SCRIPT" "${d}/scripts/objectui-changeset-digest.mjs" - cp "$INVOKED_AS_SCRIPT" "${d}/scripts/invoked-as.mjs" + local list="${d}/.closure.txt" rel + # ⚠️ Each path is emitted with its OWN trailing newline, and the loop below + # still reads an unterminated last line. Both halves, because the first time + # this was written with `join('\n')` the final path — `first-party-closure.mjs` + # itself — was silently dropped by `read`, and the sandbox then failed with the + # SAME ERR_MODULE_NOT_FOUND this derivation exists to prevent, from a list that + # had named the file correctly. + node --input-type=module -e " + import { firstPartyModuleClosure } from '${REPO_ROOT_DIR}/scripts/first-party-closure.mjs'; + for (const rel of firstPartyModuleClosure('${DIGEST_ENTRY}', { root: '${REPO_ROOT_DIR}' })) console.log(rel); + " > "$list" 2>"${list}.err" + local ec=$? + if [[ $ec -ne 0 ]]; then + printf 'bump-objectui.selftest: could not derive the digest closure (exit %s)\n' "$ec" >&2 + cat "${list}.err" >&2 + return "$ec" + fi + local staged_count=0 + while read -r rel || [[ -n "$rel" ]]; do + [[ -z "$rel" ]] && continue + mkdir -p "${d}/$(dirname "$rel")" + cp "${REPO_ROOT_DIR}/${rel}" "${d}/${rel}" + staged_count=$((staged_count + 1)) + done < "$list" + # A derivation that came back short stages a sandbox that looks runnable and + # is not, so the count is ASSERTED here rather than discovered three cases + # later as "the objectui range does not walk". + if [[ "$staged_count" -lt 2 ]]; then + printf 'bump-objectui.selftest: the digest closure staged only %s file(s) — expected the script and its imports\n' "$staged_count" >&2 + return 1 + fi + rm -f "$list" "${list}.err" } # A throwaway objectui repo with a REAL changeset commit — commit A (the diff --git a/scripts/check-adr-0087-registration.mjs b/scripts/check-adr-0087-registration.mjs index 35ab1b2c94c..8562c08b36e 100644 --- a/scripts/check-adr-0087-registration.mjs +++ b/scripts/check-adr-0087-registration.mjs @@ -365,6 +365,11 @@ import { maskComments, maskCommentsAndLiterals } from './js-comment-mask.mjs'; // ⛔ 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/check-clause2-carriers.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 +// lists for one graph is how one of them goes stale unnoticed. +import { stageFirstPartyClosure } from './first-party-closure.mjs'; // ── The self-test's own battery roster and floor (#13489) ────────────────── // @@ -6242,7 +6247,6 @@ function selfTest() { writeFileSync(join(dir, rel), text); }; const copy = 'scripts/check-adr-0087-registration.mjs'; - w(copy, readFileSync(fileURLToPath(import.meta.url), 'utf8')); // EVERY first-party module this file imports, TRANSITIVELY, travels with the // copy, or the fixture dies on ERR_MODULE_NOT_FOUND -- which reads as "I1 is // broken" rather than "the fixture is incomplete". `js-comment-mask.mjs` @@ -6250,38 +6254,25 @@ function selfTest() { // arrived after `invoked-as.mjs` and took both I-cases red on a fixture // problem that had nothing to do with what they assert. // - // ⭐ DERIVED, not listed (#16421). It was a two-name list while this gate's - // imports were two leaves. Signal (4) added an edge to - // `pm/check-clause2-carriers.mjs`, whose own closure is nine modules deep and - // includes `pm/dispatch-gates.mjs` -- a file under constant change. A hand - // list against that graph is a trap that springs on whoever adds an import - // over there, in a gate over here, with an error message about neither. The - // walk reads the same edges Node will resolve, so the fixture cannot drift - // from the real module graph by construction. - // - // ⛔ Statement-shaped matches ONLY -- the two spellings that reach a - // line-start `from`, a single-line `import … from '…'` and the closing brace - // of a multi-line one. A bare regex over the source harvests every specifier - // sitting inside a STRING in a self-test fixture, which is how a walk of this - // tree ends up chasing `./does-not-exist.mjs`. - const EDGE_PATTERNS = [ - /^[ \t]*(?:import|export)[^'"\n]*from[ \t]*['"](\.[^'"\n]+)['"]/gm, - /^[ \t]*\}[ \t]*from[ \t]*['"](\.[^'"\n]+)['"]/gm, - ]; - const staged = new Set([copy]); - const stage = (rel) => { - if (staged.has(rel)) return; - staged.add(rel); - const src = readFileSync(join(REPO_ROOT, rel), 'utf8'); - w(rel, src); - for (const pattern of EDGE_PATTERNS) { - pattern.lastIndex = 0; - for (const m of src.matchAll(pattern)) stage(join(dirname(rel), m[1])); - } - }; - for (const m of readFileSync(fileURLToPath(import.meta.url), 'utf8').matchAll(EDGE_PATTERNS[0])) { - stage(join('scripts', m[1])); - } + // ⭐ DERIVED, not listed, and derived by a module BOTH staging sites of this + // gate import (#16421). It was a two-name list here and the same two-name + // list in `objectui-changeset-digest.mjs`'s `fw-gate` sandbox. Signal (4) + // added an edge to `pm/check-clause2-carriers.mjs`, whose own closure is nine + // modules deep; this site was updated in the same edit and the other was not, + // and `check:objectui-changeset` went red in CI with an error naming neither + // the import nor the manifest. One in two is the measured hit rate of "the + // next author remembers", so neither site holds an opinion about the graph + // any more. The statement-shaped rule that keeps the walk off quoted fixture + // specifiers lives in `first-party-closure.mjs`'s header. + const staged = stageFirstPartyClosure(copy, { root: REPO_ROOT, write: w }); + // ⭐ The derivation is ASSERTED, not trusted. A walk that silently came back + // with only the entry would stage a runnable-looking fixture that dies the + // moment signal (4) is reached, and the two I-cases below would again report + // about the wrong thing. This names the one edge that made the list a graph. + assert( + staged.includes(copy) && staged.includes('scripts/pm/check-clause2-carriers.mjs'), + `I1/I2: the staged closure must carry the gate AND the declaration reader it imports — got ${staged.length} file(s): ${staged.join(', ')}`, + ); w( 'importer.mjs', "import { readDisposition } from './scripts/check-adr-0087-registration.mjs';\n" + diff --git a/scripts/first-party-closure.mjs b/scripts/first-party-closure.mjs new file mode 100644 index 00000000000..58d33a1548a --- /dev/null +++ b/scripts/first-party-closure.mjs @@ -0,0 +1,137 @@ +#!/usr/bin/env node +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * first-party-closure — the ONE answer to "which files travel with this script + * when it is copied into a throwaway repo?" (#16421) + * + * Several gates in this tree prove something about ANOTHER script by COPYING it + * into a temp git repo and running it there, because nothing short of that + * script's own verdict settles the claim. A copy is only runnable if every + * first-party module it imports travels with it; miss one and the fixture dies + * on `ERR_MODULE_NOT_FOUND`, which reads as "the assertion broke" rather than + * "the fixture is incomplete". + * + * ## Why this is derived and not a list + * + * It WAS a list — twice, in two files, for the same gate. The two staging sites + * of `check-adr-0087-registration.mjs` each carried a hand-written manifest + * (`['invoked-as.mjs', 'js-comment-mask.mjs']`), and each carried a comment + * telling the next author to remember to update it. Measured: when that gate + * gained ONE import — `pm/check-clause2-carriers.mjs`, whose own closure is nine + * modules deep — the first site was updated in the same edit and the second was + * not, so `check:objectui-changeset` went red in CI with an error naming neither + * the import nor the manifest. A rule that is enforced by remembering is a rule + * with a measured failure rate, and here it was one in two. + * + * So the manifest is COMPUTED from the same edges Node will resolve. A staging + * site cannot drift from the real module graph, because it is no longer holding + * an opinion about it. + * + * ## ⛔ Statement-shaped matches only + * + * A bare regex over the source harvests every specifier sitting inside a STRING + * in a self-test fixture — this tree's gates are full of them + * (`"import { x } from './does-not-exist.mjs';"` is a real line in + * `pm/dispatch-gates.mjs`, written to be parsed, never to be resolved). Both + * patterns below therefore anchor at a line start: an `import`/`export` + * statement, and the closing brace of a multi-line one. A quoted fixture is + * preceded by its quote and reaches neither. + * + * ⚠️ A specifier that resolves to nothing THROWS. The alternative — skipping it + * — reproduces the exact defect this module exists to remove, one layer up: the + * closure would come back short, the copy would die on `ERR_MODULE_NOT_FOUND`, + * and the error would again name neither cause. + */ + +import { existsSync, readFileSync } from 'node:fs'; +import { dirname, isAbsolute, join, relative, resolve } from 'node:path'; + +/** + * The two line-anchored shapes a first-party edge is written in. + * + * ⛔ Not exported as a convenience to build a third reader from: the whole point + * is that the edge set has one spelling. It is exported so a gate can ASSERT on + * it — `check-adr-0087-registration.mjs`'s fixture pins that the quoted-fixture + * case is refused, and that assertion needs the patterns it is asserting about. + */ +export const FIRST_PARTY_EDGE_PATTERNS = Object.freeze([ + /^[ \t]*(?:import|export)[^'"\n]*from[ \t]*['"](\.[^'"\n]+)['"]/gm, + /^[ \t]*\}[ \t]*from[ \t]*['"](\.[^'"\n]+)['"]/gm, +]); + +/** + * The relative specifiers ONE source file imports, in source order, deduped. + * + * @param {string} source + * @returns {string[]} + */ +export function firstPartyImportEdges(source) { + const out = []; + const text = String(source ?? ''); + for (const pattern of FIRST_PARTY_EDGE_PATTERNS) { + // A `g` regex carries state, and these live at module scope: reset before + // each use or the second caller starts reading from wherever the first + // stopped. That failure is order-dependent and intermittent, which is the + // worst shape a fixture helper can have. + pattern.lastIndex = 0; + for (const m of text.matchAll(pattern)) if (!out.includes(m[1])) out.push(m[1]); + } + return out; +} + +/** + * Every first-party module reachable from `entryRel`, INCLUDING `entryRel`. + * + * Paths come back repo-relative and in walk order, which is the order a staging + * site wants: the entry first, then what it needs. + * + * @param {string} entryRel — repo-relative path of the entry module. + * @param {{ root: string }} options — the repo root `entryRel` is relative to. + * @returns {string[]} + */ +export function firstPartyModuleClosure(entryRel, { root }) { + if (!root || !isAbsolute(root)) throw new Error(`firstPartyModuleClosure: \`root\` must be an absolute path, got ${JSON.stringify(root)}`); + const seen = []; + const walk = (rel) => { + if (seen.includes(rel)) return; + const abs = resolve(root, rel); + if (!existsSync(abs)) { + throw new Error( + `firstPartyModuleClosure: ${rel} does not exist under ${root}.\n` + + ' An edge was harvested from a source file and did not resolve. Either the walk read a\n' + + ' specifier it should not have (see the statement-shaped rule in this module\'s header),\n' + + ' or the import really is broken — and both are worth a loud stop rather than a copy that\n' + + ' dies later on ERR_MODULE_NOT_FOUND naming neither cause.', + ); + } + seen.push(rel); + // Only a JS module has edges to follow. A relative `.json` import is real + // and must travel with the copy, so it is COLLECTED — it is simply not read + // for further edges. + if (!/\.m?js$/.test(rel)) return; + for (const spec of firstPartyImportEdges(readFileSync(abs, 'utf8'))) { + walk(relative(root, resolve(root, dirname(rel), spec))); + } + }; + walk(entryRel); + return seen; +} + +/** + * Copy `entryRel`'s whole closure into a sandbox, at the same relative paths. + * + * The one line a staging site needs. `write` is the site's own writer, because + * every one of them already has one that makes parent directories — handing the + * closure back through the caller's writer keeps this module free of any opinion + * about how the sandbox is built. + * + * @param {string} entryRel + * @param {{ root: string, write: (rel: string, text: string) => void }} options + * @returns {string[]} the paths written, for a caller that wants to assert on them. + */ +export function stageFirstPartyClosure(entryRel, { root, write }) { + const closure = firstPartyModuleClosure(entryRel, { root }); + for (const rel of closure) write(rel, readFileSync(join(root, rel), 'utf8')); + return closure; +} diff --git a/scripts/objectui-changeset-digest.mjs b/scripts/objectui-changeset-digest.mjs index f51a5a29ffa..a56a8e5a6b8 100644 --- a/scripts/objectui-changeset-digest.mjs +++ b/scripts/objectui-changeset-digest.mjs @@ -211,6 +211,10 @@ import { tmpdir } from 'node:os'; import { dirname, join, resolve } from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; import { isEntrypoint } from './invoked-as.mjs'; +// #16421 — the `fw-gate` sandbox below copies `check-adr-0087-registration.mjs` +// in and runs it. Its staging manifest is DERIVED from that gate's module graph, +// by the same module the gate's own fixture uses, so the two cannot disagree. +import { stageFirstPartyClosure } from './first-party-closure.mjs'; // ── The self-test's own battery roster and floor (#13489) ────────────────── // @@ -259,6 +263,37 @@ const UNATTRIBUTED_BATTERY = '(no battery open)'; const __dirname = dirname(fileURLToPath(import.meta.url)); const REPO_ROOT = resolve(__dirname, '..'); +/** + * Stage the shell driver and everything it needs into a throwaway framework + * root, so `bump-objectui.sh` can be run there for real. (#16421) + * + * Seven of this file's self-test cases build such a root, and all seven used to + * carry the SAME hand-written three-name manifest — `bump-objectui.sh`, this + * script, `invoked-as.mjs`. One function now, for the reason the other staging + * site in this file just learned the hard way: a manifest is a rule enforced by + * remembering, and the first time this script gained an import, CI went red with + * an `ERR_MODULE_NOT_FOUND` naming neither the import nor the list. + * + * ⭐ `bump-objectui.sh` stays NAMED, and that is not an oversight. It is a shell + * script: no `import` statement reaches it, so no module walk can find it. What + * is derived is exactly the part a walk can answer — this script's own + * first-party JS closure — and what is named is the part it cannot. + * + * @param {string} root — the throwaway framework root. + * @returns {string[]} every repo-relative path written, for a caller to assert on. + */ +function stageBumpDriver(root) { + const write = (rel, text) => { + mkdirSync(dirname(join(root, rel)), { recursive: true }); + writeFileSync(join(root, rel), text); + }; + write('scripts/bump-objectui.sh', readFileSync(join(__dirname, 'bump-objectui.sh'), 'utf8')); + return [ + 'scripts/bump-objectui.sh', + ...stageFirstPartyClosure('scripts/objectui-changeset-digest.mjs', { root: REPO_ROOT, write }), + ]; +} + /** * Default cap on EACH rendered list. The releasing entries (#4731) and the * undeclared commits (#6174) are capped INDEPENDENTLY, so a long release can @@ -1713,9 +1748,7 @@ function selfTest() { mkdirSync(join(fwRun, '.changeset'), { recursive: true }); writeFileSync(join(fwRun, '.changeset', 'pre.json'), '{"mode":"pre","tag":"rc"}\n'); writeFileSync(join(fwRun, '.objectui-sha'), `${base}\n`); - for (const f of ['bump-objectui.sh', 'objectui-changeset-digest.mjs', 'invoked-as.mjs']) { - writeFileSync(join(fwRun, 'scripts', f), readFileSync(join(__dirname, f), 'utf8')); - } + stageBumpDriver(fwRun); const bumpStdout = execFileSync( 'bash', [join(fwRun, 'scripts', 'bump-objectui.sh'), '--no-commit', head], @@ -1760,9 +1793,7 @@ function selfTest() { mkdirSync(join(fwDegraded, 'scripts'), { recursive: true }); mkdirSync(join(fwDegraded, '.changeset'), { recursive: true }); writeFileSync(join(fwDegraded, '.objectui-sha'), `${'0'.repeat(40)}\n`); - for (const f of ['bump-objectui.sh', 'objectui-changeset-digest.mjs', 'invoked-as.mjs']) { - writeFileSync(join(fwDegraded, 'scripts', f), readFileSync(join(__dirname, f), 'utf8')); - } + stageBumpDriver(fwDegraded); const unwalkableRun = spawnSync( 'bash', [join(fwDegraded, 'scripts', 'bump-objectui.sh'), '--no-commit', head], @@ -1788,9 +1819,7 @@ function selfTest() { const fwInitial = join(tmp, 'fw-initial-pin'); mkdirSync(join(fwInitial, 'scripts'), { recursive: true }); mkdirSync(join(fwInitial, '.changeset'), { recursive: true }); - for (const f of ['bump-objectui.sh', 'objectui-changeset-digest.mjs', 'invoked-as.mjs']) { - writeFileSync(join(fwInitial, 'scripts', f), readFileSync(join(__dirname, f), 'utf8')); - } + stageBumpDriver(fwInitial); const initialRun = spawnSync( 'bash', [join(fwInitial, 'scripts', 'bump-objectui.sh'), '--no-commit', head], @@ -2272,21 +2301,27 @@ function selfTest() { // in the judged diff) — the gate's convention-rot assertion needs its // breaking detector to match something. gw('.changeset/stock-breaking.md', '---\n"@objectstack/spec": major\n---\n\nstock\n\n**BREAKING** something\n'); - gw( - 'scripts/check-adr-0087-registration.mjs', - readFileSync(join(__dirname, 'check-adr-0087-registration.mjs'), 'utf8'), - ); - // EVERY `./`-relative sibling that gate imports travels with the copy, or it - // dies on ERR_MODULE_NOT_FOUND — and the two cases below then read as "the - // ROUND TRIP assertion broke" when nothing about the round trip moved. This - // is a MANIFEST, so adding an import over there means adding a row here; - // `js-comment-mask.mjs` (#12881) is the case that proved it has to be a list - // rather than the one hard-coded `invoked-as.mjs` line it replaced. It is the - // second staging site of this same gate to learn that (the gate's own I1/I2 - // fixture is the first), which is why both now spell it the same way. - for (const sibling of ['invoked-as.mjs', 'js-comment-mask.mjs']) { - gw(`scripts/${sibling}`, readFileSync(join(__dirname, sibling), 'utf8')); - } + // EVERY first-party module that gate imports, TRANSITIVELY, travels with the + // copy, or it dies on ERR_MODULE_NOT_FOUND — and the two cases below then + // read as "the ROUND TRIP assertion broke" when nothing about the round trip + // moved. + // + // ⭐ It was a hand MANIFEST, and the manifest is what broke (#16421). That + // gate gained one import — `pm/check-clause2-carriers.mjs`, the fleet's one + // clause-② declaration reader, whose own closure is nine modules deep — and + // the author updated the gate's OWN I1/I2 staging site in the same edit and + // not this one. CI went red HERE, on a gate about objectui changesets, with + // an error naming neither the new import nor this list. Both sites now DERIVE + // the closure from the same edges Node resolves, through + // `first-party-closure.mjs`; neither holds an opinion about the graph any + // more, and adding an import over there costs nothing here by construction. + const gateEntry = 'scripts/check-adr-0087-registration.mjs'; + const gateStaged = stageFirstPartyClosure(gateEntry, { root: join(__dirname, '..'), write: gw }); + check( + '#6494 the staged gate carries its whole first-party closure — DERIVED, not a hand manifest', + gateStaged.includes(gateEntry) && gateStaged.includes('scripts/pm/check-clause2-carriers.mjs'), + `${gateStaged.length} file(s): ${gateStaged.join(', ')}`, + ); gg('add', '-A'); gg('commit', '-q', '-m', 'base'); const gateBase = gg('rev-parse', 'HEAD').trim(); @@ -2657,9 +2692,7 @@ function selfTest() { mkdirSync(join(fwTrunc, 'scripts'), { recursive: true }); mkdirSync(join(fwTrunc, '.changeset'), { recursive: true }); writeFileSync(join(fwTrunc, '.objectui-sha'), `${c6from}\n`); - for (const f of ['bump-objectui.sh', 'objectui-changeset-digest.mjs', 'invoked-as.mjs']) { - writeFileSync(join(fwTrunc, 'scripts', f), readFileSync(join(__dirname, f), 'utf8')); - } + stageBumpDriver(fwTrunc); const truncPinBefore = readFileSync(join(fwTrunc, '.objectui-sha'), 'utf8'); // OBJECTUI_NO_DEEPEN=1 on purpose: a self-test must never reach the network, // and this run is also the opt-out's only coverage. @@ -2710,9 +2743,7 @@ function selfTest() { mkdirSync(join(fwTrunc2, 'scripts'), { recursive: true }); mkdirSync(join(fwTrunc2, '.changeset'), { recursive: true }); writeFileSync(join(fwTrunc2, '.objectui-sha'), `${c6from}\n`); - for (const f of ['bump-objectui.sh', 'objectui-changeset-digest.mjs', 'invoked-as.mjs']) { - writeFileSync(join(fwTrunc2, 'scripts', f), readFileSync(join(__dirname, f), 'utf8')); - } + stageBumpDriver(fwTrunc2); const noopDeepen = spawnSync('bash', [join(fwTrunc2, 'scripts', 'bump-objectui.sh'), '--no-commit', c6to], { encoding: 'utf8', env: { ...process.env, OBJECTUI_ROOT: ui6, GIT_TERMINAL_PROMPT: '0' }, @@ -2883,9 +2914,7 @@ function selfTest() { mkdirSync(join(dir, 'scripts'), { recursive: true }); mkdirSync(join(dir, '.changeset'), { recursive: true }); writeFileSync(join(dir, '.objectui-sha'), `${pinSha}\n`); - for (const f of ['bump-objectui.sh', 'objectui-changeset-digest.mjs', 'invoked-as.mjs']) { - writeFileSync(join(dir, 'scripts', f), readFileSync(join(__dirname, f), 'utf8')); - } + stageBumpDriver(dir); return dir; }; const runShellBump = (fwDir, uiRoot, extraEnv = {}) => @@ -2984,9 +3013,7 @@ function selfTest() { mkdirSync(join(dir, 'scripts'), { recursive: true }); mkdirSync(join(dir, '.changeset'), { recursive: true }); writeFileSync(join(dir, '.objectui-sha'), `${pinSha}\n`); - for (const f of ['bump-objectui.sh', 'objectui-changeset-digest.mjs', 'invoked-as.mjs']) { - writeFileSync(join(dir, 'scripts', f), readFileSync(join(__dirname, f), 'utf8')); - } + stageBumpDriver(dir); return dir; }; // Offline by construction — a self-test must never reach the network. diff --git a/scripts/pm/check-clause2-carriers.mjs b/scripts/pm/check-clause2-carriers.mjs index bd9e31b0a17..beed7f7bab6 100644 --- a/scripts/pm/check-clause2-carriers.mjs +++ b/scripts/pm/check-clause2-carriers.mjs @@ -767,7 +767,8 @@ export const CLAUSE2_VALUES = Object.freeze(['yes', 'no']); * #16296, a value-domain narrowing shipped to consumers with every gate green, * because `check-adr-0087-registration.mjs` read breaking-ness out of a * `**BREAKING**` PROSE BANNER the author simply did not type. Maintainer ruling, - * director summon #17, decision batch #2 item 1, option B, verbatim 「同意」. + * director summon #17, decision batch #2 item 1, option B — #16421 comment + * 5572145955, 2026-09-07 — verbatim 「同意」. * * ## The arm is OPTIONAL, and that is a measurement, not a kindness * From d4d5fe9aa58c925ba95633476c146fbbf5fc1356 Mon Sep 17 00:00:00 2001 From: "claude[bot]" Date: Wed, 16 Sep 2026 03:42:29 +0000 Subject: [PATCH 4/4] fix(devx): the new staging edge gets its two declaration faces back MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `check:pm-dispatch-gates` went red on the previous commit, and both failures are registration gaps rather than logic: a new cross-module edge has three faces — the copy surface, the declared path population, and the read key — and the last commit repaired only the first. ⭐ Run it the way the tree already prescribes. `platform-readings.md` records `check:pm-dispatch-gates` as exceeding the container's foreground cap and names the remedy — detach, then wait with `tail --pid`. A timeout is not a reading, and this gate has a recorded way to be read. Measured that way: 3 of 1730 cases failed before, 1730/1730 pass after, 759s. FACE 2 — the declared path population. `bump-objectui.selftest.sh` carries a `no-path-population` marker whose own text promises that no quoted literal in it names a tracked path. `DIGEST_ENTRY='scripts/objectui-changeset-digest.mjs'` broke that promise: `dispatch-gates` reads a quoted literal carrying a separator as a declared watched path, so the gate began naming paths while still declaring it had none. The basename is spelled alone and the directory interpolated onto it — the same idiom this file already uses for `CHANGESET_NAME`, and for the same machine reason. ⛔ The declaration is not withdrawn and no case is relaxed. FACE 3 — the read key. `dispatch-gates` derives "which gate does this family run a copy of?" from an anchored `readFileSync` whose target resolves to a tracked path; a loop variable, or a read performed inside another module, is deliberately not followed. Folding the entry's staging into the derived walk therefore staged it correctly and made it invisible. The entry is read by name again, and the walk now stages only the DEPENDENCIES, so neither line is redundant with the other. Also corrects a sentence this branch wrote about `bump-objectui.sh`: that script swallows no stderr and its `WALK_RC` branch already separates a probe verdict from a no-answer, refusing to offer `--unshallow` for a crash. The misleading presentation lives in the self-test, and is carded as #18354. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_017ef78bLdybu3AffehKkhfk --- scripts/bump-objectui.selftest.sh | 26 ++++++++++++++++++++------ scripts/objectui-changeset-digest.mjs | 21 +++++++++++++++++---- 2 files changed, 37 insertions(+), 10 deletions(-) diff --git a/scripts/bump-objectui.selftest.sh b/scripts/bump-objectui.selftest.sh index 9d0c72a6b13..25f2781026e 100755 --- a/scripts/bump-objectui.selftest.sh +++ b/scripts/bump-objectui.selftest.sh @@ -84,13 +84,27 @@ case_begin() { CASE="$1"; echo " • ${CASE}"; } # ⭐ The module list is DERIVED, not typed (#16421). It was two names here # (`objectui-changeset-digest.mjs`, `invoked-as.mjs`) and the same two names in # three other places; when the digest script gained one import, this file was the -# THIRD staging site to go red, and the symptom it produced was not an -# `ERR_MODULE_NOT_FOUND` a reader could act on — the digest died as a subprocess -# and `bump-objectui.sh` reported the objectui RANGE as unwalkable, which is a -# true sentence about the wrong thing. The derivation lives in -# `first-party-closure.mjs` and is shared with the JS staging sites. -DIGEST_ENTRY='scripts/objectui-changeset-digest.mjs' +# THIRD staging site to go red, and what a reader saw was not an +# `ERR_MODULE_NOT_FOUND` they could act on: the staged digest died as a +# subprocess, so `range_walkable` never returned one of its two verdicts and +# case 5 failed on the refusal's wording instead. (⚠️ `bump-objectui.sh` itself +# is NOT at fault and is not to be touched for this: it swallows no stderr, and +# its `WALK_RC` branch already separates a verdict from a no-answer and refuses +# to offer `--unshallow` for a crash. See #18354 for what IS carded.) The +# derivation lives in `first-party-closure.mjs`, shared with the JS sites. +# +# ⚠️ THE BASENAME IS SPELLED ALONE AND THE DIRECTORY IS INTERPOLATED ONTO IT — +# the same discipline the declaration above states for CHANGESET_NAME, and for +# the same machine reason: `dispatch-gates` reads a quoted literal carrying a +# separator as a DECLARED WATCHED PATH, so writing the repo-relative path as one +# bare token here gives this gate a path population and CONTRADICTS the +# `no-path-population` marker above. Measured, not theorised — +# `check:pm-dispatch-gates`: "no family both DECLARES no path population and +# names paths anyway (contradicted: check:objectui-bump)". ⛔ Do not tidy this +# back into a single literal. +DIGEST_BASENAME='objectui-changeset-digest.mjs' REPO_ROOT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" +DIGEST_ENTRY="scripts/${DIGEST_BASENAME}" # The same script, absolute, for the two cases that run it from THIS checkout # rather than from a sandbox — those need no staging at all. DIGEST_SCRIPT="${REPO_ROOT_DIR}/${DIGEST_ENTRY}" diff --git a/scripts/objectui-changeset-digest.mjs b/scripts/objectui-changeset-digest.mjs index a56a8e5a6b8..3bca1d83825 100644 --- a/scripts/objectui-changeset-digest.mjs +++ b/scripts/objectui-changeset-digest.mjs @@ -214,7 +214,7 @@ import { isEntrypoint } from './invoked-as.mjs'; // #16421 — the `fw-gate` sandbox below copies `check-adr-0087-registration.mjs` // in and runs it. Its staging manifest is DERIVED from that gate's module graph, // by the same module the gate's own fixture uses, so the two cannot disagree. -import { stageFirstPartyClosure } from './first-party-closure.mjs'; +import { firstPartyModuleClosure, stageFirstPartyClosure } from './first-party-closure.mjs'; // ── The self-test's own battery roster and floor (#13489) ────────────────── // @@ -2315,12 +2315,25 @@ function selfTest() { // the closure from the same edges Node resolves, through // `first-party-closure.mjs`; neither holds an opinion about the graph any // more, and adding an import over there costs nothing here by construction. + // + // ⚠️ THE ENTRY IS READ HERE, BY NAME, and that line is not redundant with the + // walk below — it is this family's REGISTRATION. `dispatch-gates` derives + // "which gate does this family run a copy of?" from an anchored + // `readFileSync` whose target resolves to a tracked path; a loop variable or + // a read that happens inside another module is deliberately NOT followed. So + // the first spelling of this fix staged the gate correctly and made the + // dependency invisible, and `check:pm-dispatch-gates` said so: "the staged + // gate reaches the family that runs a copy of it (no key)". ⛔ Do not fold + // this read into the walk to save a line — the walk stages the DEPENDENCIES, + // this read stages the GATE, and each is load-bearing for a different reader. const gateEntry = 'scripts/check-adr-0087-registration.mjs'; - const gateStaged = stageFirstPartyClosure(gateEntry, { root: join(__dirname, '..'), write: gw }); + gw(gateEntry, readFileSync(join(__dirname, 'check-adr-0087-registration.mjs'), 'utf8')); + const gateDeps = firstPartyModuleClosure(gateEntry, { root: join(__dirname, '..') }).filter((rel) => rel !== gateEntry); + for (const rel of gateDeps) gw(rel, readFileSync(join(__dirname, '..', rel), 'utf8')); check( '#6494 the staged gate carries its whole first-party closure — DERIVED, not a hand manifest', - gateStaged.includes(gateEntry) && gateStaged.includes('scripts/pm/check-clause2-carriers.mjs'), - `${gateStaged.length} file(s): ${gateStaged.join(', ')}`, + gateDeps.includes('scripts/pm/check-clause2-carriers.mjs') && gateDeps.length >= 2, + `${gateDeps.length} dependenc(ies): ${gateDeps.join(', ')}`, ); gg('add', '-A'); gg('commit', '-q', '-m', 'base');