From 67e365b398e0d6c755bdcd2165a4975c1feafb77 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 10 Sep 2026 02:27:21 +0000 Subject: [PATCH 1/3] fix(types): refuse `breadcrumbs` by name on the `page` node (objectui#8871) `PageNodeSchema` never declared `breadcrumbs` and no renderer ever read it, so an authored trail survived purely through `BaseSchema`'s `.passthrough()`: the document parsed green and drew nothing. It is now an ADR-0049 retirement tombstone with the remedy in the message, plus a `breadcrumbs?: never` twin so `tsc` refuses it at the authoring site. objectui#7926 refused `actions` on this same node and deliberately left this key parsing, with a pin saying a later retirement had to say so out loud. That pin is flipped rather than deleted. Its maintainer ruling is NOT borrowed - it covers `actions` only; the authority here is the standing ADR-0049 enforce-or-remove gate, which this package already applies to this face. Measured with a point-access probe, never a bare word: `\.breadcrumbs` scores 0 tree-wide against 10 files for `\.breadcrumb\b` as the lit control. Three author sites, all teaching passages in content/docs/guide/layout.md - correcting #7926's "1 site", whose census filtered on `page`-tagged objects while two of the three passages carry no `type` at all. All three are rewritten onto the `breadcrumb` NODE, which is a registered renderer taking the same item shape. The node is NOT strict, and the census leg that says so is re-asserted. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Jmxdo7bmeqCQHLSfmLVX9w --- .../8871-page-node-refuses-breadcrumbs.md | 77 +++++ content/docs/guide/layout.md | 48 ++- .../page-actions-refusal-7926.test.ts | 21 +- .../page-app-dashboard-spec-parity.test.ts | 15 +- .../page-breadcrumbs-refusal-8871.test.ts | 291 ++++++++++++++++++ packages/types/src/layout.ts | 30 ++ packages/types/src/zod/layout.zod.ts | 81 ++++- 7 files changed, 542 insertions(+), 21 deletions(-) create mode 100644 .changeset/8871-page-node-refuses-breadcrumbs.md create mode 100644 packages/types/src/__tests__/page-breadcrumbs-refusal-8871.test.ts diff --git a/.changeset/8871-page-node-refuses-breadcrumbs.md b/.changeset/8871-page-node-refuses-breadcrumbs.md new file mode 100644 index 0000000000..4c7b3a044c --- /dev/null +++ b/.changeset/8871-page-node-refuses-breadcrumbs.md @@ -0,0 +1,77 @@ +--- +'@object-ui/types': patch +--- + +Refuse `breadcrumbs` by name on the `page` node (objectui#8871, ADR-0049 enforce-or-remove). + +**Accept-set change, deliberately.** A `page` document carrying `breadcrumbs` used to parse +GREEN and render nothing. `PageNodeSchema` never declared the key and no renderer ever read +it, so the array survived purely through `BaseSchema`'s `.passthrough()`. It now fails at +parse with the remedy in the message, and the TypeScript twin is `breadcrumbs?: never`, so +`tsc` refuses it at the authoring site before anything runs. + +**Why ADR-0049 and not a fresh ruling.** objectui#7926 refused `actions` on this same node +and, by its own comments, ruled on that key ONLY — its ruling is not borrowed here. What +reaches this key is the standing enforce-or-remove gate, which this repository applies to +this exact face: `packages/types/src/zod/tombstone.zod.ts`'s `retirementTombstone` is +documented as the "ADR-0049 RETIREMENT TOMBSTONE" helper and is internal to these zod +modules, 63 changesets cite the ADR, and `PageNodeSchema` already carried one of its refusal +arms one member up. objectui#7926 left this key parsing on purpose so that retiring it would +be a decision rather than an accident, and wrote a pin saying so; that pin is **flipped**, +not deleted. + +**What was measured.** Zero readers, with a **point-access** probe rather than a bare word: +`\.breadcrumbs` scores 0 tree-wide (exit 1) against 10 files for `\.breadcrumb\b` as the lit +control. The bare word would have lied — it also names Sentry's own unrelated concept +(`app-shell/src/observability/sentry.ts`) and appears in two comments listing UI surfaces +(`core/src/utils/record-title.ts`, `layout/src/NavigationRenderer.tsx`), so a bare probe +reports five readers that do not exist. + +Three author sites, all teaching passages in `content/docs/guide/layout.md`, and that count +**corrects objectui#7926's "1 site"**: its census filtered on `page`-TAGGED objects, and two +of the three passages carry no `type` at all — the Schema API block declared +`breadcrumbs?: Array<{ label, href }>` outright, and Best Practices §2 authored it on an +untagged fragment. No example app, catalog fixture, template or customer document writes the +key, so the refusal strands no authored document in this tree. + +**Migration** — the trail is a NODE, and it already ships: + +```json +{ + "type": "page", + "title": "Acme Corporation", + "body": [ + { + "type": "breadcrumb", + "items": [ + { "label": "Home", "href": "/" }, + { "label": "Customers", "href": "/customers" }, + { "label": "Acme Corporation" } + ] + } + ] +} +``` + +`breadcrumb` is a registered renderer taking the same `{ label, href }` item shape the +retired key carried, plus `separator`, `maxItems` and a per-item `icon`. ⛔ Not the +`page:header` block's `breadcrumb`, which is **singular** and a **boolean** display toggle +rather than a list of links — the guide's own "There is no `breadcrumbs` array" passage is +about that component, and is unchanged. + +**Why a refusal and not a deletion.** There was nothing to delete: the key was never in the +shape, and under `.passthrough()` an undeclared key is not refused, it is KEPT. Declaring the +refusal is what makes it audible, and what converts a write from OUTSIDE this repository — +the half no in-tree census can read — into a named refusal carrying its own remedy. + +**Scope.** One key, by name; the node is **not** strict. Only 2 of the 23 passthrough- +surviving undeclared keys land on a real SDUI `page` node (`actions` and this one); the rest +belong to different declarations that merely spell `type: 'page'`. Strictness would also have +reddened a living pin — `page-app-dashboard-spec-parity.test.ts`, "the component envelope +still passes unknown renderer props through" — which stays green and is re-asserted from this +card's side. + +Marked `patch` on the precedent of objectui#7926, which took `patch` for the identical shape +on this same node one release earlier. (The `ComponentInput.inputType` tombstone, +objectui#5905, took `minor` for the same helper on a different node; the closer precedent is +the one that shares the file, the node and the mechanism.) diff --git a/content/docs/guide/layout.md b/content/docs/guide/layout.md index 963ce79377..de515f406b 100644 --- a/content/docs/guide/layout.md +++ b/content/docs/guide/layout.md @@ -193,6 +193,15 @@ and `button.tsx`, which reads `schema.label`, renders a button with no text. > the `page:header` block's own `actions` — which are **action ids**, not nodes > (see the [PageHeader reference](/docs/layout/page-header)). +> **⛔ `breadcrumbs` on a `page` node is refused by name** (objectui#8871). This page used +> to declare it in the Schema API block below and author it in two passages, and it drew +> **nothing**: no renderer has ever read the key, and `BaseSchema`'s `.passthrough()` kept +> the array rather than refusing it — the same silent-accept shape as `actions`, retired +> under the same ADR-0049 enforce-or-remove gate. The trail is a **node**, not a key: put +> a `breadcrumb` node in `body`, as [Breadcrumbs for Deep Navigation](#2-breadcrumbs-for-deep-navigation) +> shows. ⛔ Not the `page:header` block's `breadcrumb` either — that one is singular and a +> **boolean** display toggle, not a list of links. + ### Schema API @@ -204,11 +213,8 @@ and `button.tsx`, which reads `schema.label`, renders a button with no text. title?: string, // Page title description?: string, // Page description/subtitle icon?: string, // Optional icon - breadcrumbs?: Array<{ // Breadcrumb navigation - label: string, - href?: string - }>, // NO `actions` — refused by name (objectui#7926); put the buttons in `body` + // NO `breadcrumbs` — refused by name (objectui#8871); put a `breadcrumb` node in `body` // Content body: SchemaNode, // Main page content @@ -528,18 +534,22 @@ Omit `sidebar` and the content fills the width under the top bar. ### Detail Page with Actions -Same rule as above: the buttons are nodes in `body`, not an `actions` key on the page. +Same rule as above, and it governs the trail too: the breadcrumb and the buttons are both +**nodes in `body`** — never a `breadcrumbs` or an `actions` key on the page. ```json { "type": "page", "title": "Acme Corporation", - "breadcrumbs": [ - { "label": "Home", "href": "/" }, - { "label": "Customers", "href": "/customers" }, - { "label": "Acme Corporation" } - ], "body": [ + { + "type": "breadcrumb", + "items": [ + { "label": "Home", "href": "/" }, + { "label": "Customers", "href": "/customers" }, + { "label": "Acme Corporation" } + ] + }, { "type": "flex", "justify": "end", @@ -675,19 +685,31 @@ Compose the shell once and let the page JSON change per route: ### 2. Breadcrumbs for Deep Navigation -Add breadcrumbs to help users navigate: +Add a breadcrumb trail to help users navigate. It is a **node in `body`**, not a key on the +page — `breadcrumb`, singular, is the registered renderer: ```json { - "breadcrumbs": [ + "type": "breadcrumb", + "items": [ { "label": "Home", "href": "/" }, { "label": "Products", "href": "/products" }, { "label": "Electronics", "href": "/products/electronics" }, { "label": "Laptops" } - ] + ], + "separator": "/", + "maxItems": 3 } ``` +`separator` defaults to `/`, and `maxItems` collapses the middle of a long trail behind an +ellipsis while keeping the first crumb and the current page. See the +[Breadcrumb reference](/docs/components/data-display/breadcrumb) for the per-key face. + +⛔ Not `"breadcrumbs"` on the `page` node — that key has no reader and is refused by name +(objectui#8871), the same way `actions` is. ⛔ Nor the `page:header` block's `breadcrumb`, +which is a **boolean** display toggle rather than a list of links. + ### 3. Action Buttons at the Top of the Body Place primary actions in the first `body` node, so they sit above the content: diff --git a/packages/types/src/__tests__/page-actions-refusal-7926.test.ts b/packages/types/src/__tests__/page-actions-refusal-7926.test.ts index add0333e2b..e04dd7ce69 100644 --- a/packages/types/src/__tests__/page-actions-refusal-7926.test.ts +++ b/packages/types/src/__tests__/page-actions-refusal-7926.test.ts @@ -38,7 +38,10 @@ * a measured zero. On a real `page` NODE only two undeclared keys survive * passthrough: `actions` (3 sites, all of them the guide passages this card * rewrites) and `breadcrumbs` (1 site, no reader either — its own question, NOT - * ruled on here). Every other undeclared key the same grep found belongs to a + * ruled on here; ruled and refused since by objectui#8871 under ADR-0049, whose + * own census also corrected the "1 site" reading recorded here to THREE — this + * one filtered on `page`-TAGGED objects, and two of the guide's `breadcrumbs` + * passages carry no `type` at all). Every other undeclared key the same grep found belongs to a * DIFFERENT declaration that merely spells `type: 'page'`: nav items, spec `page` * list views, `registerMetadataResource` rows. None of them is parsed by this * schema — and `page-app-dashboard-spec-parity.test.ts` PINS the node staying open @@ -128,16 +131,22 @@ describe('objectui#7926 — the `page` node refuses `actions` (contract half)', // break it: the cheap way to refuse `actions` is `.strict()`, and the census // is the reason that is the wrong shape. expect(PageNodeSchema.safeParse({ type: 'page', someRendererProp: 42 }).success).toBe(true); - // `breadcrumbs` is the OTHER undeclared key the census found on a real page - // node. It has no reader either, and objectui#7926 does NOT rule on it — so - // it must still parse. If a later card retires it, this line is the one that - // says so out loud instead of the change happening by accident here. + // `breadcrumbs` was the OTHER undeclared key the census found on a real page + // node, and this line used to assert it STILL PARSED — objectui#7926 did not + // rule on it, and the assertion existed so that a later retirement would have + // to say so out loud here instead of happening by accident. + // + // objectui#8871 is that retirement (ADR-0049 enforce-or-remove), so the leg is + // FLIPPED rather than deleted: the closure stays asserted instead of becoming + // a silent absence. Its own pins live in `page-breadcrumbs-refusal-8871.test.ts`; + // what this line still owns is the fact that the node did NOT go strict to get + // there — `someRendererProp` above is the same census leg, unmoved. expect( PageNodeSchema.safeParse({ type: 'page', breadcrumbs: [{ label: 'Home', href: '/' }], }).success, - ).toBe(true); + ).toBe(false); }); it('the TypeScript twin refuses it too', () => { diff --git a/packages/types/src/__tests__/page-app-dashboard-spec-parity.test.ts b/packages/types/src/__tests__/page-app-dashboard-spec-parity.test.ts index 6cfdcaec13..a2e877de50 100644 --- a/packages/types/src/__tests__/page-app-dashboard-spec-parity.test.ts +++ b/packages/types/src/__tests__/page-app-dashboard-spec-parity.test.ts @@ -81,7 +81,20 @@ const CASES: Record = { // this shape, and this ledger is the right place for the decision to be // visible. `../__tests__/page-actions-refusal-7926.test.ts` owns the // behaviour; this row owns the fact that the key exists at all. - local: ['title', 'pageType', 'body', 'children', 'actions'], + // + // `breadcrumbs` is the SECOND of that kind and joins for the same reason, + // under the same gate (objectui#8871, ADR-0049 enforce-or-remove). Its + // authority is not objectui#7926's ruling — that one covers `actions` only — + // but the standing enforce-or-remove discipline this package applies to this + // face. Same shape, same helper, same reason to be visible here rather than + // exempted: a local REFUSAL, not a local capability. + // `../__tests__/page-breadcrumbs-refusal-8871.test.ts` owns the behaviour. + // + // ⚠️ These two are the ONLY members of this row that are refusals. Adding a + // third means a third undeclared key was found surviving `.passthrough()` on + // this node — which is the census that decides between one more named refusal + // and finally making the node strict. ⛔ Do not grow this list reflexively. + local: ['title', 'pageType', 'body', 'children', 'actions', 'breadcrumbs'], }, App: { spec: SpecAppSchema, diff --git a/packages/types/src/__tests__/page-breadcrumbs-refusal-8871.test.ts b/packages/types/src/__tests__/page-breadcrumbs-refusal-8871.test.ts new file mode 100644 index 0000000000..dab299ebc6 --- /dev/null +++ b/packages/types/src/__tests__/page-breadcrumbs-refusal-8871.test.ts @@ -0,0 +1,291 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * `breadcrumbs` on a `page` NODE is refused at parse, and + * `content/docs/guide/layout.md` no longer teaches it (objectui#8871, ADR-0049 + * enforce-or-remove). + * + * ## What this is, and what it deliberately is NOT + * + * objectui#7926 refused `actions` on this same node and, in the same breath, + * LEFT `breadcrumbs` parsing on purpose — its census had found both, its ruling + * covered only the first, and `page-actions-refusal-7926.test.ts` carried a pin + * saying so out loud precisely so that a later retirement could not happen by + * accident. This file is that retirement; that pin is FLIPPED, not deleted, so + * the closure stays asserted rather than becoming a silent absence. + * + * ⛔ objectui#7926's maintainer ruling is NOT borrowed. By its own comments it + * covers `actions` and nothing else. What governs this key is the standing + * ADR-0049 enforce-or-remove discipline, which this repository applies to this + * exact face: `zod/tombstone.zod.ts`'s `retirementTombstone` is documented as + * the "ADR-0049 RETIREMENT TOMBSTONE" helper and is internal to these zod + * modules; 63 changesets cite the ADR; and `PageNodeSchema` already carries one + * of its refusal arms one member up. + * + * ## What was measured + * + * ZERO readers, ⛔ measured with a POINT-ACCESS probe rather than a bare word. + * `\.breadcrumbs` scores 0 over the whole tree (exit 1) against 10 files for + * `\.breadcrumb\b` as the lit control. The bare word would have lied: it also + * names Sentry's own unrelated concept (`app-shell/src/observability/sentry.ts`) + * and appears in two comments listing UI surfaces (`core/src/utils/record-title.ts`, + * `layout/src/NavigationRenderer.tsx`) — three prose sites, no reader, no + * declaration, and a bare probe reports "5 readers" that do not exist. + * + * THREE author sites, all teaching passages in one file, and the count CORRECTS + * objectui#7926's "1 site": that census filtered on `page`-TAGGED objects, and + * two of the three passages carry no `type` at all. `the guide teaches the node, + * not the key` below is that correction as an assertion — it reads the guide by + * TEXT rather than by parsed page nodes, which is the blind spot that produced + * the undercount. + * + * ## Why a refusal rather than a reader + * + * The remedy already ships as a node: `breadcrumb` is registered + * (`packages/components/src/renderers/data-display/breadcrumb.tsx`) and its + * `items` take the very `{ label, href }` shape these passages authored. Growing + * a second road to the same trail would mint a rival spelling for a vocabulary + * that already renders. + */ + +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { execFileSync } from 'node:child_process'; +import { resolve } from 'node:path'; +import { PageNodeSchema } from '../zod/layout.zod.js'; +import type { PageNodeSchema as TsPageNodeSchema } from '../layout.js'; + +const REPO_ROOT = resolve(__dirname, '../../../..'); +const GUIDE_PATH = resolve(REPO_ROOT, 'content/docs/guide/layout.md'); + +/** The document the guide used to teach, verbatim in shape. */ +const RETIRED_DOC = { + type: 'page', + title: 'Acme Corporation', + breadcrumbs: [ + { label: 'Home', href: '/' }, + { label: 'Customers', href: '/customers' }, + { label: 'Acme Corporation' }, + ], +}; + +describe('objectui#8871 — the `page` node refuses `breadcrumbs` (contract half)', () => { + it('the key is DECLARED, which is what makes the refusal loud rather than a strip', () => { + // The whole defect was that it was NOT in the shape: an undeclared key on a + // `.passthrough()` object is kept in silence. A refusal has to be declared. + // This is also why a bare DELETION was never on the table — there was + // nothing to delete. + expect(Object.keys(PageNodeSchema.shape)).toContain('breadcrumbs'); + }); + + it('refuses the retired document at parse, at the `breadcrumbs` path', () => { + const r = PageNodeSchema.safeParse(RETIRED_DOC); + expect(r.success).toBe(false); + const issues = r.success ? [] : r.error.issues; + expect(issues.map((i) => i.path.join('.'))).toContain('breadcrumbs'); + }); + + it('the refusal reports `invalid_type` — the tombstone code, not a custom arm', () => { + // `retirementTombstone` is a `z.never` arm: the CODE and PATH are what a bare + // `z.never()` reports, and only the MESSAGE is customised. Its sibling + // `handlerKeyRefusal` reports `custom` instead, and the two are deliberately + // distinguishable — asserting the code is what keeps them from drifting into + // each other. + const r = PageNodeSchema.safeParse(RETIRED_DOC); + const issue = (r.success ? [] : r.error.issues).find((i) => i.path.join('.') === 'breadcrumbs'); + expect(issue).toBeDefined(); + expect(issue!.code).toBe('invalid_type'); + }); + + it('the refusal message carries the remedy, not just a type name', () => { + const r = PageNodeSchema.safeParse(RETIRED_DOC); + const issue = (r.success ? [] : r.error.issues).find((i) => i.path.join('.') === 'breadcrumbs'); + expect(issue).toBeDefined(); + const message = issue!.message; + // Named subject + the node that actually draws + the key that is NOT the + // answer. NOT the whole sentence: pinning prose byte-for-byte turns every + // wording fix red for no gain (AGENTS.md — assert the named subject, not the + // copy). + expect(message).toContain('breadcrumbs'); + expect(message).toContain('breadcrumb'); + expect(message).toContain('body'); + expect(message).toContain('page:header'); + // Zod's own default for a `never` arm says none of this. + expect(message).not.toBe('Invalid input: expected never, received array'); + }); + + it('POSITIVE CONTROL — the same document without `breadcrumbs` parses green', () => { + // Without this leg, a schema that refused EVERY page document would pass the + // assertions above. + const { breadcrumbs, ...withoutBreadcrumbs } = RETIRED_DOC; + expect(breadcrumbs).toBeDefined(); + expect(PageNodeSchema.safeParse(withoutBreadcrumbs).success).toBe(true); + }); + + it('the remedy the message names actually parses — a `breadcrumb` node in `body`', () => { + const r = PageNodeSchema.safeParse({ + type: 'page', + title: 'Acme Corporation', + body: [ + { + type: 'breadcrumb', + items: [ + { label: 'Home', href: '/' }, + { label: 'Customers', href: '/customers' }, + { label: 'Acme Corporation' }, + ], + }, + ], + }); + expect(r.success).toBe(true); + }); + + it('the refusal is TARGETED, not a strict node', () => { + // Restated from the other card's side too (`page-actions-refusal-7926.test.ts`, + // and `page-app-dashboard-spec-parity.test.ts` from the other direction). It + // is restated HERE because this card is now the one that would break it: the + // cheap way to refuse a second key is `.strict()`, and the census is the + // reason that is the wrong shape — only two of the 23 passthrough-surviving + // undeclared keys land on a real `page` node, and strictness would redden a + // living pin. + expect(PageNodeSchema.safeParse({ type: 'page', someRendererProp: 42 }).success).toBe(true); + }); + + it('the TypeScript twin refuses it too', () => { + // `?: never` — the pair `zod-mirror-parity.test.ts` compares. The + // `@ts-expect-error` IS the assertion: it fails to compile (packages/types + // `type-check`) if the key ever becomes assignable again. + const page: TsPageNodeSchema = { + type: 'page', + title: 'Acme Corporation', + // @ts-expect-error `breadcrumbs` is refused by name on the page node (objectui#8871) + breadcrumbs: [{ label: 'Home', href: '/' }], + }; + expect(page.type).toBe('page'); + }); +}); + +describe('objectui#8871 — the guide teaches the node, not the key', () => { + const guide = readFileSync(GUIDE_PATH, 'utf8'); + + it('LIT CONTROL — the guide is readable and still teaches page nodes', () => { + // Three ways the assertions below could report a vacuous zero, so three + // controls: the file is non-empty, it still declares page nodes, and it still + // contains the SINGULAR spelling this card steers authors to. + expect(guide.length).toBeGreaterThan(1000); + expect(guide).toContain('"type": "page"'); + expect(guide).toContain('"type": "breadcrumb"'); + }); + + it('no passage authors or declares `breadcrumbs` any more', () => { + // Read by TEXT, not by parsed page nodes. That is deliberate: objectui#7926's + // census filtered on `type: 'page'` and therefore counted ONE site where there + // were three — the Schema API block declared the member with no `type` in + // scope, and Best Practices §2 authored it on an untagged fragment. A text + // scan is the shape that sees all three. + const offenders = guide + .split('\n') + .map((line, i) => ({ line: i + 1, text: line })) + // The refusal callout and the "not this key" warnings NAME the retired key + // on purpose; what must be gone is the key being WRITTEN or DECLARED. + .filter(({ text }) => /(^|[^`\w])"?breadcrumbs"?\s*[:?]/.test(text)) + .map(({ line, text }) => `${line}: ${text.trim()}`); + expect(offenders).toEqual([]); + }); + + it('LIT CONTROL — that scan can see an authored key when one is there', () => { + // Without this the assertion above passes on a broken regex. `title` is + // authored in the same fences the retired key used to sit in. + const seen = guide + .split('\n') + .filter((text) => /(^|[^`\w])"?title"?\s*[:?]/.test(text)); + expect(seen.length).toBeGreaterThan(3); + }); + + it('the retirement is EXPLAINED where it was taught, not silently dropped', () => { + // A deletion that leaves no trace teaches the next author nothing — they + // rewrite the key from memory. The guide must name the card and the remedy. + expect(guide).toContain('objectui#8871'); + expect(guide).toMatch(/breadcrumb.*node.*`body`|`body`.*breadcrumb.*node/s); + }); +}); + +describe('objectui#8871 — the key is gone from the tree, not just from the guide', () => { + /** + * ⭐ TREE-SCOPED, never file-scoped. A file-scoped absence check only sees the + * files its author thought of; what escapes is exactly the reference he did not + * know about — including one written AFTER the removal. The scan therefore runs + * over every tracked file and subtracts only what cannot be an author site: + * + * - `CHANGELOG.md` / `.changeset/` — the historical record of this very + * retirement, which must keep naming the key; + * - this pin and its sibling `page-actions-refusal-7926.test.ts`, which assert + * the refusal and must therefore write the key to trip it; + * - `content/docs/guide/layout.md`, whose refusal callout names the key to + * steer authors off it (that the key is not AUTHORED there is asserted + * above, by a scan shaped for prose); + * - the two DECLARATION files, `zod/layout.zod.ts` and `layout.ts`. The + * tombstone and its `?: never` twin ARE the refusal — they are the only two + * places the key is SUPPOSED to be spelled, and a scan that reddened on them + * would be asserting the retirement had not landed. That they refuse rather + * than read is not taken on trust here: it is asserted by the parse legs and + * the `@ts-expect-error` leg above, which fail if either face ever accepts + * the key again. + * - `page-app-dashboard-spec-parity.test.ts`, whose `local` ledger row records + * that the key exists on this shape at all. + * + * ⛔ No allow-list FILE: a list that lives on disk outlives the reason for each + * of its rows. The exclusions are spelled here, beside the reason. + */ + const EXCLUDED = [ + ':!*CHANGELOG.md', + ':!.changeset/', + ':!packages/types/src/__tests__/page-breadcrumbs-refusal-8871.test.ts', + ':!packages/types/src/__tests__/page-actions-refusal-7926.test.ts', + ':!packages/types/src/__tests__/page-app-dashboard-spec-parity.test.ts', + ':!content/docs/guide/layout.md', + ':!packages/types/src/zod/layout.zod.ts', + ':!packages/types/src/layout.ts', + ]; + + /** `git grep -n -- . `, exit 1 (no match) normalised to an empty list. */ + const grepTree = (pattern: string): string[] => { + try { + const out = execFileSync('git', ['grep', '-nE', pattern, '--', '.', ...EXCLUDED], { + cwd: REPO_ROOT, + encoding: 'utf8', + }); + return out.split('\n').filter(Boolean); + } catch (e) { + // `git grep` exits 1 for "no matches" — that is the PASS case here, and it + // is distinguished from a real failure (exit >1) rather than swallowed. + const status = (e as { status?: number }).status; + if (status === 1) return []; + throw e; + } + }; + + it('nothing outside the record and the pins AUTHORS `breadcrumbs`', () => { + expect(grepTree('(^|[^a-zA-Z])("breadcrumbs"|\'breadcrumbs\'|breadcrumbs)\\s*[:?]')).toEqual([]); + }); + + it('nothing READS `.breadcrumbs` — the point-access probe, tree-wide', () => { + // The measurement this retirement rests on, kept as a standing assertion so + // a reader cannot be added without this line going red beside it. + expect(grepTree('\\.breadcrumbs')).toEqual([]); + }); + + it('LIT CONTROL — the same probe one letter shorter still finds the LIVE singular key', () => { + // Without this, both assertions above would pass on a broken `git grep` + // invocation, a wrong cwd, or an exclusion list that swallowed the tree. + // `.breadcrumb` (singular) is the live `page:header` toggle and the + // config-panel segment list, and it must keep firing. + expect(grepTree('\\.breadcrumb\\b').length).toBeGreaterThan(3); + }); +}); diff --git a/packages/types/src/layout.ts b/packages/types/src/layout.ts index f455dcb486..1c813f911a 100644 --- a/packages/types/src/layout.ts +++ b/packages/types/src/layout.ts @@ -768,6 +768,36 @@ export interface PageNodeSchema extends BaseSchema { * ⛔ Do not "restore" it as a reader: that is option B, and it was refused. */ actions?: never; + /** + * ⛔ REFUSED BY NAME — `breadcrumbs` is not a member of this node and never + * was (objectui#8871, ADR-0049 enforce-or-remove). + * + * objectui#7926 measured this key on this node and deliberately LEFT it + * parsing, so that retiring it would be a decision rather than an accident; + * this is that decision. Its ruling is not borrowed — it covers `actions` + * only — what reaches this key is the standing enforce-or-remove discipline, + * which this package already applies to this face (see + * `zod/tombstone.zod.ts`'s `retirementTombstone`). + * + * Nothing ever read it: a point-access probe (`\.breadcrumbs`) scores 0 + * across the tree against 10 files for `\.breadcrumb\b` as the lit control. + * ⛔ A bare-word probe is worthless here — the word also names Sentry's own + * unrelated concept and appears in two comments listing UI surfaces, so a + * bare grep reports readers that do not exist. `BaseSchema` is + * `.passthrough()`, so the authored array was never refused, only KEPT. + * + * The remedy is a NODE that already ships: put + * `{ "type": "breadcrumb", "items": [{ "label": "Home", "href": "/" }] }` in + * {@link body}. `breadcrumb` is a registered renderer taking that exact item + * shape, plus `separator`, `maxItems` and a per-item `icon`. + * ⛔ Not the `page:header` block's `breadcrumb`: that one is SINGULAR and a + * BOOLEAN display toggle, not a list of links. + * + * `?: never` is the twin of `layout.zod.ts`'s `retirementTombstone` arm — the + * pair is what `__tests__/zod-mirror-parity.test.ts` compares, and it is what + * makes `tsc` refuse the key at the authoring site before anything runs. + */ + breadcrumbs?: never; /** * Page title */ diff --git a/packages/types/src/zod/layout.zod.ts b/packages/types/src/zod/layout.zod.ts index 77794aaa9b..093f43d931 100644 --- a/packages/types/src/zod/layout.zod.ts +++ b/packages/types/src/zod/layout.zod.ts @@ -474,7 +474,11 @@ const SpecPageFields = specFieldsExcept(stripImportedDefaults(SpecPageSchema).sh * plus one literal carrying a spread), and the undeclared keys that survive * passthrough on a real `page` NODE are exactly `actions` (3 sites, all of them * the `content/docs/guide/layout.md` passages this card rewrites) and - * `breadcrumbs` (1 site, its own question — objectui#7926 does not rule on it). + * `breadcrumbs` (its own question — objectui#7926 does not rule on it; RULED + * and refused separately by objectui#8871, see {@link PAGE_BREADCRUMBS_REFUSAL} + * below, which also corrects the "1 site" reading recorded here — the census + * shape that produced it read `page`-TAGGED objects, and two of the guide's + * three `breadcrumbs` passages carry no `type` at all). * Every other undeclared key the grep found sits on a DIFFERENT declaration * that merely spells `type: 'page'` — nav items (`pageName`, `href`, `badge`, * `labelKey`, `requiredPermissions`), `registerMetadataResource` rows @@ -496,6 +500,80 @@ const PAGE_ACTIONS_REFUSAL = 'block instead, whose own `actions` are ACTION IDS resolved from the object metadata ' + '(objectui#7182), not nodes.'; +/** + * The `breadcrumbs` REFUSAL on the `page` node (objectui#8871) — the key + * objectui#7926 measured on this same node and deliberately left parsing, so + * that retiring it would be a DECISION rather than an accident. This is that + * decision, taken under ADR-0049 enforce-or-remove. + * + * ## Why ADR-0049 governs this, and not a fresh ruling + * + * objectui#7926's maintainer ruling covers `actions` and, by its own comments, + * nothing else — so it is NOT borrowed here. What reaches this key instead is + * the standing enforce-or-remove discipline, which this repository applies to + * this exact face: {@link retirementTombstone} is documented as the "ADR-0049 + * RETIREMENT TOMBSTONE" helper and is internal to these zod modules; 63 + * changesets under `.changeset/` cite the ADR; and `PageNodeSchema` itself + * already carries one of its refusal arms one member up. "Declared-or-authored + * but unread" is the population the gate names, and this key is in it. + * + * ## What was measured (objectui#8871, on this branch's base) + * + * ZERO readers. `git grep "\.breadcrumbs"` over the whole tree returns nothing + * (exit 1); the same shape one letter shorter, `"\.breadcrumb\b"`, returns 10 + * files — the lit control that says the probe runs. ⛔ A BARE-WORD probe is + * useless here and the reason this note spells the shape out: `breadcrumbs` + * is heavily overloaded in this tree, and a bare grep hits Sentry's own + * unrelated breadcrumbs concept (`app-shell/src/observability/sentry.ts`) plus + * two comments listing UI surfaces (`core/src/utils/record-title.ts`, + * `layout/src/NavigationRenderer.tsx`) — three prose sites, no reader, no + * declaration. A bare probe reads "5 readers" and every one of them is false. + * + * THREE author sites, all of them teaching passages in one file — and the + * count corrects objectui#7926's "1 site", which came from a census that read + * `page`-TAGGED objects: `content/docs/guide/layout.md`'s Schema API block + * declared the member outright, the "Detail Page with Actions" fence authored + * it on a real `page` node, and Best Practices §2 authored it on an UNTAGGED + * fragment — and the third is exactly the one a `type: 'page'` filter cannot + * see. No example app, catalog fixture, template or customer document writes + * the key; this refusal therefore strands no authored document in the tree. + * + * ## Why a REFUSAL and not a bare deletion + * + * There is nothing to delete: the key was never in the shape. `BaseSchema` is + * `.passthrough()`, so an undeclared key is not refused, it is KEPT — deleting + * a declaration that does not exist would leave the silent accept exactly as it + * is. Declaring the refusal is what makes it audible, and it is what converts a + * write from OUTSIDE this repository — the half no in-tree census can read — + * into a named refusal carrying its own remedy. + * + * ## Why a REFUSAL and not a reader + * + * The remedy is a NODE, and it already ships. `breadcrumb` is a REGISTERED, + * live component (`ComponentRegistry.register('breadcrumb', …)` in + * `packages/components/src/renderers/data-display/breadcrumb.tsx`) whose + * `BreadcrumbSchema.items` takes the very `{ label, href }` shape these + * passages authored, and which honours `separator`, `maxItems` and per-item + * `icon` besides. Growing a second road to the same trail would mint a rival + * spelling for a vocabulary that already renders — the `wrap` test + * (objectui#5453) run in the opposite direction: there the key was retired + * because it had NO second road to a consumer; here it is retired because the + * road that exists is the one that draws. + * + * ⛔ NOT `.strict()` on the node, for the reason {@link PAGE_ACTIONS_REFUSAL} + * records above: the census found only these two keys on a real `page` node, + * and `page-app-dashboard-spec-parity.test.ts` pins the node staying open to + * unknown renderer props. One key, by name — again. + */ +const PAGE_BREADCRUMBS_REFUSAL = + '`breadcrumbs` is not a key of the `page` node and never was (objectui#8871, ADR-0049 ' + + 'enforce-or-remove): no renderer reads it, so an authored trail drew nothing and rode ' + + '`.passthrough()` through the validator as a silent accept. Author the trail as a NODE ' + + 'in `body` instead — { "type": "breadcrumb", "items": [{ "label": "Home", "href": "/" }] } ' + + '— which is a registered renderer and takes the same item shape, plus `separator` and ' + + '`maxItems`. ⛔ Not the `page:header` block\'s `breadcrumb` either: that one is SINGULAR ' + + 'and a BOOLEAN display toggle, not a list of links.'; + /** * Page Schema — top-level page layout, derived from `@objectstack/spec/ui` * `PageSchema` (see {@link SpecPageFields}). The drift guard is @@ -504,6 +582,7 @@ const PAGE_ACTIONS_REFUSAL = export const PageNodeSchema = BaseSchema.extend(SpecPageFields.shape).extend({ type: z.literal('page'), actions: retirementTombstone(PAGE_ACTIONS_REFUSAL), + breadcrumbs: retirementTombstone(PAGE_BREADCRUMBS_REFUSAL), title: z.string().optional().describe('Page title'), icon: z.string().optional().describe('Page icon (Lucide icon name)'), description: z.string().optional().describe('Page description'), From 24a14c2724642b227a4cb0cc1d9fc5681c194b5d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 10 Sep 2026 03:06:55 +0000 Subject: [PATCH 2/3] docs(types): name the fence-language blind spot correctly, bump breadcrumbs changeset to minor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Contract-review patch round (PR comment 5611939627, objectui#8871), F1-F4, docs/changeset/comments only: - F1: "two of the three carry no `type`" was wrong for one of the two undercounted sites. `layout.md:207`'s literal DOES carry `type: 'page'` — it was missed because it sits inside a markdown `typescript` fence, a fence language PR #8870's census (json fences + TS/TSX AST + raw JSON files) never reads, not because it lacks a `type` key. `:676` was already correctly described (a `json`-fenced, untagged fragment). Corrected in all six locations: the PR body, this changeset, both `layout.zod.ts` docblocks, and both refusal test docblocks. - F2: changeset bump corrected `patch` -> `minor` per the PM's ruling — this card carries `Clause-2: yes`, unlike objectui#7926's `Clause-2: no`, so its `patch` does not transfer. Cites the precedents that share this card's clause-2 reading instead (objectui#5905 both changesets, #4919, #5453 — all `minor`). Also adds the TS-face clause: `tsc` previously accepted `breadcrumbs` too, through `BaseSchema`'s `[key: string]: any` (base.ts:467), before this narrowed both faces together. - F3: `\.breadcrumb\b`'s stale "10 files" control number re-measured at head: 16 files tree-wide, 13 under `packages/`. The pin itself asserts `> 3`, so it was never at risk — only the prose was stale. - F4: confirmed `page-app-dashboard-spec-parity.test.ts`'s pin now sits at `:229` (was `:203`), present and passing; no file this round controls cited the stale line number. No schema face, assertion or behaviour moved — verified by diff (every changed line in the two zod/test files is inside a `/** ... */` comment). `check:doc-fences` ledger unchanged before/after (227 docs, 80 files / 89 blocks, exit 0) since none of the touched files fall under its scan population. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01Jmxdo7bmeqCQHLSfmLVX9w --- .../8871-page-node-refuses-breadcrumbs.md | 47 ++++++++++++------- .../page-actions-refusal-7926.test.ts | 7 ++- .../page-breadcrumbs-refusal-8871.test.ts | 22 +++++---- packages/types/src/zod/layout.zod.ts | 46 +++++++++++------- 4 files changed, 76 insertions(+), 46 deletions(-) diff --git a/.changeset/8871-page-node-refuses-breadcrumbs.md b/.changeset/8871-page-node-refuses-breadcrumbs.md index 4c7b3a044c..90a7431e8d 100644 --- a/.changeset/8871-page-node-refuses-breadcrumbs.md +++ b/.changeset/8871-page-node-refuses-breadcrumbs.md @@ -1,14 +1,17 @@ --- -'@object-ui/types': patch +'@object-ui/types': minor --- Refuse `breadcrumbs` by name on the `page` node (objectui#8871, ADR-0049 enforce-or-remove). **Accept-set change, deliberately.** A `page` document carrying `breadcrumbs` used to parse GREEN and render nothing. `PageNodeSchema` never declared the key and no renderer ever read -it, so the array survived purely through `BaseSchema`'s `.passthrough()`. It now fails at -parse with the remedy in the message, and the TypeScript twin is `breadcrumbs?: never`, so -`tsc` refuses it at the authoring site before anything runs. +it, so the array survived purely through `BaseSchema`'s `.passthrough()`. On the TypeScript +face, `tsc` **previously accepted** it too, through `BaseSchema`'s own `[key: string]: any` +index signature (`packages/types/src/base.ts:467`) — the same open door the zod mirror +walked through at runtime. It now fails at parse with the remedy in the message, and the +TypeScript twin is `breadcrumbs?: never`, so `tsc` refuses it at the authoring site before +anything runs — both faces narrow together. **Why ADR-0049 and not a fresh ruling.** objectui#7926 refused `actions` on this same node and, by its own comments, ruled on that key ONLY — its ruling is not borrowed here. What @@ -21,18 +24,23 @@ be a decision rather than an accident, and wrote a pin saying so; that pin is ** not deleted. **What was measured.** Zero readers, with a **point-access** probe rather than a bare word: -`\.breadcrumbs` scores 0 tree-wide (exit 1) against 10 files for `\.breadcrumb\b` as the lit -control. The bare word would have lied — it also names Sentry's own unrelated concept -(`app-shell/src/observability/sentry.ts`) and appears in two comments listing UI surfaces -(`core/src/utils/record-title.ts`, `layout/src/NavigationRenderer.tsx`), so a bare probe -reports five readers that do not exist. +`\.breadcrumbs` scores 0 tree-wide (exit 1) against `\.breadcrumb\b`'s 16 files tree-wide (13 +under `packages/`) as the lit control. The bare word would have lied — it also names +Sentry's own unrelated concept (`app-shell/src/observability/sentry.ts`) and appears in two +comments listing UI surfaces (`core/src/utils/record-title.ts`, +`layout/src/NavigationRenderer.tsx`), so a bare probe reports five readers that do not exist. Three author sites, all teaching passages in `content/docs/guide/layout.md`, and that count -**corrects objectui#7926's "1 site"**: its census filtered on `page`-TAGGED objects, and two -of the three passages carry no `type` at all — the Schema API block declared -`breadcrumbs?: Array<{ label, href }>` outright, and Best Practices §2 authored it on an -untagged fragment. No example app, catalog fixture, template or customer document writes the -key, so the refusal strands no authored document in this tree. +**corrects objectui#7926's "1 site"**: its census reads every git-tracked JSON file, every +`json` fence in `.md`/`.mdx`, and every TS/TSX object literal via the TypeScript AST (PR +#8870), and it undercounted for **two different reasons**. The Schema API block declared +`breadcrumbs?: Array<{ label, href }>` outright and its literal does carry `type: 'page'`, but +that literal sits inside a markdown `typescript` fence — a fence **language** the census's +`json`-fence reader never visits, so it was never read at all. Best Practices §2 authored it +on a fragment inside a `json` fence the census does read, but that fragment never writes +`type`, so a `page`-tagged filter correctly excluded it. No example app, catalog fixture, +template or customer document writes the key, so the refusal strands no authored document in +this tree. **Migration** — the trail is a NODE, and it already ships: @@ -71,7 +79,10 @@ reddened a living pin — `page-app-dashboard-spec-parity.test.ts`, "the compone still passes unknown renderer props through" — which stays green and is re-asserted from this card's side. -Marked `patch` on the precedent of objectui#7926, which took `patch` for the identical shape -on this same node one release earlier. (The `ComponentInput.inputType` tombstone, -objectui#5905, took `minor` for the same helper on a different node; the closer precedent is -the one that shares the file, the node and the mechanism.) +Marked `minor`. This card carries `Clause-②: yes`, declared on the dispatch claim, and this +changeset's own lead sentence is *"Accept-set change, deliberately"* — the reading AGENTS.md's +版本号策略 gives `minor` for objectui's own breaking changes. objectui#7926's `patch` does not +transfer here: its ruling was **specified** with `Clause-②: no`, a different premise, so +citing it for the level would import that ruling's conclusion without its premise. The +precedents that share this card's `Clause-②: yes` reading — objectui#5905 (both changesets), +objectui#4919, objectui#5453 — all took `minor`. diff --git a/packages/types/src/__tests__/page-actions-refusal-7926.test.ts b/packages/types/src/__tests__/page-actions-refusal-7926.test.ts index e04dd7ce69..10179f5bfd 100644 --- a/packages/types/src/__tests__/page-actions-refusal-7926.test.ts +++ b/packages/types/src/__tests__/page-actions-refusal-7926.test.ts @@ -40,8 +40,11 @@ * rewrites) and `breadcrumbs` (1 site, no reader either — its own question, NOT * ruled on here; ruled and refused since by objectui#8871 under ADR-0049, whose * own census also corrected the "1 site" reading recorded here to THREE — this - * one filtered on `page`-TAGGED objects, and two of the guide's `breadcrumbs` - * passages carry no `type` at all). Every other undeclared key the same grep found belongs to a + * one read `page`-TAGGED objects and missed two of the guide's `breadcrumbs` + * passages for two DIFFERENT reasons: one passage's literal does carry + * `type: 'page'` but sits inside a markdown `typescript` fence the census's + * `json`-fence reader never visits, and the other is a `json`-fenced fragment + * that never writes `type` at all). Every other undeclared key the same grep found belongs to a * DIFFERENT declaration that merely spells `type: 'page'`: nav items, spec `page` * list views, `registerMetadataResource` rows. None of them is parsed by this * schema — and `page-app-dashboard-spec-parity.test.ts` PINS the node staying open diff --git a/packages/types/src/__tests__/page-breadcrumbs-refusal-8871.test.ts b/packages/types/src/__tests__/page-breadcrumbs-refusal-8871.test.ts index dab299ebc6..fb780b0ee9 100644 --- a/packages/types/src/__tests__/page-breadcrumbs-refusal-8871.test.ts +++ b/packages/types/src/__tests__/page-breadcrumbs-refusal-8871.test.ts @@ -31,18 +31,24 @@ * ## What was measured * * ZERO readers, ⛔ measured with a POINT-ACCESS probe rather than a bare word. - * `\.breadcrumbs` scores 0 over the whole tree (exit 1) against 10 files for - * `\.breadcrumb\b` as the lit control. The bare word would have lied: it also - * names Sentry's own unrelated concept (`app-shell/src/observability/sentry.ts`) - * and appears in two comments listing UI surfaces (`core/src/utils/record-title.ts`, + * `\.breadcrumbs` scores 0 over the whole tree (exit 1) against 16 files + * tree-wide (13 under `packages/`) for `\.breadcrumb\b` as the lit control. + * The bare word would have lied: it also names Sentry's own unrelated concept + * (`app-shell/src/observability/sentry.ts`) and appears in two comments + * listing UI surfaces (`core/src/utils/record-title.ts`, * `layout/src/NavigationRenderer.tsx`) — three prose sites, no reader, no * declaration, and a bare probe reports "5 readers" that do not exist. * * THREE author sites, all teaching passages in one file, and the count CORRECTS - * objectui#7926's "1 site": that census filtered on `page`-TAGGED objects, and - * two of the three passages carry no `type` at all. `the guide teaches the node, - * not the key` below is that correction as an assertion — it reads the guide by - * TEXT rather than by parsed page nodes, which is the blind spot that produced + * objectui#7926's "1 site": that census reads every git-tracked JSON file, + * every `json` fence in `.md`/`.mdx`, and every TS/TSX object literal via the + * TypeScript AST (PR #8870), and it missed two of the three passages for two + * DIFFERENT reasons — one passage's literal does carry `type: 'page'` but sits + * inside a markdown `typescript` fence, a fence LANGUAGE the census's + * `json`-fence reader never visits, and the other is a `json`-fenced fragment + * that never writes `type` at all. `the guide teaches the node, not the key` + * below is that correction as an assertion — it reads the guide by TEXT rather + * than by parsed page nodes, which sidesteps both blind spots that produced * the undercount. * * ## Why a refusal rather than a reader diff --git a/packages/types/src/zod/layout.zod.ts b/packages/types/src/zod/layout.zod.ts index 093f43d931..f9e48139a5 100644 --- a/packages/types/src/zod/layout.zod.ts +++ b/packages/types/src/zod/layout.zod.ts @@ -476,9 +476,11 @@ const SpecPageFields = specFieldsExcept(stripImportedDefaults(SpecPageSchema).sh * the `content/docs/guide/layout.md` passages this card rewrites) and * `breadcrumbs` (its own question — objectui#7926 does not rule on it; RULED * and refused separately by objectui#8871, see {@link PAGE_BREADCRUMBS_REFUSAL} - * below, which also corrects the "1 site" reading recorded here — the census - * shape that produced it read `page`-TAGGED objects, and two of the guide's - * three `breadcrumbs` passages carry no `type` at all). + * below, which also corrects the "1 site" reading recorded here to THREE — two + * were missed for two DIFFERENT reasons: one passage's literal does carry + * `type: 'page'` but sits inside a markdown `typescript` fence, a fence + * LANGUAGE the census's `json`-fence reader never visits; the other is a + * `json`-fenced fragment that never writes `type` at all). * Every other undeclared key the grep found sits on a DIFFERENT declaration * that merely spells `type: 'page'` — nav items (`pageName`, `href`, `badge`, * `labelKey`, `requiredPermissions`), `registerMetadataResource` rows @@ -520,23 +522,31 @@ const PAGE_ACTIONS_REFUSAL = * ## What was measured (objectui#8871, on this branch's base) * * ZERO readers. `git grep "\.breadcrumbs"` over the whole tree returns nothing - * (exit 1); the same shape one letter shorter, `"\.breadcrumb\b"`, returns 10 - * files — the lit control that says the probe runs. ⛔ A BARE-WORD probe is - * useless here and the reason this note spells the shape out: `breadcrumbs` - * is heavily overloaded in this tree, and a bare grep hits Sentry's own - * unrelated breadcrumbs concept (`app-shell/src/observability/sentry.ts`) plus - * two comments listing UI surfaces (`core/src/utils/record-title.ts`, - * `layout/src/NavigationRenderer.tsx`) — three prose sites, no reader, no - * declaration. A bare probe reads "5 readers" and every one of them is false. + * (exit 1); the same shape one letter shorter, `"\.breadcrumb\b"`, returns 16 + * files tree-wide (13 under `packages/`) — the lit control that says the + * probe runs. ⛔ A BARE-WORD probe is useless here and the reason this note + * spells the shape out: `breadcrumbs` is heavily overloaded in this tree, and + * a bare grep hits Sentry's own unrelated breadcrumbs concept + * (`app-shell/src/observability/sentry.ts`) plus two comments listing UI + * surfaces (`core/src/utils/record-title.ts`, `layout/src/NavigationRenderer.tsx`) + * — three prose sites, no reader, no declaration. A bare probe reads "5 + * readers" and every one of them is false. * * THREE author sites, all of them teaching passages in one file — and the - * count corrects objectui#7926's "1 site", which came from a census that read - * `page`-TAGGED objects: `content/docs/guide/layout.md`'s Schema API block - * declared the member outright, the "Detail Page with Actions" fence authored - * it on a real `page` node, and Best Practices §2 authored it on an UNTAGGED - * fragment — and the third is exactly the one a `type: 'page'` filter cannot - * see. No example app, catalog fixture, template or customer document writes - * the key; this refusal therefore strands no authored document in the tree. + * count corrects objectui#7926's "1 site", which came from a census that + * reads every git-tracked JSON file, every `json` fence in `.md`/`.mdx`, and + * every TS/TSX object literal via the TypeScript AST (PR #8870). It + * undercounted for TWO DIFFERENT reasons: the Schema API block declared the + * member outright and its literal does carry `type: 'page'`, but that literal + * sits inside a markdown `typescript` fence — a fence LANGUAGE the census's + * `json`-fence reader never visits, so it was never read at all. Best + * Practices §2 authored it on a fragment inside a `json` fence the census + * DOES read, but that fragment never writes `type`, so a `page`-TAGGED filter + * correctly excluded it. The "Detail Page with Actions" fence is the one site + * both instruments would see — a `json` fence, tagged `type: 'page'` — and is + * the "1 site" the earlier census counted. No example app, catalog fixture, + * template or customer document writes the key; this refusal therefore + * strands no authored document in the tree. * * ## Why a REFUSAL and not a bare deletion * From 073026a4fc847fccb0c291275cb428c79ea42bb0 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 10 Sep 2026 03:47:06 +0000 Subject: [PATCH 3/3] docs(types): state the base/head frame at all four probe sites, name the census blind spot correctly (objectui#8871) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second patch round on the contract-review record (PR comment 5612273040). Comments, docblocks and changeset prose only: over layout.ts, zod/layout.zod.ts and page-breadcrumbs-refusal-8871.test.ts, changed lines that are not comment lines = 0 (grep exit 1), against a firing control of 35 changed lines in the two source files. F1 - the seventh site. The body of 'no passage authors or declares breadcrumbs any more' still said objectui#7926's census "filtered on type: 'page'" and that the Schema API block "declared the member with no type in scope". Re-verified on the base: guide/layout.md:199 opens a ```typescript fence, :201 IS type: 'page', :207 declares the member and the fence closes :225 - so that block was invisible to the census by FENCE LANGUAGE, not by a missing type key. :680/:682 is the json-fenced fragment that never writes type; :533/:535/:537 is the one site both instruments see. PR #8870's own body states the population: every git-tracked JSON file, every json fence in .md/.mdx, every TS/TSX object literal via the TypeScript AST. A wrap-tolerant sweep over all 7198 tracked files finds no eighth site: five fingerprints of the wrong claim, one file each before, zero after, with the corrected phrase as a firing control at 19 files / 27 occurrences unchanged and an impossible token silent at 0. F3 - the frame. Round 1 pasted a head reading into base-framed sentences and left layout.ts at the old number, so the twin faces disagreed. Re-derived, exit codes captured before any pipe: on base 93127bd6f, \.breadcrumbs is exit 1 tree-wide against 12 files (10 under packages/) for \.breadcrumb\b; at head those read 16 and 13 and \.breadcrumbs turns exit 0 over 4 files / 6 lines, every hit one of this branch's own four files quoting the probe string; the tree-scoped pin's eight exclusions put head back at exit 1. All four sites now state the frame in one sentence. No assertion changed. F2 - the precedent list. Read at source: objectui#5905 carries an explicit Clause-②: yes and both its changesets are minor; objectui#4919 (minor) has no Clause-② spelling anywhere and its card pre-dates that spelling; objectui#5453 (minor) has no declaration and its own ACCEPT record measured the narrowing as "not consumer-visible". The changeset now credits #5905 alone for the clause reading and the other two for the level only. The minor level does not move. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Jmxdo7bmeqCQHLSfmLVX9w --- .../8871-page-node-refuses-breadcrumbs.md | 18 +++++++--- .../page-breadcrumbs-refusal-8871.test.ts | 33 ++++++++++++++----- packages/types/src/layout.ts | 11 +++++-- packages/types/src/zod/layout.zod.ts | 20 +++++++---- 4 files changed, 61 insertions(+), 21 deletions(-) diff --git a/.changeset/8871-page-node-refuses-breadcrumbs.md b/.changeset/8871-page-node-refuses-breadcrumbs.md index 90a7431e8d..a0baf1ece0 100644 --- a/.changeset/8871-page-node-refuses-breadcrumbs.md +++ b/.changeset/8871-page-node-refuses-breadcrumbs.md @@ -23,9 +23,13 @@ arms one member up. objectui#7926 left this key parsing on purpose so that retir be a decision rather than an accident, and wrote a pin saying so; that pin is **flipped**, not deleted. -**What was measured.** Zero readers, with a **point-access** probe rather than a bare word: -`\.breadcrumbs` scores 0 tree-wide (exit 1) against `\.breadcrumb\b`'s 16 files tree-wide (13 -under `packages/`) as the lit control. The bare word would have lied — it also names +**What was measured, on this branch's base `93127bd6f`.** Zero readers, with a **point-access** +probe rather than a bare word: on that base `\.breadcrumbs` scores 0 tree-wide (exit 1) against +`\.breadcrumb\b`'s **12** files tree-wide (**10** under `packages/`) as the lit control. At head +the same two probes read 16 and 13 and `\.breadcrumbs` is exit 0 over 4 files — every hit one of +this branch's own four files (this changeset, the refusal pin, `layout.ts`, `zod/layout.zod.ts`) +quoting the probe string, and the pin's own exclusions put head back at exit 1. The base reading +is the measurement; the head reading is this branch's echo of it. The bare word would have lied — it also names Sentry's own unrelated concept (`app-shell/src/observability/sentry.ts`) and appears in two comments listing UI surfaces (`core/src/utils/record-title.ts`, `layout/src/NavigationRenderer.tsx`), so a bare probe reports five readers that do not exist. @@ -84,5 +88,9 @@ changeset's own lead sentence is *"Accept-set change, deliberately"* — the rea 版本号策略 gives `minor` for objectui's own breaking changes. objectui#7926's `patch` does not transfer here: its ruling was **specified** with `Clause-②: no`, a different premise, so citing it for the level would import that ruling's conclusion without its premise. The -precedents that share this card's `Clause-②: yes` reading — objectui#5905 (both changesets), -objectui#4919, objectui#5453 — all took `minor`. +precedent that literally shares this card's `Clause-②: yes` reading is **objectui#5905**, where +the declaration is explicit on the card and both changesets took `minor`. Two further +retirements of the same shape also took `minor` but do **not** carry the declaration, so they +corroborate the level and ⛔ not the clause reading: objectui#4919 (a published TS type removed, +but the card pre-dates the `Clause-②:` spelling entirely) and objectui#5453 (no Clause-② +declaration, and its own ACCEPT record measured that narrowing as *"not consumer-visible"*). diff --git a/packages/types/src/__tests__/page-breadcrumbs-refusal-8871.test.ts b/packages/types/src/__tests__/page-breadcrumbs-refusal-8871.test.ts index fb780b0ee9..e74b87ec07 100644 --- a/packages/types/src/__tests__/page-breadcrumbs-refusal-8871.test.ts +++ b/packages/types/src/__tests__/page-breadcrumbs-refusal-8871.test.ts @@ -28,11 +28,19 @@ * modules; 63 changesets cite the ADR; and `PageNodeSchema` already carries one * of its refusal arms one member up. * - * ## What was measured + * ## What was measured — the frame is BASE `93127bd6f`, stated out loud * * ZERO readers, ⛔ measured with a POINT-ACCESS probe rather than a bare word. - * `\.breadcrumbs` scores 0 over the whole tree (exit 1) against 16 files - * tree-wide (13 under `packages/`) for `\.breadcrumb\b` as the lit control. + * On the base, `\.breadcrumbs` scores 0 over the whole tree (exit 1) against 12 + * files tree-wide (10 under `packages/`) for `\.breadcrumb\b` as the lit + * control. At HEAD those two become 16 and 13, and `\.breadcrumbs` itself turns + * exit 0 over 4 files / 6 lines — every one of those hits is one of THIS + * branch's own four files (the changeset, this pin, `layout.ts`, + * `zod/layout.zod.ts`) matching only because it QUOTES the probe string, and + * the eight exclusions spelled out at the tree-scoped pin below take HEAD back + * to exit 1. ⛔ Do not mix the two frames: the base numbers are the + * measurement, the head numbers are this branch's own echo of it, and no + * single tree satisfies a sentence that pairs one with the other. * The bare word would have lied: it also names Sentry's own unrelated concept * (`app-shell/src/observability/sentry.ts`) and appears in two comments * listing UI surfaces (`core/src/utils/record-title.ts`, @@ -190,11 +198,20 @@ describe('objectui#8871 — the guide teaches the node, not the key', () => { }); it('no passage authors or declares `breadcrumbs` any more', () => { - // Read by TEXT, not by parsed page nodes. That is deliberate: objectui#7926's - // census filtered on `type: 'page'` and therefore counted ONE site where there - // were three — the Schema API block declared the member with no `type` in - // scope, and Best Practices §2 authored it on an untagged fragment. A text - // scan is the shape that sees all three. + // Read by TEXT, not by parsed page nodes. That is deliberate, and the reason + // is a FENCE-LANGUAGE blind spot — ⛔ not a missing `type` key. objectui#7926's + // census (PR #8870) reads, in that PR's own words, "every git-tracked JSON + // file, every `json` fence in `.md`/`.mdx`, and every TS/TSX object literal + // (TypeScript AST)". It reported ONE site where there are three, for TWO + // DIFFERENT reasons. The Schema API block's literal DOES carry `type: 'page'` + // (guide `:201`, inside the fence opened at `:199` and closed at `:225`) — + // but that fence is tagged `typescript`, a fence LANGUAGE that population + // never visits, so the block was never read at all. Best Practices §2 does + // sit in a `json` fence the census reads (`:680`), but that fragment never + // writes `type` (`:682` is the bare `"breadcrumbs": [`), so a `page`-tagged + // filter correctly excluded it. Only the "Detail Page with Actions" fence + // (`:533`/`:535`/`:537`) is visible to both instruments. A text scan has + // neither blind spot, which is why it is the shape that sees all three. const offenders = guide .split('\n') .map((line, i) => ({ line: i + 1, text: line })) diff --git a/packages/types/src/layout.ts b/packages/types/src/layout.ts index 1c813f911a..8fb7098404 100644 --- a/packages/types/src/layout.ts +++ b/packages/types/src/layout.ts @@ -779,8 +779,15 @@ export interface PageNodeSchema extends BaseSchema { * which this package already applies to this face (see * `zod/tombstone.zod.ts`'s `retirementTombstone`). * - * Nothing ever read it: a point-access probe (`\.breadcrumbs`) scores 0 - * across the tree against 10 files for `\.breadcrumb\b` as the lit control. + * Nothing ever read it, and the frame that number belongs to is stated so + * this docblock and `zod/layout.zod.ts`'s twin cannot drift apart on it. On + * this branch's BASE (`93127bd6f`) a point-access probe (`\.breadcrumbs`) + * scores 0 across the tree (exit 1), against 12 files tree-wide — 10 of them + * under `packages/` — for `\.breadcrumb\b` as the lit control. At HEAD those + * rise to 16 and 13 and `\.breadcrumbs` turns exit 0 over 4 files, every one + * of them a file of THIS branch quoting the probe string (the changeset, the + * refusal pin, this file and `zod/layout.zod.ts`); the tree-scoped pin's own + * exclusions take HEAD back to exit 1. * ⛔ A bare-word probe is worthless here — the word also names Sentry's own * unrelated concept and appears in two comments listing UI surfaces, so a * bare grep reports readers that do not exist. `BaseSchema` is diff --git a/packages/types/src/zod/layout.zod.ts b/packages/types/src/zod/layout.zod.ts index f9e48139a5..9bc5f3508c 100644 --- a/packages/types/src/zod/layout.zod.ts +++ b/packages/types/src/zod/layout.zod.ts @@ -519,12 +519,20 @@ const PAGE_ACTIONS_REFUSAL = * already carries one of its refusal arms one member up. "Declared-or-authored * but unread" is the population the gate names, and this key is in it. * - * ## What was measured (objectui#8871, on this branch's base) - * - * ZERO readers. `git grep "\.breadcrumbs"` over the whole tree returns nothing - * (exit 1); the same shape one letter shorter, `"\.breadcrumb\b"`, returns 16 - * files tree-wide (13 under `packages/`) — the lit control that says the - * probe runs. ⛔ A BARE-WORD probe is useless here and the reason this note + * ## What was measured (objectui#8871, on this branch's BASE `93127bd6f`) + * + * ZERO readers — and the FRAME is load-bearing: every number below is a + * reading on the BASE unless it says HEAD. On the base, + * `git grep -E "\.breadcrumbs"` over the whole tree returns nothing (exit 1); + * the same shape one letter shorter, `"\.breadcrumb\b"`, returns 12 files + * tree-wide (10 under `packages/`) — the lit control that says the probe + * runs. At HEAD those two read 16 and 13, and `\.breadcrumbs` itself turns + * exit 0 over 4 files / 6 lines, because THIS branch's own four files — the + * changeset, `page-breadcrumbs-refusal-8871.test.ts`, `layout.ts` and this one + * — QUOTE the probe string; subtract the eight exclusions the tree-scoped pin + * spells out and HEAD is back at exit 1. `layout.ts`'s twin docblock states the + * same frame, and the two must not be allowed to drift apart on it again. + * ⛔ A BARE-WORD probe is useless here and the reason this note * spells the shape out: `breadcrumbs` is heavily overloaded in this tree, and * a bare grep hits Sentry's own unrelated breadcrumbs concept * (`app-shell/src/observability/sentry.ts`) plus two comments listing UI