From 6a7d43038cd7584a032cc3b0880eddd51c693565 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 7 Aug 2026 01:14:06 +0000 Subject: [PATCH] fix(spec): zodShapeOf reads a z.preprocess pipe from its OUT side (#5317) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `a.transform(fn)` and `z.preprocess(fn, schema)` compile to the same `pipe` node with OPPOSITE authorable sides: IN for the first, OUT for the second. `zodShapeOf` read `def.in` unconditionally, so every preprocess node resolved to a transform, derived no shape, and the authorable-surface reachability computation silently fell through to its fail-closed default. This is the #4488 blind spot's fourth independent site — after scripts/liveness/check-liveness.mts (#4488), src/kernel/metadata-authoring-lint.ts and src/system/metadata-form-zod-reconciliation.test.ts (both #5074). The three earlier sites carried the lesson as a comment and it recurred anyway, so this one lands with an assertion: the walkers move to scripts/lib/zod-graph.ts (the same extraction route as schema-name #4592, format-type #4912 and def-key-collisions #5832) and scripts/zod-graph.test.ts pins the direction on both synthetic and live schemas. Measured, not assumed: generated output does not move. `gen:schema` leaves authorable-surface/ and json-schema.manifest/ byte-identical, and `check:generated` reports all 10 artifacts up to date. One reachability verdict changes — ui/InlineAction, root-graph (fail-closed) -> null (computed) — which matches its sole holder ui/ElementButtonProps and its eight ui/Element*Props siblings, all already null on main. No new derived-clone bridge (6 -> 6), so the #5056 false-reachability risk did not materialise. Fixes #5317 --- packages/spec/scripts/build-schemas.ts | 99 ++----------- packages/spec/scripts/lib/zod-graph.ts | 189 ++++++++++++++++++++++++ packages/spec/scripts/zod-graph.test.ts | 148 +++++++++++++++++++ 3 files changed, 353 insertions(+), 83 deletions(-) create mode 100644 packages/spec/scripts/lib/zod-graph.ts create mode 100644 packages/spec/scripts/zod-graph.test.ts diff --git a/packages/spec/scripts/build-schemas.ts b/packages/spec/scripts/build-schemas.ts index f3fcb3e684..b829671dab 100644 --- a/packages/spec/scripts/build-schemas.ts +++ b/packages/spec/scripts/build-schemas.ts @@ -16,6 +16,10 @@ import { type EmittedDef, } from './lib/def-key-collisions'; import { RENAMED_DEFS, carryAuthorableKey, checkRenameTable } from './lib/renamed-defs'; +// The Zod-graph walkers the authorable-surface reachability BFS runs on. Extracted +// at #5317 so the pipe-direction rule (#4488) is assertable without running the +// whole generator — see scripts/zod-graph.test.ts. +import { zodChildSchemas, zodShapeOf } from './lib/zod-graph'; import { AUTHORABLE_SURFACE_DESCRIPTION, AUTHORABLE_SURFACE_DIR_NAME, @@ -930,89 +934,6 @@ function registeredClauseMajors(): Map { return out; } -function zodDefOf(schema: z.ZodType): Record | null { - const def = (schema as unknown as { _zod?: { def?: unknown } })._zod?.def; - return def && typeof def === 'object' ? (def as Record) : null; -} - -/** - * Every Zod schema instance a node's def references directly: shape values, - * union options, pipe in/out, record key/value, array element, wrapper inner - * types — found by walking the def's plain objects/arrays generically instead - * of enumerating node kinds (which would silently miss the next kind Zod - * adds). Two edges a generic def walk cannot see are added explicitly: - * `z.lazy` hides its target behind `getter()`, and check-clones (`.refine()`, - * `.describe()`, …) point back at the schema they cloned via `_zod.parent` — - * the clone is what a parent schema embeds (`ViewSchema.refine(…)` inside - * ViewMetadataSchema), while the BASELINE def is the original. - */ -function zodChildSchemas(schema: z.ZodType): z.ZodType[] { - const out: z.ZodType[] = []; - const def = zodDefOf(schema); - if (!def) return out; - const seen = new Set(); - const walk = (v: unknown): void => { - if (v == null) return; - if (v instanceof z.ZodType) { - out.push(v); - return; - } - if (typeof v !== 'object') return; - if (seen.has(v)) return; - seen.add(v); - if (Array.isArray(v)) { - for (const x of v) walk(x); - return; - } - if (v instanceof Map) { - for (const x of v.values()) walk(x); - return; - } - const proto = Object.getPrototypeOf(v); - if (proto === Object.prototype || proto === null) { - for (const x of Object.values(v)) walk(x); - } - }; - walk(def); - if (def.type === 'lazy' && typeof def.getter === 'function') { - try { - const inner = (def.getter as () => unknown)(); - if (inner instanceof z.ZodType) out.push(inner); - } catch { - // An unresolvable lazy getter has no graph to traverse; the schema it - // would have produced cannot be parsed against either. - } - } - const parent = (schema as unknown as { _zod?: { parent?: unknown } })._zod?.parent; - if (parent instanceof z.ZodType) out.push(parent); - return out; -} - -/** Unwrap pipes/wrappers/lazies down to a plain object def's shape, if any. */ -function zodShapeOf(schema: z.ZodType, depth = 0): Record | null { - if (depth > 12) return null; - const def = zodDefOf(schema); - if (!def) return null; - if (def.type === 'object') { - const shape = def.shape; - return shape && typeof shape === 'object' ? (shape as Record) : null; - } - if (def.type === 'pipe' && def.in instanceof z.ZodType) return zodShapeOf(def.in, depth + 1); - if (def.type === 'lazy' && typeof def.getter === 'function') { - try { - const inner = (def.getter as () => unknown)(); - if (inner instanceof z.ZodType) return zodShapeOf(inner, depth + 1); - } catch { - return null; - } - } - const wrappers = new Set(['optional', 'nullable', 'default', 'catch', 'readonly', 'nonoptional']); - if (typeof def.type === 'string' && wrappers.has(def.type) && def.innerType instanceof z.ZodType) { - return zodShapeOf(def.innerType, depth + 1); - } - return null; -} - interface SurfaceReachability { /** The metadata-type roots the BFS started from. */ rootTypes: string[]; @@ -1083,6 +1004,17 @@ function computeSurfaceReachability(): SurfaceReachability { // Emitted with authorable keys but no derivable object shape: nothing // to bridge on, so fail closed — demand the tombstone route rather // than silently widening the exception. + // + // #5317 narrowed WHO lands here rather than changing what happens once + // you do. Until then `zodShapeOf` read a `z.preprocess` node's IN side — + // the transform — so every preprocess node arrived shapeless and got + // this answer by accident rather than by measurement. One def actually + // did: `ui/InlineAction` (a `z.preprocess` with an object OUT) read + // 'root-graph' here, while its sole holder `ui/ElementButtonProps` — and + // its eight `ui/Element*Props` siblings — already read null. With the + // direction corrected it resolves its real 12-key shape, finds no bridge, + // and answers null like the rest of that family. Fail-closed is still the + // rule; it is just no longer the walker's default report. return 'root-graph'; } for (const [name, prop] of Object.entries(shape)) { @@ -1970,3 +1902,4 @@ writeFileWithRetry(bundledPath, JSON.stringify(bundledSchema, null, 2)); console.log(`\n✅ Generated bundled schema: objectstack.json (${Object.keys(defs).length} definitions)`); console.log(`\n✅ Successfully generated ${count} schemas.`); + diff --git a/packages/spec/scripts/lib/zod-graph.ts b/packages/spec/scripts/lib/zod-graph.ts new file mode 100644 index 0000000000..008e55ea2e --- /dev/null +++ b/packages/spec/scripts/lib/zod-graph.ts @@ -0,0 +1,189 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The Zod-graph walkers behind the authorable-surface deletion gate (#4650). + * + * `build-schemas.ts` is a top-level script with side effects, so these are + * extracted for the same reason `schema-index` (#4696), `format-type` (#4912), + * `schema-name` (#4592) and `def-key-collisions` (#5832) were: the only other + * way to assert on them is to run the whole generator and read what it wrote, + * and "what it wrote" is exactly the evidence a silent walker miss destroys. + * + * The miss in question is the one this module exists to pin — see + * `pipeAuthorableSide` below (#4488 / #5074 / #5317). + */ +import { z } from 'zod'; + +export function zodDefOf(schema: z.ZodType): Record | null { + const def = (schema as unknown as { _zod?: { def?: unknown } })._zod?.def; + return def && typeof def === 'object' ? (def as Record) : null; +} + +/** + * Every Zod schema instance a node's def references directly: shape values, + * union options, pipe in/out, record key/value, array element, wrapper inner + * types — found by walking the def's plain objects/arrays generically instead + * of enumerating node kinds (which would silently miss the next kind Zod + * adds). Two edges a generic def walk cannot see are added explicitly: + * `z.lazy` hides its target behind `getter()`, and check-clones (`.refine()`, + * `.describe()`, …) point back at the schema they cloned via `_zod.parent` — + * the clone is what a parent schema embeds (`ViewSchema.refine(…)` inside + * ViewMetadataSchema), while the BASELINE def is the original. + * + * Note this walk is direction-agnostic on purpose: it recurses into EVERY def + * value, so a pipe contributes both `in` and `out`. That is why the pipe-side + * bug below never affected BFS reachability itself — only the shape derivation. + */ +export function zodChildSchemas(schema: z.ZodType): z.ZodType[] { + const out: z.ZodType[] = []; + const def = zodDefOf(schema); + if (!def) return out; + const seen = new Set(); + const walk = (v: unknown): void => { + if (v == null) return; + if (v instanceof z.ZodType) { + out.push(v); + return; + } + if (typeof v !== 'object') return; + if (seen.has(v)) return; + seen.add(v); + if (Array.isArray(v)) { + for (const x of v) walk(x); + return; + } + if (v instanceof Map) { + for (const x of v.values()) walk(x); + return; + } + const proto = Object.getPrototypeOf(v); + if (proto === Object.prototype || proto === null) { + for (const x of Object.values(v)) walk(x); + } + }; + walk(def); + if (def.type === 'lazy' && typeof def.getter === 'function') { + try { + const inner = (def.getter as () => unknown)(); + if (inner instanceof z.ZodType) out.push(inner); + } catch { + // An unresolvable lazy getter has no graph to traverse; the schema it + // would have produced cannot be parsed against either. + } + } + const parent = (schema as unknown as { _zod?: { parent?: unknown } })._zod?.parent; + if (parent instanceof z.ZodType) out.push(parent); + return out; +} + +/** + * 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). + */ +const SHAPE_WRAPPER_TYPES = new Set([ + 'optional', + 'nullable', + 'default', + 'catch', + 'readonly', + 'nonoptional', +]); + +/** Does this pipe's IN side resolve to a `transform` — i.e. is it a `z.preprocess`? */ +function pipeInIsTransform(inSide: z.ZodType, depth: number): boolean { + if (depth > 12) return false; + const def = zodDefOf(inSide); + if (!def) return false; + if (def.type === 'transform') return true; + if (def.type === 'lazy' && typeof def.getter === 'function') { + try { + const inner = (def.getter as () => unknown)(); + return inner instanceof z.ZodType ? pipeInIsTransform(inner, depth + 1) : false; + } catch { + return false; + } + } + if (typeof def.type === 'string' && SHAPE_WRAPPER_TYPES.has(def.type) && def.innerType instanceof z.ZodType) { + return pipeInIsTransform(def.innerType, depth + 1); + } + return false; +} + +/** + * The authorable side of a `pipe` def — the side a metadata author writes. + * + * Two different constructs compile to the same `pipe` node, and their authorable + * sides are OPPOSITE: + * + * - `a.transform(fn)` — IN is `a`, the accepted input shape; OUT is the + * transform. Authors write the **IN** side. + * - `z.preprocess(fn, schema)` — IN is the **TRANSFORM**; OUT is `schema`. + * Authors write the **OUT** side. + * + * Reading `def.in` unconditionally therefore hands back a transform for every + * preprocess node, and a transform has no shape — so the caller concludes "no + * shape" and silently stops governing that schema. Silently is the whole + * problem: nothing anywhere reports it. + * + * This is the #4488 blind spot, and this is its FOURTH independent site: + * + * 1. `scripts/liveness/check-liveness.mts:191-205` — #4488, after + * `TranslationItemSchema`'s retired-dialect preprocess (#3778) made + * `translation` "walk to no shape, ungovernable"; + * 2. `src/kernel/metadata-authoring-lint.ts` — #5074; + * 3. `src/system/metadata-form-zod-reconciliation.test.ts` — #5074; + * 4. here — deliberately deferred out of #5074 because moving it can move + * generated evidence, then fixed as #5317 once that move was measured. + * + * Measured on the 25 registered metadata-type roots (2026-08-07): `action` is an + * `a.transform(fn)` pipe (`in=object out=transform`) and must keep reading IN — + * it resolves to a 43-key shape either way; `view` is a `z.preprocess` pipe + * (`in=transform out=union`, the console-decoration strip of #5074) and was + * reading the transform. + * + * The unwrap before the transform test matters: a preprocess node's IN can sit + * behind a `lazy`/wrapper, and a transform one level down is still a transform. + */ +export function pipeAuthorableSide(def: Record, depth = 0): z.ZodType | null { + const inSide = def.in instanceof z.ZodType ? def.in : null; + const outSide = def.out instanceof z.ZodType ? def.out : null; + if (inSide && pipeInIsTransform(inSide, depth)) return outSide; + return inSide ?? outSide; +} + +/** + * Unwrap pipes/wrappers/lazies down to a plain object def's shape, if any. + * + * 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. + */ +export function zodShapeOf(schema: z.ZodType, depth = 0): Record | null { + if (depth > 12) return null; + const def = zodDefOf(schema); + if (!def) return null; + if (def.type === 'object') { + const shape = def.shape; + return shape && typeof shape === 'object' ? (shape as Record) : null; + } + if (def.type === 'pipe') { + const side = pipeAuthorableSide(def); + return side ? zodShapeOf(side, depth + 1) : null; + } + if (def.type === 'lazy' && typeof def.getter === 'function') { + try { + const inner = (def.getter as () => unknown)(); + if (inner instanceof z.ZodType) return zodShapeOf(inner, depth + 1); + } catch { + return null; + } + } + if (typeof def.type === 'string' && SHAPE_WRAPPER_TYPES.has(def.type) && def.innerType instanceof z.ZodType) { + return zodShapeOf(def.innerType, depth + 1); + } + return null; +} diff --git a/packages/spec/scripts/zod-graph.test.ts b/packages/spec/scripts/zod-graph.test.ts new file mode 100644 index 0000000000..59b0942505 --- /dev/null +++ b/packages/spec/scripts/zod-graph.test.ts @@ -0,0 +1,148 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Pins the pipe-direction rule in `scripts/lib/zod-graph.ts` (#5317). + * + * ── The recurrence this file exists to stop ─────────────────────────────── + * `a.transform(fn)` and `z.preprocess(fn, a)` compile to the SAME `pipe` node, + * and the side an author writes is opposite in the two cases: IN for the first, + * OUT for the second. A walker that reads `def.in` unconditionally therefore + * hands back a transform for every preprocess node, derives no shape from it, + * and silently stops governing that schema. + * + * That bug has now been found and fixed FOUR times, independently: + * + * 1. `scripts/liveness/check-liveness.mts` — #4488, after + * `TranslationItemSchema`'s retired-dialect preprocess (#3778) made + * `translation` "walk to no shape, ungovernable"; + * 2. `src/kernel/metadata-authoring-lint.ts` — #5074; + * 3. `src/system/metadata-form-zod-reconciliation.test.ts` — #5074; + * 4. `scripts/lib/zod-graph.ts` — #5317, the site #5074 deliberately left + * alone because moving it can move generated evidence. + * + * Three sites carried the #4488 lesson as a code comment and it recurred anyway. + * So the fourth one gets an assertion instead: the synthetic cases below fail on + * 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. + */ +import { describe, expect, it } from 'vitest'; +import { z } from 'zod'; + +import { pipeAuthorableSide, zodDefOf, zodShapeOf } from './lib/zod-graph'; +import { + getMetadataTypeSchema, + listMetadataTypeSchemaTypes, +} from '../src/kernel/metadata-type-schemas'; +import { InlineActionSchema } from '../src/ui/action.zod'; + +const defTypeOf = (schema: z.ZodType | null): string | undefined => + schema ? (zodDefOf(schema)?.type as string | undefined) : undefined; + +describe('zodShapeOf — pipe direction (#4488, #5074, #5317)', () => { + it('reads the OUT side of a `z.preprocess` — the shape the author writes', () => { + const schema = z.preprocess((raw) => raw, z.object({ alpha: z.string(), beta: z.number() })); + + // Pre-#5317 this returned null: `def.in` is the preprocess transform. + expect(Object.keys(zodShapeOf(schema) ?? {})).toEqual(['alpha', 'beta']); + }); + + it('still reads the IN side of an `a.transform(fn)` — the opposite pipe', () => { + const schema = z.object({ alpha: z.string() }).transform((v) => v.alpha); + + // The regression guard for `action`, the registry's one transform-pipe root: + // taking OUT here would break exactly what taking IN breaks for preprocess. + expect(Object.keys(zodShapeOf(schema) ?? {})).toEqual(['alpha']); + }); + + it('sees a preprocess transform hidden behind a lazy or a wrapper', () => { + // `z.preprocess` over a lazily-constructed transform: the IN side is a + // `lazy` whose target is the transform, so a bare `def.in.type` test misses + // it. `lazySchema()` produces this shape whenever OS_EAGER_SCHEMAS is unset. + const viaLazy = z.preprocess((raw) => raw, z.object({ alpha: z.string() })); + const lazyWrapped = z.lazy(() => viaLazy); + expect(Object.keys(zodShapeOf(lazyWrapped) ?? {})).toEqual(['alpha']); + + const optionalWrapped = z.preprocess((raw) => raw, z.object({ beta: z.string() })).optional(); + expect(Object.keys(zodShapeOf(optionalWrapped) ?? {})).toEqual(['beta']); + }); + + it('resolves a REAL preprocess node in the shipped graph to its real shape', () => { + // `InlineActionSchema` is `lazySchema(() => z.preprocess(normalizeInlineAction, + // actionObject().pick({…}).partial({…}).refine(…)))` — a live preprocess whose + // OUT is an object, so it is the specimen the issue's pin describes. Pre-#5317 + // `zodShapeOf` returned null for it and the deletion gate fell through to its + // fail-closed default; now it reads the twelve keys an inline action accepts. + const shape = zodShapeOf(InlineActionSchema as unknown as z.ZodType); + + expect(shape).not.toBeNull(); + expect(Object.keys(shape ?? {})).toEqual( + expect.arrayContaining(['type', 'target', 'params', 'confirmText', 'opensInNewTab']), + ); + }); +}); + +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) => { + const schema = getMetadataTypeSchema(type); + if (!schema) return []; + const def = zodDefOf(schema); + if (def?.type !== 'pipe') return []; + return [{ type, def, side: pipeAuthorableSide(def) }]; + }); + + it('finds the registry pipes this pin was written against', () => { + // Not a snapshot of the registry — just proof the cases below are not + // vacuously green because every root stopped being a pipe. + expect(pipeRoots.map((r) => r.type).sort()).toEqual(expect.arrayContaining(['action', 'view'])); + }); + + it.each(['action', 'view'])( + 'never resolves `%s` to a transform — the #4488 failure mode', + (type) => { + const root = pipeRoots.find((r) => r.type === type); + expect(root, `${type} is no longer a pipe root — update this pin deliberately`).toBeDefined(); + expect(defTypeOf(root!.side)).not.toBe('transform'); + }, + ); + + it('resolves every registry pipe root to a non-transform side', () => { + // The recurrence guard proper: a NEW preprocess registration that the walker + // reads from the wrong end fails here on the day it lands, not three issues later. + for (const { type, side } of pipeRoots) { + expect(defTypeOf(side), `metadata type '${type}' resolved to a transform`).not.toBe('transform'); + } + }); + + it('keeps `action` (an `a.transform(fn)` root) resolving to its object shape', () => { + // The direction that was already right, pinned so a future edit cannot fix + // preprocess by breaking transform. + const schema = getMetadataTypeSchema('action'); + expect(schema).toBeDefined(); + expect(defTypeOf(pipeAuthorableSide(zodDefOf(schema!)!))).toBe('object'); + 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). + const schema = getMetadataTypeSchema('view'); + expect(schema).toBeDefined(); + expect(defTypeOf(pipeAuthorableSide(zodDefOf(schema!)!))).toBe('union'); + expect(zodShapeOf(schema!)).toBeNull(); + }); +});