diff --git a/.changeset/16421-clause2-direction-arm.md b/.changeset/16421-clause2-direction-arm.md new file mode 100644 index 00000000000..881f2c4630d --- /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 +(#16421 comment 5572145955, 2026-09-07), quoted verbatim and untranslated: 「同意」. 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/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..04bced465db --- /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 ' + + '(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.', + 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..f6ce2499a53 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 ' + + '(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.', + 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: diff --git a/scripts/bump-objectui.selftest.sh b/scripts/bump-objectui.selftest.sh index 3285ea441e6..25f2781026e 100755 --- a/scripts/bump-objectui.selftest.sh +++ b/scripts/bump-objectui.selftest.sh @@ -77,11 +77,37 @@ 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 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}" # 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 +149,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 68879be77bf..8562c08b36e 100644 --- a/scripts/check-adr-0087-registration.mjs +++ b/scripts/check-adr-0087-registration.mjs @@ -361,6 +361,15 @@ 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'; +// #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) ────────────────── // @@ -553,18 +562,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 +617,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 +1908,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) { @@ -6185,16 +6247,32 @@ function selfTest() { writeFileSync(join(dir, rel), text); }; 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, 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/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/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..3bca1d83825 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 { firstPartyModuleClosure, 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,40 @@ 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. + // + // ⚠️ 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'; + 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', + 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'); const gateBase = gg('rev-parse', 'HEAD').trim(); @@ -2657,9 +2705,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 +2756,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 +2927,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 +3026,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 af1a124a285..beed7f7bab6 100644 --- a/scripts/pm/check-clause2-carriers.mjs +++ b/scripts/pm/check-clause2-carriers.mjs @@ -755,6 +755,44 @@ 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 — #16421 comment + * 5572145955, 2026-09-07 — 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 +912,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 +1075,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 +1084,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 +1126,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 +4337,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');