Skip to content

Commit 1d576b6

Browse files
committed
feat(devx): the clause-② declaration grows a direction arm, and three gates read it
A narrowing — "these spellings stop being accepted" — could ship to consumers with every gate green. `check-adr-0087-registration.mjs` decided breaking-ness from a hand-written `**BREAKING**` prose banner, so an author who did not type it was classified non-breaking and owed no ADR-0087 disposition. Measured on #16296 (and #14238 one surface earlier), both of which shipped that way. The declaration now carries a closed direction arm — `(widening)` or `(narrowing)` — read once, in `readClause2Line`, and imported by every reader: * `scripts/pm/check-clause2-carriers.mjs` — `CLAUSE2_ARMS`, the arm reader, and the two shapes that must fail closed: a near-arm spelling (`(narrowed)`, the unfilled `(widening|narrowing)` menu) and the self-contradiction `no (widening)`. Both read `malformed`, never as an absent arm. * `scripts/check-adr-0087-registration.mjs` — signal (4). `narrowing` declares breaking; `widening` declares nothing. The `**BREAKING**` banner is KEPT: 52 changesets in stock carry it and nothing else. * `scripts/check-changeset-no-major.mjs` — a declared narrowing owes the same grade a declared widening owes, because inside the launch window a breaking change ships `minor`. The arm is OPTIONAL, and that is a measurement: all five declarations on the open board read `Clause-②: no` with no parenthetical, and all five read byte-identically after this change. AGENTS.md carries the format at net zero lines — its ratchet has no headroom — paid for by deleting the widening-rationale clause and the CI step name the gate prints for itself. Maintainer ruling: director summon #17, decision batch #2 item 1, option B, verbatim 「同意」. Co-Authored-By: Claude <[email protected]> Claude-Session: https://claude.ai/code/session_017ef78bLdybu3AffehKkhfk
1 parent 3dbb49c commit 1d576b6

4 files changed

Lines changed: 308 additions & 33 deletions

File tree

AGENTS.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1040,8 +1040,8 @@ registry? Add it to `OPEN_CAPABILITY_REGISTRIES` in the same PR that fixes it.
10401040
3. **Add a changeset for anything that publishes.** Feature, functional improvement or fix — run `pnpm changeset`
10411041
(or add a `.changeset/*.md` entry) describing it before committing. A bug fix in a released package takes a
10421042
**`patch`** changeset — never none, and ⛔ never `skip-changeset`: that label is for a diff that publishes
1043-
nothing from any released package. A PR that declares `Clause-②: yes` takes at least **`minor`** instead —
1044-
the widening it declares is what makes it more than a patch, whatever else the diff fixes.
1043+
nothing from any released package. The declaration is `Clause-②: yes|no` plus at most one arm from the closed pair
1044+
`(widening)`/`(narrowing)`: `yes` takes at least **`minor`**, `(narrowing)` is BREAKING, `no (widening)` malformed.
10451045
**Breaking changesets must carry their migration.** If the change removes or renames anything an author can write (a
10461046
spec key, an export, a config field), the changeset body must state the FROM → TO mapping and the one-line fix —
10471047
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.
10511051
schema is `.strict()`. The changeset is one of fourteen surfaces a retirement touches — follow the
10521052
`spec-property-retirement` skill (`.claude/skills/`) rather than reconstructing the kit, and note the two routes
10531053
imply **opposite** liveness-ledger dispositions.
1054-
**A breaking changeset must also state its ADR-0087 disposition, in writing** — exactly one marker in the
1055-
changeset body, enforced by `pnpm check:adr-0087-registration` (CI step *Require an ADR-0087 disposition on a
1056-
declared-breaking changeset*). ⛔ The categories are NOT copied here — the gate prints the full set when it fails.
1054+
**A breaking changeset must also state its ADR-0087 disposition, in writing** — exactly one marker in the changeset
1055+
body, which also carries the PR's `Clause-②` line: `pnpm check:adr-0087-registration` reads the arm there. ⛔ The
1056+
categories are NOT copied here — the gate prints the full set when it fails.
10571057
4. **A removal that breaks the pinned sibling checkout ships together with the sibling fix and the pin bump — or it
10581058
does not ship.** The `Console Pin Gate` job builds objectui at the pinned `.objectui-sha` against **current** `main`,
10591059
so a removal or rename the pinned sibling still imports turns `main` red for every PR in the repo the moment it

