Skip to content

Commit 2fc092b

Browse files
feat(devx): the clause-② declaration grows a direction arm, and three gates read it (#18346)
Fixes #16421 Clause-②: no A gate strengthens; no published runtime contract moves. Stated by the ruling, and re-derived here: the declaration's accept set only grows (the arm is optional and every existing spelling reads byte-identically), and the three gates that read it become stricter, never looser. ## The defect A **narrowing** — "these spellings stop being accepted" — could ship to customers with the release notes saying nothing, and every gate green. `check-adr-0087-registration.mjs` decided breaking-ness from an author's hand-written `**BREAKING**` prose banner; an author who did not type it was classified non-breaking and was asked for no ADR-0087 disposition. Measured, not hypothesised: **#16296** narrowed `sys_job.timezone` / `sys_report_schedule.timezone` to the IANA value domain and shipped with no banner, no `major` and no `!`. **#14238** did the same one surface earlier. ## The ruling being executed Director summon #17, decision batch #2 item 1, **option B**, maintainer verbatim 「同意」 (comment `5572145955`, 2026-09-07T14:32:32Z). Options A (keep the prose banner) and C (a static accept-set differ) are refused there and are not reopened here. ## What landed | # | deliverable | where | | --- | --- | --- | | 1 | the closed two-arm enumeration in the declaration format | `AGENTS.md` item 3 | | 2 | `readClause2Line` reads the arm — **once**, no second parser | `scripts/pm/check-clause2-carriers.mjs` | | 3 | signal (4): a declared `narrowing` is breaking | `scripts/check-adr-0087-registration.mjs` | | 4 | the level axis reads the arm | `scripts/check-changeset-no-major.mjs` | | 5 | **ONE** ADR-0087 ledger row, recording #16296 | `packages/spec/src/migrations/entries/semantic/` | > ⚠️ **Row 5 is deliberately one row, not the two the ruling names — corrected by the `domain:devx` seat (#6023) on the dev's measurement.** > The ruling lists #14238 and #16296 as the two already-shipped narrowings. **#14238 is not a narrowing**: its diff is 535 insertions and 0 deletions across 11 files (two nullable columns ADDED, carrying `valueDomain: 'iana_time_zone'` from their first release), and its own shipped changeset says so in writing — *"A NON-BREAKING ADDITION, registered here in writing because 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's #13080 addendum refuses by name. ⇒ One row is written, the second is **not fabricated**, and the falsified premise is carried to the maintainer rather than silently dropped. > ⚠️ **The citation above was wrong in the dispatch order and is corrected here.** The seat's dispatch word cited comment `5573372199`, which answers **HTTP 404**; the real ruling is `5572145955` (HTTP 200, verified by both the dev and the seat). The quoted ruling text itself is unchanged — only the provenance id moved. | 6 | pins for both arms, in both directions | the three gates' self-tests | ### The shape | spelling | reading | | --- | --- | | `Clause-②: yes` | a widening, declared through the value — unchanged | | `Clause-②: yes (widening)` | the same, said out loud | | `Clause-②: yes (narrowing)` | widens one surface, narrows another; both facts are read | | `Clause-②: no (narrowing)` | NOT a widening, but BREAKING — the point of the arm | | `Clause-②: no (widening)` | MALFORMED: the value and the arm contradict each other | (The rows are table cells on purpose: a line-initial second declaration in this body would be a second answer to the same question.) The arm is **optional**, and that is a measurement rather than a kindness: all five declarations on the open board the day this landed carry no parenthetical arm, and all five read byte-identically afterwards (proof below). Two shapes fail **closed** rather than reading as an absent arm, because reading them as absent is the direction a declared narrowing silently disappears in: a near-arm spelling (`(narrowed)`, `(Narrowing)`, `(widen)`) and the unfilled template `(widening|narrowing)`. Both land in `malformed`, the state this reader already owns for "the slot holds something nobody can grade". ## Measurements ### Back-compat — the five in-flight declarations Read from the live GitHub payload, before and after the change, comparing the whole reading object: | PR | reading | arm | | --- | --- | --- | | #18344 | `declared no` — IDENTICAL | none | | #18268 | `declared no` (em-dash reasoning, parenthesised identifiers) — IDENTICAL | none | | #18231 | `near-miss inline-key` — IDENTICAL | n/a | | #18227 | `declared no` — IDENTICAL | none | | #18096 | `declared no` — IDENTICAL | none | **5/5 byte-identical** apart from the additive `arm: null` field. Nothing in flight loses its reading. ### `AGENTS.md` — both ratchet axes Its line ratchet has **zero headroom** (1075 lines, ceiling 1075), so the format is carried at **net zero lines**, paid for by deleting content rather than by re-wrapping: - deleted: the widening-rationale clause (`the widening it declares is what makes it more than a patch…`), 93 bytes; - deleted: the CI step name the gate already prints in its own failure, 78 bytes. | axis | before | after | | --- | --- | --- | | lines | 1075 / ceiling 1075 | **1075** — headroom 0, unchanged | | widest line | ≤ 120 bytes | edited lines measure 119 / 118 / 120 / 119 / 81 bytes | | widest table row | 768 / pin 768 | **768**, untouched | `check:pm-skill-ratchet` self-test and run both exit 0. ### Both arms, both directions Every arm pin is a **pair**, because one direction alone cannot tell a reading from a constant — a gate that classified both arms as breaking would satisfy a `narrowing` test while telling a consumer nothing: - `check-adr-0087-registration`: `narrowing` is in `MUST_MATCH_BREAKING`, `widening` is in `MUST_NOT_MATCH_BREAKING`, both on a banner-free `minor` changeset — the exact shape #16296 shipped in. - `check-changeset-no-major`: same tree, same levels, only the declaration moves — `no (narrowing)` reaches `enforce`, bare `no` reaches `not-declared`. - `check-clause2-carriers`: both arms read; the contradiction, the near-arm family and the unfilled menu are all `malformed`; and three CONTROL rows pin the arm-less spellings unmoved. ## Two judgement calls, declared rather than made quietly 1. **The `**BREAKING**` banner signal is KEPT.** The dispatch word asked for breaking-ness to stop being read from the prose banner. Removing signal (2) would un-declare the **52 changesets in stock** that carry it and nothing else — the gate's own docblock has refused that direction ("narrowing to any one of them would drop real declarations") since it was written, and the ruling's own text asks only that the gate *read the arm*. Signal (4) is added beside it, so breaking-ness no longer **depends** on prose. If the maintainer wants (2) retired, that is a second, deliberate card with its own stock count. 2. **`check-adr-0087-registration` reads the arm from the CHANGESET body, not the PR body.** It has no pull-request payload by construction — `cut-rc.yml` runs it on a `workflow_dispatch` over a whole snapshot range with no PR at all. All three of its existing signals are changeset-local, and the fourth is too. ## Acceptance notes - noted, not filed: `readClause2Correction` does not carry the arm. A correction comment can change a declaration's *value* but not its *direction*. No half-state results today — the arm's three readers are the changeset body (ADR-0087), the PR body (no-major) and this file's own rows, and none of them reads corrections — so this is recorded rather than filed. Carrier: `scripts/pm/check-clause2-carriers.mjs`, the next card that touches `readClause2Correction`. - noted, not filed: the I1/I2 fixture in `check-adr-0087-registration.mjs` staged its import siblings from a hand-written two-name list. Signal (4) added an edge into a nine-module closure including `pm/dispatch-gates.mjs`, so the list is now **derived** by walking real import statements. That is a guard extension inside this card's own defect class, not a drive-by: without it, the next import added over in `pm/` would kill this gate's fixture with an error about neither file. ## 维护者速读(草稿) **改了什么** —「本卡是否放宽契约」这条申报,现在可以再带一个方向:`(widening)` 或 `(narrowing)`,只有这两种拼法。声明 `(narrowing)` 的改动会被三个门禁当作破坏性变更处理:必须写 ADR-0087 处置、必须给到 `minor` 档。`AGENTS.md` 记下格式,行数一行没多。 **为什么改** — 一次「以后不再接受某些写法」的收紧,过去可以在发布说明里一个字都不提就发给客户,所有门禁照样亮绿:门禁是从作者手写的 `**BREAKING**` 横幅判断破坏性的,忘了写就当没有。#16296 和 #14238 就是这么发出去的。改完之后,判据是一个封闭的词,不是一段散文。 **风险与代价(含回滚)** — 风险面是「在飞的申报会不会一夜失效」。方向词是可选的,板上 5 个在飞 PR 的申报实测全部逐字不变,已列在上面的表里。代价是作者多写一个词,并且要把同一行抄进 changeset 正文。两条已申报的判断:`**BREAKING**` 横幅这条旧信号保留(库存里 52 个 changeset 只靠它),以及 ADR-0087 门禁从 changeset 正文读方向(它跑 RC 切版时根本没有 PR)。回滚是单笔 revert:三个门禁各自的自测都是新增用例,没有既有用例被改写。 **席位意见** — (留空,复核席定稿) **你要做的** — 确认两件事:① 旧的 `**BREAKING**` 横幅信号保留,是否合你意(裁定原文只说「读分肢」,派发词的转述是「不再从横幅判断」);② 两条 ADR-0087 账本行记的是平台对象列的取值域收紧 —— 该账本服务的是元数据升级者,请确认这两行落在那里是你要的记录位置。 --- 🤖 Generated with [Claude Code](https://claude.com/claude-code) _Generated by [Claude Code](https://claude.ai/code/session_017ef78bLdybu3AffehKkhfk)_ --- _Generated by [Claude Code](https://claude.ai/code/session_017ef78bLdybu3AffehKkhfk)_ --- _Generated by [Claude Code](https://claude.ai/code)_ --------- Co-authored-by: claude[bot] <noreply@anthropic.com>
1 parent 335e6ad commit 2fc092b

10 files changed

Lines changed: 729 additions & 80 deletions
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
---
2+
'@objectstack/spec': patch
3+
---
4+
5+
fix(spec): record the shipped `sys_job` / `sys_report_schedule` IANA narrowing in the ADR-0087 ledger (#16421)
6+
7+
Clause-②: no
8+
9+
`#16296` gave `sys_job.timezone` and `sys_report_schedule.timezone` the
10+
`valueDomain: 'iana_time_zone'` declaration. That is a write-time narrowing — a
11+
string these columns used to accept is now refused with the ADR-0114 field code
12+
`value_domain` — and it shipped with no breaking-change marker at all, so the
13+
repo's own detector classified it non-breaking and asked for no ADR-0087
14+
disposition. Measured on the shipped changeset, not inferred.
15+
16+
The ledger now carries a `semantic` entry for it
17+
(`platform-timezone-columns-iana-domain-refused`, protocol 18). Nothing is
18+
re-released and nothing is ratified in silence: the entry states what narrowed,
19+
the one-line fix per offending row (write the canonical zone id, or clear the
20+
column), and the fact that a stored non-member is still readable and still
21+
returned unchanged — it fails only on the row's next write. For
22+
`sys_report_schedule` that refusal is the point: a non-member zone was silently
23+
discarding the cron expression and falling back to `interval_minutes` forever.
24+
25+
No authorable key, export, config field or stored shape moves, and no DDL is
26+
planned — this is a record of a change that already shipped, published so that
27+
`objectstack migrate meta`'s consumers can read it.
28+
29+
Maintainer ruling, director summon #17, decision batch #2 item 1, option B
30+
(#16421 comment 5572145955, 2026-09-07), quoted verbatim and untranslated: 「同意」.

‎AGENTS.md‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1064,8 +1064,8 @@ Both non-handshake shapes, and how to classify and probe your own:
10641064
3. **Add a changeset for anything that publishes.** Feature, functional improvement or fix — run `pnpm changeset`
10651065
(or add a `.changeset/*.md` entry) describing it before committing. A bug fix in a released package takes a
10661066
**`patch`** changeset — never none, and ⛔ never `skip-changeset`: that label is for a diff that publishes
1067-
nothing from any released package. A PR that declares `Clause-②: yes` takes at least **`minor`** instead —
1068-
the widening it declares is what makes it more than a patch, whatever else the diff fixes.
1067+
nothing from any released package. The declaration is `Clause-②: yes|no` plus at most one arm from the closed pair
1068+
`(widening)`/`(narrowing)`: `yes` takes at least **`minor`**, `(narrowing)` is BREAKING, `no (widening)` malformed.
10691069
**Breaking changesets must carry their migration.** If the change removes or renames anything an author can write (a
10701070
spec key, an export, a config field), the changeset body must state the FROM → TO mapping and the one-line fix —
10711071
this text ships to consumers as `CHANGELOG.md` inside the npm package and is what an upgrading agent greps after the
@@ -1075,9 +1075,9 @@ Both non-handshake shapes, and how to classify and probe your own:
10751075
schema is `.strict()`. The changeset is one of fourteen surfaces a retirement touches — follow the
10761076
`spec-property-retirement` skill (`.claude/skills/`) rather than reconstructing the kit, and note the two routes
10771077
imply **opposite** liveness-ledger dispositions.
1078-
**A breaking changeset must also state its ADR-0087 disposition, in writing** — exactly one marker in the
1079-
changeset body, enforced by `pnpm check:adr-0087-registration` (CI step *Require an ADR-0087 disposition on a
1080-
declared-breaking changeset*). ⛔ The categories are NOT copied here — the gate prints the full set when it fails.
1078+
**A breaking changeset must also state its ADR-0087 disposition, in writing** — exactly one marker in the changeset
1079+
body, which also carries the PR's `Clause-②` line: `pnpm check:adr-0087-registration` reads the arm there. ⛔ The
1080+
categories are NOT copied here — the gate prints the full set when it fails.
10811081
4. **A removal that breaks the pinned sibling checkout ships together with the sibling fix and the pin bump — or it
10821082
does not ship.** The `Console Pin Gate` job builds objectui at the pinned `.objectui-sha` against **current** `main`,
10831083
so a removal or rename the pinned sibling still imports turns `main` red for every PR in the repo the moment it
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import type { SemanticMigration } from '../../types.js';
4+
5+
export const entry: SemanticMigration = {
6+
id: 'platform-timezone-columns-iana-domain-refused',
7+
surface:
8+
'The two platform audit time-zone columns — `sys_job.timezone` and '
9+
+ '`sys_report_schedule.timezone` — carrying a string that is not a member of the '
10+
+ 'IANA time-zone database (`Asia/Shangai`, `Europe/Munich`, `UTC+8`, `PST`).',
11+
replacement:
12+
'The canonical IANA zone id the deployment meant, written in the spelling the tzdb '
13+
+ 'uses: `Asia/Shanghai`, `Europe/Berlin`, `America/Los_Angeles`. `UTC` is a member '
14+
+ 'and is admitted — membership is the shared `Intl.DateTimeFormat` probe, never the '
15+
+ '`Intl.supportedValuesOf(\'timeZone\')` enumeration, which omits `UTC` and would '
16+
+ 'refuse the one fallback this contract names. ⚠️ A non-member is RE-AUTHORED, never '
17+
+ 'repaired on the deployment\'s behalf: the correct zone behind a typo is a fact only '
18+
+ 'the deployment holds, which is what makes this entry semantic rather than a D2 '
19+
+ 'conversion.',
20+
reason:
21+
'#16296 gave both columns `valueDomain: \'iana_time_zone\'`, which had been declared '
22+
+ 'on `sys_business_unit.timezone` / `sys_organization.timezone` since #14238. It is a '
23+
+ 'WRITE-TIME narrowing of the `min`/`max`/`maxLength` transition-gate class: a value '
24+
+ 'already stored outside the domain is never re-read against it, no DDL is planned, '
25+
+ 'and `objectstack migrate meta` has nothing to rewrite — the changeset that shipped '
26+
+ 'it says so in those words, and this entry does not contradict it. What the '
27+
+ 'changeset had no way to carry is that a deployment holding such a value now has '
28+
+ 'WORK TO DO: the next write of that row is refused with the ADR-0114 field code '
29+
+ '`value_domain`, and until then `sys_report_schedule.timezone` keeps doing the thing '
30+
+ 'the narrowing exists to stop — `ReportService.nextRunAt` hands a non-member zone to '
31+
+ 'croner, whose throw was caught and turned into a silent fall back to '
32+
+ '`interval_minutes`, so "every weekday 09:00 Asia/Shanghai" became "every 1440 '
33+
+ 'minutes, forever". Not a throw and not a fall back to UTC: the wrong instant, '
34+
+ 'permanently. ⛔ It went out with NO `**BREAKING**` marker, so the repo\'s own '
35+
+ 'breaking-change detector classified it non-breaking and asked for no ADR-0087 '
36+
+ 'disposition at all — measured on the shipped changeset. #16421 closed that hole '
37+
+ '(the declaration now carries a `(narrowing)` arm the gate reads instead of a prose '
38+
+ 'banner) and this row is the other half of the same ruling: the narrowing that '
39+
+ 'already shipped is RECORDED, ⛔ not re-released and ⛔ not ratified in silence. '
40+
+ 'Maintainer ruling, director summon #17, decision batch #2 item 1, option B '
41+
+ '(objectstack#16421 comment 5572145955, 2026-09-07), verbatim and untranslated: 「同意」. The direct precedents for registering a change '
42+
+ 'no transform can apply are `schedule-flow-acting-organization-required` (protocol '
43+
+ '18) and `rest-requireauth-default-flip` (protocol 12) — behaviour-only, a '
44+
+ 'deployment judgement, registered anyway because the prescription is real.',
45+
acceptanceCriteria:
46+
'Every `sys_job.timezone` and `sys_report_schedule.timezone` value stored in the '
47+
+ 'deployment is an IANA member. The one-line fix per offending row: write the '
48+
+ 'canonical zone id (`UPDATE … SET timezone = \'Asia/Shanghai\'`), or clear the '
49+
+ 'column — `sys_report_schedule` documents a `UTC` default and `sys_job` has no '
50+
+ 'reader at all. Rows already holding a member parse and behave byte-identically to '
51+
+ 'before; rows holding none are readable, are returned unchanged, and fail only on '
52+
+ 'their next WRITE. A report schedule that was silently running on '
53+
+ '`interval_minutes` resumes its cron cadence once its zone is a member — that '
54+
+ 'resumption, not the absence of an error, is how the fix is verified. ⚠️ The two '
55+
+ 'columns\' `maxLength` (100 vs 64) and defaults (none vs `UTC`) are deliberately '
56+
+ 'still unconverged and are NOT part of this entry; no member is longer than 32 '
57+
+ 'characters on the current Node baseline, so neither bound admits anything the '
58+
+ 'domain does not.',
59+
};

‎packages/spec/src/migrations/registry.ts‎

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9676,6 +9676,61 @@ const step18: MigrationStep = {
96769676
+ 'who was previously outside an `assignedProfiles` list and could nonetheless open the page '
96779677
+ 'is the pre-existing state, not a regression introduced by the removal.',
96789678
},
9679+
{
9680+
id: 'platform-timezone-columns-iana-domain-refused',
9681+
surface:
9682+
'The two platform audit time-zone columns — `sys_job.timezone` and '
9683+
+ '`sys_report_schedule.timezone` — carrying a string that is not a member of the '
9684+
+ 'IANA time-zone database (`Asia/Shangai`, `Europe/Munich`, `UTC+8`, `PST`).',
9685+
replacement:
9686+
'The canonical IANA zone id the deployment meant, written in the spelling the tzdb '
9687+
+ 'uses: `Asia/Shanghai`, `Europe/Berlin`, `America/Los_Angeles`. `UTC` is a member '
9688+
+ 'and is admitted — membership is the shared `Intl.DateTimeFormat` probe, never the '
9689+
+ '`Intl.supportedValuesOf(\'timeZone\')` enumeration, which omits `UTC` and would '
9690+
+ 'refuse the one fallback this contract names. ⚠️ A non-member is RE-AUTHORED, never '
9691+
+ 'repaired on the deployment\'s behalf: the correct zone behind a typo is a fact only '
9692+
+ 'the deployment holds, which is what makes this entry semantic rather than a D2 '
9693+
+ 'conversion.',
9694+
reason:
9695+
'#16296 gave both columns `valueDomain: \'iana_time_zone\'`, which had been declared '
9696+
+ 'on `sys_business_unit.timezone` / `sys_organization.timezone` since #14238. It is a '
9697+
+ 'WRITE-TIME narrowing of the `min`/`max`/`maxLength` transition-gate class: a value '
9698+
+ 'already stored outside the domain is never re-read against it, no DDL is planned, '
9699+
+ 'and `objectstack migrate meta` has nothing to rewrite — the changeset that shipped '
9700+
+ 'it says so in those words, and this entry does not contradict it. What the '
9701+
+ 'changeset had no way to carry is that a deployment holding such a value now has '
9702+
+ 'WORK TO DO: the next write of that row is refused with the ADR-0114 field code '
9703+
+ '`value_domain`, and until then `sys_report_schedule.timezone` keeps doing the thing '
9704+
+ 'the narrowing exists to stop — `ReportService.nextRunAt` hands a non-member zone to '
9705+
+ 'croner, whose throw was caught and turned into a silent fall back to '
9706+
+ '`interval_minutes`, so "every weekday 09:00 Asia/Shanghai" became "every 1440 '
9707+
+ 'minutes, forever". Not a throw and not a fall back to UTC: the wrong instant, '
9708+
+ 'permanently. ⛔ It went out with NO `**BREAKING**` marker, so the repo\'s own '
9709+
+ 'breaking-change detector classified it non-breaking and asked for no ADR-0087 '
9710+
+ 'disposition at all — measured on the shipped changeset. #16421 closed that hole '
9711+
+ '(the declaration now carries a `(narrowing)` arm the gate reads instead of a prose '
9712+
+ 'banner) and this row is the other half of the same ruling: the narrowing that '
9713+
+ 'already shipped is RECORDED, ⛔ not re-released and ⛔ not ratified in silence. '
9714+
+ 'Maintainer ruling, director summon #17, decision batch #2 item 1, option B '
9715+
+ '(objectstack#16421 comment 5572145955, 2026-09-07), verbatim and untranslated: 「同意」. The direct precedents for registering a change '
9716+
+ 'no transform can apply are `schedule-flow-acting-organization-required` (protocol '
9717+
+ '18) and `rest-requireauth-default-flip` (protocol 12) — behaviour-only, a '
9718+
+ 'deployment judgement, registered anyway because the prescription is real.',
9719+
acceptanceCriteria:
9720+
'Every `sys_job.timezone` and `sys_report_schedule.timezone` value stored in the '
9721+
+ 'deployment is an IANA member. The one-line fix per offending row: write the '
9722+
+ 'canonical zone id (`UPDATE … SET timezone = \'Asia/Shanghai\'`), or clear the '
9723+
+ 'column — `sys_report_schedule` documents a `UTC` default and `sys_job` has no '
9724+
+ 'reader at all. Rows already holding a member parse and behave byte-identically to '
9725+
+ 'before; rows holding none are readable, are returned unchanged, and fail only on '
9726+
+ 'their next WRITE. A report schedule that was silently running on '
9727+
+ '`interval_minutes` resumes its cron cadence once its zone is a member — that '
9728+
+ 'resumption, not the absence of an error, is how the fix is verified. ⚠️ The two '
9729+
+ 'columns\' `maxLength` (100 vs 64) and defaults (none vs `UTC`) are deliberately '
9730+
+ 'still unconverged and are NOT part of this entry; no member is longer than 32 '
9731+
+ 'characters on the current Node baseline, so neither bound admits anything the '
9732+
+ 'domain does not.',
9733+
},
96799734
{
96809735
id: 'plugin-auto-restart-never-reinitialised',
96819736
surface:

‎scripts/bump-objectui.selftest.sh‎

Lines changed: 70 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -77,11 +77,37 @@ bad() { echo " ✗ $*" >&2; FAILED=$((FAILED + 1)); }
7777
case_begin() { CASE="$1"; echo " • ${CASE}"; }
7878

7979
# case_5 additionally needs a copy of the digest script `bump-objectui.sh`
80-
# calls and the `isEntrypoint` helper it imports, alongside the script under
81-
# test — mirroring `objectui-changeset-digest.mjs`'s own self-test fixtures
82-
# (which copy the same trio for the same reason).
83-
DIGEST_SCRIPT="${SCRIPT_DIR}/objectui-changeset-digest.mjs"
84-
INVOKED_AS_SCRIPT="${SCRIPT_DIR}/invoked-as.mjs"
80+
# calls, plus every first-party module that script imports, alongside the script
81+
# under test — mirroring `objectui-changeset-digest.mjs`'s own self-test
82+
# fixtures, which stage the same closure for the same reason.
83+
#
84+
# ⭐ The module list is DERIVED, not typed (#16421). It was two names here
85+
# (`objectui-changeset-digest.mjs`, `invoked-as.mjs`) and the same two names in
86+
# three other places; when the digest script gained one import, this file was the
87+
# THIRD staging site to go red, and what a reader saw was not an
88+
# `ERR_MODULE_NOT_FOUND` they could act on: the staged digest died as a
89+
# subprocess, so `range_walkable` never returned one of its two verdicts and
90+
# case 5 failed on the refusal's wording instead. (⚠️ `bump-objectui.sh` itself
91+
# is NOT at fault and is not to be touched for this: it swallows no stderr, and
92+
# its `WALK_RC` branch already separates a verdict from a no-answer and refuses
93+
# to offer `--unshallow` for a crash. See #18354 for what IS carded.) The
94+
# derivation lives in `first-party-closure.mjs`, shared with the JS sites.
95+
#
96+
# ⚠️ THE BASENAME IS SPELLED ALONE AND THE DIRECTORY IS INTERPOLATED ONTO IT —
97+
# the same discipline the declaration above states for CHANGESET_NAME, and for
98+
# the same machine reason: `dispatch-gates` reads a quoted literal carrying a
99+
# separator as a DECLARED WATCHED PATH, so writing the repo-relative path as one
100+
# bare token here gives this gate a path population and CONTRADICTS the
101+
# `no-path-population` marker above. Measured, not theorised —
102+
# `check:pm-dispatch-gates`: "no family both DECLARES no path population and
103+
# names paths anyway (contradicted: check:objectui-bump)". ⛔ Do not tidy this
104+
# back into a single literal.
105+
DIGEST_BASENAME='objectui-changeset-digest.mjs'
106+
REPO_ROOT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
107+
DIGEST_ENTRY="scripts/${DIGEST_BASENAME}"
108+
# The same script, absolute, for the two cases that run it from THIS checkout
109+
# rather than from a sandbox — those need no staging at all.
110+
DIGEST_SCRIPT="${REPO_ROOT_DIR}/${DIGEST_ENTRY}"
85111

86112
# Case 5's fixture changeset BASENAME — interpolated into a changeset path
87113
# everywhere it is used, never spelled as one bare path literal: that path
@@ -123,13 +149,48 @@ new_framework() {
123149
}
124150

125151
# Same, plus a byte copy of the digest script `bump-objectui.sh` shells out to
126-
# and the `invoked-as.mjs` helper it imports — needed only by cases that do NOT
127-
# pass `--no-changeset` and so actually reach the changeset section.
152+
# and every first-party module that script imports — needed only by cases that
153+
# do NOT pass `--no-changeset` and so actually reach the changeset section.
154+
#
155+
# ⛔ The closure is written to a file and its exit code read on its own line: a
156+
# `node ... | while read` pipeline would hand this function the exit code of
157+
# `while`, so a derivation that died would stage NOTHING and look like it worked,
158+
# which is the failure shape this whole change exists to remove.
128159
new_framework_with_digest() {
129160
local d="$1" pin="${2-}"
130161
new_framework "$d" "$pin"
131-
cp "$DIGEST_SCRIPT" "${d}/scripts/objectui-changeset-digest.mjs"
132-
cp "$INVOKED_AS_SCRIPT" "${d}/scripts/invoked-as.mjs"
162+
local list="${d}/.closure.txt" rel
163+
# ⚠️ Each path is emitted with its OWN trailing newline, and the loop below
164+
# still reads an unterminated last line. Both halves, because the first time
165+
# this was written with `join('\n')` the final path — `first-party-closure.mjs`
166+
# itself — was silently dropped by `read`, and the sandbox then failed with the
167+
# SAME ERR_MODULE_NOT_FOUND this derivation exists to prevent, from a list that
168+
# had named the file correctly.
169+
node --input-type=module -e "
170+
import { firstPartyModuleClosure } from '${REPO_ROOT_DIR}/scripts/first-party-closure.mjs';
171+
for (const rel of firstPartyModuleClosure('${DIGEST_ENTRY}', { root: '${REPO_ROOT_DIR}' })) console.log(rel);
172+
" > "$list" 2>"${list}.err"
173+
local ec=$?
174+
if [[ $ec -ne 0 ]]; then
175+
printf 'bump-objectui.selftest: could not derive the digest closure (exit %s)\n' "$ec" >&2
176+
cat "${list}.err" >&2
177+
return "$ec"
178+
fi
179+
local staged_count=0
180+
while read -r rel || [[ -n "$rel" ]]; do
181+
[[ -z "$rel" ]] && continue
182+
mkdir -p "${d}/$(dirname "$rel")"
183+
cp "${REPO_ROOT_DIR}/${rel}" "${d}/${rel}"
184+
staged_count=$((staged_count + 1))
185+
done < "$list"
186+
# A derivation that came back short stages a sandbox that looks runnable and
187+
# is not, so the count is ASSERTED here rather than discovered three cases
188+
# later as "the objectui range does not walk".
189+
if [[ "$staged_count" -lt 2 ]]; then
190+
printf 'bump-objectui.selftest: the digest closure staged only %s file(s) — expected the script and its imports\n' "$staged_count" >&2
191+
return 1
192+
fi
193+
rm -f "$list" "${list}.err"
133194
}
134195

135196
# A throwaway objectui repo with a REAL changeset commit — commit A (the

0 commit comments

Comments
 (0)