|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * The Zod-graph walkers behind the authorable-surface deletion gate (#4650). |
| 5 | + * |
| 6 | + * `build-schemas.ts` is a top-level script with side effects, so these are |
| 7 | + * extracted for the same reason `schema-index` (#4696), `format-type` (#4912), |
| 8 | + * `schema-name` (#4592) and `def-key-collisions` (#5832) were: the only other |
| 9 | + * way to assert on them is to run the whole generator and read what it wrote, |
| 10 | + * and "what it wrote" is exactly the evidence a silent walker miss destroys. |
| 11 | + * |
| 12 | + * The miss in question is the one this module exists to pin — see |
| 13 | + * `pipeAuthorableSide` below (#4488 / #5074 / #5317). |
| 14 | + */ |
| 15 | +import { z } from 'zod'; |
| 16 | + |
| 17 | +export function zodDefOf(schema: z.ZodType): Record<string, unknown> | null { |
| 18 | + const def = (schema as unknown as { _zod?: { def?: unknown } })._zod?.def; |
| 19 | + return def && typeof def === 'object' ? (def as Record<string, unknown>) : null; |
| 20 | +} |
| 21 | + |
| 22 | +/** |
| 23 | + * Every Zod schema instance a node's def references directly: shape values, |
| 24 | + * union options, pipe in/out, record key/value, array element, wrapper inner |
| 25 | + * types — found by walking the def's plain objects/arrays generically instead |
| 26 | + * of enumerating node kinds (which would silently miss the next kind Zod |
| 27 | + * adds). Two edges a generic def walk cannot see are added explicitly: |
| 28 | + * `z.lazy` hides its target behind `getter()`, and check-clones (`.refine()`, |
| 29 | + * `.describe()`, …) point back at the schema they cloned via `_zod.parent` — |
| 30 | + * the clone is what a parent schema embeds (`ViewSchema.refine(…)` inside |
| 31 | + * ViewMetadataSchema), while the BASELINE def is the original. |
| 32 | + * |
| 33 | + * Note this walk is direction-agnostic on purpose: it recurses into EVERY def |
| 34 | + * value, so a pipe contributes both `in` and `out`. That is why the pipe-side |
| 35 | + * bug below never affected BFS reachability itself — only the shape derivation. |
| 36 | + */ |
| 37 | +export function zodChildSchemas(schema: z.ZodType): z.ZodType[] { |
| 38 | + const out: z.ZodType[] = []; |
| 39 | + const def = zodDefOf(schema); |
| 40 | + if (!def) return out; |
| 41 | + const seen = new Set<unknown>(); |
| 42 | + const walk = (v: unknown): void => { |
| 43 | + if (v == null) return; |
| 44 | + if (v instanceof z.ZodType) { |
| 45 | + out.push(v); |
| 46 | + return; |
| 47 | + } |
| 48 | + if (typeof v !== 'object') return; |
| 49 | + if (seen.has(v)) return; |
| 50 | + seen.add(v); |
| 51 | + if (Array.isArray(v)) { |
| 52 | + for (const x of v) walk(x); |
| 53 | + return; |
| 54 | + } |
| 55 | + if (v instanceof Map) { |
| 56 | + for (const x of v.values()) walk(x); |
| 57 | + return; |
| 58 | + } |
| 59 | + const proto = Object.getPrototypeOf(v); |
| 60 | + if (proto === Object.prototype || proto === null) { |
| 61 | + for (const x of Object.values(v)) walk(x); |
| 62 | + } |
| 63 | + }; |
| 64 | + walk(def); |
| 65 | + if (def.type === 'lazy' && typeof def.getter === 'function') { |
| 66 | + try { |
| 67 | + const inner = (def.getter as () => unknown)(); |
| 68 | + if (inner instanceof z.ZodType) out.push(inner); |
| 69 | + } catch { |
| 70 | + // An unresolvable lazy getter has no graph to traverse; the schema it |
| 71 | + // would have produced cannot be parsed against either. |
| 72 | + } |
| 73 | + } |
| 74 | + const parent = (schema as unknown as { _zod?: { parent?: unknown } })._zod?.parent; |
| 75 | + if (parent instanceof z.ZodType) out.push(parent); |
| 76 | + return out; |
| 77 | +} |
| 78 | + |
| 79 | +/** |
| 80 | + * Wrapper defs that carry their subject in `innerType` and never change its shape. |
| 81 | + * |
| 82 | + * Deliberately the set `zodShapeOf` already used, byte for byte, so #5317 moves |
| 83 | + * ONLY the pipe direction. `prefault` — which the three sibling walkers below do |
| 84 | + * unwrap — is knowingly absent; adding it is a separate, separately measured |
| 85 | + * change (filed as its own finding, not smuggled in here). |
| 86 | + */ |
| 87 | +const SHAPE_WRAPPER_TYPES = new Set([ |
| 88 | + 'optional', |
| 89 | + 'nullable', |
| 90 | + 'default', |
| 91 | + 'catch', |
| 92 | + 'readonly', |
| 93 | + 'nonoptional', |
| 94 | +]); |
| 95 | + |
| 96 | +/** Does this pipe's IN side resolve to a `transform` — i.e. is it a `z.preprocess`? */ |
| 97 | +function pipeInIsTransform(inSide: z.ZodType, depth: number): boolean { |
| 98 | + if (depth > 12) return false; |
| 99 | + const def = zodDefOf(inSide); |
| 100 | + if (!def) return false; |
| 101 | + if (def.type === 'transform') return true; |
| 102 | + if (def.type === 'lazy' && typeof def.getter === 'function') { |
| 103 | + try { |
| 104 | + const inner = (def.getter as () => unknown)(); |
| 105 | + return inner instanceof z.ZodType ? pipeInIsTransform(inner, depth + 1) : false; |
| 106 | + } catch { |
| 107 | + return false; |
| 108 | + } |
| 109 | + } |
| 110 | + if (typeof def.type === 'string' && SHAPE_WRAPPER_TYPES.has(def.type) && def.innerType instanceof z.ZodType) { |
| 111 | + return pipeInIsTransform(def.innerType, depth + 1); |
| 112 | + } |
| 113 | + return false; |
| 114 | +} |
| 115 | + |
| 116 | +/** |
| 117 | + * The authorable side of a `pipe` def — the side a metadata author writes. |
| 118 | + * |
| 119 | + * Two different constructs compile to the same `pipe` node, and their authorable |
| 120 | + * sides are OPPOSITE: |
| 121 | + * |
| 122 | + * - `a.transform(fn)` — IN is `a`, the accepted input shape; OUT is the |
| 123 | + * transform. Authors write the **IN** side. |
| 124 | + * - `z.preprocess(fn, schema)` — IN is the **TRANSFORM**; OUT is `schema`. |
| 125 | + * Authors write the **OUT** side. |
| 126 | + * |
| 127 | + * Reading `def.in` unconditionally therefore hands back a transform for every |
| 128 | + * preprocess node, and a transform has no shape — so the caller concludes "no |
| 129 | + * shape" and silently stops governing that schema. Silently is the whole |
| 130 | + * problem: nothing anywhere reports it. |
| 131 | + * |
| 132 | + * This is the #4488 blind spot, and this is its FOURTH independent site: |
| 133 | + * |
| 134 | + * 1. `scripts/liveness/check-liveness.mts:191-205` — #4488, after |
| 135 | + * `TranslationItemSchema`'s retired-dialect preprocess (#3778) made |
| 136 | + * `translation` "walk to no shape, ungovernable"; |
| 137 | + * 2. `src/kernel/metadata-authoring-lint.ts` — #5074; |
| 138 | + * 3. `src/system/metadata-form-zod-reconciliation.test.ts` — #5074; |
| 139 | + * 4. here — deliberately deferred out of #5074 because moving it can move |
| 140 | + * generated evidence, then fixed as #5317 once that move was measured. |
| 141 | + * |
| 142 | + * Measured on the 25 registered metadata-type roots (2026-08-07): `action` is an |
| 143 | + * `a.transform(fn)` pipe (`in=object out=transform`) and must keep reading IN — |
| 144 | + * it resolves to a 43-key shape either way; `view` is a `z.preprocess` pipe |
| 145 | + * (`in=transform out=union`, the console-decoration strip of #5074) and was |
| 146 | + * reading the transform. |
| 147 | + * |
| 148 | + * The unwrap before the transform test matters: a preprocess node's IN can sit |
| 149 | + * behind a `lazy`/wrapper, and a transform one level down is still a transform. |
| 150 | + */ |
| 151 | +export function pipeAuthorableSide(def: Record<string, unknown>, depth = 0): z.ZodType | null { |
| 152 | + const inSide = def.in instanceof z.ZodType ? def.in : null; |
| 153 | + const outSide = def.out instanceof z.ZodType ? def.out : null; |
| 154 | + if (inSide && pipeInIsTransform(inSide, depth)) return outSide; |
| 155 | + return inSide ?? outSide; |
| 156 | +} |
| 157 | + |
| 158 | +/** |
| 159 | + * Unwrap pipes/wrappers/lazies down to a plain object def's shape, if any. |
| 160 | + * |
| 161 | + * Returns `null` for anything that is not (or does not unwrap to) a single |
| 162 | + * object node — a union included. See the `zod-graph.test.ts` pin for what that |
| 163 | + * means for `view`, whose preprocess OUT is a union. |
| 164 | + */ |
| 165 | +export function zodShapeOf(schema: z.ZodType, depth = 0): Record<string, unknown> | null { |
| 166 | + if (depth > 12) return null; |
| 167 | + const def = zodDefOf(schema); |
| 168 | + if (!def) return null; |
| 169 | + if (def.type === 'object') { |
| 170 | + const shape = def.shape; |
| 171 | + return shape && typeof shape === 'object' ? (shape as Record<string, unknown>) : null; |
| 172 | + } |
| 173 | + if (def.type === 'pipe') { |
| 174 | + const side = pipeAuthorableSide(def); |
| 175 | + return side ? zodShapeOf(side, depth + 1) : null; |
| 176 | + } |
| 177 | + if (def.type === 'lazy' && typeof def.getter === 'function') { |
| 178 | + try { |
| 179 | + const inner = (def.getter as () => unknown)(); |
| 180 | + if (inner instanceof z.ZodType) return zodShapeOf(inner, depth + 1); |
| 181 | + } catch { |
| 182 | + return null; |
| 183 | + } |
| 184 | + } |
| 185 | + if (typeof def.type === 'string' && SHAPE_WRAPPER_TYPES.has(def.type) && def.innerType instanceof z.ZodType) { |
| 186 | + return zodShapeOf(def.innerType, depth + 1); |
| 187 | + } |
| 188 | + return null; |
| 189 | +} |
0 commit comments