Skip to content

Commit a0a0252

Browse files
committed
fix(core-internal): prefault/intersection/promise tolerance deferral; symbol wire-drop through nonoptional; context-aware id-strip gates
- Three more false-tolerance spellings defer to the validate(undefined) probe: .prefault(v) feeds v THROUGH the inner schema (filling-then-revalidating is not filling - .min(1).prefault(0) rejects a missing key), intersections claim structural tolerance only for the provably-mergeable distinct-key plain-object-defaults shape (zod throws Unmergable intersection for two scalar defaults with different fills; the pinned async-refined object-defaults spelling keeps its structural claim), and z.promise gets a claims-nothing branch in this walk (zod 4's promise parse rejects undefined outright; the wrapper stays transparent for the root-type-verdict walks). - The nonoptional branch recognizes SERIALIZATION-drop tolerance: a symbol/function leaf can never appear on the wire regardless of validation, so it survives the re-forbid (z.object({s: z.symbol().optional()}) .required() drops s like the bare spelling) while acceptance tolerance still does not propagate. Added the Known-residual-gaps bullet for async-staged validating pipe OUT sides (neither structural direction is sound; conservative stay-required is pre-fix parity). - The id-strip gates are context-aware: any ref lexically inside a draft-04 id-carrying resource counts as hand-authored (cfworker resolves refs there base-relatively, and zod never emits a bare '#' inside an id entry - the strip silently INVERTED validation verdicts on both io paths), and $recursiveRef values count regardless of shape (zod never emits the keyword). Registry-only documents keep getting the strip. Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 74ab2bd commit a0a0252

2 files changed

Lines changed: 170 additions & 30 deletions

File tree

packages/core-internal/src/util/standardSchema.ts

Lines changed: 123 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,12 @@ export const JSON_SCHEMA_CONVERSION_TARGET = 'draft-2020-12';
229229
* cannot evaluate the fn, and deferring the preprocess spelling to the
230230
* validate(undefined) probe would mis-require async-refined preprocess fields
231231
* (the probe goes async and conservatively claims nothing).
232+
* - The converse trade for async-staged VALIDATING pipe OUT sides: a genuinely
233+
* tolerant `.default(5).pipe(z.number().min(1).refine(async () => true))` stays
234+
* advertised as required — the probe goes async, and claiming IN-side tolerance
235+
* structurally would wrongly drop `.default(0).pipe(z.number().min(1).refine(
236+
* async …))`. Neither structural direction is sound there, so the conservative
237+
* stay-required posture (byte-parity with the pre-#2464 emission) wins.
232238
* - Output schemas containing `.transform()`/`.pipe()`/`z.coerce` still advertise the
233239
* post-transform shape (`io: 'output'`) even though the server validates and ships
234240
* the raw pre-transform value — rewriting pipe nodes to their input side per-node
@@ -530,21 +536,25 @@ function rewriteOneOfToAnyOf(node: unknown, seen: Set<unknown> = new Set()): voi
530536
const ZOD_REGISTRY_REF_PATTERN = /^#\/\$defs\/[^/]+$/;
531537

532538
/**
533-
* Whether any reference keyword in the document carries a hand-authored value —
534-
* anything but zod's own registry shapes (`#`, `#/$defs/<name>`). Zod's registry
535-
* refs are root-base JSON Pointers that never resolve through a draft-04 `id`
536-
* base, so stripping `id` around them is safe; every OTHER ref may depend on an
537-
* `id` base on the cfworker engine (URI-form refs resolve through
538-
* `schema.$id || schema.id` registration, and even a fragment pointer INSIDE an
539-
* `id` resource resolves relative to that base). Position-aware like the guard
540-
* walk.
539+
* Whether any reference keyword in the document carries a hand-authored value.
540+
* Hand-authored-ness is decided by value SHAPE and lexical CONTEXT together:
541+
* outside `id` resources, anything but zod's own registry shapes (`#`,
542+
* `#/$defs/<name>` — root-base JSON Pointers that never resolve through a
543+
* draft-04 `id` base) is hand-authored; INSIDE a draft-04 `id`-carrying
544+
* resource, EVERY ref counts — resolution there is base-relative on the
545+
* cfworker engine (`schema.$id || schema.id` registration), so even a
546+
* registry-shaped `$ref: '#'` addresses the resource, a spelling zod's emitter
547+
* never produces at that position. `$recursiveRef` counts regardless of value
548+
* or position — zod never emits the keyword at all.
541549
*/
542550
function hasHandAuthoredRefValues(document: Record<string, unknown>): boolean {
543-
return someSchemaNode(document, record =>
551+
return someSchemaNode(document, (record, insideIdResource) =>
544552
['$ref', '$dynamicRef', '$recursiveRef'].some(refKey => {
545553
const value = record[refKey];
546554
if (value === undefined) return false;
547-
return typeof value !== 'string' || (value !== '#' && !ZOD_REGISTRY_REF_PATTERN.test(value));
555+
if (refKey === '$recursiveRef') return true;
556+
if (typeof value !== 'string' || insideIdResource) return true;
557+
return value !== '#' && !ZOD_REGISTRY_REF_PATTERN.test(value);
548558
})
549559
);
550560
}
@@ -571,30 +581,37 @@ function stripLegacyIdKeywords(document: Record<string, unknown>): void {
571581
* keywords are descended into (data-valued `const`/`enum`/`default`/`examples`
572582
* and annotation values stay opaque), and schema-map VALUES are schemas while
573583
* their keys stay names — a property literally named `id` or `$ref` is user
574-
* data, not a keyword. Stops at the first node where `predicate` returns true.
584+
* data, not a keyword. The predicate also receives whether the node sits
585+
* lexically inside (or at) a draft-04 `id`-carrying resource, where ref
586+
* resolution is base-relative. Stops at the first node where `predicate`
587+
* returns true.
575588
*/
576-
function someSchemaNode(document: Record<string, unknown>, predicate: (record: Record<string, unknown>) => boolean): boolean {
577-
const walk = (node: unknown, seen: Set<unknown>): boolean => {
589+
function someSchemaNode(
590+
document: Record<string, unknown>,
591+
predicate: (record: Record<string, unknown>, insideIdResource: boolean) => boolean
592+
): boolean {
593+
const walk = (node: unknown, seen: Set<unknown>, insideIdResource: boolean): boolean => {
578594
if (typeof node !== 'object' || node === null || seen.has(node)) return false;
579595
seen.add(node);
580-
if (Array.isArray(node)) return node.some(item => walk(item, seen));
596+
if (Array.isArray(node)) return node.some(item => walk(item, seen, insideIdResource));
581597
const record = node as Record<string, unknown>;
582-
if (predicate(record)) return true;
598+
const inIdResource = insideIdResource || typeof record.id === 'string';
599+
if (predicate(record, inIdResource)) return true;
583600
for (const [key, value] of Object.entries(record)) {
584601
if (!SCHEMA_CARRYING_JSON_SCHEMA_KEYWORDS.has(key)) continue;
585602
if (SCHEMA_MAP_JSON_SCHEMA_KEYWORDS.has(key) && typeof value === 'object' && value !== null && !Array.isArray(value)) {
586603
if (seen.has(value)) continue;
587604
seen.add(value);
588605
for (const subschema of Object.values(value as Record<string, unknown>)) {
589-
if (walk(subschema, seen)) return true;
606+
if (walk(subschema, seen, inIdResource)) return true;
590607
}
591608
continue;
592609
}
593-
if (walk(value, seen)) return true;
610+
if (walk(value, seen, inIdResource)) return true;
594611
}
595612
return false;
596613
};
597-
return walk(document, new Set());
614+
return walk(document, new Set(), false);
598615
}
599616

600617
/**
@@ -630,11 +647,18 @@ function someSchemaNode(document: Record<string, unknown>, predicate: (record: R
630647
* break all the same.
631648
*/
632649
function hasHandAuthoredReferenceConstructs(document: Record<string, unknown>): boolean {
633-
const walk = (node: unknown, isRoot: boolean, underPolarityBoundary: boolean, seen: Set<unknown>): boolean => {
650+
const walk = (
651+
node: unknown,
652+
isRoot: boolean,
653+
underPolarityBoundary: boolean,
654+
insideIdResource: boolean,
655+
seen: Set<unknown>
656+
): boolean => {
634657
if (typeof node !== 'object' || node === null || seen.has(node)) return false;
635658
seen.add(node);
636-
if (Array.isArray(node)) return node.some(item => walk(item, false, underPolarityBoundary, seen));
659+
if (Array.isArray(node)) return node.some(item => walk(item, false, underPolarityBoundary, insideIdResource, seen));
637660
const record = node as Record<string, unknown>;
661+
const inIdResource = insideIdResource || typeof record.id === 'string';
638662
for (const refKey of ['$ref', '$dynamicRef'] as const) {
639663
const value = record[refKey];
640664
if (value === undefined) continue;
@@ -645,6 +669,11 @@ function hasHandAuthoredReferenceConstructs(document: Record<string, unknown>):
645669
// loosening as a tightening the rename walk's lexical polarity skip
646670
// cannot see through the ref indirection.
647671
if (underPolarityBoundary) return true;
672+
// And they are loosen-safe only OUTSIDE draft-04 `id` resources: the
673+
// cfworker engine resolves refs base-relatively there, and zod never
674+
// emits a bare `#` inside an id-carrying entry — such refs are
675+
// hand-authored and observe the strip/loosening.
676+
if (inIdResource) return true;
648677
if (value !== '#' && !ZOD_REGISTRY_REF_PATTERN.test(value)) return true;
649678
}
650679
if (record.$anchor !== undefined || record.$dynamicAnchor !== undefined || record.$id !== undefined) return true;
@@ -670,15 +699,15 @@ function hasHandAuthoredReferenceConstructs(document: Record<string, unknown>):
670699
if (seen.has(value)) continue;
671700
seen.add(value);
672701
for (const subschema of Object.values(value as Record<string, unknown>)) {
673-
if (walk(subschema, false, childUnderBoundary, seen)) return true;
702+
if (walk(subschema, false, childUnderBoundary, inIdResource, seen)) return true;
674703
}
675704
continue;
676705
}
677-
if (walk(value, false, childUnderBoundary, seen)) return true;
706+
if (walk(value, false, childUnderBoundary, inIdResource, seen)) return true;
678707
}
679708
return false;
680709
};
681-
return walk(document, true, false, new Set());
710+
return walk(document, true, false, false, new Set());
682711
}
683712

684713
/**
@@ -893,7 +922,15 @@ function hasStructuralMissingKeyTolerance(field: unknown, ancestors: ReadonlySet
893922
// would step past the very node granting tolerance (bare `.optional()` fields
894923
// are already excluded from `required` by zod's emitter, but one inside a pipe
895924
// — `z.string().optional().transform(async ...)` — is not).
896-
if (def.type === 'default' || def.type === 'prefault' || def.type === 'catch' || def.type === 'optional') return true;
925+
if (def.type === 'default' || def.type === 'catch' || def.type === 'optional') return true;
926+
if (def.type === 'prefault') {
927+
// UNLIKE `.default()`, `.prefault(v)` feeds v THROUGH the inner schema —
928+
// `z.number().min(1).prefault(0)` rejects a missing key. Filling-then-
929+
// revalidating is not filling: claim nothing and let the probe decide
930+
// (sync verdicts are correct both ways; an async-refined valid-prefault
931+
// field conservatively stays required, matching the documented posture).
932+
return false;
933+
}
897934
if (def.type === 'any' || def.type === 'unknown' || def.type === 'undefined' || def.type === 'void') return true;
898935
if (def.type === 'symbol' || def.type === 'function') return true;
899936
if (def.type === 'literal' && Array.isArray(def.values) && def.values.includes(undefined)) return true;
@@ -931,28 +968,84 @@ function hasStructuralMissingKeyTolerance(field: unknown, ancestors: ReadonlySet
931968
return def.options.some(option => hasStructuralMissingKeyTolerance(option, path));
932969
}
933970
if (def.type === 'intersection' && def.left !== undefined && def.right !== undefined) {
934-
// EVERY-side semantics: `undefined` must parse through BOTH sides (each
935-
// filling its default) for zod to merge the results.
936-
return hasStructuralMissingKeyTolerance(def.left, path) && hasStructuralMissingKeyTolerance(def.right, path);
971+
// `undefined` must parse through BOTH sides AND the two filled results
972+
// must MERGE — zod throws 'Unmergable intersection' otherwise (two scalar
973+
// defaults with different values reject every payload omitting the key).
974+
// Merging is provable structurally only for the distinct-key
975+
// plain-object-defaults shape (the pinned async-refined spelling, where
976+
// the probe cannot be used); everything else defers to the probe.
977+
return intersectionSidesFillDisjointObjects(def.left, def.right);
978+
}
979+
if (def.type === 'promise') {
980+
// zod 4's promise parse rejects `undefined` outright regardless of the
981+
// inner type — there is no undefined-tolerant z.promise spelling. Claim
982+
// nothing (the generic unwind below would wrongly grant the inner's
983+
// tolerance); `promise` stays in WRAPPER_ZOD_DEF_TYPES for the
984+
// root-TYPE-verdict walks, where transparency is correct.
985+
return false;
937986
}
938987
if (def.type === 'nonoptional') {
939988
// z.nonoptional() RE-FORBIDS undefined, so tolerance by ACCEPTANCE inside
940989
// it (an inner optional, any/unknown, undefined-valued literals) does NOT
941-
// survive the wrapper — only tolerance by FILLING does (default/prefault/
942-
// static catch replace undefined before nonoptional's check runs). The
990+
// survive the wrapper — only tolerance by FILLING does (default/static
991+
// catch replace undefined before nonoptional's check runs). The
943992
// structural walk cannot tell the two apart, so it claims nothing and the
944993
// validate(undefined) probe in fieldAcceptsMissingKey decides: it returns
945994
// issues for `.optional().nonoptional()` (stays required) and success for
946995
// `.default(1).nonoptional()` (stays droppable). The generic unwind below
947996
// would wrongly propagate acceptance-tolerance through the re-forbid.
948-
return false;
997+
// SERIALIZATION-drop tolerance is the exception: a symbol/function leaf
998+
// can never appear on the wire (JSON.stringify drops the key) no matter
999+
// what validation demands, and the probe cannot see that — it survives
1000+
// the re-forbid.
1001+
return hasSerializationDroppedLeaf(def.innerType);
9491002
}
9501003
if (WRAPPER_ZOD_DEF_TYPES.has(def.type) && def.innerType !== undefined) {
9511004
return hasStructuralMissingKeyTolerance(def.innerType, path);
9521005
}
9531006
return false;
9541007
}
9551008

1009+
/**
1010+
* Whether the field unwinds (through transparent wrappers) to a symbol- or
1011+
* function-typed leaf — values `JSON.stringify` drops from the payload entirely,
1012+
* so the key can never appear on the wire regardless of what validation demands.
1013+
* Used where VALIDATION-based tolerance must not propagate but
1014+
* SERIALIZATION-based tolerance still applies (the `nonoptional` re-forbid).
1015+
*/
1016+
function hasSerializationDroppedLeaf(field: unknown): boolean {
1017+
if (typeof field !== 'object' || field === null) return false;
1018+
const def = (field as { _zod?: { def?: { type?: string; innerType?: unknown } } })._zod?.def;
1019+
if (def === undefined || typeof def.type !== 'string') return false;
1020+
if (def.type === 'symbol' || def.type === 'function') return true;
1021+
if (WRAPPER_ZOD_DEF_TYPES.has(def.type) && def.innerType !== undefined) return hasSerializationDroppedLeaf(def.innerType);
1022+
return false;
1023+
}
1024+
1025+
/**
1026+
* The one structurally-provable mergeable-intersection shape: BOTH sides are
1027+
* `.default()`s whose fill values are plain objects with disjoint key sets, so
1028+
* zod's merge of the two fills cannot throw. Function-form defaults
1029+
* (`.default(() => …)`) and every other spelling defer to the probe.
1030+
*/
1031+
function intersectionSidesFillDisjointObjects(left: unknown, right: unknown): boolean {
1032+
const leftFill = plainObjectDefaultFill(left);
1033+
if (leftFill === undefined) return false;
1034+
const rightFill = plainObjectDefaultFill(right);
1035+
if (rightFill === undefined) return false;
1036+
return Object.keys(leftFill).every(key => !Object.hasOwn(rightFill, key));
1037+
}
1038+
1039+
/** The side's `.default()` fill value, when it is a plain (non-array) object. */
1040+
function plainObjectDefaultFill(side: unknown): Record<string, unknown> | undefined {
1041+
if (typeof side !== 'object' || side === null) return undefined;
1042+
const def = (side as { _zod?: { def?: { type?: string; defaultValue?: unknown } } })._zod?.def;
1043+
if (def?.type !== 'default') return undefined;
1044+
const fill = def.defaultValue;
1045+
if (typeof fill !== 'object' || fill === null || Array.isArray(fill)) return undefined;
1046+
return fill as Record<string, unknown>;
1047+
}
1048+
9561049
/** Options for {@linkcode standardSchemaToJsonSchema}. */
9571050
export interface StandardSchemaToJsonSchemaOptions {
9581051
/**

packages/core-internal/test/util/standardSchema.test.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1200,6 +1200,53 @@ describe('zod conversion options (#2464)', () => {
12001200
expect(standardSchemaToJsonSchema(bareTransform, 'output').required).toEqual(['name']);
12011201
});
12021202

1203+
test('prefault, unmergeable intersections, and promise fields stay advertised required', () => {
1204+
// .prefault(v) feeds v THROUGH the inner schema — filling-then-revalidating
1205+
// is not filling, so the probe decides: the rejected fill keeps the field
1206+
// required, a valid fill keeps it droppable.
1207+
const prefaulted = z.object({ p: z.number().min(1).prefault(0), name: z.string() });
1208+
expect(standardSchemaToJsonSchema(prefaulted, 'output').required).toEqual(['p', 'name']);
1209+
const validPrefault = z.object({ p: z.number().min(1).prefault(5), name: z.string() });
1210+
expect(standardSchemaToJsonSchema(validPrefault, 'output').required).toEqual(['name']);
1211+
// Two scalar defaults with different values make zod throw 'Unmergable
1212+
// intersection' on every payload omitting the key.
1213+
const unmergeable = z.object({ m: z.intersection(z.number().default(0), z.number().default(1)), name: z.string() });
1214+
expect(standardSchemaToJsonSchema(unmergeable, 'output').required).toEqual(['m', 'name']);
1215+
// zod 4's promise parse rejects undefined regardless of the inner type.
1216+
const promised = z.object({ p: z.promise(z.number().default(0)), name: z.string() });
1217+
expect(standardSchemaToJsonSchema(promised, 'output').required).toEqual(['p', 'name']);
1218+
});
1219+
1220+
test('symbol leaves keep their wire-drop tolerance through nonoptional', () => {
1221+
// JSON.stringify drops symbol-valued keys regardless of what validation
1222+
// demands — serialization tolerance survives the re-forbid even though
1223+
// the probe (validation-only) cannot see it.
1224+
const required = z.object({ s: z.symbol().optional(), name: z.string() }).required();
1225+
expect(standardSchemaToJsonSchema(required, 'output').required).toEqual(['name']);
1226+
// Acceptance tolerance still must NOT survive.
1227+
const acceptance = z.object({ a: z.string().optional(), name: z.string() }).required();
1228+
expect(standardSchemaToJsonSchema(acceptance, 'output').required).toEqual(['a', 'name']);
1229+
});
1230+
1231+
test('registry-shaped refs inside an id resource keep the id (context-aware gate)', () => {
1232+
// cfworker resolves a `$ref: '#'` inside a draft-04 id resource
1233+
// base-relatively to THAT resource — zod never emits that spelling there,
1234+
// so it is hand-authored and the strip would invert every verdict.
1235+
const reg = z.object({ q: z.string(), self: z.unknown().optional().meta({ $ref: '#' }) }).meta({ id: 'RegSelf' });
1236+
const schema = z.object({ x: reg, name: z.string() });
1237+
for (const io of ['output', 'input'] as const) {
1238+
const result = standardSchemaToJsonSchema(schema, io);
1239+
expect(((result.$defs as Record<string, Record<string, unknown>>).RegSelf ?? {}).id).toBe('RegSelf');
1240+
const validate = new CfWorkerJsonSchemaValidator().getValidator(result);
1241+
expect(validate({ x: { q: 'a', self: { q: 'b' } }, name: 'n' }).valid).toBe(true); // resource-shaped self
1242+
expect(validate({ x: { q: 'a', self: { x: { q: 'c' }, name: 'm' } }, name: 'n' }).valid).toBe(false); // root-shaped self
1243+
}
1244+
// Registry-only documents (no refs inside entries) still get the strip.
1245+
const plain = z.object({ q: z.string() }).meta({ id: 'RegPlain' });
1246+
const stripped = standardSchemaToJsonSchema(z.object({ x: plain, y: plain, name: z.string() }), 'output');
1247+
expect(((stripped.$defs as Record<string, Record<string, unknown>>).RegPlain ?? {}).id).toBeUndefined();
1248+
});
1249+
12031250
test('input conversions keep draft-04 id when hand-authored refs need its base', () => {
12041251
// The input branch defers the strip under the same gate as the output
12051252
// paths — no reference guard runs on input, but the hand-authored-ref

0 commit comments

Comments
 (0)