From fc1ab897c6281a3670da8d0dc6e049fb46ca6f5e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 10 Sep 2026 21:57:03 +0000 Subject: [PATCH 1/4] test(types): pin every AnyComponentSchema arm to a named zod export (objectui#8784) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit objectui#7917 found two arms with no named export on the `./zod` barrel, so `breadcrumb` and `object-tree` could not be validated on their own. PR #8777 repaired both and nothing kept it repaired. Each blindness was re-measured on this base rather than inherited: `zod-mirror-parity.test.ts` builds its export population with `/^export const NAME/m`, which matches a DECLARATION and can never match `export { NAME } from './x.js'` — so it is structurally blind to a missing re-export even though `index.zod.ts` is one of the files it reads; no test pinned a list or a count on the barrel; and `scripts/measure-strict-authoring-face.mjs`, the only script that computes the census, is a self-described MEASUREMENT THROWAWAY wired into no `package.json` script and no workflow. The pin derives the arm list on every run — flatten the union recursively through nested unions and `z.lazy`, then match each leaf arm to a named export BY IDENTITY. Both properties are controls in the file rather than claims in a comment: a walk that stops at the top-level sub-unions scores exactly `options.length` arms, and a name-based match is satisfied by `export { NavigationSchema as BreadcrumbSchema }`, the parent sub-union under the arm's name, which passes every behavioural leg of #8777's pin. No count is hard-coded; the only bound asserted against the real union is a floor read off that union's own top-level option count. Red on arrival, ledgered rather than repaired: `507b61bf7` (PR #8763, objectui#8499) armed four arms in `layout.zod.ts` and `form.zod.ts` and touched no barrel, 48 minutes after objectui#8784 was filed on a census reading 107 arms / 107 named / 0 unnamed. This base reads 111 arms / 154 type literals / 106 named / 5 unnamed. Four rows point at objectui#9067, which holds the ruling, because exporting them mints new names on a published surface; the fifth, `RetiredKanbanNodeSchema`, is a refusal arm the barrel already declares deliberately internal. A stale-row leg deletes any row the moment its arm is exported or stops existing. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Jmxdo7bmeqCQHLSfmLVX9w --- .changeset/8784-arm-named-export-pin.md | 34 ++ .../__tests__/arm-named-export-8784.test.ts | 416 ++++++++++++++++++ 2 files changed, 450 insertions(+) create mode 100644 .changeset/8784-arm-named-export-pin.md create mode 100644 packages/types/src/__tests__/arm-named-export-8784.test.ts diff --git a/.changeset/8784-arm-named-export-pin.md b/.changeset/8784-arm-named-export-pin.md new file mode 100644 index 0000000000..9e94ac267f --- /dev/null +++ b/.changeset/8784-arm-named-export-pin.md @@ -0,0 +1,34 @@ +--- +--- + +Test-only: pin that every `AnyComponentSchema` arm is a NAMED export of `@object-ui/types/zod`, +or is ledgered with the reason it is not (objectui#8784). No published source changes, so no +package is released by this change. + +objectui#7917 found two arms with no named export on the barrel — `breadcrumb` and +`object-tree` were maintained, were applied by the union, and could not be validated on their +own. PR #8777 repaired both and nothing kept it repaired: `zod-mirror-parity.test.ts` builds +its export population with `/^export const NAME/m`, which matches a DECLARATION and can never +match `export { NAME } from './x.js'`, so it is structurally blind to a missing re-export; no +test pinned a list or a count on `index.zod.ts`; and the one script that does compute the +census, `scripts/measure-strict-authoring-face.mjs`, is a self-described MEASUREMENT THROWAWAY +wired into no `package.json` script and no workflow. + +The pin derives the arm list on every run — flatten the union recursively through nested +unions and `z.lazy`, then match each leaf arm to a named export **by identity**. Both +properties are load-bearing and both are controls in the file rather than claims in a comment: +a walk that stops at the top-level sub-unions scores exactly `options.length` arms, and a +name-based match is satisfied by `export { NavigationSchema as BreadcrumbSchema }` — the +parent sub-union under the arm's name, which passes every behavioural leg of #8777's pin. No +count is hard-coded; the only bound asserted against the real union is a floor read off that +union's own top-level option count. + +Red on arrival, reported rather than declared away: `507b61bf7` (PR #8763, objectui#8499) +armed `SemanticElementSchema`, `HtmlElementSchema`, `InputShorthandSchema` and +`UiCalendarSchema` in `layout.zod.ts` and `form.zod.ts` and touched no barrel — 48 minutes +after objectui#8784 was filed on a census reading 107 arms / 107 named / 0 unnamed. The +current reading is 111 arms / 154 `type` literals / 106 named / 5 unnamed. Four of the five +carry ledger rows against objectui#9067, which holds the ruling, because exporting them mints +new names on a published surface. The fifth, `RetiredKanbanNodeSchema`, is a refusal arm the +barrel already declares deliberately internal (objectui#8802). A stale-row leg deletes any +ledger row the moment its arm is exported or stops existing. diff --git a/packages/types/src/__tests__/arm-named-export-8784.test.ts b/packages/types/src/__tests__/arm-named-export-8784.test.ts new file mode 100644 index 0000000000..64307d61a6 --- /dev/null +++ b/packages/types/src/__tests__/arm-named-export-8784.test.ts @@ -0,0 +1,416 @@ +/** + * 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. + */ + +/** + * Every `AnyComponentSchema` arm is a NAMED export of `./zod` — or it is + * ledgered below with the reason it is not (objectui#8784). + * + * ## The defect this closes + * + * objectui#7917 found that 2 of the union's arms had no named export from + * `@object-ui/types/zod`, so `breadcrumb` and `object-tree` could not be + * validated on their own: the schemas existed, were maintained, and were + * applied by the union, while no consumer could name them. PR #8777 repaired + * both. Nothing kept it repaired. + * + * `./zod` is this package's ONLY zod subpath (`packages/types/package.json` + * maps it to `dist/zod/index.zod.js`) and `index.zod.ts` carries no star + * re-export — every name on that barrel is written out by hand. So an arm added + * to a category module and not to the barrel is declared-and-unreachable, and + * BOTH of the ways that has happened were silent: + * + * - `BreadcrumbSchema` was on the barrel at creation and was dropped by + * `d908a82f4`, a "Fix schema duplication" dedup, as COLLATERAL — no + * decision, no diagnostic — and stayed gone for 41 commits while this + * directory's `README.md` kept advertising it; + * - `ObjectTreeSchema` was never added at all: PR #1892's file list omits the + * barrel. + * + * ⚠️ Neither failure mode was hypothetical when this pin was written, and one + * of them was LIVE: `507b61bf7` (PR #8763, objectui#8499) armed four new arms + * in `layout.zod.ts` and `form.zod.ts` and touched no barrel, 48 minutes after + * objectui#8784 was filed on a census that read 107 arms / 107 named / 0 + * unnamed. Those four are the `ABSENT_PENDING_RULING` rows below. + * + * ## Why the existing checks could not see it + * + * - `zod-mirror-parity.test.ts` reads its export population with + * `/^export const NAME/m` over `src/zod/*.zod.ts`. That pattern matches a + * DECLARATION; a barrel line is `export { NAME } from './x.js'`, which it + * cannot match — so the census is structurally blind to a missing + * re-export even though `index.zod.ts` is one of the files it reads. + * - No test pinned an export list or a count on `index.zod.ts`. The per-name + * presence assertions that exist (`crud-retirement-5373.test.ts`, + * `block-family-retired-4895.test.ts`, `breadcrumb-object-tree-nameable-7917.test.ts`) + * each pin the names their own card was about, which is one arm at a time. + * - `scripts/measure-strict-authoring-face.mjs` does compute it, and is + * self-described `MEASUREMENT THROWAWAY`: no `package.json` script and no + * workflow runs it. + * + * ## The two properties this pin is built on + * + * 1. ⭐ **It reads the BARREL, not the modules.** The population is + * `Object.entries` of the `../zod/index.zod.js` module namespace — the + * runtime export surface itself, so a re-export counts and a declaration + * that never reached the barrel does not. A pin that read `^export const` + * from the same files as the parity census would have rebuilt the blind spot + * under a new filename. (The category modules ARE imported below, but only + * to turn a failing arm into a name a reader can act on; they never decide + * the verdict. `namesOn` is given ONE namespace and the real reading passes + * it the barrel.) + * + * 2. ⭐ **It matches by IDENTITY, not by name.** Adversarial probing of #8777's + * pin found that `export { NavigationSchema as BreadcrumbSchema }` — the + * PARENT sub-union re-exported under the arm's name — satisfies a name-based + * check and every behavioural leg of that pin. Only identity catches it, and + * the arm's identity chain deliberately starts at the union OPTION, so no + * containing union is ever a candidate. `the parent union under the arm's + * name is still unnamed` below is that property as a control. + * + * ⛔ The arm list is DERIVED on every run and no count is hard-coded: arms land + * (four did, mid-card), and a number stated in a comment that nothing checks is + * objectui#8606. The only bound asserted against the real union is a floor read + * off that union's own top-level option count, which is what proves the walk + * recursed rather than stopping at the 13 sub-unions — the reviewer's first + * census stopped there and reported a plausible, fully-populated, entirely + * wrong "13 arms / 2 literals". + */ + +import { describe, it, expect } from 'vitest'; +import { z } from 'zod'; + +// The population under test: the published barrel's own module namespace. +import * as barrel from '../zod/index.zod.js'; + +// Diagnostics only — see property 1 above. These give a failing arm a name. +import * as appZod from '../zod/app.zod.js'; +import * as complexZod from '../zod/complex.zod.js'; +import * as crudZod from '../zod/crud.zod.js'; +import * as dataDisplayZod from '../zod/data-display.zod.js'; +import * as disclosureZod from '../zod/disclosure.zod.js'; +import * as feedbackZod from '../zod/feedback.zod.js'; +import * as formZod from '../zod/form.zod.js'; +import * as layoutZod from '../zod/layout.zod.js'; +import * as navigationZod from '../zod/navigation.zod.js'; +import * as objectqlZod from '../zod/objectql.zod.js'; +import * as overlayZod from '../zod/overlay.zod.js'; +import * as reportsZod from '../zod/reports.zod.js'; +import * as viewsZod from '../zod/views.zod.js'; + +/* ── The ledger ────────────────────────────────────────────────────────────── + * + * ⛔ NOT a claim that every arm must be exported — only that the answer is + * DECLARED rather than accidental. A row is keyed by ONE `type` literal of the + * arm, because an arm with no barrel export has, by definition, no barrel name + * to key on; `every ledger row still names an arm` below fails if that literal + * stops existing, so the key cannot rot into a comment. + */ + +/** Arms whose absence from the barrel is a decision someone took, with its reason. */ +const ABSENT_BY_DECISION: Readonly> = { + kanban: + 'complex.zod.ts#RetiredKanbanNodeSchema — an ADR-0049 refusal arm rather than a mirror. ' + + 'The decision is written on the barrel itself, beside the `complex.zod.js` block: ' + + '"it is deliberately NOT exported — nothing outside this package parses against a ' + + 'refusal arm" (objectui#8802, maintainer ruling 2026-09-09).', +}; + +/** + * Arms absent from the barrel with the answer still OWED. + * + * ⚠️ These are NOT exemptions. All four were armed by `507b61bf7` + * (objectui#8499) without a barrel line, all four are registered as first-class + * mirror pairs by `zod-mirror-parity.test.ts` with a TypeScript counterpart, + * and none carries any of this directory's "Deliberately NOT exported" markers + * — the same evidence objectui#7917 used to conclude its two omissions were + * accidental. Exporting them mints new names on a published surface, so the + * reading was reported and the ruling is objectui#9067's. The rows exist so the + * state is visible and so a NEW omission cannot hide behind them. + */ +const ABSENT_PENDING_RULING: Readonly> = { + aside: 'layout.zod.ts#SemanticElementSchema — ruling owed, objectui#9067', + h1: 'layout.zod.ts#HtmlElementSchema — ruling owed, objectui#9067', + email: 'form.zod.ts#InputShorthandSchema — ruling owed, objectui#9067', + 'ui:calendar': 'form.zod.ts#UiCalendarSchema — ruling owed, objectui#9067', +}; + +const LEDGER: Readonly> = { ...ABSENT_BY_DECISION, ...ABSENT_PENDING_RULING }; + +/* ── The census ──────────────────────────────────────────────────────────────*/ + +interface ZodDef { + readonly type: string; + readonly options?: readonly unknown[]; + readonly getter?: () => unknown; + readonly shape?: Readonly>; + readonly values?: readonly unknown[]; + readonly entries?: Readonly>; +} + +/** Zod 4 keeps a schema's definition on `_zod`; nothing public exposes it. */ +function defOf(schema: unknown): ZodDef | undefined { + return (schema as { _zod?: { def?: ZodDef } } | null | undefined)?._zod?.def; +} + +/** The `type` literals a member declares, or `[]` when it declares none. */ +function typeLiterals(schema: unknown): string[] { + const def = defOf(schema); + if (!def) return []; + if (def.type === 'literal') return (def.values ?? []).filter((v): v is string => typeof v === 'string'); + if (def.type === 'enum') return Object.values(def.entries ?? {}).filter((v): v is string => typeof v === 'string'); + if (def.type === 'union') return (def.options ?? []).flatMap((option) => typeLiterals(option)); + return []; +} + +interface Arm { + /** + * Every schema object this arm is reachable AS: the union option, then each + * `z.lazy` unwrapped along the way. ⛔ A containing union is never a member — + * that is what makes a name on the parent union not a name on the arm. + */ + readonly chain: readonly unknown[]; + readonly literals: readonly string[]; +} + +interface Census { + readonly arms: readonly Arm[]; + /** Members that are neither union, `z.lazy` nor object — a finding, ⛔ never a silent skip. */ + readonly unresolved: readonly string[]; +} + +/** + * Flatten a component union to its leaf object arms, recursively, through + * nested unions and `z.lazy`. + */ +function censusOf(union: unknown): Census { + const arms: Arm[] = []; + const unresolved: string[] = []; + const seen = new Set(); + + const walk = (schema: unknown, chain: readonly unknown[]): void => { + const def = defOf(schema); + if (!def || seen.has(schema)) return; + seen.add(schema); + if (def.type === 'union') { + // A union is a CONTAINER, so each option starts its own chain. + for (const option of def.options ?? []) walk(option, [option]); + return; + } + if (def.type === 'lazy') { + const inner = def.getter?.(); + if (inner === undefined) { + unresolved.push('lazy (getter returned nothing)'); + return; + } + walk(inner, [...chain, inner]); + return; + } + if (def.type === 'object') { + arms.push({ chain, literals: typeLiterals(def.shape?.type) }); + return; + } + unresolved.push(def.type); + }; + + walk(union, []); + return { arms, unresolved }; +} + +/** Named exports of `namespace` whose VALUE is one of the schemas this arm is reachable as. */ +function namesOn(namespace: Readonly>, arm: Arm): string[] { + const reachableAs = new Set(arm.chain); + return Object.entries(namespace) + .filter(([, value]) => reachableAs.has(value)) + .map(([name]) => name); +} + +/* ── The real reading ────────────────────────────────────────────────────────*/ + +const CENSUS = censusOf(barrel.AnyComponentSchema); +const BARREL = barrel as unknown as Readonly>; + +/** `file#name` for every schema declared by a category module — diagnostics only. */ +const MODULE_NAMES = new Map(); +for (const [file, namespace] of Object.entries({ + 'app.zod.ts': appZod, + 'complex.zod.ts': complexZod, + 'crud.zod.ts': crudZod, + 'data-display.zod.ts': dataDisplayZod, + 'disclosure.zod.ts': disclosureZod, + 'feedback.zod.ts': feedbackZod, + 'form.zod.ts': formZod, + 'layout.zod.ts': layoutZod, + 'navigation.zod.ts': navigationZod, + 'objectql.zod.ts': objectqlZod, + 'overlay.zod.ts': overlayZod, + 'reports.zod.ts': reportsZod, + 'views.zod.ts': viewsZod, +} as Readonly>>>)) { + for (const [name, value] of Object.entries(namespace)) { + if (value !== null && typeof value === 'object' && !MODULE_NAMES.has(value)) { + MODULE_NAMES.set(value, `${file}#${name}`); + } + } +} + +/** What a failure prints: the declaring name, then the literals it owns. */ +function describeArm(arm: Arm): string { + const declared = arm.chain.map((schema) => MODULE_NAMES.get(schema)).find((name) => name !== undefined); + const shown = arm.literals.slice(0, 6).join(', '); + const rest = arm.literals.length > 6 ? `, and ${arm.literals.length - 6} more` : ''; + return `${declared ?? '(arm declared by no exported const)'} [type: ${shown}${rest}]`; +} + +const UNNAMED = CENSUS.arms.filter((arm) => namesOn(BARREL, arm).length === 0); + +describe('every AnyComponentSchema arm is nameable on `./zod` (objectui#8784)', () => { + it('no arm is missing from the barrel without a ledger row saying why', () => { + const undeclared = UNNAMED.filter((arm) => !arm.literals.some((literal) => literal in LEDGER)); + expect( + undeclared.map(describeArm), + 'These `AnyComponentSchema` arms have no named export on `./zod`, so no consumer can ' + + 'validate one of these node types on its own — `AnyComponentSchema` only answers "is ' + + 'this SOME valid node". Re-export each from packages/types/src/zod/index.zod.ts, or, ' + + 'if it is deliberately internal, add a row to ABSENT_BY_DECISION in this file with the ' + + 'reason (the way RetiredKanbanNodeSchema carries one).', + ).toEqual([]); + }); + + it.each(Object.keys(LEDGER))('ledger row `%s` still names a live, still-absent arm', (literal) => { + const arm = CENSUS.arms.find((candidate) => candidate.literals.includes(literal)); + expect( + arm, + `No arm of AnyComponentSchema declares type '${literal}' any more. The ledger row in this ` + + 'file outlived its arm — delete it, or key it on a literal the arm still declares.', + ).toBeDefined(); + if (arm === undefined) return; + expect( + namesOn(BARREL, arm), + `The arm declaring type '${literal}' IS exported by index.zod.ts now, so its ledger row is ` + + 'stale — delete the row. A ledger that keeps rows after their reason is gone stops being read.', + ).toEqual([]); + }); + + it('states each absence once — the two ledgers do not overlap', () => { + const both = Object.keys(ABSENT_BY_DECISION).filter((key) => key in ABSENT_PENDING_RULING); + expect(both, 'A row cannot be both a decision taken and a ruling owed.').toEqual([]); + }); +}); + +describe('the census reads the real union, and reads all of it', () => { + it('recurses BELOW the top-level arms', () => { + // Derived floor, ⛔ not a count: the reviewer's first census stopped at the + // top-level sub-unions and reported one "arm" per union. Anything that does + // that scores exactly `options.length` here. + const topLevel = defOf(barrel.AnyComponentSchema)?.options ?? []; + expect(topLevel.length).toBeGreaterThan(0); + expect(CENSUS.arms.length).toBeGreaterThan(topLevel.length); + }); + + it('leaves no member unresolved', () => { + // A member that is neither union, `z.lazy` nor object is a shape this walk + // does not understand — it must be reported, never dropped, or the arm it + // hides is invisible to the assertion above. + expect(CENSUS.unresolved).toEqual([]); + }); + + it('every arm declares at least one `type` literal', () => { + // An arm contributing zero literals would satisfy the ledger check + // vacuously — `literals.some(...)` is false for an empty list, so it would + // read as "undeclared" rather than sneak through; this states the stronger + // property the discriminated union already rests on. + expect(CENSUS.arms.filter((arm) => arm.literals.length === 0).map(describeArm)).toEqual([]); + }); + + it('resolves an arm reached through `z.lazy`', () => { + // `crud.zod.ts`'s `ActionSchema` reaches its literal through a `z.lazy`. If + // the unwrap silently failed, this arm would simply not be in the census + // and the whole pin would go quiet by shrinking its population. + const action = CENSUS.arms.find((arm) => arm.literals.includes('action')); + expect(action).toBeDefined(); + if (action === undefined) return; + expect(namesOn(BARREL, action)).not.toEqual([]); + }); + + it('matches a known arm to its barrel name BY IDENTITY', () => { + const button = CENSUS.arms.find((arm) => arm.literals.includes('button')); + expect(button).toBeDefined(); + if (button === undefined) return; + expect(namesOn(BARREL, button)).toContain('ButtonSchema'); + }); +}); + +describe('the census fires — controls', () => { + // Every control below is built on schemas the census has never seen, so each + // one is free to be red: the arm under test is absent from the namespace it + // is judged against, which is precisely the state the real reading must + // detect. A census that reported nothing would leave all four green only if + // it reported nothing for the real union too, and the `recurses BELOW` and + // `matches a known arm` legs above already refuse that. + const armA = z.object({ type: z.literal('probe-a') }); + const armB = z.object({ type: z.literal('probe-b') }); + const armC = z.object({ type: z.literal('probe-c') }); + const pair = z.discriminatedUnion('type', [armA, armB]); + + it('reports an arm whose re-export was dropped', () => { + // The historical break, in miniature: `d908a82f4` deleted one line from the + // barrel and left the module untouched. + const census = censusOf(pair); + const unnamed = census.arms.filter((arm) => namesOn({ ProbeASchema: armA }, arm).length === 0); + expect(unnamed.flatMap((arm) => [...arm.literals])).toEqual(['probe-b']); + }); + + it('reports nothing when every arm is on the namespace', () => { + // The other direction, so the control above is not just "this always fails". + const census = censusOf(pair); + const unnamed = census.arms.filter( + (arm) => namesOn({ ProbeASchema: armA, ProbeBSchema: armB }, arm).length === 0, + ); + expect(unnamed).toEqual([]); + }); + + it('still reports the arm when the PARENT union carries the arm name', () => { + // ⭐ `export { NavigationSchema as BreadcrumbSchema }` passes a name-based + // check and every behavioural leg of #8777's pin. The namespace below is + // that write: the name is present and it is bound to the union, not to the + // arm. + const namespace = { ProbeASchema: armA, ProbeBSchema: pair }; + expect('ProbeBSchema' in namespace).toBe(true); + const census = censusOf(pair); + const unnamed = census.arms.filter((arm) => namesOn(namespace, arm).length === 0); + expect(unnamed.flatMap((arm) => [...arm.literals])).toEqual(['probe-b']); + }); + + it('counts the arms of a NESTED union, not the nested union', () => { + // The "13 arms / 2 literals" failure. A walk that stops at the outer + // options sees two members here and two literals; this one sees three arms. + const nested = z.discriminatedUnion('type', [pair, armC]); + const census = censusOf(nested); + expect(census.arms.length).toBe(3); + expect(census.arms.flatMap((arm) => [...arm.literals]).sort()).toEqual([ + 'probe-a', + 'probe-b', + 'probe-c', + ]); + }); + + it('accepts either side of a `z.lazy` as the arm name, and neither is still unnamed', () => { + // `z.union` rather than a discriminated one: a bare `z.lazy` member + // computes no `propValues`, which zod 4 refuses as a discriminated option + // (`any-component-union-fanout.test.ts` measures that refusal). The real + // tree reaches its lazy arm through an annotated member; what matters here + // is only which objects count as the arm's identity. + const lazyArm = z.lazy(() => armC); + const lazyUnion = z.union([lazyArm]); + const unnamedAgainst = (namespace: Readonly>) => + censusOf(lazyUnion).arms.filter((arm) => namesOn(namespace, arm).length === 0).length; + expect(unnamedAgainst({ LazyProbeSchema: lazyArm })).toBe(0); + expect(unnamedAgainst({ ProbeCSchema: armC })).toBe(0); + expect(unnamedAgainst({ SomethingElseSchema: armA })).toBe(1); + }); +}); From b61b3343e8dae7b8ff9a104990fd71f8a9f14243 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 10 Sep 2026 22:52:17 +0000 Subject: [PATCH 2/4] test(types): correct the pin's account of why four arms are unexported (objectui#8784) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The contract review on `fc1ab897c` returned FAIL on a factual point, and it is right. This commit changes prose and two hardening details; the census, the identity matching, the ledger rows, the firing controls and the derived floor are untouched. WHAT WAS WRONG. The docblock filed `507b61bf7` (PR #8763, objectui#8499) under the barrel's SILENT failure modes, beside `d908a82f4`'s collateral drop and PR #1892's omission, and `ABSENT_PENDING_RULING`'s comment argued from "the same evidence objectui#7917 used to conclude its two omissions were accidental". That is refuted by three records, re-derived here rather than taken on trust: 1. `.changeset/8499-node-slot-registered-arms.md`, present in this very tree (blob c055695d1 on origin/main), declares the omission and its reason — exporting a `SemanticElementSchema` / `HtmlElementSchema` pair "would publish a NAMED authoring surface (`z.enum` families, not per-tag schemas) that this card's ruling does not cover", so "the metric is left to move and said out loud instead", and "whoever wants the number back down should treat naming these families as its own decision". It also declares the movement it caused: `unexportedNodeSchemas` from `[breadcrumb, object-tree]` to 49 node types, "Measured at this branch's head, not estimated." 2. objectui#8499's ceiling review raised exactly this as blocking F1 (`5597569641`: the four "are published by the union but unreachable by name from any published entry"), with the eight barrel lines named as the remedy. 3. That review then PASSed the deferral "on the surface-widening reason alone" (`5598263402`). So the omission was declared, reasoned and reviewed. The state "a decision is owed" is correct and the rows stay; their stated BASIS was wrong. WHAT CHANGED. The docblock gains a third, non-silent way an arm loses its barrel line and says why the pin still earns its place against it: the declaration lives in a `.changeset/*.md`, which is consumed and deleted at release (measured: `59f61cfb8` "chore: release packages (#4655)" removes the batch it versioned), so the absence outlives its own explanation. `ABSENT_PENDING_RULING` becomes `ABSENT_PENDING_DECISION`, and its comment now carries the recorded argument AGAINST naming these families rather than only the case for it. The failure message names all three acceptable answers. Timing corrected: PR #8763's `merged_at` is 2026-09-09T08:11:20Z; `07:53:59Z` was the squash commit's committer date, and the "48 minutes" arithmetic built on it is gone. The "13 sub-unions" aside is corrected to 12 sub-unions plus one object arm. HARDENING, both nits the review raised. `hasRow` replaces `key in LEDGER`, so an arm declaring `type: 'constructor'` cannot ledger itself off `Object.prototype`; `Object.hasOwn` does not compile under this package's `"lib": ["ES2020", "DOM"]` (tsc TS2550), so the `.call` form stands with that measurement recorded beside it. The walk's `if (!def || seen.has(schema)) return` is split: a member with no zod definition is now pushed to `unresolved` instead of being dropped, which is what the docblock already promised. Readings unchanged after both: types suite 172 files / 3385 tests green, `type-check` exit 0, eslint 0/0 on the file. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Jmxdo7bmeqCQHLSfmLVX9w --- .../__tests__/arm-named-export-8784.test.ts | 130 +++++++++++++----- 1 file changed, 97 insertions(+), 33 deletions(-) diff --git a/packages/types/src/__tests__/arm-named-export-8784.test.ts b/packages/types/src/__tests__/arm-named-export-8784.test.ts index 64307d61a6..6e9b590225 100644 --- a/packages/types/src/__tests__/arm-named-export-8784.test.ts +++ b/packages/types/src/__tests__/arm-named-export-8784.test.ts @@ -22,7 +22,7 @@ * maps it to `dist/zod/index.zod.js`) and `index.zod.ts` carries no star * re-export — every name on that barrel is written out by hand. So an arm added * to a category module and not to the barrel is declared-and-unreachable, and - * BOTH of the ways that has happened were silent: + * TWO of the three ways that has happened were silent: * * - `BreadcrumbSchema` was on the barrel at creation and was dropped by * `d908a82f4`, a "Fix schema duplication" dedup, as COLLATERAL — no @@ -31,11 +31,31 @@ * - `ObjectTreeSchema` was never added at all: PR #1892's file list omits the * barrel. * - * ⚠️ Neither failure mode was hypothetical when this pin was written, and one - * of them was LIVE: `507b61bf7` (PR #8763, objectui#8499) armed four new arms - * in `layout.zod.ts` and `form.zod.ts` and touched no barrel, 48 minutes after - * objectui#8784 was filed on a census that read 107 arms / 107 named / 0 - * unnamed. Those four are the `ABSENT_PENDING_RULING` rows below. + * ⭐ The third way is neither of those, and it is the one live on this tree: + * a DECLARED deferral. `507b61bf7` (PR #8763, objectui#8499, merged + * 2026-09-09T08:11:20Z) armed four new arms in `layout.zod.ts` and + * `form.zod.ts` and left the barrel alone ON PURPOSE. Its own changeset, + * `.changeset/8499-node-slot-registered-arms.md`, declares the omission, the + * metric it moves, and the reason: exporting a `SemanticElementSchema` / + * `HtmlElementSchema` pair "would publish a NAMED authoring surface (`z.enum` + * families, not per-tag schemas) that this card's ruling does not cover", so + * "the metric is left to move and said out loud instead" and "whoever wants + * the number back down should treat naming these families as its own + * decision". objectui#8499's ceiling review raised the omission as blocking + * F1 (`5597569641`), a delta commit carrying the eight barrel lines was + * prepared, and the review then PASSed the deferral "on the surface-widening + * reason alone" (`5598263402`). ⛔ So this is NOT `d908a82f4` recurring: no + * one forgot, and the four rows below say "a decision is owed", not "someone + * slipped". + * + * ⚠️ It is still exactly what this pin is for, and the reason is where the + * declaration LIVES. A `.changeset/*.md` is consumed and deleted at release — + * measured, e.g. `59f61cfb8` "chore: release packages (#4655)" removes the + * batch it versioned. The absence outlives its own explanation, and once the + * explanation is gone a deferred non-export and a collateral drop are the same + * two lines of nothing in `index.zod.ts`. The rows below move that reason into + * the tree, where it is re-read on every run and deleted only when it stops + * being true. * * ## Why the existing checks could not see it * @@ -76,9 +96,12 @@ * (four did, mid-card), and a number stated in a comment that nothing checks is * objectui#8606. The only bound asserted against the real union is a floor read * off that union's own top-level option count, which is what proves the walk - * recursed rather than stopping at the 13 sub-unions — the reviewer's first - * census stopped there and reported a plausible, fully-populated, entirely - * wrong "13 arms / 2 literals". + * recursed rather than stopping at those top-level members — the reviewer's + * first census stopped there and reported a plausible, fully-populated, + * entirely wrong "13 arms / 2 literals". ⚠️ Those 13 are 12 sub-unions plus one + * object arm (`AppComponentSchema`, an arm at depth 1), not 13 sub-unions; the + * floor holds either way, and the count is read off the union rather than + * written down here. */ import { describe, it, expect } from 'vitest'; @@ -105,7 +128,9 @@ import * as viewsZod from '../zod/views.zod.js'; /* ── The ledger ────────────────────────────────────────────────────────────── * * ⛔ NOT a claim that every arm must be exported — only that the answer is - * DECLARED rather than accidental. A row is keyed by ONE `type` literal of the + * DECLARED, in the tree, where it is re-read on every run. Neither row below + * records a slip: one is a decision taken and four are a decision deferred with + * its reason on the record. A row is keyed by ONE `type` literal of the * arm, because an arm with no barrel export has, by definition, no barrel name * to key on; `every ledger row still names an arm` below fails if that literal * stops existing, so the key cannot rot into a comment. @@ -121,25 +146,54 @@ const ABSENT_BY_DECISION: Readonly> = { }; /** - * Arms absent from the barrel with the answer still OWED. + * Arms whose absence from the barrel was DEFERRED, with the decision still owed. + * + * ⛔ These are not oversights, and reading them as oversights is the mistake + * this comment exists to prevent. All four were armed by `507b61bf7` + * (objectui#8499), which left the barrel alone deliberately: its changeset, + * `.changeset/8499-node-slot-registered-arms.md`, declares the omission, states + * that exporting a `SemanticElementSchema` / `HtmlElementSchema` pair "would + * publish a NAMED authoring surface (`z.enum` families, not per-tag schemas) + * that this card's ruling does not cover", and closes by saying "whoever wants + * the number back down should treat naming these families as its own decision". + * The objectui#8499 ceiling review raised the omission as blocking F1 + * (`5597569641`) and then PASSed the deferral "on the surface-widening reason + * alone" (`5598263402`). + * + * ⇒ The state is "a decision is owed", which is what these rows record, and the + * argument AGAINST naming them is on the record too. objectui#9067 carries the + * decision; anyone acting on it should read the changeset passage first, not + * only the case for exporting. * - * ⚠️ These are NOT exemptions. All four were armed by `507b61bf7` - * (objectui#8499) without a barrel line, all four are registered as first-class - * mirror pairs by `zod-mirror-parity.test.ts` with a TypeScript counterpart, - * and none carries any of this directory's "Deliberately NOT exported" markers - * — the same evidence objectui#7917 used to conclude its two omissions were - * accidental. Exporting them mints new names on a published surface, so the - * reading was reported and the ruling is objectui#9067's. The rows exist so the - * state is visible and so a NEW omission cannot hide behind them. + * ⚠️ They are ledgered anyway rather than left to that changeset, because a + * `.changeset/*.md` is consumed and deleted at release (measured: `59f61cfb8` + * "chore: release packages (#4655)" removes the batch it versioned). The rows + * keep the reason next to the state it explains. */ -const ABSENT_PENDING_RULING: Readonly> = { - aside: 'layout.zod.ts#SemanticElementSchema — ruling owed, objectui#9067', - h1: 'layout.zod.ts#HtmlElementSchema — ruling owed, objectui#9067', - email: 'form.zod.ts#InputShorthandSchema — ruling owed, objectui#9067', - 'ui:calendar': 'form.zod.ts#UiCalendarSchema — ruling owed, objectui#9067', +const ABSENT_PENDING_DECISION: Readonly> = { + aside: 'layout.zod.ts#SemanticElementSchema — deferred by objectui#8499, decision owed on objectui#9067', + h1: 'layout.zod.ts#HtmlElementSchema — deferred by objectui#8499, decision owed on objectui#9067', + email: 'form.zod.ts#InputShorthandSchema — deferred by objectui#8499, decision owed on objectui#9067', + 'ui:calendar': 'form.zod.ts#UiCalendarSchema — deferred by objectui#8499, decision owed on objectui#9067', }; -const LEDGER: Readonly> = { ...ABSENT_BY_DECISION, ...ABSENT_PENDING_RULING }; +const LEDGER: Readonly> = { ...ABSENT_BY_DECISION, ...ABSENT_PENDING_DECISION }; + +/** + * Own-key test. ⛔ Not `key in ledger`: these are plain object literals, so `in` + * answers `true` for every `Object.prototype` key and an arm declaring + * `type: 'constructor'` or `'toString'` would ledger itself. + * + * `Object.hasOwn` is the modern spelling and does not compile here — measured: + * `packages/types/tsconfig.json` sets `"lib": ["ES2020", "DOM"]`, and tsc says + * `TS2550: Property 'hasOwn' does not exist on type 'ObjectConstructor'. …Try + * changing the 'lib' compiler option to 'es2022' or later`. A lib bump is a + * build-config change this pin does not own, so the `.call` form stands. + * ⛔ Do not "modernise" it back without moving the lib first. + */ +function hasRow(ledger: Readonly>, key: string): boolean { + return Object.prototype.hasOwnProperty.call(ledger, key); +} /* ── The census ──────────────────────────────────────────────────────────────*/ @@ -193,9 +247,15 @@ function censusOf(union: unknown): Census { const seen = new Set(); const walk = (schema: unknown, chain: readonly unknown[]): void => { - const def = defOf(schema); - if (!def || seen.has(schema)) return; + if (seen.has(schema)) return; seen.add(schema); + const def = defOf(schema); + if (def === undefined) { + // ⛔ Not a silent `return`: a member with no `_zod` is a shape this walk + // does not understand, and dropping it would hide whatever arm it holds. + unresolved.push('(member carries no zod definition)'); + return; + } if (def.type === 'union') { // A union is a CONTAINER, so each option starts its own chain. for (const option of def.options ?? []) walk(option, [option]); @@ -270,14 +330,18 @@ const UNNAMED = CENSUS.arms.filter((arm) => namesOn(BARREL, arm).length === 0); describe('every AnyComponentSchema arm is nameable on `./zod` (objectui#8784)', () => { it('no arm is missing from the barrel without a ledger row saying why', () => { - const undeclared = UNNAMED.filter((arm) => !arm.literals.some((literal) => literal in LEDGER)); + // `hasRow`, ⛔ not `in` — see its docblock for why, and why not `Object.hasOwn`. + const undeclared = UNNAMED.filter((arm) => !arm.literals.some((literal) => hasRow(LEDGER, literal))); expect( undeclared.map(describeArm), 'These `AnyComponentSchema` arms have no named export on `./zod`, so no consumer can ' + 'validate one of these node types on its own — `AnyComponentSchema` only answers "is ' - + 'this SOME valid node". Re-export each from packages/types/src/zod/index.zod.ts, or, ' - + 'if it is deliberately internal, add a row to ABSENT_BY_DECISION in this file with the ' - + 'reason (the way RetiredKanbanNodeSchema carries one).', + + 'this SOME valid node". Three answers are acceptable and a fourth is not. Re-export ' + + 'each from packages/types/src/zod/index.zod.ts; or, if it is deliberately internal, ' + + 'add a row to ABSENT_BY_DECISION with the reason (the way RetiredKanbanNodeSchema ' + + 'carries one); or, if naming it is a decision your card does not cover, add a row to ' + + 'ABSENT_PENDING_DECISION naming the card that holds it. What is not acceptable is ' + + 'leaving the answer to be inferred from an absence.', ).toEqual([]); }); @@ -297,8 +361,8 @@ describe('every AnyComponentSchema arm is nameable on `./zod` (objectui#8784)', }); it('states each absence once — the two ledgers do not overlap', () => { - const both = Object.keys(ABSENT_BY_DECISION).filter((key) => key in ABSENT_PENDING_RULING); - expect(both, 'A row cannot be both a decision taken and a ruling owed.').toEqual([]); + const both = Object.keys(ABSENT_BY_DECISION).filter((key) => hasRow(ABSENT_PENDING_DECISION, key)); + expect(both, 'A row cannot be both a decision taken and a decision owed.').toEqual([]); }); }); From 2f31f7d0ecddd23b257370cb4fb11c7135a3fe41 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 10 Sep 2026 23:17:20 +0000 Subject: [PATCH 3/4] docs(changeset): drop the withdrawn "48 minutes" clause from the 8784 changeset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One clause, on the seat's ruling. `.changeset/8784-arm-named-export-pin.md` is release-note input — it is consumed into `packages/types/CHANGELOG.md` at the next publish — so it outlives the PR body that already carries the correction, and it was the last place in this branch still reading off the committer date. Replaced: "and touched no barrel — 48 minutes after objectui#8784 was filed on a census reading 107 arms / 107 named / 0 unnamed." With: "and touched no barrel deliberately (PR #8763 merged 2026-09-09T08:11:20Z; the omission and its reason are declared in `.changeset/8499-node-slot-registered-arms.md`)." ⛔ The interval is not restated as 66 minutes. Measured here — card created 2026-09-09T07:05:02Z, PR objectui#8763 `merged_at` 2026-09-09T08:11:20Z, so the true figure is 66.3 rather than the 48 that came off `507b61bf7`'s committer date 07:53:59Z — but the framing that made an interval meaningful was the accident premise, and that premise is withdrawn: the barrel omission was declared in objectui#8499's own changeset, raised as blocking F1 (`5597569641`) and passed on the surface-widening reason (`5598263402`). Nothing else in the file moves. Gates on the edited tree: check-changeset-presence exit 0 ("Every one of them has an EMPTY frontmatter"), check-changeset-no-major exit 0, check-changeset-fixed exit 0, check-changeset-overwrite exit 0 ("No pre-existing changeset was modified or deleted"), check-control-bytes exit 0, plus a direct `grep -naP` control-byte scan of the edited file (exit 1, no lines). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Jmxdo7bmeqCQHLSfmLVX9w --- .changeset/8784-arm-named-export-pin.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.changeset/8784-arm-named-export-pin.md b/.changeset/8784-arm-named-export-pin.md index 9e94ac267f..992de762f9 100644 --- a/.changeset/8784-arm-named-export-pin.md +++ b/.changeset/8784-arm-named-export-pin.md @@ -25,8 +25,9 @@ union's own top-level option count. Red on arrival, reported rather than declared away: `507b61bf7` (PR #8763, objectui#8499) armed `SemanticElementSchema`, `HtmlElementSchema`, `InputShorthandSchema` and -`UiCalendarSchema` in `layout.zod.ts` and `form.zod.ts` and touched no barrel — 48 minutes -after objectui#8784 was filed on a census reading 107 arms / 107 named / 0 unnamed. The +`UiCalendarSchema` in `layout.zod.ts` and `form.zod.ts` and touched no barrel deliberately +(PR #8763 merged 2026-09-09T08:11:20Z; the omission and its reason are declared in +`.changeset/8499-node-slot-registered-arms.md`). The current reading is 111 arms / 154 `type` literals / 106 named / 5 unnamed. Four of the five carry ledger rows against objectui#9067, which holds the ruling, because exporting them mints new names on a published surface. The fifth, `RetiredKanbanNodeSchema`, is a refusal arm the From 19f8ab48d3803690935db75f717a832ae0c8029a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 10 Sep 2026 23:40:12 +0000 Subject: [PATCH 4/4] docs(changeset): match the changeset's top-level-member wording to the pin's docblock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One clause, on the seat's ruling. Round 2 corrected this fact in the pin's docblock and did not carry the correction into the changeset, so the PR shipped two faces disagreeing about the same thing — and the changeset is the face that is published into `packages/types/CHANGELOG.md` and outlives the docblock's audience. Before: "a walk that stops at the top-level sub-unions scores exactly `options.length` arms" After: "a walk that stops at the 13 top-level members — 12 sub-unions plus one object arm (`AppComponentSchema`, an arm at depth 1), not 13 sub-unions — scores exactly `options.length` arms" The wording is taken from the docblock rather than paraphrased, and the match is measured rather than eyeballed: with jsdoc leaders stripped and whitespace collapsed, the phrase "12 sub-unions plus one object arm (`AppComponentSchema`, an arm at depth 1), not 13 sub-unions" occurs exactly once in each file. ⛔ The claim itself is unchanged — such a walk does still score exactly `options.length` arms, which is the property the derived floor rests on. ⛔ Nothing else in the file moves: "Red on arrival, reported rather than declared away" stays byte-unchanged (it asserts nothing false and is a borrowed repo idiom, `.changeset/4971-declared-arm-subset-gate.md`), and the withdrawn interval stays dropped rather than restated. Gates on the edited tree: check-changeset-presence exit 0 ("Every one of them has an EMPTY frontmatter"), check-changeset-no-major exit 0, check-changeset-fixed exit 0, check-changeset-overwrite exit 0 ("No pre-existing changeset was modified or deleted"), check-control-bytes exit 0, plus a direct `grep -naP` control-byte scan of the edited file (exit 1, no lines). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Jmxdo7bmeqCQHLSfmLVX9w --- .changeset/8784-arm-named-export-pin.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.changeset/8784-arm-named-export-pin.md b/.changeset/8784-arm-named-export-pin.md index 992de762f9..01ddd8e827 100644 --- a/.changeset/8784-arm-named-export-pin.md +++ b/.changeset/8784-arm-named-export-pin.md @@ -17,7 +17,9 @@ wired into no `package.json` script and no workflow. The pin derives the arm list on every run — flatten the union recursively through nested unions and `z.lazy`, then match each leaf arm to a named export **by identity**. Both properties are load-bearing and both are controls in the file rather than claims in a comment: -a walk that stops at the top-level sub-unions scores exactly `options.length` arms, and a +a walk that stops at the 13 top-level members — 12 sub-unions plus one object arm +(`AppComponentSchema`, an arm at depth 1), not 13 sub-unions — scores exactly +`options.length` arms, and a name-based match is satisfied by `export { NavigationSchema as BreadcrumbSchema }` — the parent sub-union under the arm's name, which passes every behavioural leg of #8777's pin. No count is hard-coded; the only bound asserted against the real union is a floor read off that