diff --git a/.changeset/button-container-exception-reason-6804.md b/.changeset/button-container-exception-reason-6804.md new file mode 100644 index 0000000000..5dda128717 --- /dev/null +++ b/.changeset/button-container-exception-reason-6804.md @@ -0,0 +1,29 @@ +--- +--- + +Makes the `button` entry in `scripts/container-declaration-baseline.json` a **permanent, +reasoned exception** instead of a provisional one, executing objectui#6804's maintainer +ruling (2026-08-30). objectui#6779 had excluded `button` from the ratchet-to-zero list +"pending a separate card"; that card has now been ruled, and the exclusion stands: `button` +does not declare `isContainer`. + +The entry's `reason` now carries the ruling's ground rather than a forward reference — +`isContainer` means layout containment, not "this tag renders children", and `button` reads +`schema.children` only as a fallback for `schema.label`, so declaring it would make one +predicate mean two things and would delete the `Button` identifier from the JSX scope of +every `kind:'react'` page, against zero measured pull the other way. Its owning `issue` +moves from `objectui#6779` (which deferred the question) to `objectui#6804` (which answered +it), with the provenance kept in the reason. + +This matters because an exception with no recorded ground is indistinguishable from a +missed one, and that indistinguishability is the mechanism behind this defect class's three +independent rediscoveries (objectui#3900 / objectui#6740 / objectui#6764). + +Also records a measurement the note previously implied away: `button` is the only public +tag among the 45 violations listed, but not the only public tag in the containment story — +ADR-0080's `PUBLIC_BLOCKS` carries `badge` and `alert` as bare keys too, so of the 14 tags +the ruling covers, three are published contract. The 11 bare `sidebar-*` keys are not (the +public sidebar is the namespaced `page:sidebar`, which already declares `isContainer`). + +Ledger and test prose only. No published behaviour changes, no registration's metadata is +altered, and every assertion that keeps the exception honest is unchanged. diff --git a/packages/components/src/renderers/__tests__/container-declaration-ratchet.test.tsx b/packages/components/src/renderers/__tests__/container-declaration-ratchet.test.tsx index 47b360f5ef..462f98468d 100644 --- a/packages/components/src/renderers/__tests__/container-declaration-ratchet.test.tsx +++ b/packages/components/src/renderers/__tests__/container-declaration-ratchet.test.tsx @@ -69,10 +69,12 @@ * - 73 render no children and correctly keep the diagnostic; * - 0 failed to render, so nothing was scored on an exception. * - * The 45 are the stock. `button` is excluded by the ruling and pinned separately; - * the other 44 are `scripts/container-declaration-baseline.json`, which is a - * RATCHET TO ZERO and not an exemption list: red when an unlisted tag violates, - * and red again when a listed tag stops violating, so the file can only shrink. + * The 45 are the stock. `button` is excluded by ruling and pinned separately — that + * exclusion was made PERMANENT and reasoned by objectui#6804 (2026-08-30); see the + * `button` block below. The other 44 are `scripts/container-declaration-baseline.json`, + * which is a RATCHET TO ZERO and not an exemption list: red when an unlisted tag + * violates, and red again when a listed tag stops violating, so the file can only + * shrink. */ import { describe, it, expect } from 'vitest'; @@ -324,28 +326,46 @@ describe('the ratchet: no NEW undeclared container (objectui#6779)', () => { ); }); -describe('`button` is EXCLUDED by ruling, not fixed and not forgotten (objectui#6779)', () => { +describe('`button` is EXCLUDED by ruling — permanently, and with its ground (objectui#6804)', () => { it( - 'still renders children, still undeclared, still the only public one', + 'still renders children, still undeclared, still the only public one of the 45', async () => { const rows = await census(); const baseline = readBaseline(); const button = rows.find((r) => r.type === 'button'); - // The 2026-08-29 ruling excluded `button` from the ratchet list and - // ordered a separate card: it is the ONLY public-tier member of the 45, so - // declaring the flag would delete `Button` from the JSX scope of every - // `kind:'react'` page, and it reads `children` as a LABEL FALLBACK rather - // than as layout containment — a public-tier product decision, not a - // mechanical fix. + // objectui#6779's ruling (2026-08-29) carved `button` out of the ratchet + // list and ordered a separate card. objectui#6804 IS that card, and its + // 2026-08-30 ruling SETTLED the question: `button` does not declare + // `isContainer`, permanently. The ground — recorded in full in the + // baseline's `reason` field, which is where a reader who opens the ledger + // will look — is that `isContainer` means LAYOUT CONTAINMENT, while + // `button` reads `children` only as a fallback for `schema.label`, so + // declaring it would make one predicate mean two different things; that it + // is the only public-tier member of the 45, so the declaration would also + // delete `Button` from the JSX scope of every `kind:'react'` page; and + // that the pull the other way measured zero. + // + // ⇒ The `issue` field points at the card that RULED it (#6804), not at the + // one that deferred it (#6779, named in the reason as provenance): a + // reader following this pointer wants the decision, not the deferral. + // + // The ruling covers 14 tags — `button` plus the 13 `schema.body` readers — + // but only `button` is LISTED here, and that is not an omission: the other + // 13 do not violate today, and listing a non-violator trips the OTHER + // direction of the baseline's red. They are pinned as non-violators by the + // `schema.body` readers block below instead. expect(Object.keys(baseline.excluded)).toEqual(['button']); - expect(baseline.excluded.button.issue).toBe('objectui#6779'); + expect(baseline.excluded.button.issue).toBe('objectui#6804'); // Pinned as a live description rather than as prose, so the exclusion - // cannot outlive its reason: when that separate card lands and `button` - // is either declared or made non-rendering, this goes RED and the entry - // must be resolved instead of standing forever. That indefinite standing - // is the tail risk the triage seat recorded against option B. + // cannot outlive its reason. These three are exactly the facts the ruling + // rests on; if any one of them stops being true, this goes RED and the + // exception must be RE-RULED rather than quietly re-based on a premise + // that no longer holds. That is what keeps a REASONED exception + // distinguishable from an oversight — and their indistinguishability is + // the mechanism behind this class's three independent rediscoveries + // (objectui#3900 / objectui#6740 / objectui#6764). expect(button?.rendersChildren, '`button` no longer renders children').toBe(true); expect(button?.isContainer, '`button` now declares `isContainer` — resolve the exclusion').toBe( false, @@ -388,9 +408,17 @@ describe('the predicate is RUNTIME, so the exception shapes need no skip-list (o }); it('the `schema.body` readers stay non-containers — `body` is a key the check never inspects', async () => { - // The population the ruling assigns to objectui#6771's separate B ruling, - // and the one a "children or body" predicate would collapse: these render - // `renderChildren(schema.body)` and never touch `schema.children`. + // The other 13 of objectui#6804's 14, and the population a "children or + // body" predicate would collapse: these render `renderChildren(schema.body)` + // and never touch `schema.children`. That 2026-08-30 ruling gives them the + // SAME answer as `button` — no `isContainer` — but they get no baseline + // entry while they do not violate, because listing a non-violator is the + // other direction of that file's red. If objectui#6771's retirement of the + // `body` dialect gives one of them a `children` read, that is when it earns + // a reasoned entry of its own. ⚠️ Two of them are PUBLIC — `badge` and + // `alert` — so that change would be moving published contract, not a + // mechanical fix. That is a measurement, so it is pinned rather than + // asserted in prose: see the block below. // `validateTree`'s containment branch is guarded by `node.children?.length` // ALONE, so no author writing `body` on them has ever drawn a false // diagnostic. Measured: 13 bare keys, not the 10 objectui#6779 estimated — @@ -404,6 +432,68 @@ describe('the predicate is RUNTIME, so the exception shapes need no skip-list (o }, CENSUS_TIMEOUT); }); +describe('the public tier of the ruled 14 is THREE, not one (objectui#6804)', () => { + // The baseline's ⚠️ paragraph tells whoever implements objectui#6771 that two of + // the 13 tags they are about to give a `children` read are PUBLISHED CONTRACT. + // That is a measurement, and this file's convention — set by the 44's own + // paragraph, "measured; the pin asserts it" — is that a measured claim in the + // ledger names the pin holding it. Unpinned, the warning goes quietly false the + // day `badge` or `alert` leaves the public tier, in the one sentence written to + // stop an unmeasured public-tier change. That is this card's own defect shape one + // level up: a claim nothing can distinguish from a stale one. + // + // Read off the LIVE REGISTRY rather than off `PUBLIC_BLOCKS`, because the list is + // the INPUT and the registry is the FACT, and this is precisely a population + // where the two differ: `getPublicConfigs()` keys the contract by the curated tag + // it was listed under, so the namespaced `page:sidebar` enters the public set + // under THAT spelling while the bare `sidebar` registration never does. Grepping + // the list would score all 12 sidebar keys off one entry that belongs to none of + // them. + it('`badge` and `alert` are public; the 11 bare `sidebar-*` keys are not', async () => { + const rows = await census(); + const byType = new Map(rows.map((r) => [r.type, r])); + const isPublic = publicTags(); + + // DIRECTION CONTROL, first and for the same reason the 44's block carries one: + // without it, "the sidebar keys are not public" is indistinguishable from "this + // reader returned nothing", and every absence below would pass vacuously. + // `button` is the known positive — it is the fact the exclusion above rests on. + expect(isPublic.size).toBeGreaterThan(0); + expect(isPublic.has('button'), 'the public reader resolved nothing — every absence below is vacuous').toBe( + true, + ); + + // (1) The two public body readers. `button` is the only public tag among the 45 + // VIOLATIONS, but not the only public tag among the ruled 14 — that is the + // distinction the baseline note now draws and this holds it. + for (const type of ['badge', 'alert']) { + expect( + byType.get(type)?.isPublic, + `\`${type}\` left the public tier — the baseline's ⚠️ public-tier paragraph is now false, fix it`, + ).toBe(true); + } + + // (2) …and the eleven that are not, so "three" is a count and not a guess. + const sidebars = bareTags().filter((t) => t.startsWith('sidebar')); + expect(sidebars.length, 'the `sidebar-*` family changed size — re-measure the note').toBe(11); + expect( + sidebars.filter((t) => byType.get(t)?.isPublic), + 'a bare `sidebar-*` key became public — declaring it would now delete a react-page identifier', + ).toEqual([]); + expect(['button', 'badge', 'alert'].filter((t) => byType.get(t)?.isPublic).length).toBe(3); + + // (3) And why that is not the whole sidebar story: the sidebar that IS public is + // the namespaced registration, and it already declares the flag — so it is not + // in this containment story at all, and a reader who finds `page:sidebar` in + // `PUBLIC_BLOCKS` must not conclude the bare family is public too. + expect(isPublic.has('page:sidebar')).toBe(true); + expect( + ComponentRegistry.getMeta('page:sidebar')?.isContainer, + '`page:sidebar` stopped declaring containment — it is now part of this story', + ).toBe(true); + }, CENSUS_TIMEOUT); +}); + describe('the baseline file is a ratchet, and says so (objectui#6779)', () => { it('carries the shrink-only contract in its own note', () => { // A reader who opens the file must not be able to mistake it for an diff --git a/scripts/container-declaration-baseline.json b/scripts/container-declaration-baseline.json index 4fb9fdb4bd..562c9780b9 100644 --- a/scripts/container-declaration-baseline.json +++ b/scripts/container-declaration-baseline.json @@ -36,14 +36,43 @@ "declaration provably removes nothing. `button` is the one that is -- which is why it", "is excluded rather than listed.", "", + "⚠️ AND THE PUBLIC TIER IS WIDER THAN THIS LIST'S ONE HIT -- do not read the paragraph", + "above as `button` being the only public tag in the containment story. It is the only", + "public tag among the 45 measured HERE. `PUBLIC_BLOCKS` (ADR-0080,", + "packages/core/src/registry/public-blocks.ts) also carries `badge` and `alert` as bare", + "keys, and both are registered bare. They are absent from this list only because they", + "render `renderChildren(schema.body)` and never touch `schema.children`, so they do not", + "violate -- yet. Measured 2026-08-30 over the live registry, and PINNED -- the ratchet", + "test's `the public tier of the ruled 14 is THREE, not one` block asserts every fact in", + "this paragraph, direction control included, so it cannot go quietly stale the way an", + "unheld measurement in a durable ledger does: of the 14 tags objectui#6804 ruled on,", + "THREE are public -- `button`, `badge`, `alert`. The 11 bare", + "`sidebar-*` keys are NOT (the public sidebar is the namespaced `page:sidebar`, a", + "different registration, and it already declares `isContainer: true`). ⇒ Whoever gives", + "`badge` or `alert` a `children` read is moving PUBLISHED CONTRACT and inherits this", + "whole trade-off; it is not the mechanical one-line fix the other 44 are.", + "", + "THE 2026-08-30 RULING (objectui#6804), AND WHY ONLY ONE OF ITS 14 TAGS IS WRITTEN", + "DOWN HERE. That ruling answered the containment question for 14 tags at once --", + "`button` plus the 13 `schema.body` readers (`badge`, `alert`, 11 `sidebar-*`) -- and", + "the answer for all 14 is the same: do NOT declare `isContainer`, because reading", + "children as a FALLBACK for `schema.label` / `schema.body` is not layout containment.", + "Only `button` has an entry below, and that is NOT an oversight: it is the only one of", + "the 14 that violates today. The other 13 put no authored child on the page, so the", + "runtime predicate already scores them as non-containers -- and a line for them here", + "would trip this file's OTHER red (`a listed tag that no longer violates`) on the day", + "it was written. They are pinned as non-violators instead, by the `schema.body` readers", + "block in the ratchet test. If objectui#6771's retirement of the `body` dialect gives", + "any of them a `children` read, that tag earns its own reasoned entry THEN.", + "", "`since` is the date the entry was admitted, so the list carries its own history: a", "line dated later than this file's first commit was added after the ruling and needs", "one of its own." ], "excluded": { "button": { - "reason": "Ruled out of this list by objectui#6779's maintainer ruling (2026-08-29), pending a separate card. It is the ONLY public-tier member of the 45, so declaring `isContainer` would delete `Button` from the JSX scope of every `kind:'react'` page -- and it reads `children` as a LABEL FALLBACK, not as layout containment, so the containment answer is a product decision rather than a mechanical fix. The pin asserts this entry still describes reality (renders children, undeclared, public): when the separate card lands, it goes red and this entry must be resolved rather than left standing.", - "issue": "objectui#6779", + "reason": "PERMANENT, RULED EXCEPTION -- not an oversight, not pending anything. Admitted 2026-08-29 as provisional by objectui#6779, which deferred the question to a separate card; that card is objectui#6804 and the maintainer ruled it 2026-08-30: `button` does NOT declare `isContainer`. Three grounds, all re-derived on this tree. (1) SEMANTICS -- `isContainer` means LAYOUT CONTAINMENT, not `this tag renders children`. `button` reads `schema.children` only as a fallback for `schema.label` (packages/components/src/renderers/form/button.tsx, `schema.label || renderChildren(...)`), so it is not a container; declaring it would make one predicate mean two different things. (2) COST -- `button` is the only public-tier member of the 45, and `renderers/layout/react-page.tsx` skips containers when it builds the JSX scope of every `kind:'react'` page, so declaring the flag DELETES the `Button` identifier from all of them. That is an immediate, user-visible capability loss. (3) PULL -- zero measured: nothing records anyone wanting to nest layout content inside a button, so the trade is a certain cost against a hypothetical gain. ⛔ Do NOT `fix` this entry by declaring the flag, and do NOT move `button` into `undeclared` -- both override the ruling. What keeps the exception honest is the pin, which asserts all three facts it rests on (still renders children, still undeclared, still public); if any one of them changes this goes RED and the exception must be RE-RULED, not quietly re-based. Scope note for the next hand: this card deliberately did not edit that `button.tsx` line -- the `schema.body || schema.children` expression on it belongs to objectui#6771 (retirement of the `body` dialect).", + "issue": "objectui#6804", "since": "2026-08-29" } },