Skip to content

Commit ce9c185

Browse files
os-zhuangclaude
andauthored
fix(spec): zodShapeOf reads a z.preprocess pipe from its OUT side (#5317) (#6102)
`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 Co-authored-by: Claude <noreply@anthropic.com>
1 parent 811c30c commit ce9c185

3 files changed

Lines changed: 353 additions & 83 deletions

File tree

packages/spec/scripts/build-schemas.ts

Lines changed: 16 additions & 83 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,10 @@ import {
1616
type EmittedDef,
1717
} from './lib/def-key-collisions';
1818
import { RENAMED_DEFS, carryAuthorableKey, checkRenameTable } from './lib/renamed-defs';
19+
// The Zod-graph walkers the authorable-surface reachability BFS runs on. Extracted
20+
// at #5317 so the pipe-direction rule (#4488) is assertable without running the
21+
// whole generator — see scripts/zod-graph.test.ts.
22+
import { zodChildSchemas, zodShapeOf } from './lib/zod-graph';
1923
import {
2024
AUTHORABLE_SURFACE_DESCRIPTION,
2125
AUTHORABLE_SURFACE_DIR_NAME,
@@ -930,89 +934,6 @@ function registeredClauseMajors(): Map<string, number> {
930934
return out;
931935
}
932936

933-
function zodDefOf(schema: z.ZodType): Record<string, unknown> | null {
934-
const def = (schema as unknown as { _zod?: { def?: unknown } })._zod?.def;
935-
return def && typeof def === 'object' ? (def as Record<string, unknown>) : null;
936-
}
937-
938-
/**
939-
* Every Zod schema instance a node's def references directly: shape values,
940-
* union options, pipe in/out, record key/value, array element, wrapper inner
941-
* types — found by walking the def's plain objects/arrays generically instead
942-
* of enumerating node kinds (which would silently miss the next kind Zod
943-
* adds). Two edges a generic def walk cannot see are added explicitly:
944-
* `z.lazy` hides its target behind `getter()`, and check-clones (`.refine()`,
945-
* `.describe()`, …) point back at the schema they cloned via `_zod.parent` —
946-
* the clone is what a parent schema embeds (`ViewSchema.refine(…)` inside
947-
* ViewMetadataSchema), while the BASELINE def is the original.
948-
*/
949-
function zodChildSchemas(schema: z.ZodType): z.ZodType[] {
950-
const out: z.ZodType[] = [];
951-
const def = zodDefOf(schema);
952-
if (!def) return out;
953-
const seen = new Set<unknown>();
954-
const walk = (v: unknown): void => {
955-
if (v == null) return;
956-
if (v instanceof z.ZodType) {
957-
out.push(v);
958-
return;
959-
}
960-
if (typeof v !== 'object') return;
961-
if (seen.has(v)) return;
962-
seen.add(v);
963-
if (Array.isArray(v)) {
964-
for (const x of v) walk(x);
965-
return;
966-
}
967-
if (v instanceof Map) {
968-
for (const x of v.values()) walk(x);
969-
return;
970-
}
971-
const proto = Object.getPrototypeOf(v);
972-
if (proto === Object.prototype || proto === null) {
973-
for (const x of Object.values(v)) walk(x);
974-
}
975-
};
976-
walk(def);
977-
if (def.type === 'lazy' && typeof def.getter === 'function') {
978-
try {
979-
const inner = (def.getter as () => unknown)();
980-
if (inner instanceof z.ZodType) out.push(inner);
981-
} catch {
982-
// An unresolvable lazy getter has no graph to traverse; the schema it
983-
// would have produced cannot be parsed against either.
984-
}
985-
}
986-
const parent = (schema as unknown as { _zod?: { parent?: unknown } })._zod?.parent;
987-
if (parent instanceof z.ZodType) out.push(parent);
988-
return out;
989-
}
990-
991-
/** Unwrap pipes/wrappers/lazies down to a plain object def's shape, if any. */
992-
function zodShapeOf(schema: z.ZodType, depth = 0): Record<string, unknown> | null {
993-
if (depth > 12) return null;
994-
const def = zodDefOf(schema);
995-
if (!def) return null;
996-
if (def.type === 'object') {
997-
const shape = def.shape;
998-
return shape && typeof shape === 'object' ? (shape as Record<string, unknown>) : null;
999-
}
1000-
if (def.type === 'pipe' && def.in instanceof z.ZodType) return zodShapeOf(def.in, depth + 1);
1001-
if (def.type === 'lazy' && typeof def.getter === 'function') {
1002-
try {
1003-
const inner = (def.getter as () => unknown)();
1004-
if (inner instanceof z.ZodType) return zodShapeOf(inner, depth + 1);
1005-
} catch {
1006-
return null;
1007-
}
1008-
}
1009-
const wrappers = new Set(['optional', 'nullable', 'default', 'catch', 'readonly', 'nonoptional']);
1010-
if (typeof def.type === 'string' && wrappers.has(def.type) && def.innerType instanceof z.ZodType) {
1011-
return zodShapeOf(def.innerType, depth + 1);
1012-
}
1013-
return null;
1014-
}
1015-
1016937
interface SurfaceReachability {
1017938
/** The metadata-type roots the BFS started from. */
1018939
rootTypes: string[];
@@ -1083,6 +1004,17 @@ function computeSurfaceReachability(): SurfaceReachability {
10831004
// Emitted with authorable keys but no derivable object shape: nothing
10841005
// to bridge on, so fail closed — demand the tombstone route rather
10851006
// than silently widening the exception.
1007+
//
1008+
// #5317 narrowed WHO lands here rather than changing what happens once
1009+
// you do. Until then `zodShapeOf` read a `z.preprocess` node's IN side —
1010+
// the transform — so every preprocess node arrived shapeless and got
1011+
// this answer by accident rather than by measurement. One def actually
1012+
// did: `ui/InlineAction` (a `z.preprocess` with an object OUT) read
1013+
// 'root-graph' here, while its sole holder `ui/ElementButtonProps` — and
1014+
// its eight `ui/Element*Props` siblings — already read null. With the
1015+
// direction corrected it resolves its real 12-key shape, finds no bridge,
1016+
// and answers null like the rest of that family. Fail-closed is still the
1017+
// rule; it is just no longer the walker's default report.
10861018
return 'root-graph';
10871019
}
10881020
for (const [name, prop] of Object.entries(shape)) {
@@ -1970,3 +1902,4 @@ writeFileWithRetry(bundledPath, JSON.stringify(bundledSchema, null, 2));
19701902
console.log(`\n✅ Generated bundled schema: objectstack.json (${Object.keys(defs).length} definitions)`);
19711903

19721904
console.log(`\n✅ Successfully generated ${count} schemas.`);
1905+
Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
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

Comments
 (0)