diff --git a/.changeset/8784-arm-named-export-pin.md b/.changeset/8784-arm-named-export-pin.md new file mode 100644 index 0000000000..01ddd8e827 --- /dev/null +++ b/.changeset/8784-arm-named-export-pin.md @@ -0,0 +1,37 @@ +--- +--- + +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 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 +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 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 +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..6e9b590225 --- /dev/null +++ b/packages/types/src/__tests__/arm-named-export-8784.test.ts @@ -0,0 +1,480 @@ +/** + * 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 + * 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 + * 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. + * + * ⭐ 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 + * + * - `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 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'; +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, 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. + */ + +/** 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 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. + * + * ⚠️ 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_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_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 ──────────────────────────────────────────────────────────────*/ + +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 => { + 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]); + 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', () => { + // `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". 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([]); + }); + + 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) => hasRow(ABSENT_PENDING_DECISION, key)); + expect(both, 'A row cannot be both a decision taken and a decision 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); + }); +});