scripts/check-adr-0087-registration.mjs

Lines changed: 101 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -361,6 +361,10 @@ import { dirname, join, resolve } from 'node:path';
361361
import { fileURLToPath } from 'node:url';
362362
import { isEntrypoint } from './invoked-as.mjs';
363363
import { maskComments, maskCommentsAndLiterals } from './js-comment-mask.mjs';
364+
// #16421 — the DIRECTION ARM, read through the fleet's one declaration reader.
365+
// ⛔ Not re-implemented here: the ruling's condition on the arm is that it has a
366+
// single legal spelling read in a single place, and every reader imports it.
367+
import { readClause2Line } from './pm/check-clause2-carriers.mjs';
364368

365369
// ── The self-test's own battery roster and floor (#13489) ──────────────────
366370
//
@@ -553,18 +557,51 @@ export function parseChangeset(text) {
553557
/**
554558
* Does this changeset DECLARE a breaking change?
555559
*
556-
* Three spellings are in live use in this repo and all three count, because the
560+
* FOUR spellings are in live use in this repo and all four count, because the
557561
* gate's subject is the author's own declaration and an author who used any of
558562
* them has declared it:
559563
*
560564
* 1. a `major` bump in the frontmatter (118 of 1304 in stock)
561565
* 2. a `**BREAKING` marker in the body (52)
562566
* 3. a conventional-commit `!` in the summary line (175)
563-
*
564-
* The union is 213. Narrowing to any one of them would drop real declarations:
565-
* #6048's changeset used (1) and (2) and NOT (3), while the launch-window guard
566-
* `check-changeset-no-major.mjs` pushes breaking changes to `minor` outside
567-
* pre-mode, which would leave (2)/(3) carrying the signal alone.
567+
* 4. a `Clause-②` declaration whose ARM reads `narrowing` (#16421)
568+
*
569+
* The union of the first three is 213. Narrowing to any one of them would drop
570+
* real declarations: #6048's changeset used (1) and (2) and NOT (3), while the
571+
* launch-window guard `check-changeset-no-major.mjs` pushes breaking changes to
572+
* `minor` outside pre-mode, which would leave (2)/(3) carrying the signal alone.
573+
*
574+
* ## Signal (4), and the hole it closes (#16421)
575+
*
576+
* Signals (1)–(3) are all things an author REMEMBERS to type, and (2) — the one
577+
* that carries breaking-ness during the launch window, when (1) is forbidden
578+
* outright — is FREE-FORM PROSE. Measured: **#16296** narrowed
579+
* `sys_job.timezone` / `sys_report_schedule.timezone` to the IANA value domain
580+
* and shipped to consumers with no banner, no `major` and no `!`, so this gate
581+
* classified it non-breaking, asked for no ADR-0087 disposition, and every gate
582+
* in the repo was green. **#14238** did the same one surface earlier. An
583+
* accept-set narrowing is exactly the change a consumer needs told about, and it
584+
* was the change with the weakest carrier.
585+
*
586+
* Signal (4) replaces "remember the banner" with the declaration the PM protocol
587+
* already requires on every card and PR: the direction ARM. The author copies
588+
* one line they have already written into the changeset body, and the gate reads
589+
* a CLOSED TOKEN rather than a phrase.
590+
*
591+
* ⛔ The arm is read through `readClause2Line`, IMPORTED — there is no second
592+
* parser here, and the ruling made that a condition rather than a preference:
593+
* one legal spelling, read in one place, by every reader.
594+
*
595+
* ⚠️ `widening` adds NO signal, and that is the half of the arm this gate has to
596+
* get right. A widening is not a break; classifying both arms as breaking would
597+
* make the reading indistinguishable from a constant, and the self-test pins
598+
* both directions for exactly that reason.
599+
*
600+
* ⛔ Signal (2) is KEPT, not replaced. Dropping the banner while 52 changesets in
601+
* stock carry it and nothing else would un-declare every one of them — the same
602+
* "narrowing to any one of them drops real declarations" this docblock has
603+
* refused since it was written. Signal (4) is the carrier that does not depend on
604+
* prose; (2) stays the one that already works where it was used.
568605
*
569606
* @param {ReturnType<typeof parseChangeset>} parsed
570607
* @returns {{ breaking: boolean, signals: string[] }}
@@ -575,6 +612,12 @@ export function breakingDeclaration(parsed) {
575612
if (/\*\*BREAKING/i.test(parsed.body) || /^\s*BREAKING[ -]CHANGE/mi.test(parsed.body)) signals.push('BREAKING');
576613
const summary = (parsed.body.trim().split(/\n/)[0] || '').replace(/^\*\*|^#+\s*/, '');
577614
if (/^[a-z]+(\([^)]*\))?!:/.test(summary)) signals.push('bang');
615+
// (4) The direction arm. A `malformed` or `near-miss` reading declares
616+
// nothing HERE and must not: this gate judges changesets, and the states that
617+
// send a seat to fix an unreadable declaration are `check-clause2-carriers`'s
618+
// rows, on the carriers that own them.
619+
const decl = readClause2Line(parsed.body);
620+
if (decl?.kind === 'declared' && decl.arm === 'narrowing') signals.push('clause-②-narrowing');
578621
return { breaking: signals.length > 0, signals };
579622
}
580623

