From dde1158948a35f68c106a537e94c7eadafcae942 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 7 Aug 2026 11:03:49 +0000 Subject: [PATCH] fix(spec): zodShapeOf resolves union members and peels prefault (#6098) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `zodShapeOf` was two spellings narrower than its sibling walkers after #5317 fixed the pipe direction: it had no `union` arm (so a union node derived no shape at all) and its `SHAPE_WRAPPER_TYPES` omitted `prefault`. Both cells are here, measured separately. union: a metadata type may register a UNION of shapes rather than a single object (#3095). `view` is the shipped specimen — the registry's one `z.preprocess` root, whose OUT is a 4-member union — so after #5317 it had the right side and still derived nothing. A union now resolves to the MERGE of its members' keys, recursively (member 0 is itself a union), first member wins on a name two members declare. That merging rule follows `keysOf`/`keyPosture` rather than check-liveness's first-object-member rule, because this walker feeds reachability, where a missed key can only waive a tombstone. prefault: measured as inert today — the 25 metadata-type roots reach zero `prefault` nodes and `.prefault(` has no call site in `packages/`. It is parity against five sibling walkers that all peel it, so the first author to write one does not silently lose this walker. Measured with the #5056 protocol (every def's reachableVia dumped before and after): - bridge table 1518 -> 1519 pairs. The one new entry (`children`, from ui/NavigationItem) comes from a `z.lazy` getter that mints fresh instances per call, so it matches nothing — 0 defs hit it. Widening this walker cannot widen the bridge in general: every object a resolved shape comes from is already in the BFS closure and has already contributed the same pairs. - 18 verdicts move, all on the QUERY side, all out of the `!shape` fail-closed default: 17 root-graph -> null and 1 root-graph -> derived-clone (ui/ViewItem, a genuine shared-instance bridge with the view root's own union members). Every one of the 17 has holders that already answer null, or no holder at all. Totals: root-graph 520 -> 502, null 1084 -> 1101, derived-clone 6 -> 7. - prefault alone: 0 verdict moves, 0 bridge changes. - merge order is immaterial: a last-member-wins build gives identical verdicts for all 1610 defs. - generated artifacts do not move; check:generated reports all 10 up to date. The `view` pin in zod-graph.test.ts is CONVERTED, not deleted: it documented that a corrected direction still derived no shape, and now pins the merged shape that direction leads to. Fixes #6098 --- packages/spec/scripts/lib/zod-graph.ts | 75 +++++++++++-- packages/spec/scripts/zod-graph.test.ts | 133 +++++++++++++++++++++--- 2 files changed, 183 insertions(+), 25 deletions(-) diff --git a/packages/spec/scripts/lib/zod-graph.ts b/packages/spec/scripts/lib/zod-graph.ts index 008e55ea2e..64f0f108e1 100644 --- a/packages/spec/scripts/lib/zod-graph.ts +++ b/packages/spec/scripts/lib/zod-graph.ts @@ -79,10 +79,20 @@ export function zodChildSchemas(schema: z.ZodType): z.ZodType[] { /** * Wrapper defs that carry their subject in `innerType` and never change its shape. * - * Deliberately the set `zodShapeOf` already used, byte for byte, so #5317 moves - * ONLY the pipe direction. `prefault` — which the three sibling walkers below do - * unwrap — is knowingly absent; adding it is a separate, separately measured - * change (filed as its own finding, not smuggled in here). + * `prefault` closes the last spelling gap against the sibling walkers (#6098). + * Measured on the shipped graph at that change: the 25 metadata-type roots reach + * **zero** `prefault` nodes, and `.prefault(` has no call site anywhere in + * `packages/`, so this entry moves no verdict and no bridge today — it is + * parity, not a fix. It is worth having anyway because the alternative is the + * #4488 failure class: five walkers over the same Zod vocabulary + * (`check-liveness.mts`, `metadata-authoring-lint.ts`, + * `metadata-form-zod-reconciliation.test.ts`, `metadata-type-schemas.test.ts`, + * `shared/strict-object.ts`) already peel `prefault`, so the first author to + * write one would have had exactly this walker — the one feeding the deletion + * gate — stop resolving a shape, silently. + * + * Read by `pipeInIsTransform` as well as `zodShapeOf`, so a preprocess transform + * parked behind a `prefault` is now seen from both ends, deliberately. */ const SHAPE_WRAPPER_TYPES = new Set([ 'optional', @@ -91,6 +101,7 @@ const SHAPE_WRAPPER_TYPES = new Set([ 'catch', 'readonly', 'nonoptional', + 'prefault', ]); /** Does this pipe's IN side resolve to a `transform` — i.e. is it a `z.preprocess`? */ @@ -156,11 +167,58 @@ export function pipeAuthorableSide(def: Record, depth = 0): z.Z } /** - * Unwrap pipes/wrappers/lazies down to a plain object def's shape, if any. + * The merged shape of a `union` node — every member's keys, first member wins on + * a name two members both declare (#6098). + * + * Why merge at all: a metadata type may register a UNION of shapes rather than a + * single object (#3095 — `view` is the shipped specimen: a `defineView` + * container, a flattened list view, a flattened form view). All three sibling + * walkers already cross that node — `keyPosture` and `keysOf` by merging every + * member, `check-liveness`'s `shapeOf` by taking the first object member — and + * only this one stopped there and returned `null`. + * + * Why the merging rule and not `check-liveness`'s first-member rule: the two + * walkers are asked different questions. `shapeOf` governs a ledger of the + * canonical authorable CONTAINER, so "first object member" is its answer by + * design. This one feeds reachability, where a missed key can only ever waive a + * tombstone — so it takes the widest reading, exactly like `keysOf`. + * + * ⚠️ The one honest narrowing: the return type is one instance per name, while + * the consumer's bridge is keyed by (name, INSTANCE). When two members declare + * the same name with different instances — measured: 11 of the 92 union nodes in + * the root closure — only the first member's instance survives into this record, + * so a bridge that would have matched a later member's instance is not tested + * for. Measured at #6098 against a last-member-wins build of this same function: + * identical verdicts for all 1610 defs, so nothing turns on the choice today. If + * it ever does, the fix belongs in the consumer's bridge (a name → instance SET), + * never in a looser test here. + */ +function mergedUnionShape(def: Record, depth: number): Record | null { + if (!Array.isArray(def.options)) return null; + let merged: Record | null = null; + for (const option of def.options) { + if (!(option instanceof z.ZodType)) continue; + const shape = zodShapeOf(option, depth + 1); + if (!shape) continue; + merged ??= {}; + for (const [name, prop] of Object.entries(shape)) { + if (!(name in merged)) merged[name] = prop; + } + } + return merged; +} + +/** + * Unwrap pipes/wrappers/lazies/unions down to an object shape, if any. + * + * Returns `null` for anything that does not resolve to at least one object node + * — a union of primitives included, since it has no keys to contribute. * - * Returns `null` for anything that is not (or does not unwrap to) a single - * object node — a union included. See the `zod-graph.test.ts` pin for what that - * means for `view`, whose preprocess OUT is a union. + * A union resolves to the MERGE of its members (see `mergedUnionShape`). In Zod + * 4.4.3 `z.discriminatedUnion` carries `def.type === 'union'` too (verified, + * #6098), so it needs no branch of its own — the `discriminated_union` arms the + * sibling walkers carry match nothing in this version and are deliberately not + * copied here. */ export function zodShapeOf(schema: z.ZodType, depth = 0): Record | null { if (depth > 12) return null; @@ -170,6 +228,7 @@ export function zodShapeOf(schema: z.ZodType, depth = 0): Record) : null; } + if (def.type === 'union') return mergedUnionShape(def, depth); if (def.type === 'pipe') { const side = pipeAuthorableSide(def); return side ? zodShapeOf(side, depth + 1) : null; diff --git a/packages/spec/scripts/zod-graph.test.ts b/packages/spec/scripts/zod-graph.test.ts index 59b0942505..a3ab62f5af 100644 --- a/packages/spec/scripts/zod-graph.test.ts +++ b/packages/spec/scripts/zod-graph.test.ts @@ -25,16 +25,24 @@ * the pre-#5317 code, and the live-graph cases fail the moment a NEW preprocess * registration appears that the walker cannot resolve. * - * ── What is deliberately NOT asserted ───────────────────────────────────── - * "Every preprocess root resolves to a real shape" is the pin the issue asked - * for, and it is not true of `view` — the one preprocess ROOT in the registry — - * because its OUT is a `z.union`, and `zodShapeOf` has no union branch. Fixing - * the pipe direction is necessary but not sufficient there. Asserting the - * literal sentence would mean either a failing test or a union branch smuggled - * in unmeasured, so what is pinned instead is the fact that actually holds and - * that actually catches recurrence #5: no pipe in the registry ever resolves its - * authorable side to a TRANSFORM. `view` passes that (its side is the union); - * a regressed walker does not. + * ── The unwrap surface, completed at #6098 ──────────────────────────────── + * #5317 fixed the pipe DIRECTION and stopped there, leaving `zodShapeOf` two + * spellings narrower than its three sibling walkers: no `union` arm, and no + * `prefault` wrapper. Both are now here, measured together (#6098), and the + * `view` case below is the one this file used to pin the other way round — it + * documented that a corrected direction still derived no shape, and it now pins + * the merged shape that direction actually leads to. + * + * What the measurement found, recorded because it is the part a future edit can + * silently undo: widening this walker cannot widen the consumer's derived-clone + * BRIDGE, because every object a resolved shape comes from is itself in the BFS + * closure (`zodChildSchemas` walks union options, wrapper `innerType`s and both + * pipe sides), so it has already contributed the same (name, instance) pairs. + * The single new bridge entry the run produced came from a `z.lazy` getter that + * mints fresh instances per call (`ui/NavigationItem`) and matches nothing, in + * either direction. What DID move is the query side: 18 defs that answered the + * `!shape` fail-closed default now answer from their real shape (17 of them + * `null`, one — `ui/ViewItem` — `derived-clone`). */ import { describe, expect, it } from 'vitest'; import { z } from 'zod'; @@ -92,6 +100,79 @@ describe('zodShapeOf — pipe direction (#4488, #5074, #5317)', () => { }); }); +describe('zodShapeOf — union members (#3095, #6098)', () => { + it('merges every member’s keys', () => { + const schema = z.union([ + z.object({ shared: z.string(), onlyA: z.string() }), + z.object({ shared: z.string(), onlyB: z.number() }), + ]); + + // Pre-#6098 this was null: no union arm, so the walker fell through. + expect(Object.keys(zodShapeOf(schema) ?? {})).toEqual(['shared', 'onlyA', 'onlyB']); + }); + + it('keeps the FIRST member’s instance when two members declare one name', () => { + // The documented narrowing: the consumer's bridge is keyed by (name, + // INSTANCE) and this record holds one instance per name. Pinned so the rule + // is a decision someone can find, not an accident of `Object.assign` order. + const first = z.string(); + const second = z.string(); + const shape = zodShapeOf( + z.union([z.object({ dup: first }), z.object({ dup: second })]), + ); + + expect(shape?.dup).toBe(first); + expect(shape?.dup).not.toBe(second); + }); + + it('descends a union nested inside a union', () => { + const schema = z.union([ + z.union([z.object({ deep: z.string() })]), + z.object({ top: z.string() }), + ]); + + // `view`'s live shape depends on this: its OUT union's first member is + // itself a union, and two of the 89 merged keys come only from there. + expect(Object.keys(zodShapeOf(schema) ?? {})).toEqual(['deep', 'top']); + }); + + it('still returns null for a union with no object member', () => { + // Widening the walker must not turn "no keys to contribute" into a shape: + // an empty shape would bridge on nothing and read as a resolved answer. + expect(zodShapeOf(z.union([z.string(), z.number()]))).toBeNull(); + }); + + it('resolves a discriminated union through the same arm', () => { + const schema = z.discriminatedUnion('kind', [ + z.object({ kind: z.literal('a'), a: z.string() }), + z.object({ kind: z.literal('b'), b: z.string() }), + ]); + + // In Zod 4.4.3 a discriminated union IS a `union` def, which is why this + // walker carries no `discriminated_union` arm — one would match nothing. + // If a Zod upgrade ever splits them, this fails rather than going quiet. + expect(zodDefOf(schema)?.type).toBe('union'); + expect(Object.keys(zodShapeOf(schema) ?? {})).toEqual(['kind', 'a', 'b']); + }); +}); + +describe('zodShapeOf — `prefault` wrapper (#6098)', () => { + it('peels a `prefault` the way every sibling walker already does', () => { + const schema = z.object({ alpha: z.string() }).prefault({ alpha: 'x' }); + + expect(Object.keys(zodShapeOf(schema) ?? {})).toEqual(['alpha']); + }); + + it('sees a preprocess transform parked behind a `prefault`', () => { + // `SHAPE_WRAPPER_TYPES` is read by `pipeInIsTransform` too, so this is the + // second half of the same entry: IN unwraps to a transform, so the + // authorable side is OUT. Reading IN here would hand back the prefault. + const schema = z.transform((raw: unknown) => raw).prefault('x').pipe(z.object({ beta: z.string() })); + + expect(Object.keys(zodShapeOf(schema as unknown as z.ZodType) ?? {})).toEqual(['beta']); + }); +}); + describe('pipeAuthorableSide — the registered metadata-type roots (#5317)', () => { /** Every registered root that compiles to a `pipe`, with its resolved side. */ const pipeRoots = listMetadataTypeSchemaTypes().flatMap((type) => { @@ -134,15 +215,33 @@ describe('pipeAuthorableSide — the registered metadata-type roots (#5317)', () expect(Object.keys(zodShapeOf(schema!) ?? {}).length).toBeGreaterThan(10); }); - it('documents that `view`s preprocess OUT is a union, so it still derives no shape', () => { - // Honest pin of the measured state rather than the issue's expectation: the - // direction is now right (the side is the union, not the transform), but - // `zodShapeOf` has no union branch, so `view` still yields null. Whoever adds - // that branch will land here — and should re-measure the derived-clone bridge - // before doing so (#5056: a new bridge can mark a dead shape reachable). + it('resolves `view`s preprocess OUT union to the MERGE of its members', () => { + // ── This case is the converted #5317 pin, not a new one ────────────── + // It used to assert `zodShapeOf(view)` was null and to say why: the + // direction was right, the union arm was missing, so the one preprocess + // ROOT in the registry still derived nothing. #6098 added that arm after the + // measurement it was waiting on, so the same case now pins the shape the + // corrected direction actually leads to. The direction assertion is kept + // verbatim — it is the #4488 recurrence guard and is independent of the + // union arm. const schema = getMetadataTypeSchema('view'); expect(schema).toBeDefined(); expect(defTypeOf(pipeAuthorableSide(zodDefOf(schema!)!))).toBe('union'); - expect(zodShapeOf(schema!)).toBeNull(); + + const shape = zodShapeOf(schema!); + expect(shape).not.toBeNull(); + + // Not a key count (89 today, and every view feature moves it) — one key that + // ONLY a given member declares, so the assertion fails if the merge ever + // silently collapses to a single member. All four members are represented, + // the first of them a nested union: + expect(Object.keys(shape ?? {})).toEqual( + expect.arrayContaining([ + 'isPinned', // member 0 only — and member 0 is itself a union + 'list', // the defineView container member + 'pagination', // the flattened list-view member + 'sections', // the flattened form-view member + ]), + ); }); });