@@ -1860,10 +1903,24 @@ export function assertInputs({ cwd, head }) {
18601903
['a `**BREAKING**` body marker', "---\n'@objectstack/spec': minor\n---\n\na summary\n\n**BREAKING**: something changed\n"],
18611904
['a `BREAKING CHANGE:` body line', "---\n'@objectstack/spec': minor\n---\n\na summary\n\nBREAKING CHANGE: something changed\n"],
18621905
['a conventional-commit `!` summary', "---\n'@objectstack/spec': patch\n---\n\nfeat(spec)!: drop a key\n"],
1906+
// #16421, signal (4) — the ARM, pinned in BOTH directions across these two
1907+
// lists. `narrowing` here, `widening` in the inverted list below: an arm
1908+
// reading that classified both as breaking would satisfy this list alone
1909+
// while telling a consumer nothing, so neither fixture means anything
1910+
// without the other. The `minor` bump and the banner-free body are the
1911+
// point — this is the exact shape #16296 shipped in, and the three older
1912+
// signals are all absent from it on purpose.
1913+
['the `narrowing` ARM with no banner, no `major` and no `!` (#16421)', "---\n'@objectstack/spec': minor\n---\n\na summary\n\nClause-②: no (narrowing)\n"],
1914+
['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"],
18631915
];
18641916
const MUST_NOT_MATCH_BREAKING = [
18651917
['a plain `patch` changeset', "---\n'@objectstack/spec': patch\n---\n\nfix a typo\n"],
18661918
['a `minor` changeset whose prose merely contains the word breaking', "---\n'@objectstack/spec': minor\n---\n\nnothing groundbreaking here\n"],
1919+
// The OTHER direction of signal (4). A widening is not a break.
1920+
['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"],
1921+
['a bare `Clause-②: no` — the shape every declaration on the board carries', "---\n'@objectstack/spec': patch\n---\n\na summary\n\nClause-②: no\n"],
1922+
['a bare `Clause-②: yes` — a widening declared through the value alone', "---\n'@objectstack/spec': minor\n---\n\na summary\n\nClause-②: yes\n"],
1923+
['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"],
18671924
];
18681925
for (const [label, text] of MUST_MATCH_BREAKING) {
18691926
if (!breakingDeclaration(parseChangeset(text)).breaking) {
@@ -6186,14 +6243,44 @@ function selfTest() {
61866243
};
61876244
const copy = 'scripts/check-adr-0087-registration.mjs';
61886245
w(copy, readFileSync(fileURLToPath(import.meta.url), 'utf8'));
6189-
// EVERY `./`-relative sibling this file imports travels with the copy, or the
6190-
// fixture dies on ERR_MODULE_NOT_FOUND -- which reads as "I1 is broken" rather
6191-
// than "the fixture is incomplete". Adding an import to this gate means adding
6192-
// it here, and `js-comment-mask.mjs` (#12881) is the case that proved the list
6193-
// has to be a list: it arrived after `invoked-as.mjs` and took both I-cases red
6194-
// on a fixture problem that had nothing to do with what they assert.
6195-
for (const sibling of ['invoked-as.mjs', 'js-comment-mask.mjs']) {
6196-
w(`scripts/${sibling}`, readFileSync(new URL(`./${sibling}`, import.meta.url), 'utf8'));
6246+
// EVERY first-party module this file imports, TRANSITIVELY, travels with the
6247+
// copy, or the fixture dies on ERR_MODULE_NOT_FOUND -- which reads as "I1 is
6248+
// broken" rather than "the fixture is incomplete". `js-comment-mask.mjs`
6249+
// (#12881) is the case that proved the closure has to be computed: it
6250+
// arrived after `invoked-as.mjs` and took both I-cases red on a fixture
6251+
// problem that had nothing to do with what they assert.
6252+
//
6253+
// ⭐ DERIVED, not listed (#16421). It was a two-name list while this gate's
6254+
// imports were two leaves. Signal (4) added an edge to
6255+
// `pm/check-clause2-carriers.mjs`, whose own closure is nine modules deep and
6256+
// includes `pm/dispatch-gates.mjs` -- a file under constant change. A hand
6257+
// list against that graph is a trap that springs on whoever adds an import
6258+
// over there, in a gate over here, with an error message about neither. The
6259+
// walk reads the same edges Node will resolve, so the fixture cannot drift
6260+
// from the real module graph by construction.
6261+
//
6262+
// ⛔ Statement-shaped matches ONLY -- the two spellings that reach a
6263+
// line-start `from`, a single-line `import … from '…'` and the closing brace
6264+
// of a multi-line one. A bare regex over the source harvests every specifier
6265+
// sitting inside a STRING in a self-test fixture, which is how a walk of this
6266+
// tree ends up chasing `./does-not-exist.mjs`.
6267+
const EDGE_PATTERNS = [
6268+
/^[ \t]*(?:import|export)[^'"\n]*from[ \t]*['"](\.[^'"\n]+)['"]/gm,
6269+
/^[ \t]*\}[ \t]*from[ \t]*['"](\.[^'"\n]+)['"]/gm,
6270+
];
6271+
const staged = new Set([copy]);
6272+
const stage = (rel) => {
6273+
if (staged.has(rel)) return;
6274+
staged.add(rel);
6275+
const src = readFileSync(join(REPO_ROOT, rel), 'utf8');
6276+
w(rel, src);
6277+
for (const pattern of EDGE_PATTERNS) {
6278+
pattern.lastIndex = 0;
6279+
for (const m of src.matchAll(pattern)) stage(join(dirname(rel), m[1]));
6280+
}
6281+
};
6282+
for (const m of readFileSync(fileURLToPath(import.meta.url), 'utf8').matchAll(EDGE_PATTERNS[0])) {
6283+
stage(join('scripts', m[1]));
61976284
}
61986285
w(
61996286
'importer.mjs',

0 commit comments

Comments
 (0)