From 3c067c4d3ef67caaac48bbf9ccf316986e2149c2 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 13:22:37 +0000 Subject: [PATCH 01/35] fix(core-internal): make zod toJSONSchema conversion wire-truthful for tool schemas The SDK validates tool payloads with the user's zod schema but ships the raw object, so the advertised JSON Schema must describe that raw shape. Pass zod-scoped conversion options (via libraryOptions on the Standard JSON Schema path, and directly on the zod 4.0-4.1 fallback): - unrepresentable: 'any' so one z.date()/z.bigint() field no longer throws and fails the entire tools/list response - rewrite z.date() to {type: 'string', format: 'date-time'}, the shape JSON.stringify actually produces for a Date - for output schemas, drop .default()-carrying fields from required and drop additionalProperties: false on plain z.object() (kept for z.strictObject()), so validating clients accept legitimate structuredContent the server ships as returned Fixes #2464 Co-Authored-By: Claude --- .changeset/zod-tojsonschema-wire-truthful.md | 14 +++ .../core-internal/src/util/standardSchema.ts | 63 +++++++++- .../test/util/standardSchema.test.ts | 44 +++++++ .../util/standardSchema.zodFallback.test.ts | 12 ++ .../test/server/toolSchemaWireShape.test.ts | 115 ++++++++++++++++++ 5 files changed, 246 insertions(+), 2 deletions(-) create mode 100644 .changeset/zod-tojsonschema-wire-truthful.md create mode 100644 packages/server/test/server/toolSchemaWireShape.test.ts diff --git a/.changeset/zod-tojsonschema-wire-truthful.md b/.changeset/zod-tojsonschema-wire-truthful.md new file mode 100644 index 0000000000..8827ec783c --- /dev/null +++ b/.changeset/zod-tojsonschema-wire-truthful.md @@ -0,0 +1,14 @@ +--- +'@modelcontextprotocol/core-internal': patch +'@modelcontextprotocol/server': patch +--- + +Make zod-to-JSON-Schema conversion wire-truthful for tool schemas. A `z.date()` (or any +unrepresentable type) in a registered tool's schema no longer throws during conversion and +fails the entire `tools/list` response — dates are advertised as `{type: 'string', format: +'date-time'}` (the shape `JSON.stringify` actually produces), and other unrepresentable +types degrade to an unconstrained schema. Output schemas no longer advertise constraints the +server doesn't enforce on the raw `structuredContent` it ships: `.default()`-carrying fields +are dropped from `required`, and `additionalProperties: false` is dropped for plain +`z.object()` (kept for `z.strictObject()`), so validating clients no longer reject legitimate +tool results. diff --git a/packages/core-internal/src/util/standardSchema.ts b/packages/core-internal/src/util/standardSchema.ts index d904c7f5fa..eff654bf9b 100644 --- a/packages/core-internal/src/util/standardSchema.ts +++ b/packages/core-internal/src/util/standardSchema.ts @@ -169,6 +169,57 @@ let warnedZodFallback = false; /** JSON Schema draft targeted by every conversion; shared so pattern references above stay in lockstep. */ export const JSON_SCHEMA_CONVERSION_TARGET = 'draft-2020-12'; +/** + * Zod-specific `toJSONSchema` options, passed as `libraryOptions` on the Standard JSON + * Schema path (scoped to `vendor === 'zod'`) and spread into the zod 4.0–4.1 + * `z.toJSONSchema()` fallback. + * + * The SDK validates payloads with the user's zod schema but ships the tool's *raw* + * object — validation never replaces `structuredContent` — so the advertised schema + * must describe the raw object's serialized wire form (#2464): + * + * - `unrepresentable: 'any'`: a single unrepresentable type (e.g. `z.bigint()`) degrades + * to an unconstrained `{}` instead of throwing and failing the entire `tools/list`. + * - `z.date()` is rewritten to `{type: 'string', format: 'date-time'}` — the shape + * `JSON.stringify` actually produces for a `Date` (and what the zod 3 converter emitted). + * - Output objects drop `additionalProperties: false` unless the object is strict: + * zod validation tolerates unknown keys on plain `z.object()`, and the raw payload + * ships them. + * - Output objects drop `.default()`-carrying properties from `required`: zod fills + * defaults during validation, but the shipped payload may legitimately omit them. + */ +function zodConversionOptions(io: 'input' | 'output'): Pick { + return { + unrepresentable: 'any', + override: ctx => { + const def = ctx.zodSchema._zod.def; + if (def.type === 'date') { + for (const key of Object.keys(ctx.jsonSchema)) delete ctx.jsonSchema[key]; + ctx.jsonSchema.type = 'string'; + ctx.jsonSchema.format = 'date-time'; + return; + } + if (io !== 'output' || def.type !== 'object') return; + const isStrict = def.catchall?._zod.def.type === 'never'; + if (!isStrict && ctx.jsonSchema.additionalProperties === false) { + delete ctx.jsonSchema.additionalProperties; + } + const properties = ctx.jsonSchema.properties; + const required = ctx.jsonSchema.required; + if (properties && Array.isArray(required)) { + const filtered = required.filter(name => { + const property = properties[name]; + return typeof property !== 'object' || property.default === undefined; + }); + if (filtered.length !== required.length) { + if (filtered.length === 0) delete ctx.jsonSchema.required; + else ctx.jsonSchema.required = filtered; + } + } + } + }; +} + /** * Converts a StandardSchema to JSON Schema for use as an MCP tool/prompt schema. * @@ -184,7 +235,11 @@ export function standardSchemaToJsonSchema(schema: StandardJSONSchemaV1, io: 'in const std = schema['~standard']; let result: Record; if (std.jsonSchema) { - result = std.jsonSchema[io]({ target: JSON_SCHEMA_CONVERSION_TARGET }); + result = std.jsonSchema[io]({ + target: JSON_SCHEMA_CONVERSION_TARGET, + // Non-zod vendors receive no libraryOptions, so their behavior is unchanged. + libraryOptions: std.vendor === 'zod' ? zodConversionOptions(io) : undefined + }); } else if (std.vendor === 'zod') { // zod 4.0–4.1 implements StandardSchemaV1 but not StandardJSONSchemaV1 (`~standard.jsonSchema`). // The SDK already bundles zod 4, so fall back to its converter rather than crashing on tools/list. @@ -203,7 +258,11 @@ export function standardSchemaToJsonSchema(schema: StandardJSONSchemaV1, io: 'in 'Falling back to z.toJSONSchema(). Upgrade to zod >=4.2.0 to silence this warning.' ); } - result = z.toJSONSchema(schema as unknown as z.ZodType, { target: JSON_SCHEMA_CONVERSION_TARGET, io }) as Record; + result = z.toJSONSchema(schema as unknown as z.ZodType, { + target: JSON_SCHEMA_CONVERSION_TARGET, + io, + ...zodConversionOptions(io) + }) as Record; } else { throw new Error( `Schema library "${std.vendor}" does not implement StandardJSONSchemaV1 (\`~standard.jsonSchema\`). ` + diff --git a/packages/core-internal/test/util/standardSchema.test.ts b/packages/core-internal/test/util/standardSchema.test.ts index 8856592ff0..e00746339a 100644 --- a/packages/core-internal/test/util/standardSchema.test.ts +++ b/packages/core-internal/test/util/standardSchema.test.ts @@ -40,3 +40,47 @@ describe('standardSchemaToJsonSchema', () => { expect(result.type).toBe('object'); }); }); + +describe('zod conversion options (#2464)', () => { + test('z.date() converts to string/date-time instead of throwing (input)', () => { + const result = standardSchemaToJsonSchema(z.object({ when: z.date() }), 'input'); + + expect((result.properties as Record).when).toEqual({ type: 'string', format: 'date-time' }); + }); + + test('z.date() converts to string/date-time instead of throwing (output)', () => { + const result = standardSchemaToJsonSchema(z.object({ when: z.date() }), 'output'); + + expect((result.properties as Record).when).toEqual({ type: 'string', format: 'date-time' }); + }); + + test('other unrepresentable types degrade to an unconstrained schema instead of throwing', () => { + const result = standardSchemaToJsonSchema(z.object({ big: z.bigint() }), 'input'); + + expect((result.properties as Record).big).toEqual({}); + }); + + test('defaulted fields are not advertised as required in output schemas', () => { + const schema = z.object({ counted: z.number().default(0), name: z.string() }); + const result = standardSchemaToJsonSchema(schema, 'output'); + + // The server ships the tool's raw structuredContent without filling defaults, + // so a payload omitting `counted` must satisfy the advertised schema. + expect(result.required).toEqual(['name']); + }); + + test('plain z.object() output schemas do not advertise additionalProperties:false', () => { + const result = standardSchemaToJsonSchema(z.object({ name: z.string() }), 'output'); + + // zod validation passes unknown keys through on plain objects, so the raw + // payload may carry extras the advertised schema must not forbid. + expect(result.additionalProperties).toBeUndefined(); + }); + + test('z.strictObject() output schemas keep additionalProperties:false', () => { + const result = standardSchemaToJsonSchema(z.strictObject({ name: z.string() }), 'output'); + + // Strict objects reject extras during validation, so the promise is kept. + expect(result.additionalProperties).toBe(false); + }); +}); diff --git a/packages/core-internal/test/util/standardSchema.zodFallback.test.ts b/packages/core-internal/test/util/standardSchema.zodFallback.test.ts index f8862b08a3..666da75ab9 100644 --- a/packages/core-internal/test/util/standardSchema.zodFallback.test.ts +++ b/packages/core-internal/test/util/standardSchema.zodFallback.test.ts @@ -22,6 +22,18 @@ describe('standardSchemaToJsonSchema — zod fallback paths', () => { warn.mockRestore(); }); + it('applies the zod conversion options on the fallback path (z.date() does not throw)', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const real = z.object({ when: z.date() }); + const { jsonSchema: _drop, ...stdNoJson } = real['~standard'] as unknown as Record; + void _drop; + Object.defineProperty(real, '~standard', { value: { ...stdNoJson, vendor: 'zod' }, configurable: true }); + + const result = standardSchemaToJsonSchema(real as unknown as SchemaArg); + expect((result.properties as Record)?.when).toEqual({ type: 'string', format: 'date-time' }); + warn.mockRestore(); + }); + it('throws a clear error for zod 3 (vendor=zod, no ~standard.jsonSchema, no _zod)', () => { // zod 3.24+ reports `~standard.vendor === 'zod'` but has no `_zod` internal marker. const zod3ish = { _def: {}, '~standard': { version: 1, vendor: 'zod', validate: () => ({ value: {} }) } }; diff --git a/packages/server/test/server/toolSchemaWireShape.test.ts b/packages/server/test/server/toolSchemaWireShape.test.ts new file mode 100644 index 0000000000..7fd0d0af26 --- /dev/null +++ b/packages/server/test/server/toolSchemaWireShape.test.ts @@ -0,0 +1,115 @@ +import type { JSONRPCMessage } from '@modelcontextprotocol/core-internal'; +import { InMemoryTransport, LATEST_PROTOCOL_VERSION } from '@modelcontextprotocol/core-internal'; +import { describe, expect, it, vi } from 'vitest'; +import * as z from 'zod/v4'; +import { McpServer } from '../../src/index'; +import { AjvJsonSchemaValidator } from '../../src/validators/ajv'; + +/** + * Regression tests for #2464: the schemas advertised in `tools/list` must describe + * the raw payloads the server actually ships — a `z.date()` field must not fail the + * whole listing, and a valid `structuredContent` must satisfy the advertised + * `outputSchema` when re-validated by a spec-compliant client. + */ + +type ResponseMessage = { id?: number; result?: Record; error?: { message: string } }; + +async function connectRawClient(server: McpServer) { + const [client, srv] = InMemoryTransport.createLinkedPair(); + await server.connect(srv); + await client.start(); + + const responses: JSONRPCMessage[] = []; + client.onmessage = m => responses.push(m); + + await client.send({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { + protocolVersion: LATEST_PROTOCOL_VERSION, + capabilities: {}, + clientInfo: { name: 'c', version: '1.0.0' } + } + } as JSONRPCMessage); + await client.send({ jsonrpc: '2.0', method: 'notifications/initialized' } as JSONRPCMessage); + + const request = async (id: number, method: string, params?: Record) => { + await client.send({ jsonrpc: '2.0', id, method, params } as JSONRPCMessage); + await vi.waitFor(() => expect(responses.some(r => 'id' in r && r.id === id)).toBe(true)); + return responses.find(r => 'id' in r && r.id === id) as ResponseMessage; + }; + + return { request }; +} + +describe('tools/list with z.date() in a tool schema (#2464)', () => { + it('lists all tools and advertises the date field as string/date-time', async () => { + const server = new McpServer({ name: 't', version: '1.0.0' }); + + server.registerTool('plain', { inputSchema: { x: z.number() } }, async ({ x }) => ({ + content: [{ type: 'text' as const, text: String(x) }] + })); + server.registerTool('dated', { inputSchema: { when: z.date() } }, async () => ({ content: [] })); + + const { request } = await connectRawClient(server); + const response = (await request(2, 'tools/list')) as { + result?: { tools: Array<{ name: string; inputSchema: { properties?: Record } }> }; + error?: { message: string }; + }; + + // Before the fix, one z.date() tool failed the ENTIRE tools/list response + // ("Date cannot be represented in JSON Schema"). + expect(response.error).toBeUndefined(); + const tools = response.result?.tools ?? []; + expect(tools.map(t => t.name).sort()).toEqual(['dated', 'plain']); + expect(tools.find(t => t.name === 'dated')?.inputSchema.properties?.when).toEqual({ + type: 'string', + format: 'date-time' + }); + + await server.close(); + }); +}); + +describe('advertised outputSchema accepts the raw structuredContent the server ships (#2464)', () => { + it('a payload omitting a defaulted field and carrying an extra key satisfies the advertised schema', async () => { + const server = new McpServer({ name: 't', version: '1.0.0' }); + + server.registerTool( + 'echo', + { inputSchema: {}, outputSchema: { counted: z.number().default(0), name: z.string() } }, + // Omits the defaulted `counted` and returns an extra key — both pass zod + // validation (defaults filled, extras tolerated), and the raw object ships. + async () => ({ content: [], structuredContent: { name: 'x', sneaky: true } as unknown as { name: string } }) + ); + + const { request } = await connectRawClient(server); + + const list = (await request(2, 'tools/list')) as { + result?: { tools: Array<{ outputSchema?: Record }> }; + }; + const outputSchema = list.result?.tools[0]?.outputSchema; + expect(outputSchema).toBeDefined(); + + const call = (await request(3, 'tools/call', { name: 'echo', arguments: {} })) as { + result?: { isError?: boolean; structuredContent?: unknown }; + error?: { message: string }; + }; + expect(call.error).toBeUndefined(); + expect(call.result?.isError).toBeFalsy(); + expect(call.result?.structuredContent).toEqual({ name: 'x', sneaky: true }); + + // Re-validate the shipped payload against the advertised schema, exactly as the + // SDK's own Client does. Before the fix this failed with "must have required + // property 'counted'" and "must NOT have additional properties". + const validate = new AjvJsonSchemaValidator().getValidator(outputSchema!); + expect(validate(call.result?.structuredContent)).toEqual({ + valid: true, + data: { name: 'x', sneaky: true }, + errorMessage: undefined + }); + + await server.close(); + }); +}); From 034ccc128ca44c438c23cfbe67c63a7c9e86b4e0 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 15:51:42 +0000 Subject: [PATCH 02/35] fix(core-internal): address review findings on zod conversion options - Keep user annotations (.describe()/.meta()) on rewritten z.date() fields: under unrepresentable: 'any' the node carries only annotation keywords, so the previous wipe loop deleted exactly the user's metadata and nothing else. - Key the output required-filter on the zod shape instead of the emitted default keyword: a registered .default() hides its default behind a $ref (and stayed required), a .meta({default}) annotation on a genuinely-required field was wrongly dropped, and undefined- accepting fields (z.any(), z.unknown(), z.undefined()) stayed required even though JSON.stringify drops undefined-valued keys from the wire. A field is now dropped from required iff validating undefined succeeds (missing key semantics). - Opt the elicitation path out of the graceful-degradation options via a new unrepresentable: 'throw' conversion option: a z.date() rewritten to string/date-time would pass the wire checks, but the accepted response could never satisfy z.date() on handler re-entry, so acceptedContent() would silently discard the user's answer. z.date() keeps throwing the documented TypeError before anything is sent. Co-Authored-By: Claude --- .../core-internal/src/shared/elicitation.ts | 7 ++- .../core-internal/src/util/standardSchema.ts | 56 +++++++++++++++---- .../test/shared/inputRequired.test.ts | 12 ++++ .../test/util/standardSchema.test.ts | 44 +++++++++++++++ 4 files changed, 108 insertions(+), 11 deletions(-) diff --git a/packages/core-internal/src/shared/elicitation.ts b/packages/core-internal/src/shared/elicitation.ts index fc728cdcef..92e61cdbd4 100644 --- a/packages/core-internal/src/shared/elicitation.ts +++ b/packages/core-internal/src/shared/elicitation.ts @@ -28,7 +28,12 @@ function isJsonObject(value: unknown): value is Record { function convertStandardElicitationSchema(schema: StandardSchemaWithJSON): Record { try { - return standardSchemaToJsonSchema(schema, 'input'); + // `unrepresentable: 'throw'`: the restricted form grammar must reject shapes it + // cannot round-trip. A `z.date()` rewritten to `string`/`date-time` would pass the + // wire checks, but the accepted response (a JSON string) could never satisfy the + // same `z.date()` schema on handler re-entry — keep the documented loud failure + // (`z.iso.date()`/`z.iso.datetime()` are the supported ways to elicit dates). + return standardSchemaToJsonSchema(schema, 'input', { unrepresentable: 'throw' }); } catch (error) { const detail = error instanceof Error ? error.message : String(error); throw new ProtocolError( diff --git a/packages/core-internal/src/util/standardSchema.ts b/packages/core-internal/src/util/standardSchema.ts index eff654bf9b..f773a470ca 100644 --- a/packages/core-internal/src/util/standardSchema.ts +++ b/packages/core-internal/src/util/standardSchema.ts @@ -194,7 +194,8 @@ function zodConversionOptions(io: 'input' | 'output'): Pick { const def = ctx.zodSchema._zod.def; if (def.type === 'date') { - for (const key of Object.keys(ctx.jsonSchema)) delete ctx.jsonSchema[key]; + // Under `unrepresentable: 'any'` the node carries only user annotations + // (`.describe()` / `.meta()`) — keep them and stamp the wire shape beside them. ctx.jsonSchema.type = 'string'; ctx.jsonSchema.format = 'date-time'; return; @@ -204,13 +205,13 @@ function zodConversionOptions(io: 'input' | 'output'): Pick { - const property = properties[name]; - return typeof property !== 'object' || property.default === undefined; - }); + if (Array.isArray(required)) { + // Keyed on the zod shape, not the emitted JSON: a registered `.default()` + // hides its `default` keyword behind a `$ref`, and undefined-accepting + // fields (`z.any()`, `z.unknown()`, …) never emit one yet may be dropped + // from the wire payload by JSON.stringify. + const filtered = required.filter(name => !fieldAcceptsMissingKey(def.shape[name])); if (filtered.length !== required.length) { if (filtered.length === 0) delete ctx.jsonSchema.required; else ctx.jsonSchema.required = filtered; @@ -220,6 +221,19 @@ function zodConversionOptions(io: 'input' | 'output'): Pick { +export interface StandardSchemaToJsonSchemaOptions { + /** + * How types JSON Schema cannot represent (`z.date()`, `z.bigint()`, …) are handled + * for zod schemas: + * + * - `'wire'` (default) — degrade gracefully: `z.date()` becomes + * `{type: 'string', format: 'date-time'}` (the shape `JSON.stringify` puts on the + * wire for a `Date`) and other unrepresentable types become an unconstrained + * schema, so one field cannot fail an entire `tools/list` response (#2464). + * - `'throw'` — surface zod's conversion error. The elicitation path uses this: its + * restricted form grammar must reject shapes it cannot round-trip, and a silently + * rewritten `string`/`date-time` request would elicit a string that the original + * `z.date()` schema can never re-validate on handler re-entry. + */ + unrepresentable?: 'wire' | 'throw'; +} + +export function standardSchemaToJsonSchema( + schema: StandardJSONSchemaV1, + io: 'input' | 'output' = 'input', + options?: StandardSchemaToJsonSchemaOptions +): Record { const std = schema['~standard']; + const zodOptions = options?.unrepresentable === 'throw' ? undefined : zodConversionOptions(io); let result: Record; if (std.jsonSchema) { result = std.jsonSchema[io]({ target: JSON_SCHEMA_CONVERSION_TARGET, // Non-zod vendors receive no libraryOptions, so their behavior is unchanged. - libraryOptions: std.vendor === 'zod' ? zodConversionOptions(io) : undefined + libraryOptions: std.vendor === 'zod' ? zodOptions : undefined }); } else if (std.vendor === 'zod') { // zod 4.0–4.1 implements StandardSchemaV1 but not StandardJSONSchemaV1 (`~standard.jsonSchema`). @@ -261,7 +297,7 @@ export function standardSchemaToJsonSchema(schema: StandardJSONSchemaV1, io: 'in result = z.toJSONSchema(schema as unknown as z.ZodType, { target: JSON_SCHEMA_CONVERSION_TARGET, io, - ...zodConversionOptions(io) + ...zodOptions }) as Record; } else { throw new Error( diff --git a/packages/core-internal/test/shared/inputRequired.test.ts b/packages/core-internal/test/shared/inputRequired.test.ts index bdc8187ac8..6f1ef3e04d 100644 --- a/packages/core-internal/test/shared/inputRequired.test.ts +++ b/packages/core-internal/test/shared/inputRequired.test.ts @@ -160,6 +160,18 @@ describe('inputRequired() builder', () => { requestedSchema: z.object({ role: z.union([z.literal('admin'), z.literal('member')]) }) }) ).toThrow(TypeError); + + // z.date() must keep failing loudly even though the tools-path conversion rewrites + // it to string/date-time (#2464): the accepted response (a JSON string) could never + // satisfy z.date() on handler re-entry, so acceptedContent() would silently return + // undefined. z.iso.date()/z.iso.datetime() are the supported ways to elicit dates. + const rejectDate = () => + inputRequired.elicit({ + message: 'When?', + requestedSchema: z.object({ when: z.date() }) + }); + expect(rejectDate).toThrow(TypeError); + expect(rejectDate).toThrow(/Date cannot be represented/); }); test.each([ diff --git a/packages/core-internal/test/util/standardSchema.test.ts b/packages/core-internal/test/util/standardSchema.test.ts index e00746339a..7fbfc28bc9 100644 --- a/packages/core-internal/test/util/standardSchema.test.ts +++ b/packages/core-internal/test/util/standardSchema.test.ts @@ -60,6 +60,22 @@ describe('zod conversion options (#2464)', () => { expect((result.properties as Record).big).toEqual({}); }); + test('z.date() keeps user annotations alongside the rewritten wire shape', () => { + const result = standardSchemaToJsonSchema(z.object({ when: z.date().describe('event timestamp') }), 'input'); + + expect((result.properties as Record).when).toEqual({ + type: 'string', + format: 'date-time', + description: 'event timestamp' + }); + }); + + test("unrepresentable: 'throw' restores zod's conversion error (elicitation contract)", () => { + expect(() => standardSchemaToJsonSchema(z.object({ when: z.date() }), 'input', { unrepresentable: 'throw' })).toThrow( + /Date cannot be represented/ + ); + }); + test('defaulted fields are not advertised as required in output schemas', () => { const schema = z.object({ counted: z.number().default(0), name: z.string() }); const result = standardSchemaToJsonSchema(schema, 'output'); @@ -69,6 +85,34 @@ describe('zod conversion options (#2464)', () => { expect(result.required).toEqual(['name']); }); + test('a registered .default() (emitted as $ref) is still dropped from output required', () => { + const schema = z.object({ counted: z.number().default(0).meta({ id: 'StandardSchemaTestCounted' }), name: z.string() }); + const result = standardSchemaToJsonSchema(schema, 'output'); + + // The `default` keyword hides inside $defs behind a bare $ref; the filter must + // key on the zod shape, not the emitted JSON. + expect((result.properties as Record>).counted?.$ref).toBeDefined(); + expect(result.required).toEqual(['name']); + }); + + test('a required field annotated with .meta({default}) stays required in output schemas', () => { + const schema = z.object({ label: z.string().meta({ default: 'n/a' }), other: z.number() }); + const result = standardSchemaToJsonSchema(schema, 'output'); + + // The annotation carries a `default` keyword, but validation still requires the + // field, so every shipped payload carries it. + expect(result.required).toEqual(['label', 'other']); + }); + + test('undefined-accepting fields are not advertised as required in output schemas', () => { + const schema = z.object({ a: z.any(), u: z.unknown(), v: z.undefined(), name: z.string() }); + const result = standardSchemaToJsonSchema(schema, 'output'); + + // A raw payload with an undefined-valued key passes validation, and + // JSON.stringify drops the key from the wire entirely. + expect(result.required).toEqual(['name']); + }); + test('plain z.object() output schemas do not advertise additionalProperties:false', () => { const result = standardSchemaToJsonSchema(z.object({ name: z.string() }), 'output'); From d11d6fc46204f87864ab02dfa68ab8f20dfd06e0 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 16:40:16 +0000 Subject: [PATCH 03/35] fix(core-internal): harden the missing-key probe and cover enum-keyed records - fieldAcceptsMissingKey no longer lets a throwing .transform()/.refine() escape the probe: depending on the zod build, ~standard.validate(undefined) either throws synchronously or returns a rejecting Promise whose unhandled rejection crashes the process during tools/list conversion. Both modes now conservatively keep the field required, and the Promise branch attaches a no-op catch so no rejection floats. - Enum-keyed records (def.type === 'record') also emit a required list zod does not enforce when the shared value schema is defaulted or undefined-accepting; the override now drops it (all-or-nothing, since every key shares the one value schema). - Move StandardSchemaToJsonSchemaOptions above the conversion function's JSDoc block so the doc comment re-attaches to the function it describes, and give the interface its own one-liner. - Stop overclaiming in the changeset and JSDoc: BigInt values embedded as defaults or metadata (.default(0n), .meta({default: 1n})) still fail conversion inside zod's own processors, outside the override's reach. Co-Authored-By: Claude --- .changeset/zod-tojsonschema-wire-truthful.md | 22 ++++--- .../core-internal/src/util/standardSchema.ts | 59 +++++++++++++------ .../test/util/standardSchema.test.ts | 31 ++++++++++ 3 files changed, 85 insertions(+), 27 deletions(-) diff --git a/.changeset/zod-tojsonschema-wire-truthful.md b/.changeset/zod-tojsonschema-wire-truthful.md index 8827ec783c..9eeabaf16f 100644 --- a/.changeset/zod-tojsonschema-wire-truthful.md +++ b/.changeset/zod-tojsonschema-wire-truthful.md @@ -3,12 +3,16 @@ '@modelcontextprotocol/server': patch --- -Make zod-to-JSON-Schema conversion wire-truthful for tool schemas. A `z.date()` (or any -unrepresentable type) in a registered tool's schema no longer throws during conversion and -fails the entire `tools/list` response — dates are advertised as `{type: 'string', format: -'date-time'}` (the shape `JSON.stringify` actually produces), and other unrepresentable -types degrade to an unconstrained schema. Output schemas no longer advertise constraints the -server doesn't enforce on the raw `structuredContent` it ships: `.default()`-carrying fields -are dropped from `required`, and `additionalProperties: false` is dropped for plain -`z.object()` (kept for `z.strictObject()`), so validating clients no longer reject legitimate -tool results. +Make zod-to-JSON-Schema conversion wire-truthful for tool schemas. A `z.date()` (or another +unrepresentable type such as `z.bigint()`) in a registered tool's schema no longer throws +during conversion and fails the entire `tools/list` response — dates are advertised as +`{type: 'string', format: 'date-time'}` (the shape `JSON.stringify` actually produces), and +other unrepresentable types degrade to an unconstrained schema. (BigInt values embedded as +defaults or metadata, e.g. `.default(0n)`, still fail conversion — JSON cannot carry them.) +Output schemas no longer advertise constraints the server doesn't enforce on the raw +`structuredContent` it ships: fields that may be legitimately absent (`.default()`, +undefined-accepting types) are dropped from `required` — on objects and enum-keyed records — +and `additionalProperties: false` is dropped for plain `z.object()` (kept for +`z.strictObject()`), so validating clients no longer reject legitimate tool results. +Elicitation is unaffected: `inputRequired.elicit()` keeps throwing on schemas its restricted +form grammar cannot round-trip, including `z.date()`. diff --git a/packages/core-internal/src/util/standardSchema.ts b/packages/core-internal/src/util/standardSchema.ts index f773a470ca..c5058befae 100644 --- a/packages/core-internal/src/util/standardSchema.ts +++ b/packages/core-internal/src/util/standardSchema.ts @@ -180,13 +180,16 @@ export const JSON_SCHEMA_CONVERSION_TARGET = 'draft-2020-12'; * * - `unrepresentable: 'any'`: a single unrepresentable type (e.g. `z.bigint()`) degrades * to an unconstrained `{}` instead of throwing and failing the entire `tools/list`. + * (BigInt *values* embedded as defaults or metadata — `.default(0n)`, `.meta({default: 1n})` + * — still throw: zod JSON-round-trips them in its own processors, outside this hook's reach.) * - `z.date()` is rewritten to `{type: 'string', format: 'date-time'}` — the shape * `JSON.stringify` actually produces for a `Date` (and what the zod 3 converter emitted). * - Output objects drop `additionalProperties: false` unless the object is strict: * zod validation tolerates unknown keys on plain `z.object()`, and the raw payload * ships them. - * - Output objects drop `.default()`-carrying properties from `required`: zod fills - * defaults during validation, but the shipped payload may legitimately omit them. + * - Output objects and enum-keyed records drop properties that may be legitimately + * absent from the shipped payload (`.default()`, undefined-accepting types) from + * `required`: zod fills defaults during validation, but ships the raw object. */ function zodConversionOptions(io: 'input' | 'output'): Pick { return { @@ -200,7 +203,16 @@ function zodConversionOptions(io: 'input' | 'output'): Pick {}); + return false; + } + return result.issues === undefined; + } catch { + return false; + } } -/** - * Converts a StandardSchema to JSON Schema for use as an MCP tool/prompt schema. - * - * MCP requires `type: "object"` at the root of tool `inputSchema` and prompt - * argument schemas; `outputSchema` may have any JSON Schema root (SEP-2106). - * Zod's discriminated unions emit `{oneOf: [...]}` without a top-level `type`, - * so for `io: 'input'` this function defaults `type` to `"object"` when absent - * and throws on an explicit non-object `type` (e.g. `z.string()`). For - * `io: 'output'` a non-object root is returned as-is; the `"object"` default is - * applied only when the root is provably object-shaped. - */ +/** Options for {@linkcode standardSchemaToJsonSchema}. */ export interface StandardSchemaToJsonSchemaOptions { /** * How types JSON Schema cannot represent (`z.date()`, `z.bigint()`, …) are handled @@ -262,6 +274,17 @@ export interface StandardSchemaToJsonSchemaOptions { unrepresentable?: 'wire' | 'throw'; } +/** + * Converts a StandardSchema to JSON Schema for use as an MCP tool/prompt schema. + * + * MCP requires `type: "object"` at the root of tool `inputSchema` and prompt + * argument schemas; `outputSchema` may have any JSON Schema root (SEP-2106). + * Zod's discriminated unions emit `{oneOf: [...]}` without a top-level `type`, + * so for `io: 'input'` this function defaults `type` to `"object"` when absent + * and throws on an explicit non-object `type` (e.g. `z.string()`). For + * `io: 'output'` a non-object root is returned as-is; the `"object"` default is + * applied only when the root is provably object-shaped. + */ export function standardSchemaToJsonSchema( schema: StandardJSONSchemaV1, io: 'input' | 'output' = 'input', diff --git a/packages/core-internal/test/util/standardSchema.test.ts b/packages/core-internal/test/util/standardSchema.test.ts index 7fbfc28bc9..acdaae0676 100644 --- a/packages/core-internal/test/util/standardSchema.test.ts +++ b/packages/core-internal/test/util/standardSchema.test.ts @@ -113,6 +113,37 @@ describe('zod conversion options (#2464)', () => { expect(result.required).toEqual(['name']); }); + test('enum-keyed records with a defaulted value drop the emitted required list (output)', () => { + const schema = z.object({ tallies: z.record(z.enum(['likes', 'shares']), z.number().default(0)), name: z.string() }); + const result = standardSchemaToJsonSchema(schema, 'output'); + + // zod fills record defaults during validation, so `{}` is a legitimate raw value + // for the record node — but the record field itself stays required on the parent. + const tallies = (result.properties as Record>).tallies!; + expect(tallies.required).toBeUndefined(); + expect(result.required).toEqual(['tallies', 'name']); + }); + + test('enum-keyed records with a strict value keep the emitted required list (output)', () => { + const schema = z.record(z.enum(['likes', 'shares']), z.number()); + const result = standardSchemaToJsonSchema(schema, 'output'); + + // Validation rejects a missing key here, so the required list is truthful. + expect(result.required).toEqual(['likes', 'shares']); + }); + + test('a required field whose transform throws on undefined stays required and does not crash', async () => { + const schema = z.object({ n: z.unknown().transform(v => (v as string).length), name: z.string() }); + const result = standardSchemaToJsonSchema(schema, 'output'); + + // The missing-key probe cannot demonstrate tolerance (the transform throws on + // undefined; depending on the zod version the probe throws synchronously or + // returns a rejecting Promise) — the field conservatively stays required, and + // no unhandled rejection may escape (vitest fails the run on one). + expect(result.required).toEqual(['n', 'name']); + await new Promise(resolve => setTimeout(resolve, 10)); + }); + test('plain z.object() output schemas do not advertise additionalProperties:false', () => { const result = standardSchemaToJsonSchema(z.object({ name: z.string() }), 'output'); From 665a05623fdfff69d303adddc3bb8bc9b5bfca17 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 17:26:24 +0000 Subject: [PATCH 04/35] docs(core-internal): scope wire-truthfulness claims to exclude pipe/coerce outputs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Output schemas containing .transform()/.pipe()/z.coerce still advertise the post-transform shape (io: 'output') while the server validates and ships the raw pre-transform value — a pre-existing mismatch this PR does not address. Note it as a known residual gap in the zodConversionOptions contract and scope the changeset claim accordingly, instead of rewriting pipe nodes in the override: a per-node input-side re-conversion would break $refs to registered schemas (a nested conversion's $defs land at the wrong document root), and advertising output schemas with input semantics wholesale is a design decision that interacts with SEP-2106 non-object output roots. Co-Authored-By: Claude --- .changeset/zod-tojsonschema-wire-truthful.md | 9 ++++++--- packages/core-internal/src/util/standardSchema.ts | 7 +++++++ 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/.changeset/zod-tojsonschema-wire-truthful.md b/.changeset/zod-tojsonschema-wire-truthful.md index 9eeabaf16f..8afe3a4ab0 100644 --- a/.changeset/zod-tojsonschema-wire-truthful.md +++ b/.changeset/zod-tojsonschema-wire-truthful.md @@ -13,6 +13,9 @@ Output schemas no longer advertise constraints the server doesn't enforce on the `structuredContent` it ships: fields that may be legitimately absent (`.default()`, undefined-accepting types) are dropped from `required` — on objects and enum-keyed records — and `additionalProperties: false` is dropped for plain `z.object()` (kept for -`z.strictObject()`), so validating clients no longer reject legitimate tool results. -Elicitation is unaffected: `inputRequired.elicit()` keeps throwing on schemas its restricted -form grammar cannot round-trip, including `z.date()`. +`z.strictObject()`), so validating clients no longer reject legitimate tool results for +these schema shapes. (Output schemas containing `.transform()`/`.pipe()`/`z.coerce` still +advertise the post-transform shape while the server ships the raw pre-transform value — a +pre-existing gap this change does not address.) Elicitation is unaffected: +`inputRequired.elicit()` keeps throwing on schemas its restricted form grammar cannot +round-trip, including `z.date()`. diff --git a/packages/core-internal/src/util/standardSchema.ts b/packages/core-internal/src/util/standardSchema.ts index c5058befae..110e4e16ae 100644 --- a/packages/core-internal/src/util/standardSchema.ts +++ b/packages/core-internal/src/util/standardSchema.ts @@ -190,6 +190,13 @@ export const JSON_SCHEMA_CONVERSION_TARGET = 'draft-2020-12'; * - Output objects and enum-keyed records drop properties that may be legitimately * absent from the shipped payload (`.default()`, undefined-accepting types) from * `required`: zod fills defaults during validation, but ships the raw object. + * + * Known residual gap: output schemas containing `.transform()`/`.pipe()`/`z.coerce` + * still advertise the post-transform shape (`io: 'output'`) even though the server + * validates and ships the raw pre-transform value — rewriting pipe nodes to their + * input side per-node would break `$ref`s to registered schemas, and converting + * output advertisements with input semantics wholesale is a design decision that + * interacts with SEP-2106 non-object output roots (see #2464 discussion). */ function zodConversionOptions(io: 'input' | 'output'): Pick { return { From 802ac991df4d86d81872bc6467077eabaa0cbc00 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 18:16:30 +0000 Subject: [PATCH 05/35] docs(core-internal): note zod <4.3.0 override skip on reused cloned schemas MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On zod 4.0-4.2.x, toJSONSchema guards the override hook with 'if (!seen.isParent)' (v4/core/to-json-schema.js), and a schema instance is marked isParent whenever a clone of it (.describe()/.meta()) appears in the same conversion — so a schema reused both bare and via a clone skips sanitization on the bare node: untruthful required/ additionalProperties survive and z.date() emits {}. zod 4.3.0 removed the guard, so the lockfile resolution (4.3.6) and the test suite cannot observe it. Verified against the published 4.2.1 and 4.3.0 tarballs. Document it as a known residual gap and scope the changeset claim; bumping the declared zod floor (^4.2.0 -> ^4.3.0) would close it for the primary path but is a maintainer decision. Co-Authored-By: Claude --- .changeset/zod-tojsonschema-wire-truthful.md | 5 ++++- .../core-internal/src/util/standardSchema.ts | 18 ++++++++++++------ 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/.changeset/zod-tojsonschema-wire-truthful.md b/.changeset/zod-tojsonschema-wire-truthful.md index 8afe3a4ab0..87073431e1 100644 --- a/.changeset/zod-tojsonschema-wire-truthful.md +++ b/.changeset/zod-tojsonschema-wire-truthful.md @@ -16,6 +16,9 @@ and `additionalProperties: false` is dropped for plain `z.object()` (kept for `z.strictObject()`), so validating clients no longer reject legitimate tool results for these schema shapes. (Output schemas containing `.transform()`/`.pipe()`/`z.coerce` still advertise the post-transform shape while the server ships the raw pre-transform value — a -pre-existing gap this change does not address.) Elicitation is unaffected: +pre-existing gap this change does not address. And on zod 4.0–4.2.x, `toJSONSchema` skips +the sanitization hook on a schema reused both bare and via a `.describe()`/`.meta()` clone +in the same conversion; full per-node sanitization requires zod >=4.3.0.) Elicitation is +unaffected: `inputRequired.elicit()` keeps throwing on schemas its restricted form grammar cannot round-trip, including `z.date()`. diff --git a/packages/core-internal/src/util/standardSchema.ts b/packages/core-internal/src/util/standardSchema.ts index 110e4e16ae..ccef0f896a 100644 --- a/packages/core-internal/src/util/standardSchema.ts +++ b/packages/core-internal/src/util/standardSchema.ts @@ -191,12 +191,18 @@ export const JSON_SCHEMA_CONVERSION_TARGET = 'draft-2020-12'; * absent from the shipped payload (`.default()`, undefined-accepting types) from * `required`: zod fills defaults during validation, but ships the raw object. * - * Known residual gap: output schemas containing `.transform()`/`.pipe()`/`z.coerce` - * still advertise the post-transform shape (`io: 'output'`) even though the server - * validates and ships the raw pre-transform value — rewriting pipe nodes to their - * input side per-node would break `$ref`s to registered schemas, and converting - * output advertisements with input semantics wholesale is a design decision that - * interacts with SEP-2106 non-object output roots (see #2464 discussion). + * Known residual gaps: + * - Output schemas containing `.transform()`/`.pipe()`/`z.coerce` still advertise the + * post-transform shape (`io: 'output'`) even though the server validates and ships + * the raw pre-transform value — rewriting pipe nodes to their input side per-node + * would break `$ref`s to registered schemas, and converting output advertisements + * with input semantics wholesale is a design decision that interacts with SEP-2106 + * non-object output roots (see #2464 discussion). + * - On zod 4.0–4.2.x, `toJSONSchema` skips the `override` hook on any node whose + * clone (`.describe()`/`.meta()`) appears in the same conversion (the + * `if (!seen.isParent)` guard in `v4/core/to-json-schema.js`, removed in zod + * 4.3.0), so a schema reused both bare and via a clone leaves the bare node + * unsanitized. Full per-node sanitization requires zod >=4.3.0. */ function zodConversionOptions(io: 'input' | 'output'): Pick { return { From d28811df6686c01fd385302fcb6f2577bcd52935 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 19:10:30 +0000 Subject: [PATCH 06/35] fix(core-internal): close async-default, .catch(), and unrepresentable-root gaps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - fieldAcceptsMissingKey short-circuits on def.type 'default'/'prefault' before the validate(undefined) probe: a defaulted field accepts a missing key by construction, but an async stage (.refine(async ...)) pushed the probe to a Promise and conservatively kept the field in the advertised required list while async-capable server validation accepts the omission. - Output .catch() nodes degrade to an unconstrained schema (annotations and the emitted default kept): catch-validation accepts any raw value — the fallback replaces it only in the parsed result, which the server never ships — so advertising the inner constraints made validating clients reject legitimate results. - The input-root guard keys on the emitted type, which unrepresentable: 'any' erases for bare z.bigint()/z.map()/z.set()/ z.symbol() roots — they were stamped {type: 'object'} and advertised as permanently-uncallable tools. Recover the signal from the zod def so misregistered roots keep throwing the actionable 'must describe objects' error. - Document the InMemoryTransport transport-dependence of the z.date() string/date-time advertisement (pass-by-reference, no JSON round-trip) as a known residual gap in the JSDoc and changeset. Co-Authored-By: Claude --- .changeset/zod-tojsonschema-wire-truthful.md | 6 +- .../core-internal/src/util/standardSchema.ts | 57 +++++++++++++++++++ .../test/util/standardSchema.test.ts | 41 +++++++++++++ 3 files changed, 102 insertions(+), 2 deletions(-) diff --git a/.changeset/zod-tojsonschema-wire-truthful.md b/.changeset/zod-tojsonschema-wire-truthful.md index 87073431e1..0bb7ee075a 100644 --- a/.changeset/zod-tojsonschema-wire-truthful.md +++ b/.changeset/zod-tojsonschema-wire-truthful.md @@ -18,7 +18,9 @@ these schema shapes. (Output schemas containing `.transform()`/`.pipe()`/`z.coer advertise the post-transform shape while the server ships the raw pre-transform value — a pre-existing gap this change does not address. And on zod 4.0–4.2.x, `toJSONSchema` skips the sanitization hook on a schema reused both bare and via a `.describe()`/`.meta()` clone -in the same conversion; full per-node sanitization requires zod >=4.3.0.) Elicitation is -unaffected: +in the same conversion; full per-node sanitization requires zod >=4.3.0. And the +`z.date()` advertisement assumes a serializing transport: `InMemoryTransport` passes the +raw `Date` by reference, so a validating client rejects it over that testing transport.) +Elicitation is unaffected: `inputRequired.elicit()` keeps throwing on schemas its restricted form grammar cannot round-trip, including `z.date()`. diff --git a/packages/core-internal/src/util/standardSchema.ts b/packages/core-internal/src/util/standardSchema.ts index ccef0f896a..98cfaad29a 100644 --- a/packages/core-internal/src/util/standardSchema.ts +++ b/packages/core-internal/src/util/standardSchema.ts @@ -190,6 +190,9 @@ export const JSON_SCHEMA_CONVERSION_TARGET = 'draft-2020-12'; * - Output objects and enum-keyed records drop properties that may be legitimately * absent from the shipped payload (`.default()`, undefined-accepting types) from * `required`: zod fills defaults during validation, but ships the raw object. + * - Output `.catch()` nodes degrade to an unconstrained schema (annotations and the + * emitted `default` kept): catch-validation accepts any raw value — the fallback + * replaces it only in the parsed result, which the server never ships. * * Known residual gaps: * - Output schemas containing `.transform()`/`.pipe()`/`z.coerce` still advertise the @@ -203,6 +206,10 @@ export const JSON_SCHEMA_CONVERSION_TARGET = 'draft-2020-12'; * `if (!seen.isParent)` guard in `v4/core/to-json-schema.js`, removed in zod * 4.3.0), so a schema reused both bare and via a clone leaves the bare node * unsanitized. Full per-node sanitization requires zod >=4.3.0. + * - The `z.date()` → `string`/`date-time` advertisement assumes a serializing + * transport. `InMemoryTransport` passes messages by reference with no JSON + * round-trip, so the raw `Date` the server must ship reaches a validating client + * as a `Date` instance and fails the advertised schema there. */ function zodConversionOptions(io: 'input' | 'output'): Pick { return { @@ -217,6 +224,15 @@ function zodConversionOptions(io: 'input' | 'output'): Pick = new Set([ + '$comment', + 'default', + 'deprecated', + 'description', + 'examples', + 'readOnly', + 'title', + 'writeOnly' +]); + /** * Whether a raw payload that omits this field still passes validation (zod treats a * missing key as `undefined` — true for `.default()`/`.prefault()`, `z.any()`, @@ -256,6 +288,12 @@ function zodConversionOptions(io: 'input' | 'output'): Pick = new Set(['bigint', 'symbol', 'map', 'set']); + /** * A typeless JSON Schema root is "provably object-shaped" when either it carries object keywords * directly (`properties`/`patternProperties`/`additionalProperties`/`required`), or it is a diff --git a/packages/core-internal/test/util/standardSchema.test.ts b/packages/core-internal/test/util/standardSchema.test.ts index acdaae0676..74256d4a03 100644 --- a/packages/core-internal/test/util/standardSchema.test.ts +++ b/packages/core-internal/test/util/standardSchema.test.ts @@ -132,6 +132,47 @@ describe('zod conversion options (#2464)', () => { expect(result.required).toEqual(['likes', 'shares']); }); + test('a defaulted field with an async stage is still dropped from output required', () => { + const schema = z.object({ + d: z + .number() + .default(0) + .refine(async () => true), + name: z.string() + }); + const result = standardSchemaToJsonSchema(schema, 'output'); + + // The async refine pushes the validate(undefined) probe to a Promise, but a + // defaulted field accepts a missing key by construction — decided structurally. + expect(result.required).toEqual(['name']); + }); + + test('.catch() output nodes degrade to an unconstrained schema (annotations kept)', () => { + const schema = z.object({ + inner: z.object({ n: z.string() }).catch({ n: 'd' }).describe('lenient'), + scalar: z.number().catch(0), + name: z.string() + }); + const result = standardSchemaToJsonSchema(schema, 'output'); + + // Catch-validation accepts any raw value (the fallback replaces it only in the + // parsed result, which never ships), so no inner constraint may be advertised. + const properties = result.properties as Record>; + expect(properties.inner).toEqual({ description: 'lenient', default: { n: 'd' } }); + expect(properties.scalar).toEqual({ default: 0 }); + // A raw payload omitting the catch fields also validates, so they are not required. + expect(result.required).toEqual(['name']); + }); + + test('unrepresentable non-object roots still throw on the input path', () => { + // `unrepresentable: 'any'` degrades these roots to a typeless {}, which must not + // be stamped `type: 'object'` — the tool would be advertised but never callable. + expect(() => standardSchemaToJsonSchema(z.bigint(), 'input')).toThrow(/must describe objects/); + expect(() => standardSchemaToJsonSchema(z.map(z.string(), z.number()), 'input')).toThrow(/must describe objects/); + expect(() => standardSchemaToJsonSchema(z.set(z.string()), 'input')).toThrow(/must describe objects/); + expect(() => standardSchemaToJsonSchema(z.symbol(), 'input')).toThrow(/must describe objects/); + }); + test('a required field whose transform throws on undefined stays required and does not crash', async () => { const schema = z.object({ n: z.unknown().transform(v => (v as string).length), name: z.string() }); const result = standardSchemaToJsonSchema(schema, 'output'); From b2bd59da32a993e64ff61901290909c93dd6b672 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 19:49:58 +0000 Subject: [PATCH 07/35] fix(core-internal): root-safe .catch() degrade, x-* annotations, fuller root guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Skip the .catch() degrade at the conversion root: deleting 'type: object' there flipped the 2025-era codec's legacy-wrap predicate (isNonObjectJsonSchemaRoot), silently shipping structuredContent as {result: ...} to 2025-era peers for root-level .catch() output schemas that were object-rooted pre-PR. - Preserve x-* vendor-extension keys through the nested .catch() degrade, mirroring the elicitation walker's annotation-only convention — they carry no validation constraint. - Extend NON_OBJECT_UNREPRESENTABLE_ROOTS with 'void', 'undefined', 'nan', and 'function': all degrade to a typeless {} under unrepresentable: 'any' and can never accept a JSON object, so they must keep throwing the actionable root error (z.custom() stays excluded — it can legitimately accept objects). - Document the input-side z.date() round-trip impossibility (advertised string/date-time vs raw-zod input validation) as a known residual gap in the JSDoc and changeset, pointing at z.iso.date()/ z.iso.datetime() as the supported input spellings. Co-Authored-By: Claude --- .changeset/zod-tojsonschema-wire-truthful.md | 6 ++-- .../core-internal/src/util/standardSchema.ts | 33 +++++++++++++++---- .../test/util/standardSchema.test.ts | 17 ++++++++++ 3 files changed, 48 insertions(+), 8 deletions(-) diff --git a/.changeset/zod-tojsonschema-wire-truthful.md b/.changeset/zod-tojsonschema-wire-truthful.md index 0bb7ee075a..1347527a10 100644 --- a/.changeset/zod-tojsonschema-wire-truthful.md +++ b/.changeset/zod-tojsonschema-wire-truthful.md @@ -20,7 +20,9 @@ pre-existing gap this change does not address. And on zod 4.0–4.2.x, `toJSONSc the sanitization hook on a schema reused both bare and via a `.describe()`/`.meta()` clone in the same conversion; full per-node sanitization requires zod >=4.3.0. And the `z.date()` advertisement assumes a serializing transport: `InMemoryTransport` passes the -raw `Date` by reference, so a validating client rejects it over that testing transport.) -Elicitation is unaffected: +raw `Date` by reference, so a validating client rejects it over that testing transport. +On the input side, a `z.date()` tool/prompt argument is advertised as `string`/`date-time` +but input validation still runs the raw zod schema, which rejects strings — use +`z.iso.date()`/`z.iso.datetime()` for date-valued inputs.) Elicitation is unaffected: `inputRequired.elicit()` keeps throwing on schemas its restricted form grammar cannot round-trip, including `z.date()`. diff --git a/packages/core-internal/src/util/standardSchema.ts b/packages/core-internal/src/util/standardSchema.ts index 98cfaad29a..e03741ca83 100644 --- a/packages/core-internal/src/util/standardSchema.ts +++ b/packages/core-internal/src/util/standardSchema.ts @@ -190,11 +190,17 @@ export const JSON_SCHEMA_CONVERSION_TARGET = 'draft-2020-12'; * - Output objects and enum-keyed records drop properties that may be legitimately * absent from the shipped payload (`.default()`, undefined-accepting types) from * `required`: zod fills defaults during validation, but ships the raw object. - * - Output `.catch()` nodes degrade to an unconstrained schema (annotations and the - * emitted `default` kept): catch-validation accepts any raw value — the fallback - * replaces it only in the parsed result, which the server never ships. + * - Output `.catch()` nodes (below the root) degrade to an unconstrained schema + * (annotations and the emitted `default` kept): catch-validation accepts any raw + * value — the fallback replaces it only in the parsed result, which the server + * never ships. The conversion root keeps its emitted shape so the 2025-era + * legacy-wrap decision is unchanged. * * Known residual gaps: + * - Input schemas (tool `inputSchema`, prompt `argsSchema`) containing `z.date()` + * advertise `string`/`date-time`, but input validation still runs the raw zod + * schema, which rejects strings — such a tool is listed yet uncallable via JSON. + * Use `z.iso.date()`/`z.iso.datetime()` for date-valued inputs. * - Output schemas containing `.transform()`/`.pipe()`/`z.coerce` still advertise the * post-transform shape (`io: 'output'`) even though the server validates and ships * the raw pre-transform value — rewriting pipe nodes to their input side per-node @@ -227,9 +233,15 @@ function zodConversionOptions(io: 'input' | 'output'): Pick = new Set(['bigint', 'symbol', 'map', 'set']); +const NON_OBJECT_UNREPRESENTABLE_ROOTS: ReadonlySet = new Set([ + 'bigint', + 'symbol', + 'map', + 'set', + 'void', + 'undefined', + 'nan', + 'function' +]); /** * A typeless JSON Schema root is "provably object-shaped" when either it carries object keywords diff --git a/packages/core-internal/test/util/standardSchema.test.ts b/packages/core-internal/test/util/standardSchema.test.ts index 74256d4a03..a10fb472e2 100644 --- a/packages/core-internal/test/util/standardSchema.test.ts +++ b/packages/core-internal/test/util/standardSchema.test.ts @@ -1,6 +1,7 @@ import * as z from 'zod/v4'; import { standardSchemaToJsonSchema } from '../../src/util/standardSchema'; +import { isNonObjectJsonSchemaRoot } from '../../src/wire/rev2025-11-25/legacyWrap'; describe('standardSchemaToJsonSchema', () => { test('emits type:object for plain z.object schemas', () => { @@ -151,6 +152,7 @@ describe('zod conversion options (#2464)', () => { const schema = z.object({ inner: z.object({ n: z.string() }).catch({ n: 'd' }).describe('lenient'), scalar: z.number().catch(0), + annotated: z.number().catch(0).meta({ 'x-ui': 1, title: 't' }), name: z.string() }); const result = standardSchemaToJsonSchema(schema, 'output'); @@ -160,10 +162,21 @@ describe('zod conversion options (#2464)', () => { const properties = result.properties as Record>; expect(properties.inner).toEqual({ description: 'lenient', default: { n: 'd' } }); expect(properties.scalar).toEqual({ default: 0 }); + // `x-*` vendor extensions are annotation-only and must survive the degrade. + expect(properties.annotated).toEqual({ default: 0, title: 't', 'x-ui': 1 }); // A raw payload omitting the catch fields also validates, so they are not required. expect(result.required).toEqual(['name']); }); + test('a root-position .catch() output schema keeps its emitted object root', () => { + const result = standardSchemaToJsonSchema(z.object({ n: z.string() }).catch({ n: 'd' }), 'output'); + + // Degrading the ROOT would delete `type: 'object'` and flip the 2025-era + // codec's legacy-wrap predicate — a silent wire change for such tools. + expect(result.type).toBe('object'); + expect(isNonObjectJsonSchemaRoot(result)).toBe(false); + }); + test('unrepresentable non-object roots still throw on the input path', () => { // `unrepresentable: 'any'` degrades these roots to a typeless {}, which must not // be stamped `type: 'object'` — the tool would be advertised but never callable. @@ -171,6 +184,10 @@ describe('zod conversion options (#2464)', () => { expect(() => standardSchemaToJsonSchema(z.map(z.string(), z.number()), 'input')).toThrow(/must describe objects/); expect(() => standardSchemaToJsonSchema(z.set(z.string()), 'input')).toThrow(/must describe objects/); expect(() => standardSchemaToJsonSchema(z.symbol(), 'input')).toThrow(/must describe objects/); + expect(() => standardSchemaToJsonSchema(z.void(), 'input')).toThrow(/must describe objects/); + expect(() => standardSchemaToJsonSchema(z.undefined(), 'input')).toThrow(/must describe objects/); + expect(() => standardSchemaToJsonSchema(z.nan(), 'input')).toThrow(/must describe objects/); + expect(() => standardSchemaToJsonSchema(z.function(), 'input')).toThrow(/must describe objects/); }); test('a required field whose transform throws on undefined stays required and does not crash', async () => { From a1484e86ce22f55303a084da2a8b912f9f3c6e81 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 20:34:05 +0000 Subject: [PATCH 08/35] fix(core-internal): protect root-composition catch members and unwrap the root guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extend the .catch() degrade bail from the conversion root to every position that feeds the output epilogue's root object proof (members of root-level, possibly nested, anyOf/oneOf/allOf compositions): degrading such a member broke isProvablyObjectShapedRoot's every-member proof, left the root typeless, and flipped the 2025-era legacy wrap — silently shipping structuredContent as {result: ...} for previously-working union/intersection output schemas. - The input-root guard now unwraps the zod def chain (optional/ nullable/readonly/default/prefault/catch via innerType, lazy via its getter with a seen-set cycle guard) before consulting the non-object set, so z.bigint().optional() and z.lazy(() => z.bigint()) no longer become phantom {type: 'object'} tools; 'literal' joins the set (typeless literal roots — unrepresentable values like z.literal(undefined) or mixed-type value lists — cannot describe objects; representable single-type literal roots already throw via the explicit-type guard). - Document dynamic catch values (.catch(ctx => ...)) as a known residual gap: zod's catchProcessor throws before the override hook runs, so the degrade covers static fallback values only; changeset claim scoped to match. Co-Authored-By: Claude --- .changeset/zod-tojsonschema-wire-truthful.md | 4 +- .../core-internal/src/util/standardSchema.ts | 81 ++++++++++++++++--- .../test/util/standardSchema.test.ts | 27 +++++++ 3 files changed, 100 insertions(+), 12 deletions(-) diff --git a/.changeset/zod-tojsonschema-wire-truthful.md b/.changeset/zod-tojsonschema-wire-truthful.md index 1347527a10..e47269316a 100644 --- a/.changeset/zod-tojsonschema-wire-truthful.md +++ b/.changeset/zod-tojsonschema-wire-truthful.md @@ -8,7 +8,9 @@ unrepresentable type such as `z.bigint()`) in a registered tool's schema no long during conversion and fails the entire `tools/list` response — dates are advertised as `{type: 'string', format: 'date-time'}` (the shape `JSON.stringify` actually produces), and other unrepresentable types degrade to an unconstrained schema. (BigInt values embedded as -defaults or metadata, e.g. `.default(0n)`, still fail conversion — JSON cannot carry them.) +defaults or metadata, e.g. `.default(0n)`, still fail conversion — JSON cannot carry them — +and so do dynamic catch values, `.catch(ctx => …)`; the `.catch()` degrade covers static +fallback values only.) Output schemas no longer advertise constraints the server doesn't enforce on the raw `structuredContent` it ships: fields that may be legitimately absent (`.default()`, undefined-accepting types) are dropped from `required` — on objects and enum-keyed records — diff --git a/packages/core-internal/src/util/standardSchema.ts b/packages/core-internal/src/util/standardSchema.ts index e03741ca83..167a8c6ce5 100644 --- a/packages/core-internal/src/util/standardSchema.ts +++ b/packages/core-internal/src/util/standardSchema.ts @@ -201,6 +201,10 @@ export const JSON_SCHEMA_CONVERSION_TARGET = 'draft-2020-12'; * advertise `string`/`date-time`, but input validation still runs the raw zod * schema, which rejects strings — such a tool is listed yet uncallable via JSON. * Use `z.iso.date()`/`z.iso.datetime()` for date-valued inputs. + * - Dynamic catch values (`.catch(ctx => …)`) still throw inside zod's own + * catchProcessor before this hook runs ("Dynamic catch values are not supported + * in JSON Schema"), so one such tool still fails the entire `tools/list` — the + * degrade below covers static `.catch(value)` only. * - Output schemas containing `.transform()`/`.pipe()`/`z.coerce` still advertise the * post-transform shape (`io: 'output'`) even though the server validates and ships * the raw pre-transform value — rewriting pipe nodes to their input side per-node @@ -233,11 +237,13 @@ function zodConversionOptions(io: 'input' | 'output'): Pick): boolean { + for (let i = 0; i < path.length; i += 2) { + const key = path[i]; + if ((key !== 'anyOf' && key !== 'oneOf' && key !== 'allOf') || typeof path[i + 1] !== 'number') return false; + } + return true; +} + /** * JSON Schema annotation-vocabulary keywords (plus `default`, itself an annotation) * preserved when a node's constraints are degraded because validation does not enforce @@ -417,10 +436,10 @@ export function standardSchemaToJsonSchema( // so misregistered roots keep failing loudly instead of being advertised as // permanently-uncallable `{type: 'object'}` tools. if (result.type === undefined) { - const defType = (schema as { _zod?: { def?: { type?: string } } })._zod?.def?.type; - if (defType !== undefined && NON_OBJECT_UNREPRESENTABLE_ROOTS.has(defType)) { + const defType = unwrappedZodDefType(schema); + if (defType !== undefined && NON_OBJECT_TYPELESS_ROOTS.has(defType)) { throw new Error( - `MCP tool and prompt schemas must describe objects (got an unrepresentable ${defType} schema). ` + + `MCP tool and prompt schemas must describe objects (got a non-object ${defType} schema). ` + `Wrap your schema in z.object({...}) or equivalent.` ); } @@ -429,10 +448,15 @@ export function standardSchemaToJsonSchema( } /** - * Zod root types that `unrepresentable: 'any'` degrades to a typeless `{}` but which + * Zod root types that can emit a typeless `{}` under `unrepresentable: 'any'` but * provably do not describe objects — the input-root guard must keep rejecting them. + * `literal` reaches this branch only when typeless (an unrepresentable-value literal + * like `z.literal(undefined)`, or a mixed-type multi-value literal), neither of which + * describes an object; representable single-type literal roots emit an explicit + * `type` and are rejected by the earlier guard. `custom` is deliberately excluded — + * it can legitimately accept objects. */ -const NON_OBJECT_UNREPRESENTABLE_ROOTS: ReadonlySet = new Set([ +const NON_OBJECT_TYPELESS_ROOTS: ReadonlySet = new Set([ 'bigint', 'symbol', 'map', @@ -440,9 +464,44 @@ const NON_OBJECT_UNREPRESENTABLE_ROOTS: ReadonlySet = new Set([ 'void', 'undefined', 'nan', - 'function' + 'function', + 'literal' ]); +/** Transparent wrapper def types whose `innerType` carries the real root semantics. */ +const WRAPPER_ZOD_DEF_TYPES: ReadonlySet = new Set(['optional', 'nullable', 'readonly', 'default', 'prefault', 'catch']); + +/** + * The innermost def type of a zod schema, unwrapped through transparent wrappers + * (`optional`/`nullable`/`readonly`/`default`/`prefault`/`catch` via `innerType`, + * `lazy` via its getter) so `z.bigint().optional()` and `z.lazy(() => z.bigint())` + * report `'bigint'`. A seen-set bounds recursive lazies; non-zod schemas and + * throwing lazy getters yield `undefined`. + */ +function unwrappedZodDefType(schema: unknown): string | undefined { + const seen = new Set(); + let current = schema; + while (typeof current === 'object' && current !== null && !seen.has(current)) { + seen.add(current); + const def = (current as { _zod?: { def?: { type?: string; innerType?: unknown; getter?: unknown } } })._zod?.def; + if (def === undefined || typeof def.type !== 'string') return undefined; + if (def.type === 'lazy' && typeof def.getter === 'function') { + try { + current = (def.getter as () => unknown)(); + } catch { + return undefined; + } + continue; + } + if (WRAPPER_ZOD_DEF_TYPES.has(def.type) && def.innerType !== undefined) { + current = def.innerType; + continue; + } + return def.type; + } + return undefined; +} + /** * A typeless JSON Schema root is "provably object-shaped" when either it carries object keywords * directly (`properties`/`patternProperties`/`additionalProperties`/`required`), or it is a diff --git a/packages/core-internal/test/util/standardSchema.test.ts b/packages/core-internal/test/util/standardSchema.test.ts index a10fb472e2..c4816d2866 100644 --- a/packages/core-internal/test/util/standardSchema.test.ts +++ b/packages/core-internal/test/util/standardSchema.test.ts @@ -177,6 +177,18 @@ describe('zod conversion options (#2464)', () => { expect(isNonObjectJsonSchemaRoot(result)).toBe(false); }); + test('a .catch() member of a root union keeps its emitted shape (root still proves object)', () => { + const schema = z.union([z.object({ a: z.string() }), z.object({ b: z.string() }).catch({ b: 'd' })]); + const result = standardSchemaToJsonSchema(schema, 'output'); + + // Degrading the member would break `isProvablyObjectShapedRoot`'s every-member + // proof, leave the root typeless, and flip the 2025-era legacy wrap — a silent + // wire change for a previously-working registration. + expect(result.type).toBe('object'); + expect(isNonObjectJsonSchemaRoot(result)).toBe(false); + expect((result.anyOf as Array>)[1]?.type).toBe('object'); + }); + test('unrepresentable non-object roots still throw on the input path', () => { // `unrepresentable: 'any'` degrades these roots to a typeless {}, which must not // be stamped `type: 'object'` — the tool would be advertised but never callable. @@ -188,6 +200,21 @@ describe('zod conversion options (#2464)', () => { expect(() => standardSchemaToJsonSchema(z.undefined(), 'input')).toThrow(/must describe objects/); expect(() => standardSchemaToJsonSchema(z.nan(), 'input')).toThrow(/must describe objects/); expect(() => standardSchemaToJsonSchema(z.function(), 'input')).toThrow(/must describe objects/); + // Wrappers and lazies must not hide a non-object root from the guard. + expect(() => standardSchemaToJsonSchema(z.bigint().optional(), 'input')).toThrow(/must describe objects/); + expect(() => standardSchemaToJsonSchema(z.bigint().nullable(), 'input')).toThrow(/must describe objects/); + expect(() => standardSchemaToJsonSchema(z.bigint().readonly(), 'input')).toThrow(/must describe objects/); + expect(() => + standardSchemaToJsonSchema( + z.lazy(() => z.bigint()), + 'input' + ) + ).toThrow(/must describe objects/); + // Typeless literal roots (unrepresentable or mixed-type values) are not objects. + expect(() => standardSchemaToJsonSchema(z.literal(undefined), 'input')).toThrow(/must describe objects/); + expect(() => standardSchemaToJsonSchema(z.literal(['a', 1]), 'input')).toThrow(/must describe objects/); + // Wrapped OBJECT roots stay accepted. + expect(standardSchemaToJsonSchema(z.object({ a: z.string() }).optional(), 'input').type).toBe('object'); }); test('a required field whose transform throws on undefined stays required and does not crash', async () => { From 0cee9010f14132ea2c3112a20d6d7530eac520c7 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 21:19:13 +0000 Subject: [PATCH 09/35] fix(core-internal): position-independent catch degrade; unwrap pipe/promise roots MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The catch-degrade verdict must be position-independent: zod deduplicates reused schema instances and runs the override once per instance, so a .catch() shared between a nested position and a root(-composition) position got one verdict applied to both sites — nested-first broke the root object proof (2025-era legacy-wrap flip), root-first kept unenforced inner constraints at the nested copy (the #2464 client-rejection class). The degrade now always keeps the emitted 'type' (dropping properties/required/additionalProperties and other constraints), which preserves the root proof at every position; the position-dependent feedsRootObjectProof branch is removed and the stale JSDoc bullet reworded to the new rule. - unwrappedZodDefType now unwraps pipe nodes via their INPUT side (def.in — the side io: 'input' conversion and input validation consume; never def.out) and promise nodes via innerType, so z.bigint().transform(...), z.bigint().pipe(...), and z.promise(z.bigint()) roots throw the actionable 'must describe objects' error instead of becoming phantom {type: 'object'} tools. z.object({...}).transform(...) stays accepted, and bare standalone z.transform(fn) stays excluded like z.custom(). Co-Authored-By: Claude --- .../core-internal/src/util/standardSchema.ts | 61 +++++++++---------- .../test/util/standardSchema.test.ts | 49 ++++++++++++++- 2 files changed, 75 insertions(+), 35 deletions(-) diff --git a/packages/core-internal/src/util/standardSchema.ts b/packages/core-internal/src/util/standardSchema.ts index 167a8c6ce5..e62538dc50 100644 --- a/packages/core-internal/src/util/standardSchema.ts +++ b/packages/core-internal/src/util/standardSchema.ts @@ -190,11 +190,13 @@ export const JSON_SCHEMA_CONVERSION_TARGET = 'draft-2020-12'; * - Output objects and enum-keyed records drop properties that may be legitimately * absent from the shipped payload (`.default()`, undefined-accepting types) from * `required`: zod fills defaults during validation, but ships the raw object. - * - Output `.catch()` nodes (below the root) degrade to an unconstrained schema - * (annotations and the emitted `default` kept): catch-validation accepts any raw - * value — the fallback replaces it only in the parsed result, which the server - * never ships. The conversion root keeps its emitted shape so the 2025-era - * legacy-wrap decision is unchanged. + * - Output `.catch()` nodes drop their constraint keywords (`properties`, `required`, + * `additionalProperties`, …) but keep the emitted `type`, annotations, and + * `default`: catch-validation accepts any raw value — the fallback replaces it + * only in the parsed result, which the server never ships. `type` is kept at every + * position (zod deduplicates reused instances, so the verdict must be + * position-independent) and preserves the 2025-era legacy-wrap object proof at the + * root and in root-level compositions. * * Known residual gaps: * - Input schemas (tool `inputSchema`, prompt `argsSchema`) containing `z.date()` @@ -237,17 +239,19 @@ function zodConversionOptions(io: 'input' | 'output'): Pick): boolean { - for (let i = 0; i < path.length; i += 2) { - const key = path[i]; - if ((key !== 'anyOf' && key !== 'oneOf' && key !== 'allOf') || typeof path[i + 1] !== 'number') return false; - } - return true; -} - /** * JSON Schema annotation-vocabulary keywords (plus `default`, itself an annotation) * preserved when a node's constraints are degraded because validation does not enforce @@ -469,21 +460,23 @@ const NON_OBJECT_TYPELESS_ROOTS: ReadonlySet = new Set([ ]); /** Transparent wrapper def types whose `innerType` carries the real root semantics. */ -const WRAPPER_ZOD_DEF_TYPES: ReadonlySet = new Set(['optional', 'nullable', 'readonly', 'default', 'prefault', 'catch']); +const WRAPPER_ZOD_DEF_TYPES: ReadonlySet = new Set(['optional', 'nullable', 'readonly', 'default', 'prefault', 'catch', 'promise']); /** * The innermost def type of a zod schema, unwrapped through transparent wrappers - * (`optional`/`nullable`/`readonly`/`default`/`prefault`/`catch` via `innerType`, - * `lazy` via its getter) so `z.bigint().optional()` and `z.lazy(() => z.bigint())` - * report `'bigint'`. A seen-set bounds recursive lazies; non-zod schemas and - * throwing lazy getters yield `undefined`. + * (`optional`/`nullable`/`readonly`/`default`/`prefault`/`catch`/`promise` via + * `innerType`, `lazy` via its getter, and `pipe` via its INPUT side `def.in` — the + * side `io: 'input'` conversion and input validation both consume) so + * `z.bigint().optional()`, `z.lazy(() => z.bigint())`, and + * `z.bigint().transform(...)` all report `'bigint'`. A seen-set bounds recursive + * lazies; non-zod schemas and throwing lazy getters yield `undefined`. */ function unwrappedZodDefType(schema: unknown): string | undefined { const seen = new Set(); let current = schema; while (typeof current === 'object' && current !== null && !seen.has(current)) { seen.add(current); - const def = (current as { _zod?: { def?: { type?: string; innerType?: unknown; getter?: unknown } } })._zod?.def; + const def = (current as { _zod?: { def?: { type?: string; innerType?: unknown; getter?: unknown; in?: unknown } } })._zod?.def; if (def === undefined || typeof def.type !== 'string') return undefined; if (def.type === 'lazy' && typeof def.getter === 'function') { try { @@ -493,6 +486,10 @@ function unwrappedZodDefType(schema: unknown): string | undefined { } continue; } + if (def.type === 'pipe' && def.in !== undefined) { + current = def.in; + continue; + } if (WRAPPER_ZOD_DEF_TYPES.has(def.type) && def.innerType !== undefined) { current = def.innerType; continue; diff --git a/packages/core-internal/test/util/standardSchema.test.ts b/packages/core-internal/test/util/standardSchema.test.ts index c4816d2866..bb1853c0dd 100644 --- a/packages/core-internal/test/util/standardSchema.test.ts +++ b/packages/core-internal/test/util/standardSchema.test.ts @@ -159,15 +159,41 @@ describe('zod conversion options (#2464)', () => { // Catch-validation accepts any raw value (the fallback replaces it only in the // parsed result, which never ships), so no inner constraint may be advertised. + // The emitted `type` is kept: the verdict must be position-independent (zod + // deduplicates reused instances), and root positions need it for the 2025-era + // legacy-wrap object proof. const properties = result.properties as Record>; - expect(properties.inner).toEqual({ description: 'lenient', default: { n: 'd' } }); - expect(properties.scalar).toEqual({ default: 0 }); + expect(properties.inner).toEqual({ description: 'lenient', default: { n: 'd' }, type: 'object' }); + expect(properties.scalar).toEqual({ default: 0, type: 'number' }); // `x-*` vendor extensions are annotation-only and must survive the degrade. - expect(properties.annotated).toEqual({ default: 0, title: 't', 'x-ui': 1 }); + expect(properties.annotated).toEqual({ default: 0, title: 't', 'x-ui': 1, type: 'number' }); // A raw payload omitting the catch fields also validates, so they are not required. expect(result.required).toEqual(['name']); }); + test('a .catch() instance reused at nested and root-proof positions is safe in both orderings', () => { + // zod deduplicates reused instances and runs the override once per instance, + // so the degrade verdict is shared by every occurrence — it must not depend + // on which position is seen first. + for (const makeUnion of [ + (c: z.ZodType) => z.union([z.object({ x: c }), c]), // nested seen first + (c: z.ZodType) => z.union([c, z.object({ x: c })]) // root member seen first + ]) { + const shared = z.object({ q: z.string() }).catch({ q: 'd' }); + const result = standardSchemaToJsonSchema(makeUnion(shared), 'output'); + + // The root object proof must hold (no 2025-era legacy-wrap flip) ... + expect(result.type).toBe('object'); + expect(isNonObjectJsonSchemaRoot(result)).toBe(false); + const members = result.anyOf as Array>; + const nested = members.find(m => m.properties !== undefined)!; + const rootMember = members.find(m => m.properties === undefined)!; + // ... and no occurrence may advertise the unenforced inner constraints. + expect((nested.properties as Record>).x).toEqual({ default: { q: 'd' }, type: 'object' }); + expect(rootMember).toEqual({ default: { q: 'd' }, type: 'object' }); + } + }); + test('a root-position .catch() output schema keeps its emitted object root', () => { const result = standardSchemaToJsonSchema(z.object({ n: z.string() }).catch({ n: 'd' }), 'output'); @@ -213,8 +239,25 @@ describe('zod conversion options (#2464)', () => { // Typeless literal roots (unrepresentable or mixed-type values) are not objects. expect(() => standardSchemaToJsonSchema(z.literal(undefined), 'input')).toThrow(/must describe objects/); expect(() => standardSchemaToJsonSchema(z.literal(['a', 1]), 'input')).toThrow(/must describe objects/); + // Pipes unwrap via their INPUT side, and promises via their inner type. + expect(() => + standardSchemaToJsonSchema( + z.bigint().transform(x => Number(x)), + 'input' + ) + ).toThrow(/must describe objects/); + expect(() => standardSchemaToJsonSchema(z.bigint().pipe(z.transform((x: bigint) => Number(x))), 'input')).toThrow( + /must describe objects/ + ); + expect(() => standardSchemaToJsonSchema(z.promise(z.bigint()), 'input')).toThrow(/must describe objects/); // Wrapped OBJECT roots stay accepted. expect(standardSchemaToJsonSchema(z.object({ a: z.string() }).optional(), 'input').type).toBe('object'); + expect( + standardSchemaToJsonSchema( + z.object({ a: z.string() }).transform(o => o), + 'input' + ).type + ).toBe('object'); }); test('a required field whose transform throws on undefined stays required and does not crash', async () => { From 16fab7268524eeca2620e26843f8b1e8014b8c64 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 22:10:53 +0000 Subject: [PATCH 10/35] fix(core-internal): composition-aware root guard, symbol/function required, catch-of-union skeleton MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The input-root guard now recurses into compositions via a new nonObjectTypelessRootType helper: a union root is rejected when EVERY member unwinds to a non-object typeless type (one representable object member keeps it accepted), an intersection when ANY side does (the value must satisfy both) — so z.union([z.bigint(), z.symbol()]) and z.intersection(z.bigint(), z.bigint()) throw the actionable root error again instead of becoming phantom {type: 'object'} tools. 'nonoptional' joins the transparent-wrapper set (safe: z.object({...}).nonoptional() emits an explicit type: 'object'). - fieldAcceptsMissingKey also returns true for fields whose unwrapped def type is 'symbol' or 'function': JSON.stringify drops Symbol- and function-valued keys from the payload by the same mechanism that drops undefined-valued keys, so no serialized result can carry them (covers both the object required-filter and the record branch). - The .catch() degrade reduces composition keywords (anyOf/oneOf/allOf, emitted when the catch wraps a union or intersection) to member type skeletons instead of deleting them: the catch node emits no 'type' key for these shapes, so the keep-type rule alone left the root typeless and flipped the 2025-era legacy wrap for previously-working registrations; the JSDoc bullet is updated to the full rule. Audited sibling shapes: z.file() roots already throw via the explicit type guard (representable as string/binary); z.never() and fully/mixed-representable non-object unions were silently stamped pre-PR too (status quo, not regressed here); catch-of-$ref roots were typeless pre-PR as well (no wrap change) and now correctly drop the unenforced $ref constraints; BigInt-valued output fields make JSON.stringify throw (a transport-level ship failure already noted for defaults in the changeset). Co-Authored-By: Claude --- .../core-internal/src/util/standardSchema.ts | 124 ++++++++++++++++-- .../test/util/standardSchema.test.ts | 28 ++++ 2 files changed, 138 insertions(+), 14 deletions(-) diff --git a/packages/core-internal/src/util/standardSchema.ts b/packages/core-internal/src/util/standardSchema.ts index e62538dc50..bcbb844b2f 100644 --- a/packages/core-internal/src/util/standardSchema.ts +++ b/packages/core-internal/src/util/standardSchema.ts @@ -191,12 +191,13 @@ export const JSON_SCHEMA_CONVERSION_TARGET = 'draft-2020-12'; * absent from the shipped payload (`.default()`, undefined-accepting types) from * `required`: zod fills defaults during validation, but ships the raw object. * - Output `.catch()` nodes drop their constraint keywords (`properties`, `required`, - * `additionalProperties`, …) but keep the emitted `type`, annotations, and - * `default`: catch-validation accepts any raw value — the fallback replaces it - * only in the parsed result, which the server never ships. `type` is kept at every - * position (zod deduplicates reused instances, so the verdict must be - * position-independent) and preserves the 2025-era legacy-wrap object proof at the - * root and in root-level compositions. + * `additionalProperties`, …) but keep the emitted `type` (with composition keywords + * reduced to member type skeletons), annotations, and `default`: catch-validation + * accepts any raw value — the fallback replaces it only in the parsed result, which + * the server never ships. The type signal is kept at every position (zod + * deduplicates reused instances, so the verdict must be position-independent) and + * preserves the 2025-era legacy-wrap object proof at the root and in root-level + * compositions. * * Known residual gaps: * - Input schemas (tool `inputSchema`, prompt `argsSchema`) containing `z.date()` @@ -240,17 +241,23 @@ function zodConversionOptions(io: 'input' | 'output'): Pick compositionTypeSkeleton(member)); + continue; + } delete ctx.jsonSchema[key]; } return; @@ -284,6 +291,25 @@ function zodConversionOptions(io: 'input' | 'output'): Pick { + if (typeof node !== 'object' || node === null) return {}; + const source = node as Record; + const skeleton: Record = {}; + if (source.type !== undefined) skeleton.type = source.type; + for (const key of ['anyOf', 'oneOf', 'allOf'] as const) { + if (Array.isArray(source[key])) { + skeleton[key] = (source[key] as unknown[]).map(member => compositionTypeSkeleton(member)); + } + } + return skeleton; +} + /** * JSON Schema annotation-vocabulary keywords (plus `default`, itself an annotation) * preserved when a node's constraints are degraded because validation does not enforce @@ -316,6 +342,11 @@ function fieldAcceptsMissingKey(field: z.core.$ZodType | undefined): boolean { // keep the field required. const defType = field._zod.def.type; if (defType === 'default' || defType === 'prefault') return true; + // JSON.stringify drops Symbol- and function-valued keys from the serialized + // payload entirely (the same mechanism that drops undefined-valued keys), so + // such fields can never appear on the wire. + const unwrapped = unwrappedZodDefType(field); + if (unwrapped === 'symbol' || unwrapped === 'function') return true; try { const result = field['~standard'].validate(undefined); if (result instanceof Promise) { @@ -427,10 +458,10 @@ export function standardSchemaToJsonSchema( // so misregistered roots keep failing loudly instead of being advertised as // permanently-uncallable `{type: 'object'}` tools. if (result.type === undefined) { - const defType = unwrappedZodDefType(schema); - if (defType !== undefined && NON_OBJECT_TYPELESS_ROOTS.has(defType)) { + const nonObjectType = nonObjectTypelessRootType(schema); + if (nonObjectType !== undefined) { throw new Error( - `MCP tool and prompt schemas must describe objects (got a non-object ${defType} schema). ` + + `MCP tool and prompt schemas must describe objects (got a non-object ${nonObjectType} schema). ` + `Wrap your schema in z.object({...}) or equivalent.` ); } @@ -438,6 +469,62 @@ export function standardSchemaToJsonSchema( return { type: 'object', ...result }; } +/** + * The def type of a zod root that emits a typeless node yet provably cannot describe + * an object, or `undefined` when the root may. Unwraps via {@linkcode unwrappedZodDefType}'s + * wrapper rules, then also recurses into compositions: a union is non-object when EVERY + * member is (one representable object member keeps it accepted), an intersection when + * ANY side is (the value must satisfy both). The shared seen-set bounds recursive lazies. + */ +function nonObjectTypelessRootType(schema: unknown, seen: Set = new Set()): string | undefined { + let current = schema; + while (typeof current === 'object' && current !== null && !seen.has(current)) { + seen.add(current); + const def = ( + current as { + _zod?: { + def?: { + type?: string; + innerType?: unknown; + getter?: unknown; + in?: unknown; + options?: unknown; + left?: unknown; + right?: unknown; + }; + }; + } + )._zod?.def; + if (def === undefined || typeof def.type !== 'string') return undefined; + if (def.type === 'lazy' && typeof def.getter === 'function') { + try { + current = (def.getter as () => unknown)(); + } catch { + return undefined; + } + continue; + } + if (def.type === 'pipe' && def.in !== undefined) { + current = def.in; + continue; + } + if (WRAPPER_ZOD_DEF_TYPES.has(def.type) && def.innerType !== undefined) { + current = def.innerType; + continue; + } + if (def.type === 'union' && Array.isArray(def.options) && def.options.length > 0) { + return def.options.every(option => nonObjectTypelessRootType(option, seen) !== undefined) ? 'union' : undefined; + } + if (def.type === 'intersection' && def.left !== undefined && def.right !== undefined) { + return nonObjectTypelessRootType(def.left, seen) !== undefined || nonObjectTypelessRootType(def.right, seen) !== undefined + ? 'intersection' + : undefined; + } + return NON_OBJECT_TYPELESS_ROOTS.has(def.type) ? def.type : undefined; + } + return undefined; +} + /** * Zod root types that can emit a typeless `{}` under `unrepresentable: 'any'` but * provably do not describe objects — the input-root guard must keep rejecting them. @@ -460,7 +547,16 @@ const NON_OBJECT_TYPELESS_ROOTS: ReadonlySet = new Set([ ]); /** Transparent wrapper def types whose `innerType` carries the real root semantics. */ -const WRAPPER_ZOD_DEF_TYPES: ReadonlySet = new Set(['optional', 'nullable', 'readonly', 'default', 'prefault', 'catch', 'promise']); +const WRAPPER_ZOD_DEF_TYPES: ReadonlySet = new Set([ + 'optional', + 'nonoptional', + 'nullable', + 'readonly', + 'default', + 'prefault', + 'catch', + 'promise' +]); /** * The innermost def type of a zod schema, unwrapped through transparent wrappers diff --git a/packages/core-internal/test/util/standardSchema.test.ts b/packages/core-internal/test/util/standardSchema.test.ts index bb1853c0dd..9be1bbd2ce 100644 --- a/packages/core-internal/test/util/standardSchema.test.ts +++ b/packages/core-internal/test/util/standardSchema.test.ts @@ -250,6 +250,12 @@ describe('zod conversion options (#2464)', () => { /must describe objects/ ); expect(() => standardSchemaToJsonSchema(z.promise(z.bigint()), 'input')).toThrow(/must describe objects/); + // `.nonoptional()` is a transparent wrapper like its siblings. + expect(() => standardSchemaToJsonSchema(z.bigint().nonoptional(), 'input')).toThrow(/must describe objects/); + // Compositions: a union is non-object when EVERY member is, an intersection + // when ANY side is. + expect(() => standardSchemaToJsonSchema(z.union([z.bigint(), z.symbol()]), 'input')).toThrow(/must describe objects/); + expect(() => standardSchemaToJsonSchema(z.intersection(z.bigint(), z.bigint()), 'input')).toThrow(/must describe objects/); // Wrapped OBJECT roots stay accepted. expect(standardSchemaToJsonSchema(z.object({ a: z.string() }).optional(), 'input').type).toBe('object'); expect( @@ -258,6 +264,28 @@ describe('zod conversion options (#2464)', () => { 'input' ).type ).toBe('object'); + // A union with one representable object member stays accepted. + expect(standardSchemaToJsonSchema(z.union([z.bigint(), z.object({ a: z.string() })]), 'input').type).toBe('object'); + }); + + test('symbol- and function-valued output fields are not advertised as required', () => { + const schema = z.object({ s: z.symbol(), f: z.function(), name: z.string() }); + const result = standardSchemaToJsonSchema(schema, 'output'); + + // JSON.stringify drops Symbol- and function-valued keys from the payload, so + // no serialized result can ever carry them. + expect(result.required).toEqual(['name']); + }); + + test('a .catch() wrapping a union keeps a composition type skeleton (root still proves object)', () => { + const schema = z.union([z.object({ a: z.string() }), z.object({ b: z.string() })]).catch({ a: 'd' }); + const result = standardSchemaToJsonSchema(schema, 'output'); + + // The catch node emits {anyOf, default} with no `type`; deleting `anyOf` + // would leave the root typeless and flip the 2025-era legacy wrap. + expect(result.anyOf).toEqual([{ type: 'object' }, { type: 'object' }]); + expect(result.type).toBe('object'); + expect(isNonObjectJsonSchemaRoot(result)).toBe(false); }); test('a required field whose transform throws on undefined stays required and does not crash', async () => { From 568ebcfb546334f5bb227ea5b61c05d93dfafd7e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 23:01:53 +0000 Subject: [PATCH 11/35] fix(core-internal): correct composition member classification; object-only catch type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fixes a regression introduced by the composition recursion: 'literal' in the reject set classified fully-representable members like z.literal('admin') as non-object, so z.union([z.literal('admin'), z.literal('member')]) — zod's idiomatic enum spelling, which converted and listed fine pre-PR — threw and failed the ENTIRE tools/list (and prompts/list) with -32603, the exact whole-list outage this change eliminates. A composition member now counts as non-object ONLY when it unwinds to a genuinely unrepresentable type (bigint/symbol/map/set/void/undefined/nan/ function) or to a literal whose values are unrepresentable or mixed-type; representable members (single-type literals, z.string(), z.null()) keep the composition accepted exactly as pre-PR. - Fixes the fail-open direction of the same recursion: the shared seen-set conflated cycle protection with the member verdict, so const b = z.bigint(); z.union([b, b]) returned a false may-be-object verdict. The guard now tracks only the current traversal path (ancestors), giving shared instances a real verdict at every occurrence while recursive lazies stay bounded. - The .catch() degrade keeps 'type' only when it is 'object': a non-object type is an unenforced constraint that rejects the wrong-typed raw values catch exists to tolerate, and the 2025-era legacy-wrap object proof only ever consumes type === 'object' (same rule in compositionTypeSkeleton). z.number().catch(0) now advertises {default: 0}. - Generalize the input-side residual-gap prose beyond z.date(): any REQUIRED unrepresentable input field (z.bigint()/z.map()/z.set()/ z.symbol()) makes the tool listed yet uncallable via JSON — use JSON-representable types or make the field optional (JSDoc + changeset). Co-Authored-By: Claude --- .changeset/zod-tojsonschema-wire-truthful.md | 10 +- .../core-internal/src/util/standardSchema.ts | 180 +++++++++++------- .../test/util/standardSchema.test.ts | 33 +++- 3 files changed, 142 insertions(+), 81 deletions(-) diff --git a/.changeset/zod-tojsonschema-wire-truthful.md b/.changeset/zod-tojsonschema-wire-truthful.md index e47269316a..b8ae480eb9 100644 --- a/.changeset/zod-tojsonschema-wire-truthful.md +++ b/.changeset/zod-tojsonschema-wire-truthful.md @@ -23,8 +23,12 @@ the sanitization hook on a schema reused both bare and via a `.describe()`/`.met in the same conversion; full per-node sanitization requires zod >=4.3.0. And the `z.date()` advertisement assumes a serializing transport: `InMemoryTransport` passes the raw `Date` by reference, so a validating client rejects it over that testing transport. -On the input side, a `z.date()` tool/prompt argument is advertised as `string`/`date-time` -but input validation still runs the raw zod schema, which rejects strings — use -`z.iso.date()`/`z.iso.datetime()` for date-valued inputs.) Elicitation is unaffected: +On the input side, a required tool/prompt argument of a type JSON cannot carry makes the +tool listed yet uncallable: `z.date()` is advertised as `string`/`date-time` and other +unrepresentable types (`z.bigint()`, `z.map()`, `z.set()`, `z.symbol()`) as an +unconstrained `{}`, but input validation still runs the raw zod schema, which rejects +every JSON payload — use a JSON-representable type such as `z.iso.date()`/ +`z.iso.datetime()`, `z.number()`, `z.record(...)`, or `z.array(...)`, or make the field +optional.) Elicitation is unaffected: `inputRequired.elicit()` keeps throwing on schemas its restricted form grammar cannot round-trip, including `z.date()`. diff --git a/packages/core-internal/src/util/standardSchema.ts b/packages/core-internal/src/util/standardSchema.ts index bcbb844b2f..069bc5b18f 100644 --- a/packages/core-internal/src/util/standardSchema.ts +++ b/packages/core-internal/src/util/standardSchema.ts @@ -191,19 +191,23 @@ export const JSON_SCHEMA_CONVERSION_TARGET = 'draft-2020-12'; * absent from the shipped payload (`.default()`, undefined-accepting types) from * `required`: zod fills defaults during validation, but ships the raw object. * - Output `.catch()` nodes drop their constraint keywords (`properties`, `required`, - * `additionalProperties`, …) but keep the emitted `type` (with composition keywords - * reduced to member type skeletons), annotations, and `default`: catch-validation - * accepts any raw value — the fallback replaces it only in the parsed result, which - * the server never ships. The type signal is kept at every position (zod - * deduplicates reused instances, so the verdict must be position-independent) and - * preserves the 2025-era legacy-wrap object proof at the root and in root-level + * `additionalProperties`, and any non-object `type`, all unenforced on the raw + * value) but keep an emitted `type: 'object'` (with composition keywords reduced to + * member type skeletons), annotations, and `default`: catch-validation accepts any + * raw value — the fallback replaces it only in the parsed result, which the server + * never ships. The object-type signal is kept at every position (zod deduplicates + * reused instances, so the verdict must be position-independent) and is all the + * 2025-era legacy-wrap object proof consumes at the root and in root-level * compositions. * * Known residual gaps: - * - Input schemas (tool `inputSchema`, prompt `argsSchema`) containing `z.date()` - * advertise `string`/`date-time`, but input validation still runs the raw zod - * schema, which rejects strings — such a tool is listed yet uncallable via JSON. - * Use `z.iso.date()`/`z.iso.datetime()` for date-valued inputs. + * - A REQUIRED input field (tool `inputSchema`, prompt `argsSchema`) of a type JSON + * cannot carry makes the tool listed yet uncallable: `z.date()` advertises + * `string`/`date-time` and other unrepresentable types (`z.bigint()`, `z.map()`, + * `z.set()`, `z.symbol()`) an unconstrained `{}`, but input validation still runs + * the raw zod schema, which rejects every JSON payload. Use a JSON-representable + * type (`z.iso.date()`/`z.iso.datetime()`, `z.number()`, `z.record(...)`, + * `z.array(...)`) or make the field optional. * - Dynamic catch values (`.catch(ctx => …)`) still throw inside zod's own * catchProcessor before this hook runs ("Dynamic catch values are not supported * in JSON Schema"), so one such tool still fails the entire `tools/list` — the @@ -241,19 +245,23 @@ function zodConversionOptions(io: 'input' | 'output'): Pick compositionTypeSkeleton(member)); continue; @@ -301,7 +309,8 @@ function compositionTypeSkeleton(node: unknown): Record { if (typeof node !== 'object' || node === null) return {}; const source = node as Record; const skeleton: Record = {}; - if (source.type !== undefined) skeleton.type = source.type; + // Only `type: 'object'` feeds the proof; any other type is an unenforced constraint. + if (source.type === 'object') skeleton.type = 'object'; for (const key of ['anyOf', 'oneOf', 'allOf'] as const) { if (Array.isArray(source[key])) { skeleton[key] = (source[key] as unknown[]).map(member => compositionTypeSkeleton(member)); @@ -471,70 +480,98 @@ export function standardSchemaToJsonSchema( /** * The def type of a zod root that emits a typeless node yet provably cannot describe - * an object, or `undefined` when the root may. Unwraps via {@linkcode unwrappedZodDefType}'s - * wrapper rules, then also recurses into compositions: a union is non-object when EVERY - * member is (one representable object member keeps it accepted), an intersection when - * ANY side is (the value must satisfy both). The shared seen-set bounds recursive lazies. + * an object, or `undefined` when the root may. Unwraps transparent wrappers, lazies, + * and pipe input sides, then recurses into compositions: a union is non-object when + * EVERY member is, an intersection when ANY side is (the value must satisfy both). A + * member counts as non-object ONLY when it unwinds to a genuinely unrepresentable + * type, or to a literal whose values are unrepresentable or mixed-type — representable + * members (including single-type literals, zod's idiomatic enum spelling, and + * representable non-object types like `z.string()`) keep the composition accepted, + * exactly as such roots converted before #2464. `ancestors` tracks only the current + * traversal path, so a shared instance gets a real verdict at every occurrence while + * recursive lazies stay bounded. */ -function nonObjectTypelessRootType(schema: unknown, seen: Set = new Set()): string | undefined { - let current = schema; - while (typeof current === 'object' && current !== null && !seen.has(current)) { - seen.add(current); - const def = ( - current as { - _zod?: { - def?: { - type?: string; - innerType?: unknown; - getter?: unknown; - in?: unknown; - options?: unknown; - left?: unknown; - right?: unknown; - }; +function nonObjectTypelessRootType(schema: unknown, ancestors: ReadonlySet = new Set()): string | undefined { + if (typeof schema !== 'object' || schema === null || ancestors.has(schema)) return undefined; + const def = ( + schema as { + _zod?: { + def?: { + type?: string; + innerType?: unknown; + getter?: unknown; + in?: unknown; + options?: unknown; + left?: unknown; + right?: unknown; + values?: unknown; }; - } - )._zod?.def; - if (def === undefined || typeof def.type !== 'string') return undefined; - if (def.type === 'lazy' && typeof def.getter === 'function') { - try { - current = (def.getter as () => unknown)(); - } catch { - return undefined; - } - continue; + }; } - if (def.type === 'pipe' && def.in !== undefined) { - current = def.in; - continue; + )._zod?.def; + if (def === undefined || typeof def.type !== 'string') return undefined; + const path = new Set(ancestors); + path.add(schema); + if (def.type === 'lazy' && typeof def.getter === 'function') { + try { + return nonObjectTypelessRootType((def.getter as () => unknown)(), path); + } catch { + return undefined; } - if (WRAPPER_ZOD_DEF_TYPES.has(def.type) && def.innerType !== undefined) { - current = def.innerType; + } + if (def.type === 'pipe' && def.in !== undefined) { + return nonObjectTypelessRootType(def.in, path); + } + if (WRAPPER_ZOD_DEF_TYPES.has(def.type) && def.innerType !== undefined) { + return nonObjectTypelessRootType(def.innerType, path); + } + if (def.type === 'union' && Array.isArray(def.options) && def.options.length > 0) { + return def.options.every(option => nonObjectTypelessRootType(option, path) !== undefined) ? 'union' : undefined; + } + if (def.type === 'intersection' && def.left !== undefined && def.right !== undefined) { + return nonObjectTypelessRootType(def.left, path) !== undefined || nonObjectTypelessRootType(def.right, path) !== undefined + ? 'intersection' + : undefined; + } + if (def.type === 'literal') { + return isNonObjectTypelessLiteral(def.values) ? 'literal' : undefined; + } + return NON_OBJECT_UNREPRESENTABLE_TYPES.has(def.type) ? def.type : undefined; +} + +/** + * Whether a literal's values make its node both typeless and non-object: any + * unrepresentable value (`undefined`, bigint, symbol) or a mixed-JSON-type value list. + * Single-type representable literals emit an explicit `type` and may legitimately ride + * compositions (`z.union([z.literal('admin'), z.literal('member')])`, zod's idiomatic + * enum spelling, must keep listing exactly as it did pre-#2464). + */ +function isNonObjectTypelessLiteral(values: unknown): boolean { + if (!Array.isArray(values) || values.length === 0) return true; + const jsonTypes = new Set(); + for (const value of values) { + if (value === null) { + jsonTypes.add('null'); continue; } - if (def.type === 'union' && Array.isArray(def.options) && def.options.length > 0) { - return def.options.every(option => nonObjectTypelessRootType(option, seen) !== undefined) ? 'union' : undefined; - } - if (def.type === 'intersection' && def.left !== undefined && def.right !== undefined) { - return nonObjectTypelessRootType(def.left, seen) !== undefined || nonObjectTypelessRootType(def.right, seen) !== undefined - ? 'intersection' - : undefined; + const valueType = typeof value; + if (valueType !== 'string' && valueType !== 'number' && valueType !== 'boolean') { + return true; // undefined / bigint / symbol values are unrepresentable } - return NON_OBJECT_TYPELESS_ROOTS.has(def.type) ? def.type : undefined; + jsonTypes.add(valueType); } - return undefined; + return jsonTypes.size > 1; } /** - * Zod root types that can emit a typeless `{}` under `unrepresentable: 'any'` but - * provably do not describe objects — the input-root guard must keep rejecting them. - * `literal` reaches this branch only when typeless (an unrepresentable-value literal - * like `z.literal(undefined)`, or a mixed-type multi-value literal), neither of which - * describes an object; representable single-type literal roots emit an explicit - * `type` and are rejected by the earlier guard. `custom` is deliberately excluded — - * it can legitimately accept objects. + * Zod def types that are genuinely unrepresentable in JSON Schema (they degrade to a + * typeless `{}` under `unrepresentable: 'any'`) and whose values can never be a JSON + * object — the input-root guard must keep rejecting roots and compositions built from + * them. `custom` and bare `transform` are deliberately excluded (they can legitimately + * accept objects), and typeless literals are classified separately by value in + * {@linkcode isNonObjectTypelessLiteral}. */ -const NON_OBJECT_TYPELESS_ROOTS: ReadonlySet = new Set([ +const NON_OBJECT_UNREPRESENTABLE_TYPES: ReadonlySet = new Set([ 'bigint', 'symbol', 'map', @@ -542,8 +579,7 @@ const NON_OBJECT_TYPELESS_ROOTS: ReadonlySet = new Set([ 'void', 'undefined', 'nan', - 'function', - 'literal' + 'function' ]); /** Transparent wrapper def types whose `innerType` carries the real root semantics. */ diff --git a/packages/core-internal/test/util/standardSchema.test.ts b/packages/core-internal/test/util/standardSchema.test.ts index 9be1bbd2ce..b196a660d7 100644 --- a/packages/core-internal/test/util/standardSchema.test.ts +++ b/packages/core-internal/test/util/standardSchema.test.ts @@ -158,15 +158,16 @@ describe('zod conversion options (#2464)', () => { const result = standardSchemaToJsonSchema(schema, 'output'); // Catch-validation accepts any raw value (the fallback replaces it only in the - // parsed result, which never ships), so no inner constraint may be advertised. - // The emitted `type` is kept: the verdict must be position-independent (zod - // deduplicates reused instances), and root positions need it for the 2025-era - // legacy-wrap object proof. + // parsed result, which never ships), so no inner constraint may be advertised — + // including a non-object `type`, which would reject the wrong-typed raw values + // catch exists to tolerate. Only `type: 'object'` is kept: the verdict must be + // position-independent (zod deduplicates reused instances), and it is all the + // 2025-era legacy-wrap object proof consumes. const properties = result.properties as Record>; expect(properties.inner).toEqual({ description: 'lenient', default: { n: 'd' }, type: 'object' }); - expect(properties.scalar).toEqual({ default: 0, type: 'number' }); + expect(properties.scalar).toEqual({ default: 0 }); // `x-*` vendor extensions are annotation-only and must survive the degrade. - expect(properties.annotated).toEqual({ default: 0, title: 't', 'x-ui': 1, type: 'number' }); + expect(properties.annotated).toEqual({ default: 0, title: 't', 'x-ui': 1 }); // A raw payload omitting the catch fields also validates, so they are not required. expect(result.required).toEqual(['name']); }); @@ -268,6 +269,26 @@ describe('zod conversion options (#2464)', () => { expect(standardSchemaToJsonSchema(z.union([z.bigint(), z.object({ a: z.string() })]), 'input').type).toBe('object'); }); + test('shared-instance union members each get a real verdict', () => { + // The cycle guard tracks only the current traversal path, so a schema constant + // reused across union arms must not short-circuit the second member's verdict. + const shared = z.bigint(); + expect(() => standardSchemaToJsonSchema(z.union([shared, shared]), 'input')).toThrow(/must describe objects/); + }); + + test('representable literal unions keep listing (idiomatic zod enum spelling)', () => { + // Regression: these converted and listed pre-#2464 — classifying every literal + // member as non-object made one such registration fail the ENTIRE tools/list. + expect(standardSchemaToJsonSchema(z.union([z.literal('admin'), z.literal('member')]), 'input').type).toBe('object'); + expect(standardSchemaToJsonSchema(z.intersection(z.object({ a: z.string() }), z.literal('a')), 'input').type).toBe('object'); + // Representable non-object members also keep the composition accepted, exactly + // as such roots converted pre-#2464. + expect(standardSchemaToJsonSchema(z.union([z.string(), z.number()]), 'input').type).toBe('object'); + expect(standardSchemaToJsonSchema(z.union([z.literal('a'), z.null()]), 'input').type).toBe('object'); + // Literals with unrepresentable values still reject inside compositions. + expect(() => standardSchemaToJsonSchema(z.union([z.literal(undefined), z.bigint()]), 'input')).toThrow(/must describe objects/); + }); + test('symbol- and function-valued output fields are not advertised as required', () => { const schema = z.object({ s: z.symbol(), f: z.function(), name: z.string() }); const result = standardSchemaToJsonSchema(schema, 'output'); From 591c054c74a19b372f536ebbf9b5e0e91ca677b0 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 23:47:11 +0000 Subject: [PATCH 12/35] fix(core-internal): classify date members and non-finite literals; unwind piped defaults MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Composition member classification: 'date' joins NON_OBJECT_UNREPRESENTABLE_TYPES — a Date value can never be a JSON object, and at member position the rewritten string/date-time node nests inside anyOf/allOf where the explicit-type root guard cannot see it, so z.union([z.date(), z.date()]), z.union([z.date(), z.bigint()]), and z.intersection(z.object({...}), z.date()) were stamped as phantom {type: 'object'} tools (pre-PR each threw loudly). Bare date ROOTS are unaffected (they throw via the explicit-type guard after the rewrite). isNonObjectTypelessLiteral now also rejects non-finite number literal values (Infinity/-Infinity/NaN are typeof 'number' but cannot ride JSON). Mixed unions with a representable member (z.union([z.date(), z.string()])) stay accepted under the every-member rule. - fieldAcceptsMissingKey's structural default short-circuit read only the outermost def type, missing a default hidden inside a pipe: z.number().default(7).transform(async v => v) is outer-'pipe' with the ZodDefault at def.in, so the probe went async and the field wrongly stayed in the advertised output required list while async-capable validation fills the default. A new hasStructuralDefault helper unwinds pipe INPUT sides, lazies, and transparent wrappers looking for a default/prefault node (verified: every wrapper in the set fills the inner default on undefined). Co-Authored-By: Claude --- .../core-internal/src/util/standardSchema.ts | 58 +++++++++++++++---- .../test/util/standardSchema.test.ts | 35 +++++++++++ 2 files changed, 83 insertions(+), 10 deletions(-) diff --git a/packages/core-internal/src/util/standardSchema.ts b/packages/core-internal/src/util/standardSchema.ts index 069bc5b18f..ad4f23869e 100644 --- a/packages/core-internal/src/util/standardSchema.ts +++ b/packages/core-internal/src/util/standardSchema.ts @@ -346,11 +346,11 @@ const ANNOTATION_JSON_SCHEMA_KEYWORDS: ReadonlySet = new Set([ function fieldAcceptsMissingKey(field: z.core.$ZodType | undefined): boolean { if (field === undefined) return false; // Defaulted fields accept a missing key by construction (zod fills the default - // before any refinement runs) — decide structurally, since an async stage - // (`.refine(async ...)`) would push the probe below to a Promise and wrongly - // keep the field required. - const defType = field._zod.def.type; - if (defType === 'default' || defType === 'prefault') return true; + // before any refinement or transform runs) — decide structurally, since an async + // stage (`.refine(async ...)`, `.transform(async ...)`) would push the probe + // below to a Promise and wrongly keep the field required. The default may hide + // inside a pipe's input side (`.default(7).transform(...)` is outer-`'pipe'`). + if (hasStructuralDefault(field)) return true; // JSON.stringify drops Symbol- and function-valued keys from the serialized // payload entirely (the same mechanism that drops undefined-valued keys), so // such fields can never appear on the wire. @@ -369,6 +369,37 @@ function fieldAcceptsMissingKey(field: z.core.$ZodType | undefined): boolean { } } +/** + * Whether the field's def chain carries a `default`/`prefault` node, unwinding pipe + * INPUT sides, lazies, and transparent wrappers — so + * `z.number().default(7).transform(async v => v)` (outer def `'pipe'`, the ZodDefault + * at `def.in`) is recognized structurally. A missing key resolves against the pipe's + * input side first, where the default fills before any later stage runs. `ancestors` + * tracks the current traversal path so recursive lazies stay bounded. + */ +function hasStructuralDefault(field: unknown, ancestors: ReadonlySet = new Set()): boolean { + if (typeof field !== 'object' || field === null || ancestors.has(field)) return false; + const def = (field as { _zod?: { def?: { type?: string; innerType?: unknown; getter?: unknown; in?: unknown } } })._zod?.def; + if (def === undefined || typeof def.type !== 'string') return false; + if (def.type === 'default' || def.type === 'prefault') return true; + const path = new Set(ancestors); + path.add(field); + if (def.type === 'lazy' && typeof def.getter === 'function') { + try { + return hasStructuralDefault((def.getter as () => unknown)(), path); + } catch { + return false; + } + } + if (def.type === 'pipe' && def.in !== undefined) { + return hasStructuralDefault(def.in, path); + } + if (WRAPPER_ZOD_DEF_TYPES.has(def.type) && def.innerType !== undefined) { + return hasStructuralDefault(def.innerType, path); + } + return false; +} + /** Options for {@linkcode standardSchemaToJsonSchema}. */ export interface StandardSchemaToJsonSchemaOptions { /** @@ -558,16 +589,22 @@ function isNonObjectTypelessLiteral(values: unknown): boolean { if (valueType !== 'string' && valueType !== 'number' && valueType !== 'boolean') { return true; // undefined / bigint / symbol values are unrepresentable } + if (valueType === 'number' && !Number.isFinite(value as number)) { + return true; // Infinity / -Infinity / NaN cannot ride JSON either + } jsonTypes.add(valueType); } return jsonTypes.size > 1; } /** - * Zod def types that are genuinely unrepresentable in JSON Schema (they degrade to a - * typeless `{}` under `unrepresentable: 'any'`) and whose values can never be a JSON - * object — the input-root guard must keep rejecting roots and compositions built from - * them. `custom` and bare `transform` are deliberately excluded (they can legitimately + * Zod def types that are genuinely unrepresentable in JSON Schema and whose values can + * never be a JSON object — the input-root guard must keep rejecting roots and + * compositions built from them. Most degrade to a typeless `{}` under + * `unrepresentable: 'any'`; `date` is instead rewritten to `string`/`date-time` (so a + * bare date ROOT throws via the explicit-type guard), but a `Date` value can never be + * a JSON object either, so date composition MEMBERS classify as non-object here. + * `custom` and bare `transform` are deliberately excluded (they can legitimately * accept objects), and typeless literals are classified separately by value in * {@linkcode isNonObjectTypelessLiteral}. */ @@ -579,7 +616,8 @@ const NON_OBJECT_UNREPRESENTABLE_TYPES: ReadonlySet = new Set([ 'void', 'undefined', 'nan', - 'function' + 'function', + 'date' ]); /** Transparent wrapper def types whose `innerType` carries the real root semantics. */ diff --git a/packages/core-internal/test/util/standardSchema.test.ts b/packages/core-internal/test/util/standardSchema.test.ts index b196a660d7..259d81620e 100644 --- a/packages/core-internal/test/util/standardSchema.test.ts +++ b/packages/core-internal/test/util/standardSchema.test.ts @@ -148,6 +148,26 @@ describe('zod conversion options (#2464)', () => { expect(result.required).toEqual(['name']); }); + test('a defaulted field piped through a transform is still dropped from output required', () => { + const schema = z.object({ + asyncPiped: z + .number() + .default(7) + .transform(async v => v), + syncPiped: z + .number() + .default(7) + .transform(v => v), + name: z.string() + }); + const result = standardSchemaToJsonSchema(schema, 'output'); + + // `.default(7).transform(...)` is outer-'pipe' with the ZodDefault at def.in; + // the structural check must unwind the pipe's input side, since the async + // stage pushes the validate(undefined) probe to a Promise. + expect(result.required).toEqual(['name']); + }); + test('.catch() output nodes degrade to an unconstrained schema (annotations kept)', () => { const schema = z.object({ inner: z.object({ n: z.string() }).catch({ n: 'd' }).describe('lenient'), @@ -289,6 +309,21 @@ describe('zod conversion options (#2464)', () => { expect(() => standardSchemaToJsonSchema(z.union([z.literal(undefined), z.bigint()]), 'input')).toThrow(/must describe objects/); }); + test('date members and non-finite literals classify as non-object inside compositions', () => { + // A Date value can never be a JSON object; at member position the rewritten + // string/date-time node nests inside anyOf and evades the explicit-type guard. + expect(() => standardSchemaToJsonSchema(z.union([z.date(), z.date()]), 'input')).toThrow(/must describe objects/); + expect(() => standardSchemaToJsonSchema(z.union([z.date(), z.bigint()]), 'input')).toThrow(/must describe objects/); + expect(() => standardSchemaToJsonSchema(z.intersection(z.object({ a: z.string() }), z.date()), 'input')).toThrow( + /must describe objects/ + ); + // Infinity/-Infinity/NaN literals are typeof 'number' but cannot ride JSON. + expect(() => standardSchemaToJsonSchema(z.union([z.literal(Infinity), z.bigint()]), 'input')).toThrow(/must describe objects/); + expect(() => standardSchemaToJsonSchema(z.union([z.literal(Number.NaN), z.bigint()]), 'input')).toThrow(/must describe objects/); + // A representable member keeps the composition accepted (every-member rule). + expect(standardSchemaToJsonSchema(z.union([z.date(), z.string()]), 'input').type).toBe('object'); + }); + test('symbol- and function-valued output fields are not advertised as required', () => { const schema = z.object({ s: z.symbol(), f: z.function(), name: z.string() }); const result = standardSchemaToJsonSchema(schema, 'output'); From 4a92accb7026ed76d336ab140630cfefb0afdf3d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Jul 2026 00:26:17 +0000 Subject: [PATCH 13/35] fix(core-internal): skeletonize oneOf as anyOf; accept mixed representable literals; broaden tolerance detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - oneOf skeletons: catch-of-discriminated-union output schemas (zod emits DUs as oneOf) kept the oneOf keyword while members reduced to identical {type: 'object'} skeletons, so every legitimate payload matched BOTH members and Ajv rejected 'must match exactly one schema in oneOf' — a strict regression vs pre-PR where the constrained members were disjoint. The degrade loop and compositionTypeSkeleton now emit oneOf skeletons under anyOf, the honest loosening once the discriminating constraints are stripped (wrap-neutral: isProvablyObjectShapedRoot treats the composition keywords identically). - isNonObjectTypelessLiteral: drop the jsonTypes.size > 1 branch — a mixed-but-REPRESENTABLE literal (z.literal(['a', 1]) emits a valid {enum: ['a', 1]}) listed silently pre-PR, and classifying it non-object made one such registration fail the entire tools/list, a round-10-introduced fail-closed regression. Only genuinely unrepresentable values (undefined/bigint/symbol/non-finite numbers) reject now, matching the loudness-parity contract. - hasStructuralDefault -> hasStructuralMissingKeyTolerance: recognize static .catch() BEFORE the wrapper unwind would step past it (any input, including undefined, is replaced by the fallback — tolerance by construction), and recurse into union def.options with ANY-member semantics (a union accepts a missing key whenever any member does; intersections deliberately excluded). Fixes catch/union-nested defaults with async stages staying in the advertised output required. Co-Authored-By: Claude --- .../core-internal/src/util/standardSchema.ts | 68 +++++++++++-------- .../test/util/standardSchema.test.ts | 55 ++++++++++++++- 2 files changed, 94 insertions(+), 29 deletions(-) diff --git a/packages/core-internal/src/util/standardSchema.ts b/packages/core-internal/src/util/standardSchema.ts index ad4f23869e..89a797de5c 100644 --- a/packages/core-internal/src/util/standardSchema.ts +++ b/packages/core-internal/src/util/standardSchema.ts @@ -263,7 +263,14 @@ function zodConversionOptions(io: 'input' | 'output'): Pick compositionTypeSkeleton(member)); + const skeletons = (ctx.jsonSchema[key] as unknown[]).map(member => compositionTypeSkeleton(member)); + // `oneOf` means EXACTLY one: with member constraints stripped, the + // skeletons are indistinguishable and every payload would match all + // of them — advertise the honest loosening `anyOf` instead + // (wrap-neutral: `isProvablyObjectShapedRoot` treats the + // composition keywords identically). + if (key === 'oneOf') delete ctx.jsonSchema[key]; + ctx.jsonSchema[key === 'oneOf' ? 'anyOf' : key] = skeletons; continue; } delete ctx.jsonSchema[key]; @@ -313,7 +320,9 @@ function compositionTypeSkeleton(node: unknown): Record { if (source.type === 'object') skeleton.type = 'object'; for (const key of ['anyOf', 'oneOf', 'allOf'] as const) { if (Array.isArray(source[key])) { - skeleton[key] = (source[key] as unknown[]).map(member => compositionTypeSkeleton(member)); + // Skeletonized `oneOf` members are indistinguishable, so exactly-one + // semantics would reject every payload — emit `anyOf` instead. + skeleton[key === 'oneOf' ? 'anyOf' : key] = (source[key] as unknown[]).map(member => compositionTypeSkeleton(member)); } } return skeleton; @@ -350,7 +359,7 @@ function fieldAcceptsMissingKey(field: z.core.$ZodType | undefined): boolean { // stage (`.refine(async ...)`, `.transform(async ...)`) would push the probe // below to a Promise and wrongly keep the field required. The default may hide // inside a pipe's input side (`.default(7).transform(...)` is outer-`'pipe'`). - if (hasStructuralDefault(field)) return true; + if (hasStructuralMissingKeyTolerance(field)) return true; // JSON.stringify drops Symbol- and function-valued keys from the serialized // payload entirely (the same mechanism that drops undefined-valued keys), so // such fields can never appear on the wire. @@ -370,32 +379,41 @@ function fieldAcceptsMissingKey(field: z.core.$ZodType | undefined): boolean { } /** - * Whether the field's def chain carries a `default`/`prefault` node, unwinding pipe - * INPUT sides, lazies, and transparent wrappers — so - * `z.number().default(7).transform(async v => v)` (outer def `'pipe'`, the ZodDefault - * at `def.in`) is recognized structurally. A missing key resolves against the pipe's - * input side first, where the default fills before any later stage runs. `ancestors` - * tracks the current traversal path so recursive lazies stay bounded. + * Whether the field's def chain carries a node that tolerates a missing key by + * construction — `default`/`prefault` (the default fills) or a static `catch` (any + * input, including `undefined`, is replaced by the fallback) — unwinding pipe INPUT + * sides, lazies, transparent wrappers, and union members (ANY tolerant member + * suffices: zod tries members and the tolerant one succeeds; intersections must NOT + * get this treatment, since `undefined` would have to satisfy both sides). This + * recognizes `z.number().default(7).transform(async v => v)` (outer def `'pipe'`, + * the ZodDefault at `def.in`) structurally, since an async stage pushes the + * validate-probe to a Promise. `ancestors` tracks the current traversal path so + * recursive lazies stay bounded. */ -function hasStructuralDefault(field: unknown, ancestors: ReadonlySet = new Set()): boolean { +function hasStructuralMissingKeyTolerance(field: unknown, ancestors: ReadonlySet = new Set()): boolean { if (typeof field !== 'object' || field === null || ancestors.has(field)) return false; - const def = (field as { _zod?: { def?: { type?: string; innerType?: unknown; getter?: unknown; in?: unknown } } })._zod?.def; + const def = (field as { _zod?: { def?: { type?: string; innerType?: unknown; getter?: unknown; in?: unknown; options?: unknown } } }) + ._zod?.def; if (def === undefined || typeof def.type !== 'string') return false; - if (def.type === 'default' || def.type === 'prefault') return true; + // `catch` must be recognized BEFORE the wrapper unwind below would step past it. + if (def.type === 'default' || def.type === 'prefault' || def.type === 'catch') return true; const path = new Set(ancestors); path.add(field); if (def.type === 'lazy' && typeof def.getter === 'function') { try { - return hasStructuralDefault((def.getter as () => unknown)(), path); + return hasStructuralMissingKeyTolerance((def.getter as () => unknown)(), path); } catch { return false; } } if (def.type === 'pipe' && def.in !== undefined) { - return hasStructuralDefault(def.in, path); + return hasStructuralMissingKeyTolerance(def.in, path); + } + if (def.type === 'union' && Array.isArray(def.options)) { + return def.options.some(option => hasStructuralMissingKeyTolerance(option, path)); } if (WRAPPER_ZOD_DEF_TYPES.has(def.type) && def.innerType !== undefined) { - return hasStructuralDefault(def.innerType, path); + return hasStructuralMissingKeyTolerance(def.innerType, path); } return false; } @@ -571,20 +589,17 @@ function nonObjectTypelessRootType(schema: unknown, ancestors: ReadonlySet(); for (const value of values) { - if (value === null) { - jsonTypes.add('null'); - continue; - } + if (value === null) continue; const valueType = typeof value; if (valueType !== 'string' && valueType !== 'number' && valueType !== 'boolean') { return true; // undefined / bigint / symbol values are unrepresentable @@ -592,9 +607,8 @@ function isNonObjectTypelessLiteral(values: unknown): boolean { if (valueType === 'number' && !Number.isFinite(value as number)) { return true; // Infinity / -Infinity / NaN cannot ride JSON either } - jsonTypes.add(valueType); } - return jsonTypes.size > 1; + return false; } /** diff --git a/packages/core-internal/test/util/standardSchema.test.ts b/packages/core-internal/test/util/standardSchema.test.ts index 259d81620e..5fc97d0646 100644 --- a/packages/core-internal/test/util/standardSchema.test.ts +++ b/packages/core-internal/test/util/standardSchema.test.ts @@ -1,6 +1,7 @@ import * as z from 'zod/v4'; import { standardSchemaToJsonSchema } from '../../src/util/standardSchema'; +import { AjvJsonSchemaValidator } from '../../src/validators/ajvProvider'; import { isNonObjectJsonSchemaRoot } from '../../src/wire/rev2025-11-25/legacyWrap'; describe('standardSchemaToJsonSchema', () => { @@ -257,9 +258,12 @@ describe('zod conversion options (#2464)', () => { 'input' ) ).toThrow(/must describe objects/); - // Typeless literal roots (unrepresentable or mixed-type values) are not objects. + // Literals with genuinely unrepresentable values are not objects. expect(() => standardSchemaToJsonSchema(z.literal(undefined), 'input')).toThrow(/must describe objects/); - expect(() => standardSchemaToJsonSchema(z.literal(['a', 1]), 'input')).toThrow(/must describe objects/); + // Mixed-but-REPRESENTABLE literals emit a valid {enum: [...]}: they listed + // silently pre-#2464 (an uncallable phantom, but harmless to other tools) + // and must keep doing so — throwing here would fail the whole tools/list. + expect(standardSchemaToJsonSchema(z.literal(['a', 1]), 'input').type).toBe('object'); // Pipes unwrap via their INPUT side, and promises via their inner type. expect(() => standardSchemaToJsonSchema( @@ -344,6 +348,53 @@ describe('zod conversion options (#2464)', () => { expect(isNonObjectJsonSchemaRoot(result)).toBe(false); }); + test('a .catch() wrapping a discriminated union skeletonizes oneOf as anyOf', () => { + const du = z + .discriminatedUnion('t', [z.object({ t: z.literal('a'), x: z.string() }), z.object({ t: z.literal('b'), y: z.string() })]) + .catch({ t: 'a', x: 'd' }); + + // With member constraints stripped, `oneOf` skeletons are indistinguishable — + // every payload would match BOTH members and Ajv rejects "must match exactly + // one schema in oneOf". The honest loosening is `anyOf` (wrap-neutral). + const root = standardSchemaToJsonSchema(du, 'output'); + expect(root.oneOf).toBeUndefined(); + expect(root.anyOf).toEqual([{ type: 'object' }, { type: 'object' }]); + expect(root.type).toBe('object'); + expect(isNonObjectJsonSchemaRoot(root)).toBe(false); + const rootValidate = new AjvJsonSchemaValidator().getValidator(root); + expect(rootValidate({ t: 'a', x: 'hello' }).valid).toBe(true); + + const nested = standardSchemaToJsonSchema(z.object({ res: du, name: z.string() }), 'output'); + const res = (nested.properties as Record>).res!; + expect(res.oneOf).toBeUndefined(); + expect(res.anyOf).toEqual([{ type: 'object' }, { type: 'object' }]); + const nestedValidate = new AjvJsonSchemaValidator().getValidator(nested); + expect(nestedValidate({ res: { t: 'a', x: 'hello' }, name: 'n' }).valid).toBe(true); + }); + + test('.catch() and union-nested defaults with async stages are dropped from output required', () => { + const schema = z.object({ + c: z + .number() + .catch(0) + .refine(async () => true), + u: z.union([ + z + .number() + .default(7) + .transform(async v => v), + z.string() + ]), + name: z.string() + }); + const result = standardSchemaToJsonSchema(schema, 'output'); + + // A static .catch() tolerates a missing key by construction (like a default), + // and a union accepts one whenever ANY member does — both must be recognized + // structurally, since the async stages push the validate probe to a Promise. + expect(result.required).toEqual(['name']); + }); + test('a required field whose transform throws on undefined stays required and does not crash', async () => { const schema = z.object({ n: z.unknown().transform(v => (v as string).length), name: z.string() }); const result = standardSchemaToJsonSchema(schema, 'output'); From 6018eae8c286702ee20b2327acba738d3ca33b6c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Jul 2026 01:13:15 +0000 Subject: [PATCH 14/35] fix(core-internal): broaden structural tolerance walk; loud/quiet root verdicts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - hasStructuralMissingKeyTolerance now also recognizes: symbol/function leaves (JSON.stringify drops such keys, now detected through union members and the record call site, not just single-child chains), any/unknown leaves (undefined-accepting by construction — previously only rescued by the sync probe, so an async stage kept them required), and defaults at a pipe's OUTPUT side (z.preprocess(fn, z.number().default(7)) puts the transform at def.in and the tolerant node at def.out). All checks err loosen-only; the now-redundant unwrappedZodDefType carve-out is removed. The throwing-transform crash pin keeps exercising the probe hardening via a z.custom pipe (no structural verdict), while the z.unknown-piped field now counts tolerant structurally. - The input-root guard's composition verdicts split into loud vs quiet (nonObjectTypelessRootVerdict + nonObjectLiteralLoudness): loud shapes made pre-#2464 conversion throw (bigint/symbol/date/..., literals with undefined/bigint/symbol values) and keep throwing; non-finite number literals (Infinity/-Infinity/NaN) are non-object but zod silently emits {type: 'number', const: null} for them, so compositions built SOLELY of non-finite literals — which listed silently pre-#2464 — keep listing instead of failing the whole tools/list. z.union([z.literal(Infinity), z.bigint()]) stays loud via the bigint co-member (pinned). Co-Authored-By: Claude --- .../core-internal/src/util/standardSchema.ts | 176 +++++++++--------- .../test/util/standardSchema.test.ts | 44 ++++- 2 files changed, 129 insertions(+), 91 deletions(-) diff --git a/packages/core-internal/src/util/standardSchema.ts b/packages/core-internal/src/util/standardSchema.ts index 89a797de5c..d610593367 100644 --- a/packages/core-internal/src/util/standardSchema.ts +++ b/packages/core-internal/src/util/standardSchema.ts @@ -357,14 +357,12 @@ function fieldAcceptsMissingKey(field: z.core.$ZodType | undefined): boolean { // Defaulted fields accept a missing key by construction (zod fills the default // before any refinement or transform runs) — decide structurally, since an async // stage (`.refine(async ...)`, `.transform(async ...)`) would push the probe - // below to a Promise and wrongly keep the field required. The default may hide - // inside a pipe's input side (`.default(7).transform(...)` is outer-`'pipe'`). + // below to a Promise and wrongly keep the field required. The walk also covers + // static `.catch()`, undefined-accepting leaves (`z.any()`/`z.unknown()`), + // Symbol-/function-typed fields (JSON.stringify drops such keys entirely), union + // members, and defaults hidden inside a pipe (`.default(7).transform(...)`, + // `z.preprocess(fn, z.number().default(7))`). if (hasStructuralMissingKeyTolerance(field)) return true; - // JSON.stringify drops Symbol- and function-valued keys from the serialized - // payload entirely (the same mechanism that drops undefined-valued keys), so - // such fields can never appear on the wire. - const unwrapped = unwrappedZodDefType(field); - if (unwrapped === 'symbol' || unwrapped === 'function') return true; try { const result = field['~standard'].validate(undefined); if (result instanceof Promise) { @@ -379,24 +377,31 @@ function fieldAcceptsMissingKey(field: z.core.$ZodType | undefined): boolean { } /** - * Whether the field's def chain carries a node that tolerates a missing key by - * construction — `default`/`prefault` (the default fills) or a static `catch` (any - * input, including `undefined`, is replaced by the fallback) — unwinding pipe INPUT - * sides, lazies, transparent wrappers, and union members (ANY tolerant member - * suffices: zod tries members and the tolerant one succeeds; intersections must NOT - * get this treatment, since `undefined` would have to satisfy both sides). This - * recognizes `z.number().default(7).transform(async v => v)` (outer def `'pipe'`, - * the ZodDefault at `def.in`) structurally, since an async stage pushes the - * validate-probe to a Promise. `ancestors` tracks the current traversal path so + * Whether the field's def chain carries a node that makes a missing key tolerable by + * construction — `default`/`prefault` (the default fills), a static `catch` (any + * input, including `undefined`, is replaced by the fallback), an undefined-accepting + * leaf (`z.any()`/`z.unknown()`), or a Symbol-/function-typed leaf (JSON.stringify + * drops such keys from the payload entirely) — unwinding pipe sides, lazies, + * transparent wrappers, and union members (ANY tolerant member suffices: zod tries + * members and the tolerant one succeeds; intersections must NOT get this treatment, + * since `undefined` would have to satisfy both sides). Deciding structurally matters + * because an async stage (`.refine(async ...)`, `.transform(async ...)`) pushes the + * validate-probe to a Promise. All checks err loosen-only: a false positive merely + * drops a field from the advertised `required`, which can never make a validating + * client reject a shipped payload. `ancestors` tracks the current traversal path so * recursive lazies stay bounded. */ function hasStructuralMissingKeyTolerance(field: unknown, ancestors: ReadonlySet = new Set()): boolean { if (typeof field !== 'object' || field === null || ancestors.has(field)) return false; - const def = (field as { _zod?: { def?: { type?: string; innerType?: unknown; getter?: unknown; in?: unknown; options?: unknown } } }) - ._zod?.def; + const def = ( + field as { + _zod?: { def?: { type?: string; innerType?: unknown; getter?: unknown; in?: unknown; out?: unknown; options?: unknown } }; + } + )._zod?.def; if (def === undefined || typeof def.type !== 'string') return false; // `catch` must be recognized BEFORE the wrapper unwind below would step past it. if (def.type === 'default' || def.type === 'prefault' || def.type === 'catch') return true; + if (def.type === 'any' || def.type === 'unknown' || def.type === 'symbol' || def.type === 'function') return true; const path = new Set(ancestors); path.add(field); if (def.type === 'lazy' && typeof def.getter === 'function') { @@ -407,7 +412,14 @@ function hasStructuralMissingKeyTolerance(field: unknown, ancestors: ReadonlySet } } if (def.type === 'pipe' && def.in !== undefined) { - return hasStructuralMissingKeyTolerance(def.in, path); + if (hasStructuralMissingKeyTolerance(def.in, path)) return true; + // `z.preprocess(fn, inner)` builds the opposite pipe — the transform sits at + // `def.in` and the tolerant node (e.g. a default) at `def.out`. + const inDef = (def.in as { _zod?: { def?: { type?: string } } })._zod?.def; + if (inDef?.type === 'transform' && def.out !== undefined) { + return hasStructuralMissingKeyTolerance(def.out, path); + } + return false; } if (def.type === 'union' && Array.isArray(def.options)) { return def.options.some(option => hasStructuralMissingKeyTolerance(option, path)); @@ -516,10 +528,12 @@ export function standardSchemaToJsonSchema( // so misregistered roots keep failing loudly instead of being advertised as // permanently-uncallable `{type: 'object'}` tools. if (result.type === undefined) { - const nonObjectType = nonObjectTypelessRootType(schema); - if (nonObjectType !== undefined) { + const verdict = nonObjectTypelessRootVerdict(schema); + // Quiet verdicts (compositions of only non-finite number literals) listed + // silently pre-#2464 and keep the unconditional stamp below. + if (verdict !== undefined && verdict.loud) { throw new Error( - `MCP tool and prompt schemas must describe objects (got a non-object ${nonObjectType} schema). ` + + `MCP tool and prompt schemas must describe objects (got a non-object ${verdict.type} schema). ` + `Wrap your schema in z.object({...}) or equivalent.` ); } @@ -528,19 +542,28 @@ export function standardSchemaToJsonSchema( } /** - * The def type of a zod root that emits a typeless node yet provably cannot describe + * The verdict for a zod root that emits a typeless node yet provably cannot describe * an object, or `undefined` when the root may. Unwraps transparent wrappers, lazies, * and pipe input sides, then recurses into compositions: a union is non-object when * EVERY member is, an intersection when ANY side is (the value must satisfy both). A * member counts as non-object ONLY when it unwinds to a genuinely unrepresentable - * type, or to a literal whose values are unrepresentable or mixed-type — representable - * members (including single-type literals, zod's idiomatic enum spelling, and - * representable non-object types like `z.string()`) keep the composition accepted, - * exactly as such roots converted before #2464. `ancestors` tracks only the current - * traversal path, so a shared instance gets a real verdict at every occurrence while - * recursive lazies stay bounded. + * type, or to a literal carrying unrepresentable values — representable members + * (including single-type literals, zod's idiomatic enum spelling, and representable + * non-object types like `z.string()`) keep the composition accepted, exactly as such + * roots converted before #2464. + * + * The verdict carries loudness for the loudness-parity contract: `loud` shapes made + * pre-#2464 conversion throw (bigint/symbol/date/…, literals with undefined/bigint/ + * symbol values) and must keep throwing; `quiet` shapes (non-finite number literals, + * which zod silently emits as `{type: 'number', const: null}`) listed silently + * pre-#2464 and must keep listing — the guard throws only for a loud-containing + * verdict. `ancestors` tracks only the current traversal path, so a shared instance + * gets a real verdict at every occurrence while recursive lazies stay bounded. */ -function nonObjectTypelessRootType(schema: unknown, ancestors: ReadonlySet = new Set()): string | undefined { +function nonObjectTypelessRootVerdict( + schema: unknown, + ancestors: ReadonlySet = new Set() +): { type: string; loud: boolean } | undefined { if (typeof schema !== 'object' || schema === null || ancestors.has(schema)) return undefined; const def = ( schema as { @@ -563,52 +586,68 @@ function nonObjectTypelessRootType(schema: unknown, ancestors: ReadonlySet unknown)(), path); + return nonObjectTypelessRootVerdict((def.getter as () => unknown)(), path); } catch { return undefined; } } if (def.type === 'pipe' && def.in !== undefined) { - return nonObjectTypelessRootType(def.in, path); + return nonObjectTypelessRootVerdict(def.in, path); } if (WRAPPER_ZOD_DEF_TYPES.has(def.type) && def.innerType !== undefined) { - return nonObjectTypelessRootType(def.innerType, path); + return nonObjectTypelessRootVerdict(def.innerType, path); } if (def.type === 'union' && Array.isArray(def.options) && def.options.length > 0) { - return def.options.every(option => nonObjectTypelessRootType(option, path) !== undefined) ? 'union' : undefined; + const verdicts = def.options.map(option => nonObjectTypelessRootVerdict(option, path)); + if (verdicts.includes(undefined)) return undefined; + return { type: 'union', loud: verdicts.some(verdict => verdict?.loud === true) }; } if (def.type === 'intersection' && def.left !== undefined && def.right !== undefined) { - return nonObjectTypelessRootType(def.left, path) !== undefined || nonObjectTypelessRootType(def.right, path) !== undefined - ? 'intersection' - : undefined; + const left = nonObjectTypelessRootVerdict(def.left, path); + const right = nonObjectTypelessRootVerdict(def.right, path); + if (left === undefined && right === undefined) return undefined; + return { type: 'intersection', loud: left?.loud === true || right?.loud === true }; } if (def.type === 'literal') { - return isNonObjectTypelessLiteral(def.values) ? 'literal' : undefined; + const loudness = nonObjectLiteralLoudness(def.values); + return loudness === undefined ? undefined : { type: 'literal', loud: loudness === 'loud' }; } - return NON_OBJECT_UNREPRESENTABLE_TYPES.has(def.type) ? def.type : undefined; + return NON_OBJECT_UNREPRESENTABLE_TYPES.has(def.type) ? { type: def.type, loud: true } : undefined; } /** - * Whether a literal carries a genuinely unrepresentable value (`undefined`, bigint, - * symbol, or a non-finite number) — only those made pre-#2464 conversion fail loudly, - * so only those may reject here. Representable literals — including single-type ones + * The non-object loudness of a literal's values, or `undefined` when the literal may + * be JSON-satisfiable. `'loud'`: a value zod's own converter threw on pre-#2464 + * (`undefined`, bigint, symbol) — such roots must keep failing loudly. `'quiet'`: + * only non-finite numbers (`Infinity`/`-Infinity`/`NaN`), which zod silently emits + * as `{type: 'number', const: null}` — non-object, but such roots listed silently + * pre-#2464 and must keep listing. Any representable value (string, finite number, + * boolean, null) makes the literal satisfiable via JSON: `undefined` * (`z.union([z.literal('admin'), z.literal('member')])`, zod's idiomatic enum - * spelling) and mixed-type value lists (`z.literal(['a', 1])` emits a valid - * `{enum: ['a', 1]}`) — keep converting and listing exactly as they did pre-#2464. + * spelling, and mixed-type lists like `z.literal(['a', 1])` all keep converting + * exactly as they did pre-#2464). */ -function isNonObjectTypelessLiteral(values: unknown): boolean { - if (!Array.isArray(values) || values.length === 0) return true; +function nonObjectLiteralLoudness(values: unknown): 'loud' | 'quiet' | undefined { + if (!Array.isArray(values) || values.length === 0) return 'loud'; + let representable = false; + let nonFinite = false; for (const value of values) { - if (value === null) continue; + if (value === null) { + representable = true; + continue; + } const valueType = typeof value; if (valueType !== 'string' && valueType !== 'number' && valueType !== 'boolean') { - return true; // undefined / bigint / symbol values are unrepresentable + return 'loud'; // undefined / bigint / symbol values threw pre-#2464 } if (valueType === 'number' && !Number.isFinite(value as number)) { - return true; // Infinity / -Infinity / NaN cannot ride JSON either + nonFinite = true; + } else { + representable = true; } } - return false; + if (representable) return undefined; + return nonFinite ? 'quiet' : undefined; } /** @@ -646,43 +685,6 @@ const WRAPPER_ZOD_DEF_TYPES: ReadonlySet = new Set([ 'promise' ]); -/** - * The innermost def type of a zod schema, unwrapped through transparent wrappers - * (`optional`/`nullable`/`readonly`/`default`/`prefault`/`catch`/`promise` via - * `innerType`, `lazy` via its getter, and `pipe` via its INPUT side `def.in` — the - * side `io: 'input'` conversion and input validation both consume) so - * `z.bigint().optional()`, `z.lazy(() => z.bigint())`, and - * `z.bigint().transform(...)` all report `'bigint'`. A seen-set bounds recursive - * lazies; non-zod schemas and throwing lazy getters yield `undefined`. - */ -function unwrappedZodDefType(schema: unknown): string | undefined { - const seen = new Set(); - let current = schema; - while (typeof current === 'object' && current !== null && !seen.has(current)) { - seen.add(current); - const def = (current as { _zod?: { def?: { type?: string; innerType?: unknown; getter?: unknown; in?: unknown } } })._zod?.def; - if (def === undefined || typeof def.type !== 'string') return undefined; - if (def.type === 'lazy' && typeof def.getter === 'function') { - try { - current = (def.getter as () => unknown)(); - } catch { - return undefined; - } - continue; - } - if (def.type === 'pipe' && def.in !== undefined) { - current = def.in; - continue; - } - if (WRAPPER_ZOD_DEF_TYPES.has(def.type) && def.innerType !== undefined) { - current = def.innerType; - continue; - } - return def.type; - } - return undefined; -} - /** * A typeless JSON Schema root is "provably object-shaped" when either it carries object keywords * directly (`properties`/`patternProperties`/`additionalProperties`/`required`), or it is a diff --git a/packages/core-internal/test/util/standardSchema.test.ts b/packages/core-internal/test/util/standardSchema.test.ts index 5fc97d0646..bb8a80573f 100644 --- a/packages/core-internal/test/util/standardSchema.test.ts +++ b/packages/core-internal/test/util/standardSchema.test.ts @@ -328,6 +328,16 @@ describe('zod conversion options (#2464)', () => { expect(standardSchemaToJsonSchema(z.union([z.date(), z.string()]), 'input').type).toBe('object'); }); + test('compositions built solely of non-finite literals keep listing (quiet verdicts)', () => { + // These converted silently pre-#2464 (zod emits {type: 'number', const: null} + // without throwing) — throwing here would fail the whole tools/list. The + // guard throws only when the composition also carries a LOUD member (one + // that made pre-#2464 conversion throw, e.g. a bigint). + expect(standardSchemaToJsonSchema(z.union([z.literal(Infinity), z.literal(-Infinity)]), 'input').type).toBe('object'); + expect(standardSchemaToJsonSchema(z.union([z.literal(Infinity), z.literal(Number.NaN)]), 'input').type).toBe('object'); + expect(standardSchemaToJsonSchema(z.intersection(z.object({ a: z.string() }), z.literal(Infinity)), 'input').type).toBe('object'); + }); + test('symbol- and function-valued output fields are not advertised as required', () => { const schema = z.object({ s: z.symbol(), f: z.function(), name: z.string() }); const result = standardSchemaToJsonSchema(schema, 'output'); @@ -395,15 +405,41 @@ describe('zod conversion options (#2464)', () => { expect(result.required).toEqual(['name']); }); + test('union-wrapped symbols, async any/unknown, and preprocess defaults are dropped from output required', () => { + const schema = z.object({ + s: z.union([z.symbol(), z.string()]), + a: z.any().refine(async () => true), + u: z.unknown().transform(async v => v), + p: z.preprocess(v => v, z.number().default(7)).refine(async () => true), + name: z.string() + }); + const result = standardSchemaToJsonSchema(schema, 'output'); + + // JSON.stringify drops Symbol-valued keys whichever union member matched; + // any/unknown accept undefined outright; and z.preprocess builds the + // opposite pipe (transform at def.in, the default at def.out). + expect(result.required).toEqual(['name']); + + // Same predicate at the record call site. + const record = standardSchemaToJsonSchema(z.record(z.enum(['a', 'b']), z.union([z.symbol(), z.string()])), 'output'); + expect(record.required).toBeUndefined(); + }); + test('a required field whose transform throws on undefined stays required and does not crash', async () => { - const schema = z.object({ n: z.unknown().transform(v => (v as string).length), name: z.string() }); + const schema = z.object({ + n: z.unknown().transform(v => (v as string).length), + c: z.custom(() => true).transform(v => (v as string).length), + name: z.string() + }); const result = standardSchemaToJsonSchema(schema, 'output'); - // The missing-key probe cannot demonstrate tolerance (the transform throws on - // undefined; depending on the zod version the probe throws synchronously or + // `n` unwinds to the undefined-accepting `z.unknown()` leaf, so it counts as + // missing-key tolerant structurally (loosen-only). `c` unwinds to `custom` + // (no structural verdict), so the probe runs: the transform throws on + // undefined (depending on the zod version the probe throws synchronously or // returns a rejecting Promise) — the field conservatively stays required, and // no unhandled rejection may escape (vitest fails the run on one). - expect(result.required).toEqual(['n', 'name']); + expect(result.required).toEqual(['c', 'name']); await new Promise(resolve => setTimeout(resolve, 10)); }); From 803464573d6bc6a81502ea5c198cd073e5544d8b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Jul 2026 01:58:39 +0000 Subject: [PATCH 15/35] fix(core-internal): preprocess-wrapped roots; optional/void/intersection tolerance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - nonObjectTypelessRootVerdict's pipe branch mirrored the opposite-pipe unwind already present in hasStructuralMissingKeyTolerance: when def.in unwinds to a 'transform' node with no verdict, the guard now recurses def.out — so z.preprocess(v => v, z.bigint()) roots (and such members inside compositions) throw the actionable 'must describe objects' error again instead of being stamped as phantom {type: 'object'} tools. Object-rooted preprocess stays accepted (explicit type: 'object' never reaches the guard) and z.preprocess(fn, z.date()) keeps throwing via the explicit-type guard. - hasStructuralMissingKeyTolerance now recognizes: 'optional' as tolerance-by-construction BEFORE the wrapper unwind would step past it (z.string().optional().transform(async v => v ?? 'x') stays in zod's output-io required while validation tolerates absence — the same bug class as the fixed catch case); 'void'/'undefined' leaves and literals whose value list includes undefined; and intersections with EVERY-side semantics (undefined must parse through BOTH sides — each default fills and zod merges the results). All loosen-only; covers the record call site via the shared predicate. Co-Authored-By: Claude --- .../core-internal/src/util/standardSchema.ts | 54 ++++++++++++---- .../test/util/standardSchema.test.ts | 64 +++++++++++++++++++ 2 files changed, 107 insertions(+), 11 deletions(-) diff --git a/packages/core-internal/src/util/standardSchema.ts b/packages/core-internal/src/util/standardSchema.ts index d610593367..59eab76d72 100644 --- a/packages/core-internal/src/util/standardSchema.ts +++ b/packages/core-internal/src/util/standardSchema.ts @@ -379,12 +379,13 @@ function fieldAcceptsMissingKey(field: z.core.$ZodType | undefined): boolean { /** * Whether the field's def chain carries a node that makes a missing key tolerable by * construction — `default`/`prefault` (the default fills), a static `catch` (any - * input, including `undefined`, is replaced by the fallback), an undefined-accepting - * leaf (`z.any()`/`z.unknown()`), or a Symbol-/function-typed leaf (JSON.stringify - * drops such keys from the payload entirely) — unwinding pipe sides, lazies, - * transparent wrappers, and union members (ANY tolerant member suffices: zod tries - * members and the tolerant one succeeds; intersections must NOT get this treatment, - * since `undefined` would have to satisfy both sides). Deciding structurally matters + * input, including `undefined`, is replaced by the fallback), `optional`, an + * undefined-accepting leaf (`z.any()`/`z.unknown()`/`z.undefined()`/`z.void()`, or a + * literal whose values include `undefined`), or a Symbol-/function-typed leaf + * (JSON.stringify drops such keys from the payload entirely) — unwinding pipe sides, + * lazies, transparent wrappers, union members (ANY tolerant member suffices: zod + * tries members and the tolerant one succeeds), and intersections with EVERY-side + * semantics (`undefined` must parse through both sides). Deciding structurally matters * because an async stage (`.refine(async ...)`, `.transform(async ...)`) pushes the * validate-probe to a Promise. All checks err loosen-only: a false positive merely * drops a field from the advertised `required`, which can never make a validating @@ -395,13 +396,30 @@ function hasStructuralMissingKeyTolerance(field: unknown, ancestors: ReadonlySet if (typeof field !== 'object' || field === null || ancestors.has(field)) return false; const def = ( field as { - _zod?: { def?: { type?: string; innerType?: unknown; getter?: unknown; in?: unknown; out?: unknown; options?: unknown } }; + _zod?: { + def?: { + type?: string; + innerType?: unknown; + getter?: unknown; + in?: unknown; + out?: unknown; + options?: unknown; + left?: unknown; + right?: unknown; + values?: unknown; + }; + }; } )._zod?.def; if (def === undefined || typeof def.type !== 'string') return false; - // `catch` must be recognized BEFORE the wrapper unwind below would step past it. - if (def.type === 'default' || def.type === 'prefault' || def.type === 'catch') return true; - if (def.type === 'any' || def.type === 'unknown' || def.type === 'symbol' || def.type === 'function') return true; + // `catch` and `optional` must be recognized BEFORE the wrapper unwind below + // would step past the very node granting tolerance (bare `.optional()` fields + // are already excluded from `required` by zod's emitter, but one inside a pipe + // — `z.string().optional().transform(async ...)` — is not). + if (def.type === 'default' || def.type === 'prefault' || def.type === 'catch' || def.type === 'optional') return true; + if (def.type === 'any' || def.type === 'unknown' || def.type === 'undefined' || def.type === 'void') return true; + if (def.type === 'symbol' || def.type === 'function') return true; + if (def.type === 'literal' && Array.isArray(def.values) && def.values.includes(undefined)) return true; const path = new Set(ancestors); path.add(field); if (def.type === 'lazy' && typeof def.getter === 'function') { @@ -424,6 +442,11 @@ function hasStructuralMissingKeyTolerance(field: unknown, ancestors: ReadonlySet if (def.type === 'union' && Array.isArray(def.options)) { return def.options.some(option => hasStructuralMissingKeyTolerance(option, path)); } + if (def.type === 'intersection' && def.left !== undefined && def.right !== undefined) { + // EVERY-side semantics: `undefined` must parse through BOTH sides (each + // filling its default) for zod to merge the results. + return hasStructuralMissingKeyTolerance(def.left, path) && hasStructuralMissingKeyTolerance(def.right, path); + } if (WRAPPER_ZOD_DEF_TYPES.has(def.type) && def.innerType !== undefined) { return hasStructuralMissingKeyTolerance(def.innerType, path); } @@ -573,6 +596,7 @@ function nonObjectTypelessRootVerdict( innerType?: unknown; getter?: unknown; in?: unknown; + out?: unknown; options?: unknown; left?: unknown; right?: unknown; @@ -592,7 +616,15 @@ function nonObjectTypelessRootVerdict( } } if (def.type === 'pipe' && def.in !== undefined) { - return nonObjectTypelessRootVerdict(def.in, path); + const inVerdict = nonObjectTypelessRootVerdict(def.in, path); + if (inVerdict !== undefined) return inVerdict; + // `z.preprocess(fn, inner)` builds the opposite pipe — the transform sits at + // `def.in` (verdict undefined by design) and the real schema at `def.out`. + const inDef = (def.in as { _zod?: { def?: { type?: string } } })._zod?.def; + if (inDef?.type === 'transform' && def.out !== undefined) { + return nonObjectTypelessRootVerdict(def.out, path); + } + return undefined; } if (WRAPPER_ZOD_DEF_TYPES.has(def.type) && def.innerType !== undefined) { return nonObjectTypelessRootVerdict(def.innerType, path); diff --git a/packages/core-internal/test/util/standardSchema.test.ts b/packages/core-internal/test/util/standardSchema.test.ts index bb8a80573f..cf8e225dbd 100644 --- a/packages/core-internal/test/util/standardSchema.test.ts +++ b/packages/core-internal/test/util/standardSchema.test.ts @@ -328,6 +328,34 @@ describe('zod conversion options (#2464)', () => { expect(standardSchemaToJsonSchema(z.union([z.date(), z.string()]), 'input').type).toBe('object'); }); + test('preprocess-wrapped unrepresentable roots throw (transform at def.in, schema at def.out)', () => { + // z.preprocess builds the opposite pipe; the guard must look past the + // transform at def.in to the real schema at def.out. + expect(() => + standardSchemaToJsonSchema( + z.preprocess(v => v, z.bigint()), + 'input' + ) + ).toThrow(/must describe objects/); + expect(() => standardSchemaToJsonSchema(z.union([z.preprocess(v => v, z.bigint()), z.symbol()]), 'input')).toThrow( + /must describe objects/ + ); + // Object-rooted preprocess stays accepted; date still throws via the + // explicit-type guard after the rewrite. + expect( + standardSchemaToJsonSchema( + z.preprocess(v => v, z.object({ a: z.string() })), + 'input' + ).type + ).toBe('object'); + expect(() => + standardSchemaToJsonSchema( + z.preprocess(v => v, z.date()), + 'input' + ) + ).toThrow(/must describe objects/); + }); + test('compositions built solely of non-finite literals keep listing (quiet verdicts)', () => { // These converted silently pre-#2464 (zod emits {type: 'number', const: null} // without throwing) — throwing here would fail the whole tools/list. The @@ -425,6 +453,42 @@ describe('zod conversion options (#2464)', () => { expect(record.required).toBeUndefined(); }); + test('optional-in-pipe, void, undefined-literals, and both-tolerant intersections drop from output required', () => { + const schema = z.object({ + opt: z + .string() + .optional() + .transform(async v => v ?? 'x'), + w: z.void().refine(async () => true), + l: z.literal(undefined).refine(async () => true), + m: z + .intersection(z.object({ a: z.string() }).default({ a: 'x' }), z.object({ b: z.string() }).default({ b: 'y' })) + .refine(async () => true), + name: z.string() + }); + const result = standardSchemaToJsonSchema(schema, 'output'); + + // `optional` grants tolerance itself and must be recognized before the + // wrapper unwind steps past it; void/undefined-literals accept a missing key + // outright; an intersection tolerates one when BOTH sides do (each default + // fills and zod merges the results). Async stages defeat the probe, so all + // must be decided structurally. + expect(result.required).toEqual(['name']); + + // Same predicate at the record call site. + const record = standardSchemaToJsonSchema( + z.record( + z.enum(['a', 'b']), + z + .string() + .optional() + .transform(async v => v ?? 'x') + ), + 'output' + ); + expect(record.required).toBeUndefined(); + }); + test('a required field whose transform throws on undefined stays required and does not crash', async () => { const schema = z.object({ n: z.unknown().transform(v => (v as string).length), From 98a3379c4b950ef48a830521d69bdfefa2d96dab Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Jul 2026 02:36:00 +0000 Subject: [PATCH 16/35] fix(core-internal): loosen parent oneOf when a conversion degraded nodes; fix stale linkcode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - A discriminated union whose DISCRIMINATORS are .catch()-wrapped (legal zod; catch-wrapped members are rejected at construction) regressed: the nested catch degrade stripped each discriminator's type/const and the required-filter dropped 't', making the members mutually satisfiable while the untouched parent DU node kept oneOf — so Ajv (including the SDK's own Client.callTool re-validation) rejected every legitimate payload with 'must match exactly one schema in oneOf'. zodConversionOptions now takes a per-conversion loosened flag set by the catch degrade and by required-filter drops (object and record branches); when an output conversion was loosened anywhere, the epilogue rewrites every emitted oneOf to anyOf (loosen-only and wrap-neutral — isProvablyObjectShapedRoot treats the composition keywords identically). Untouched schemas are not loosened: a DU without degraded nodes keeps its oneOf (pinned). - Fix the stale {@linkcode isNonObjectTypelessLiteral} reference on NON_OBJECT_UNREPRESENTABLE_TYPES — the loud/quiet refactor renamed the helper to nonObjectLiteralLoudness. Co-Authored-By: Claude --- .../core-internal/src/util/standardSchema.ts | 45 +++++++++++++++++-- .../test/util/standardSchema.test.ts | 35 +++++++++++++++ 2 files changed, 77 insertions(+), 3 deletions(-) diff --git a/packages/core-internal/src/util/standardSchema.ts b/packages/core-internal/src/util/standardSchema.ts index 59eab76d72..f74355e7c0 100644 --- a/packages/core-internal/src/util/standardSchema.ts +++ b/packages/core-internal/src/util/standardSchema.ts @@ -228,7 +228,10 @@ export const JSON_SCHEMA_CONVERSION_TARGET = 'draft-2020-12'; * round-trip, so the raw `Date` the server must ship reaches a validating client * as a `Date` instance and fails the advertised schema there. */ -function zodConversionOptions(io: 'input' | 'output'): Pick { +function zodConversionOptions( + io: 'input' | 'output', + loosened: { value: boolean } +): Pick { return { unrepresentable: 'any', override: ctx => { @@ -257,6 +260,7 @@ function zodConversionOptions(io: 'input' | 'output'): Pick !fieldAcceptsMissingKey(def.shape[name])); if (filtered.length !== required.length) { + loosened.value = true; if (filtered.length === 0) delete ctx.jsonSchema.required; else ctx.jsonSchema.required = filtered; } @@ -306,6 +312,28 @@ function zodConversionOptions(io: 'input' | 'output'): Pick = new Set()): void { + if (typeof node !== 'object' || node === null || seen.has(node)) return; + seen.add(node); + if (Array.isArray(node)) { + for (const item of node) rewriteOneOfToAnyOf(item, seen); + return; + } + const record = node as Record; + if (Array.isArray(record.oneOf) && record.anyOf === undefined) { + record.anyOf = record.oneOf; + delete record.oneOf; + } + for (const value of Object.values(record)) rewriteOneOfToAnyOf(value, seen); +} + /** * Reduces a degraded node's composition member to its type skeleton — `type` plus * recursively-skeletonized nested compositions, nothing else — so the output @@ -488,7 +516,8 @@ export function standardSchemaToJsonSchema( options?: StandardSchemaToJsonSchemaOptions ): Record { const std = schema['~standard']; - const zodOptions = options?.unrepresentable === 'throw' ? undefined : zodConversionOptions(io); + const loosened = { value: false }; + const zodOptions = options?.unrepresentable === 'throw' ? undefined : zodConversionOptions(io, loosened); let result: Record; if (std.jsonSchema) { result = std.jsonSchema[io]({ @@ -525,6 +554,16 @@ export function standardSchemaToJsonSchema( `Upgrade to a version that does, or wrap your JSON Schema with fromJsonSchema().` ); } + if (io === 'output' && loosened.value) { + // Exactly-one semantics cannot survive member loosening: once the catch + // degrade or the required-filter fired anywhere in this conversion, `oneOf` + // members (zod's discriminated-union emission) may have become mutually + // satisfiable — e.g. catch-wrapped discriminators — and Ajv would reject + // every payload with "must match exactly one schema in oneOf". Rewrite to + // the honest `anyOf` (loosen-only and wrap-neutral: + // `isProvablyObjectShapedRoot` treats the composition keywords identically). + rewriteOneOfToAnyOf(result); + } if (io === 'output') { // SEP-2106: outputSchema may have any JSON Schema root. An explicit `type` (object or // not) is returned as-is. A typeless root only gets `type:'object'` defaulted when it is @@ -691,7 +730,7 @@ function nonObjectLiteralLoudness(values: unknown): 'loud' | 'quiet' | undefined * a JSON object either, so date composition MEMBERS classify as non-object here. * `custom` and bare `transform` are deliberately excluded (they can legitimately * accept objects), and typeless literals are classified separately by value in - * {@linkcode isNonObjectTypelessLiteral}. + * {@linkcode nonObjectLiteralLoudness}. */ const NON_OBJECT_UNREPRESENTABLE_TYPES: ReadonlySet = new Set([ 'bigint', diff --git a/packages/core-internal/test/util/standardSchema.test.ts b/packages/core-internal/test/util/standardSchema.test.ts index cf8e225dbd..9a3a699f0c 100644 --- a/packages/core-internal/test/util/standardSchema.test.ts +++ b/packages/core-internal/test/util/standardSchema.test.ts @@ -410,6 +410,41 @@ describe('zod conversion options (#2464)', () => { expect(nestedValidate({ res: { t: 'a', x: 'hello' }, name: 'n' }).valid).toBe(true); }); + test('a discriminated union with catch-wrapped discriminators loosens its oneOf to anyOf', () => { + // The catch degrade strips each discriminator's type/const and the + // required-filter drops 't', so the members become mutually satisfiable — + // the untouched parent's `oneOf` would then reject EVERY payload ("must + // match exactly one schema in oneOf"). + const du = z.discriminatedUnion('t', [ + z.object({ t: z.literal('a').catch('a'), x: z.string().optional() }), + z.object({ t: z.literal('b').catch('b'), y: z.string().optional() }) + ]); + + const root = standardSchemaToJsonSchema(du, 'output'); + expect(root.oneOf).toBeUndefined(); + expect(Array.isArray(root.anyOf)).toBe(true); + expect(new AjvJsonSchemaValidator().getValidator(root)({ t: 'a', x: 'v' }).valid).toBe(true); + + const nested = standardSchemaToJsonSchema(z.object({ res: du, name: z.string() }), 'output'); + const res = (nested.properties as Record>).res!; + expect(res.oneOf).toBeUndefined(); + expect(new AjvJsonSchemaValidator().getValidator(nested)({ res: { t: 'a', x: 'v' }, name: 'n' }).valid).toBe(true); + }); + + test('a discriminated union without degraded nodes keeps its oneOf', () => { + // Untouched schemas must not be loosened: exactly-one semantics stay + // truthful while the members keep their discriminating constraints. + const du = z.discriminatedUnion('t', [ + z.object({ t: z.literal('a'), x: z.string() }), + z.object({ t: z.literal('b'), y: z.string() }) + ]); + const result = standardSchemaToJsonSchema(du, 'output'); + + expect(Array.isArray(result.oneOf)).toBe(true); + expect(result.anyOf).toBeUndefined(); + expect(new AjvJsonSchemaValidator().getValidator(result)({ t: 'a', x: 'v' }).valid).toBe(true); + }); + test('.catch() and union-nested defaults with async stages are dropped from output required', () => { const schema = z.object({ c: z From 19c5b62a4b258fe031f13d285fa7f8968c9064cd Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 11:16:16 +0000 Subject: [PATCH 17/35] fix(core-internal): null-tolerant array/tuple elements; quiet never verdicts; annotation-safe oneOf rewrite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Array/tuple ELEMENT positions: JSON.stringify turns an undefined element into null (unlike an undefined-valued object key, which it drops), so a tolerant element (.default()/.prefault(), ...) may ship as null while the advertised items/prefixItems subschema rejected it — z.array(z.number().default(0)) output rejected the wire form [1, null, 3]. The override now wraps such emitted subschemas as {anyOf: [original, {type: 'null'}]} (loosen-only), mirroring how the catch degrade already behaves at the same position. - z.never() union members yielded undefined verdicts and defeated the every-member rule: z.union([z.never(), z.bigint()]) threw pre-#2464 but listed as a phantom. never now returns a QUIET non-object verdict (it matches no value, so it can never make a union satisfiable or object-shaped) — loud co-members restore the pre-#2464 throw while bare never, never+never, and never+representable shapes keep listing exactly as they did pre-#2464 (never is deliberately NOT in the loud set). - rewriteOneOfToAnyOf walked blindly into annotation VALUES via Object.values, renaming a literal user-data oneOf key inside default/examples (advertised default: {oneOf: [1,2]} became {anyOf: [1,2]}). The walk now skips ANNOTATION_JSON_SCHEMA_KEYWORDS plus const/enum/x-* values, mirroring the catch-degrade loop's annotation carve-out. Co-Authored-By: Claude --- .../core-internal/src/util/standardSchema.ts | 42 ++++++++++++++++++- .../test/util/standardSchema.test.ts | 40 ++++++++++++++++++ 2 files changed, 81 insertions(+), 1 deletion(-) diff --git a/packages/core-internal/src/util/standardSchema.ts b/packages/core-internal/src/util/standardSchema.ts index f74355e7c0..627a201085 100644 --- a/packages/core-internal/src/util/standardSchema.ts +++ b/packages/core-internal/src/util/standardSchema.ts @@ -290,6 +290,32 @@ function zodConversionOptions( } return; } + if (def.type === 'array') { + // JSON.stringify turns an undefined array ELEMENT into `null` (unlike an + // undefined-valued object key, which it drops), so a tolerant element + // (`.default()`/`.prefault()`, …) may ship as null — the advertised item + // subschema must accept it. + const items = ctx.jsonSchema.items; + if (typeof items === 'object' && items !== null && !Array.isArray(items) && hasStructuralMissingKeyTolerance(def.element)) { + loosened.value = true; + ctx.jsonSchema.items = { anyOf: [items, { type: 'null' }] }; + } + return; + } + if (def.type === 'tuple') { + // Same wire mechanism per prefix position. + const prefixItems = ctx.jsonSchema.prefixItems; + if (Array.isArray(def.items) && Array.isArray(prefixItems)) { + for (const [index, item] of def.items.entries()) { + const emitted = prefixItems[index]; + if (typeof emitted === 'object' && emitted !== null && hasStructuralMissingKeyTolerance(item)) { + loosened.value = true; + prefixItems[index] = { anyOf: [emitted, { type: 'null' }] }; + } + } + } + return; + } if (def.type !== 'object') return; const isStrict = def.catchall?._zod.def.type === 'never'; if (!isStrict && ctx.jsonSchema.additionalProperties === false) { @@ -331,7 +357,13 @@ function rewriteOneOfToAnyOf(node: unknown, seen: Set = new Set()): voi record.anyOf = record.oneOf; delete record.oneOf; } - for (const value of Object.values(record)) rewriteOneOfToAnyOf(value, seen); + for (const [key, value] of Object.entries(record)) { + // Annotation keywords (and const/enum) carry user DATA, not schemas — a + // plain-data object inside them may legitimately have a literal `oneOf` key + // that must not be renamed. + if (ANNOTATION_JSON_SCHEMA_KEYWORDS.has(key) || key === 'const' || key === 'enum' || key.startsWith('x-')) continue; + rewriteOneOfToAnyOf(value, seen); + } } /** @@ -683,6 +715,14 @@ function nonObjectTypelessRootVerdict( const loudness = nonObjectLiteralLoudness(def.values); return loudness === undefined ? undefined : { type: 'literal', loud: loudness === 'loud' }; } + if (def.type === 'never') { + // z.never() matches no value: it can never make a union satisfiable or + // object-shaped. Quiet, though — bare/all-never shapes emitted `{not: {}}` + // without throwing pre-#2464 and must keep listing; the verdict only turns a + // union loud when a loud co-member (e.g. a bigint) is present, restoring the + // pre-#2464 throw for those. + return { type: 'never', loud: false }; + } return NON_OBJECT_UNREPRESENTABLE_TYPES.has(def.type) ? { type: def.type, loud: true } : undefined; } diff --git a/packages/core-internal/test/util/standardSchema.test.ts b/packages/core-internal/test/util/standardSchema.test.ts index 9a3a699f0c..77e5dd7413 100644 --- a/packages/core-internal/test/util/standardSchema.test.ts +++ b/packages/core-internal/test/util/standardSchema.test.ts @@ -445,6 +445,46 @@ describe('zod conversion options (#2464)', () => { expect(new AjvJsonSchemaValidator().getValidator(result)({ t: 'a', x: 'v' }).valid).toBe(true); }); + test('tolerant array/tuple elements also accept null in output schemas', () => { + // JSON.stringify turns an undefined array ELEMENT into null (it drops + // undefined-valued object keys), so the raw payload the server validates and + // ships ([1, undefined, 3]) reaches the wire as [1, null, 3]. + const schema = z.object({ + a: z.array(z.number().default(0)), + t: z.tuple([z.string().default('x'), z.number()]), + name: z.string() + }); + const result = standardSchemaToJsonSchema(schema, 'output'); + + expect(new AjvJsonSchemaValidator().getValidator(result)({ a: [1, null, 3], t: [null, 1], name: 'n' }).valid).toBe(true); + // Non-tolerant elements keep their strict subschema. + const plain = standardSchemaToJsonSchema(z.object({ a: z.array(z.number()) }), 'output'); + expect((plain.properties as Record>).a!.items).toEqual({ type: 'number' }); + }); + + test('z.never() union members are quiet non-object verdicts', () => { + // z.never() matches no value: it cannot make a union satisfiable, so a loud + // co-member must keep the pre-#2464 throw ... + expect(() => standardSchemaToJsonSchema(z.union([z.never(), z.bigint()]), 'input')).toThrow(/must describe objects/); + // ... while quiet shapes (which converted silently pre-#2464) keep listing. + expect(standardSchemaToJsonSchema(z.never(), 'input').type).toBe('object'); + expect(standardSchemaToJsonSchema(z.union([z.never(), z.string()]), 'input').type).toBe('object'); + expect(standardSchemaToJsonSchema(z.union([z.never(), z.never()]), 'input').type).toBe('object'); + }); + + test('the oneOf rewrite does not descend into annotation values', () => { + const schema = z.object({ + cfg: z.object({ oneOf: z.array(z.number()) }).default({ oneOf: [1, 2] }), + counted: z.number().default(0), // trips the loosened flag + name: z.string() + }); + const result = standardSchemaToJsonSchema(schema, 'output'); + + // `default` carries user DATA — its literal `oneOf` key must survive. + const cfg = (result.properties as Record>).cfg!; + expect(cfg.default).toEqual({ oneOf: [1, 2] }); + }); + test('.catch() and union-nested defaults with async stages are dropped from output required', () => { const schema = z.object({ c: z From 41e50b7fd54510d888e00edff709b7d571e11c1b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 11:48:00 +0000 Subject: [PATCH 18/35] fix(core-internal): output-root loudness parity; file verdicts; position-aware rewrite; rest/non-finite wire forms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Mirror the loudness-parity guard into the io === 'output' epilogue: an unrepresentable non-object output root (z.bigint(), z.map(), unions of them) threw at tools/list pre-#2464 but listed silently post-degrade as a permanently-broken tool (bigint results even fail JSON-RPC serialization; Map/Set ship {} garbage). A typeless, not-provably-object output root now consults nonObjectTypelessRootVerdict and throws on loud verdicts — parity-safe: representable non-object roots (legal per SEP-2106) carry an explicit type and return earlier, quiet shapes keep listing. - z.file() union members get a quiet non-object verdict (a File can never ride JSON), mirroring the never fix: union(file, bigint) throws again while bare file (explicit-type guard), file+string, and intersection(object, file) keep their pre-#2464 behavior. - rewriteOneOfToAnyOf's annotation skip was name-keyed, not position-aware: a user property literally named description/default/ enum/x-* under properties had its whole subschema excluded, so a degraded DU there kept its reject-everything oneOf. The walk now recurses unconditionally into the VALUES of properties/ patternProperties/$defs/dependentSchemas maps (schemas by construction) and applies the name-keyed skip only at schema-node positions. - Tuple REST elements (def.rest, emitted under items in draft-2020-12) get the same null-tolerance wrap as prefix/array elements; and non-finite number literals in output schemas (zod emits the self-contradictory {type: 'number', const: null}) are wrapped as {anyOf: [emitted, {type: 'null'}]} so their wire form (null) validates. Co-Authored-By: Claude --- .../core-internal/src/util/standardSchema.ts | 69 ++++++++++++++++++- .../test/util/standardSchema.test.ts | 56 +++++++++++++++ 2 files changed, 123 insertions(+), 2 deletions(-) diff --git a/packages/core-internal/src/util/standardSchema.ts b/packages/core-internal/src/util/standardSchema.ts index 627a201085..d2cd14adce 100644 --- a/packages/core-internal/src/util/standardSchema.ts +++ b/packages/core-internal/src/util/standardSchema.ts @@ -314,6 +314,34 @@ function zodConversionOptions( } } } + // ... and for the REST element, whose emitted subschema lands under + // `items` in draft-2020-12. + const restItems = ctx.jsonSchema.items; + if ( + def.rest !== undefined && + typeof restItems === 'object' && + restItems !== null && + !Array.isArray(restItems) && + hasStructuralMissingKeyTolerance(def.rest) + ) { + loosened.value = true; + ctx.jsonSchema.items = { anyOf: [restItems, { type: 'null' }] }; + } + return; + } + if ( + def.type === 'literal' && + Array.isArray(def.values) && + def.values.some(value => typeof value === 'number' && !Number.isFinite(value)) + ) { + // Non-finite number literal values (Infinity/-Infinity/NaN) serialize to + // null, and zod's own emission is the self-contradictory + // {type: 'number', const: null} that nothing satisfies — wrap it so the + // wire form validates. + loosened.value = true; + const emitted = { ...ctx.jsonSchema }; + for (const key of Object.keys(ctx.jsonSchema)) delete ctx.jsonSchema[key]; + ctx.jsonSchema.anyOf = [emitted, { type: 'null' }]; return; } if (def.type !== 'object') return; @@ -358,9 +386,24 @@ function rewriteOneOfToAnyOf(node: unknown, seen: Set = new Set()): voi delete record.oneOf; } for (const [key, value] of Object.entries(record)) { + // Maps whose VALUES are schemas by construction: recurse into every value — + // their keys are user-chosen names that may collide with annotation keywords + // (a property literally named `description` still carries a schema). + if ( + (key === 'properties' || key === 'patternProperties' || key === '$defs' || key === 'dependentSchemas') && + typeof value === 'object' && + value !== null && + !Array.isArray(value) + ) { + if (seen.has(value)) continue; + seen.add(value); + for (const subschema of Object.values(value as Record)) rewriteOneOfToAnyOf(subschema, seen); + continue; + } // Annotation keywords (and const/enum) carry user DATA, not schemas — a // plain-data object inside them may legitimately have a literal `oneOf` key - // that must not be renamed. + // that must not be renamed. This name-keyed skip applies only at schema-node + // positions (the schema-map case above already recursed). if (ANNOTATION_JSON_SCHEMA_KEYWORDS.has(key) || key === 'const' || key === 'enum' || key.startsWith('x-')) continue; rewriteOneOfToAnyOf(value, seen); } @@ -609,7 +652,22 @@ export function standardSchemaToJsonSchema( // `type:'object'` is wrapped as `{type:'object', properties:{result:…}}` by the 2025 // codec's legacy projection (see `wire/rev2025-11-25/legacyWrap.ts`). if (result.type !== undefined) return result; - return isProvablyObjectShapedRoot(result) ? { type: 'object', ...result } : result; + if (isProvablyObjectShapedRoot(result)) return { type: 'object', ...result }; + // Loudness parity for misregistered OUTPUT roots too: an unrepresentable + // non-object root (z.bigint(), z.map(), unions of them) threw at tools/list + // pre-#2464 and must keep doing so — post-degrade it would list silently as + // a permanently-broken tool (bigint results even fail JSON-RPC + // serialization; Map/Set ship `{}` garbage). Representable non-object roots + // (legal per SEP-2106) carry an explicit `type` and returned above; quiet + // shapes keep listing. + const outputVerdict = nonObjectTypelessRootVerdict(schema); + if (outputVerdict !== undefined && outputVerdict.loud) { + throw new Error( + `MCP tool and prompt schemas must describe objects (got a non-object ${outputVerdict.type} schema). ` + + `Wrap your schema in z.object({...}) or equivalent.` + ); + } + return result; } if (result.type !== undefined && result.type !== 'object') { throw new Error( @@ -723,6 +781,13 @@ function nonObjectTypelessRootVerdict( // pre-#2464 throw for those. return { type: 'never', loud: false }; } + if (def.type === 'file') { + // A File can never ride JSON: it cannot make a union JSON-satisfiable. Quiet — + // bare z.file() roots throw via the explicit-type guard (string/binary + // emission) and file+representable shapes listed silently pre-#2464; only a + // loud co-member restores the pre-#2464 throw. + return { type: 'file', loud: false }; + } return NON_OBJECT_UNREPRESENTABLE_TYPES.has(def.type) ? { type: def.type, loud: true } : undefined; } diff --git a/packages/core-internal/test/util/standardSchema.test.ts b/packages/core-internal/test/util/standardSchema.test.ts index 77e5dd7413..792fb5368e 100644 --- a/packages/core-internal/test/util/standardSchema.test.ts +++ b/packages/core-internal/test/util/standardSchema.test.ts @@ -472,6 +472,62 @@ describe('zod conversion options (#2464)', () => { expect(standardSchemaToJsonSchema(z.union([z.never(), z.never()]), 'input').type).toBe('object'); }); + test('unrepresentable non-object OUTPUT roots throw too (loudness parity)', () => { + // These threw at tools/list pre-#2464; post-degrade they would list silently + // as permanently-broken tools (bigint results even fail JSON-RPC + // serialization; Map/Set ship {} garbage). + expect(() => standardSchemaToJsonSchema(z.bigint(), 'output')).toThrow(/must describe objects/); + expect(() => standardSchemaToJsonSchema(z.union([z.bigint(), z.symbol()]), 'output')).toThrow(/must describe objects/); + // Representable non-object output roots are legal per SEP-2106 ... + expect(standardSchemaToJsonSchema(z.string(), 'output').type).toBe('string'); + expect(standardSchemaToJsonSchema(z.array(z.number()), 'output').type).toBe('array'); + // ... and quiet typeless shapes keep listing. + expect(standardSchemaToJsonSchema(z.never(), 'output').type).toBeUndefined(); + }); + + test('z.file() union members are quiet non-object verdicts', () => { + // A File can never ride JSON, so a loud co-member restores the pre-#2464 throw ... + expect(() => standardSchemaToJsonSchema(z.union([z.file(), z.bigint()]), 'input')).toThrow(/must describe objects/); + // ... while quiet pre-#2464 shapes keep listing (or throwing via the + // explicit-type guard, for the bare string/binary emission). + expect(standardSchemaToJsonSchema(z.union([z.file(), z.string()]), 'input').type).toBe('object'); + expect(standardSchemaToJsonSchema(z.intersection(z.object({ a: z.string() }), z.file()), 'input').type).toBe('object'); + expect(() => standardSchemaToJsonSchema(z.file(), 'input')).toThrow(/got type/); + }); + + test('the oneOf rewrite reaches schemas under keyword-named properties', () => { + // Keys inside `properties` are user names, not keywords — a property + // literally named `description` still carries a schema that the loosen + // rewrite must reach. + const schema = z.object({ + description: z.discriminatedUnion('t', [ + z.object({ t: z.literal('a').catch('a'), x: z.string().optional() }), + z.object({ t: z.literal('b').catch('b'), y: z.string().optional() }) + ]), + name: z.string() + }); + const result = standardSchemaToJsonSchema(schema, 'output'); + + const description = (result.properties as Record>).description!; + expect(description.oneOf).toBeUndefined(); + expect(Array.isArray(description.anyOf)).toBe(true); + expect(new AjvJsonSchemaValidator().getValidator(result)({ description: { t: 'a', x: 'v' }, name: 'n' }).valid).toBe(true); + }); + + test('tolerant tuple REST elements and non-finite literals accept their wire forms', () => { + const schema = z.object({ + t: z.tuple([z.string()], z.number().default(0)), + inf: z.literal(Infinity), + name: z.string() + }); + const result = standardSchemaToJsonSchema(schema, 'output'); + + // A tuple rest element lands under `items`; an undefined rest element ships + // as null. Non-finite literal values also serialize to null, and zod's own + // emission ({type: 'number', const: null}) satisfies nothing. + expect(new AjvJsonSchemaValidator().getValidator(result)({ t: ['a', 1, null], inf: null, name: 'n' }).valid).toBe(true); + }); + test('the oneOf rewrite does not descend into annotation values', () => { const schema = z.object({ cfg: z.object({ oneOf: z.array(z.number()) }).default({ oneOf: [1, 2] }), From 36657a3b5b8d282255ba3269a2a00eb2e12cba95 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 12:26:32 +0000 Subject: [PATCH 19/35] fix(core-internal): schema-position keyword classification; strip registry id; file field wire form MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Invert both annotation carve-outs from static name lists to a schema-position classification: SCHEMA_CARRYING_JSON_SCHEMA_KEYWORDS enumerates the keywords whose values hold schemas (with the four name->schema maps recursed unconditionally), and ENFORCED_JSON_SCHEMA_KEYWORDS the non-schema keywords validators enforce. Every other key — vocabulary annotations, x-* extensions, and custom .meta() keys zod merges verbatim — is annotation-opaque: rewriteOneOfToAnyOf no longer recurses into (and renames data inside) a custom key like ui, and the .catch() degrade keeps such keys while still stripping enforced constraints. The now-unused ANNOTATION_JSON_SCHEMA_KEYWORDS set is removed. - Strip the literal draft-04 'id' keyword zod copies verbatim from .meta({id: 'X'}) registrations: Ajv v8 hard-rejects it at COMPILE time regardless of strict: false, so Client.callTool failed with a ProtocolError before sending for every tool using the documented registration idiom (pre-existing; loosen-only — $refs are path-based and cannot dangle; renaming to $id would change base-URI resolution). - z.file() output FIELDS advertise string/binary while a File serializes to {} on the wire — wrap the emission as {anyOf: [emitted, {type: 'object'}]} (loosen-only). - Document two accepted trade-offs in the Known residual gaps list and changeset: required OUTPUT fields of loud unrepresentable types (bigint results fail JSON-RPC serialization; Map/Set ship {}) list silently — a field-level throw would resurrect the whole-tools/list outage — and the type: 'object' kept on degraded object-catch nodes is unenforced on the raw value (structurally forced by the 2025-era wrap proof and zod's instance dedup). Co-Authored-By: Claude --- .changeset/zod-tojsonschema-wire-truthful.md | 5 +- .../core-internal/src/util/standardSchema.ts | 141 ++++++++++++++---- .../test/util/standardSchema.test.ts | 33 ++++ 3 files changed, 148 insertions(+), 31 deletions(-) diff --git a/.changeset/zod-tojsonschema-wire-truthful.md b/.changeset/zod-tojsonschema-wire-truthful.md index b8ae480eb9..ad183c0d6a 100644 --- a/.changeset/zod-tojsonschema-wire-truthful.md +++ b/.changeset/zod-tojsonschema-wire-truthful.md @@ -29,6 +29,9 @@ unrepresentable types (`z.bigint()`, `z.map()`, `z.set()`, `z.symbol()`) as an unconstrained `{}`, but input validation still runs the raw zod schema, which rejects every JSON payload — use a JSON-representable type such as `z.iso.date()`/ `z.iso.datetime()`, `z.number()`, `z.record(...)`, or `z.array(...)`, or make the field -optional.) Elicitation is unaffected: +optional. The same holds for required OUTPUT fields of such types: bigint results fail +JSON-RPC serialization and Map/Set values serialize as `{}`. And a degraded +object-`.catch()` node keeps `type: 'object'` for the 2025-era wrap proof even though +catch-validation does not enforce it on the raw value.) Elicitation is unaffected: `inputRequired.elicit()` keeps throwing on schemas its restricted form grammar cannot round-trip, including `z.date()`. diff --git a/packages/core-internal/src/util/standardSchema.ts b/packages/core-internal/src/util/standardSchema.ts index d2cd14adce..d12bdfd4ac 100644 --- a/packages/core-internal/src/util/standardSchema.ts +++ b/packages/core-internal/src/util/standardSchema.ts @@ -208,6 +208,17 @@ export const JSON_SCHEMA_CONVERSION_TARGET = 'draft-2020-12'; * the raw zod schema, which rejects every JSON payload. Use a JSON-representable * type (`z.iso.date()`/`z.iso.datetime()`, `z.number()`, `z.record(...)`, * `z.array(...)`) or make the field optional. + * - Likewise a REQUIRED OUTPUT field of a loud unrepresentable type (`z.bigint()`, + * `z.map()`, `z.set()`) lists silently as a permanently-broken tool: bigint + * results fail JSON-RPC serialization, and Map/Set values ship as `{}` garbage + * that vacuously satisfies the unconstrained `{}` advertisement. A field-level + * throw would resurrect the whole-`tools/list` outage this fix eliminates, so + * only unrepresentable output ROOTS throw (see the output epilogue). + * - The `type: 'object'` kept on degraded object-`.catch()` nodes is itself + * unenforced on the raw value: catch-validation accepts any raw, so a wrong-typed + * raw at an object-catch position ships and fails client re-validation while the + * scalar-catch spelling of the same tolerance passes. The keep is structurally + * forced by the 2025-era legacy-wrap object proof and zod's instance dedup. * - Dynamic catch values (`.catch(ctx => …)`) still throw inside zod's own * catchProcessor before this hook runs ("Dynamic catch values are not supported * in JSON Schema"), so one such tool still fails the entire `tools/list` — the @@ -236,6 +247,15 @@ function zodConversionOptions( unrepresentable: 'any', override: ctx => { const def = ctx.zodSchema._zod.def; + if ('id' in ctx.jsonSchema) { + // zod copies registry metadata (`.meta({id: 'X'})`) verbatim, emitting a + // literal draft-04 `id` keyword that Ajv v8 hard-rejects at COMPILE time + // ('NOT SUPPORTED: keyword "id", use "$id"' — strict: false does not + // help), so the SDK's own client could never validate the advertisement. + // `$ref`s are path-based (#/$defs/Name) and cannot dangle; renaming to + // `$id` would change base-URI resolution, so plain removal. + delete ctx.jsonSchema.id; + } if (def.type === 'date') { // Under `unrepresentable: 'any'` the node carries only user annotations // (`.describe()` / `.meta()`) — keep them and stamp the wire shape beside them. @@ -262,9 +282,6 @@ function zodConversionOptions( // wire shape. loosened.value = true; for (const key of Object.keys(ctx.jsonSchema)) { - // `x-*` vendor extensions are annotation-only (same convention as the - // elicitation walker) and carry no validation constraint. - if (ANNOTATION_JSON_SCHEMA_KEYWORDS.has(key) || key.startsWith('x-')) continue; if (key === 'type' && ctx.jsonSchema.type === 'object') continue; if ((key === 'anyOf' || key === 'oneOf' || key === 'allOf') && Array.isArray(ctx.jsonSchema[key])) { const skeletons = (ctx.jsonSchema[key] as unknown[]).map(member => compositionTypeSkeleton(member)); @@ -277,7 +294,12 @@ function zodConversionOptions( ctx.jsonSchema[key === 'oneOf' ? 'anyOf' : key] = skeletons; continue; } - delete ctx.jsonSchema[key]; + // Only schema-carrying and enforced keywords constrain validation; + // everything else — vocabulary annotations, `x-*` extensions, custom + // `.meta()` keys — is annotation-opaque and kept. + if (SCHEMA_CARRYING_JSON_SCHEMA_KEYWORDS.has(key) || ENFORCED_JSON_SCHEMA_KEYWORDS.has(key)) { + delete ctx.jsonSchema[key]; + } } return; } @@ -344,6 +366,16 @@ function zodConversionOptions( ctx.jsonSchema.anyOf = [emitted, { type: 'null' }]; return; } + if (def.type === 'file') { + // A File value serializes to `{}` on the wire (no enumerable own + // properties, no toJSON) while zod emits string/binary — accept the + // actual wire form too. + loosened.value = true; + const emitted = { ...ctx.jsonSchema }; + for (const key of Object.keys(ctx.jsonSchema)) delete ctx.jsonSchema[key]; + ctx.jsonSchema.anyOf = [emitted, { type: 'object' }]; + return; + } if (def.type !== 'object') return; const isStrict = def.catchall?._zod.def.type === 'never'; if (!isStrict && ctx.jsonSchema.additionalProperties === false) { @@ -386,25 +418,20 @@ function rewriteOneOfToAnyOf(node: unknown, seen: Set = new Set()): voi delete record.oneOf; } for (const [key, value] of Object.entries(record)) { - // Maps whose VALUES are schemas by construction: recurse into every value — - // their keys are user-chosen names that may collide with annotation keywords - // (a property literally named `description` still carries a schema). - if ( - (key === 'properties' || key === 'patternProperties' || key === '$defs' || key === 'dependentSchemas') && - typeof value === 'object' && - value !== null && - !Array.isArray(value) - ) { + // Only schema-carrying keywords are recursed into: everything else — + // vocabulary annotations, `const`/`enum` data, `x-*` extensions, custom + // `.meta()` keys — carries user DATA whose literal `oneOf` keys must not be + // renamed. + if (!SCHEMA_CARRYING_JSON_SCHEMA_KEYWORDS.has(key)) continue; + // Schema MAPS hold schemas under user-chosen names that may collide with + // annotation keywords (a property literally named `description` still + // carries a schema) — recurse into every value unconditionally. + if (SCHEMA_MAP_JSON_SCHEMA_KEYWORDS.has(key) && typeof value === 'object' && value !== null && !Array.isArray(value)) { if (seen.has(value)) continue; seen.add(value); for (const subschema of Object.values(value as Record)) rewriteOneOfToAnyOf(subschema, seen); continue; } - // Annotation keywords (and const/enum) carry user DATA, not schemas — a - // plain-data object inside them may legitimately have a literal `oneOf` key - // that must not be renamed. This name-keyed skip applies only at schema-node - // positions (the schema-map case above already recursed). - if (ANNOTATION_JSON_SCHEMA_KEYWORDS.has(key) || key === 'const' || key === 'enum' || key.startsWith('x-')) continue; rewriteOneOfToAnyOf(value, seen); } } @@ -432,19 +459,73 @@ function compositionTypeSkeleton(node: unknown): Record { } /** - * JSON Schema annotation-vocabulary keywords (plus `default`, itself an annotation) - * preserved when a node's constraints are degraded because validation does not enforce - * them on the raw value (`.catch()` nodes). + * JSON Schema keywords whose values carry SCHEMAS (directly, as arrays of schemas, or + * as name→schema maps). Every key outside this set and + * {@linkcode ENFORCED_JSON_SCHEMA_KEYWORDS} — vocabulary annotations, `x-*` vendor + * extensions, and custom `.meta()` keys zod merges verbatim — is annotation-opaque: + * 2020-12 validators ignore unknown keywords, so such keys are kept and never + * recursed into. */ -const ANNOTATION_JSON_SCHEMA_KEYWORDS: ReadonlySet = new Set([ - '$comment', - 'default', - 'deprecated', - 'description', - 'examples', - 'readOnly', - 'title', - 'writeOnly' +const SCHEMA_CARRYING_JSON_SCHEMA_KEYWORDS: ReadonlySet = new Set([ + 'properties', + 'patternProperties', + '$defs', + 'dependentSchemas', + 'items', + 'prefixItems', + 'anyOf', + 'oneOf', + 'allOf', + 'not', + 'if', + 'then', + 'else', + 'additionalProperties', + 'propertyNames', + 'contains', + 'unevaluatedProperties', + 'unevaluatedItems' +]); + +/** Schema-map keywords among the above: their KEYS are user-chosen names, their values schemas. */ +const SCHEMA_MAP_JSON_SCHEMA_KEYWORDS: ReadonlySet = new Set(['properties', 'patternProperties', '$defs', 'dependentSchemas']); + +/** + * Non-schema-carrying keywords that validators ENFORCE — the `.catch()` degrade must + * delete them (catch-validation enforces nothing on the raw value), unlike unknown + * annotation-opaque keys, which are kept. + */ +const ENFORCED_JSON_SCHEMA_KEYWORDS: ReadonlySet = new Set([ + 'type', + 'const', + 'enum', + 'required', + 'format', + 'pattern', + 'minimum', + 'maximum', + 'exclusiveMinimum', + 'exclusiveMaximum', + 'multipleOf', + 'minLength', + 'maxLength', + 'minItems', + 'maxItems', + 'uniqueItems', + 'minContains', + 'maxContains', + 'minProperties', + 'maxProperties', + 'dependentRequired', + 'contentEncoding', + 'contentMediaType', + 'contentSchema', + '$ref', + '$dynamicRef', + '$dynamicAnchor', + '$anchor', + '$id', + 'id' ]); /** diff --git a/packages/core-internal/test/util/standardSchema.test.ts b/packages/core-internal/test/util/standardSchema.test.ts index 792fb5368e..fa1e09b6c1 100644 --- a/packages/core-internal/test/util/standardSchema.test.ts +++ b/packages/core-internal/test/util/standardSchema.test.ts @@ -95,6 +95,11 @@ describe('zod conversion options (#2464)', () => { // key on the zod shape, not the emitted JSON. expect((result.properties as Record>).counted?.$ref).toBeDefined(); expect(result.required).toEqual(['name']); + // zod copies the registry id verbatim as a draft-04 `id` keyword, which Ajv v8 + // hard-rejects at COMPILE time — the advertisement must stay compilable by the + // SDK's own client-side validator. + expect((result.$defs as Record>).StandardSchemaTestCounted!.id).toBeUndefined(); + expect(new AjvJsonSchemaValidator().getValidator(result)({ name: 'x' }).valid).toBe(true); }); test('a required field annotated with .meta({default}) stays required in output schemas', () => { @@ -541,6 +546,34 @@ describe('zod conversion options (#2464)', () => { expect(cfg.default).toEqual({ oneOf: [1, 2] }); }); + test('custom .meta() keys are annotation-opaque at both carve-out sites', () => { + // zod merges arbitrary .meta() keys verbatim and 2020-12 validators ignore + // unknown keywords — they are annotations by construction, like x-*. + const schema = z.object({ + v: z.number().meta({ ui: { oneOf: [1, 2] } }), + c: z + .number() + .catch(0) + .meta({ ui: { hint: 'slider' }, title: 't' }), + counted: z.number().default(0), // trips the loosened flag + name: z.string() + }); + const result = standardSchemaToJsonSchema(schema, 'output'); + const properties = result.properties as Record>; + + // The oneOf rewrite must not recurse into the custom key's DATA ... + expect(properties.v!.ui).toEqual({ oneOf: [1, 2] }); + // ... and the catch degrade must keep it (while still stripping constraints). + expect(properties.c).toEqual({ default: 0, title: 't', ui: { hint: 'slider' } }); + }); + + test('z.file() output fields also accept their wire form', () => { + // A File serializes to {} on the wire while zod emits string/binary. + const result = standardSchemaToJsonSchema(z.object({ f: z.file(), name: z.string() }), 'output'); + + expect(new AjvJsonSchemaValidator().getValidator(result)({ f: {}, name: 'n' }).valid).toBe(true); + }); + test('.catch() and union-nested defaults with async stages are dropped from output required', () => { const schema = z.object({ c: z From 8270f46cbf45634168f2d9014f0875f5f1f1995f Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 13:05:32 +0000 Subject: [PATCH 20/35] fix(core-internal): io-aware pipe loudness; quiet symbol literals; annotation-preserving anyOf wrap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The output loudness guard reused nonObjectTypelessRootVerdict, whose pipe branch unwound def.in (input semantics) — but pre-#2464 OUTPUT conversion processed a pipe's OUT side, so a working output codec like z.date().transform(d => d.toISOString()).pipe(z.string() .nullable()) (typeless anyOf emission) threw 'non-object date' and killed the whole tools/list. The verdict walk now takes the conversion io: output anchors to def.out (falling back to def.in when the out side is a bare transform — pre-#2464 zod threw 'Transforms cannot be represented' on output, so genuinely unrepresentable inputs stay loud while representable ones degrade); input callers keep def.in unchanged. - nonObjectLiteralLoudness classified symbol literal VALUES as loud, but pre-#2464 z.literal(Symbol('x')) silently emitted {type: 'symbol'} — only undefined/bigint literal values threw. Symbol values are now quiet (and the early loud return no longer skips representable co-values in mixed lists); bare symbol-literal roots keep throwing via the explicit-type guard, and 'symbol' the TYPE stays in the loud set (bare z.symbol() genuinely threw). - The non-finite-literal and z.file() output branches wholesale-wrapped the node into anyOf[0], burying .describe()/.meta() annotations and default where they apply to no payload under 2020-12 annotation-collection semantics. A shared wrapConstraintsInAnyOf helper now partitions: schema-carrying/enforced keywords move into anyOf[0], annotation-opaque keys and default stay at the node top level (matching the date/catch convention). - Changeset: scope the opening no-throw claim — a misregistered non-object ROOT still fails tools/list loudly by design. Co-Authored-By: Claude --- .changeset/zod-tojsonschema-wire-truthful.md | 4 +- .../core-internal/src/util/standardSchema.ts | 112 ++++++++++++------ .../test/util/standardSchema.test.ts | 76 ++++++++++++ 3 files changed, 154 insertions(+), 38 deletions(-) diff --git a/.changeset/zod-tojsonschema-wire-truthful.md b/.changeset/zod-tojsonschema-wire-truthful.md index ad183c0d6a..36cfbb0493 100644 --- a/.changeset/zod-tojsonschema-wire-truthful.md +++ b/.changeset/zod-tojsonschema-wire-truthful.md @@ -10,7 +10,9 @@ during conversion and fails the entire `tools/list` response — dates are adver other unrepresentable types degrade to an unconstrained schema. (BigInt values embedded as defaults or metadata, e.g. `.default(0n)`, still fail conversion — JSON cannot carry them — and so do dynamic catch values, `.catch(ctx => …)`; the `.catch()` degrade covers static -fallback values only.) +fallback values only. And a misregistered non-object ROOT — `z.bigint()` or `z.map()` as +the whole `inputSchema`/`outputSchema` — still fails `tools/list` loudly by design, +preserving the pre-fix error instead of listing a permanently-broken tool.) Output schemas no longer advertise constraints the server doesn't enforce on the raw `structuredContent` it ships: fields that may be legitimately absent (`.default()`, undefined-accepting types) are dropped from `required` — on objects and enum-keyed records — diff --git a/packages/core-internal/src/util/standardSchema.ts b/packages/core-internal/src/util/standardSchema.ts index d12bdfd4ac..419788081d 100644 --- a/packages/core-internal/src/util/standardSchema.ts +++ b/packages/core-internal/src/util/standardSchema.ts @@ -361,9 +361,7 @@ function zodConversionOptions( // {type: 'number', const: null} that nothing satisfies — wrap it so the // wire form validates. loosened.value = true; - const emitted = { ...ctx.jsonSchema }; - for (const key of Object.keys(ctx.jsonSchema)) delete ctx.jsonSchema[key]; - ctx.jsonSchema.anyOf = [emitted, { type: 'null' }]; + wrapConstraintsInAnyOf(ctx.jsonSchema, { type: 'null' }); return; } if (def.type === 'file') { @@ -371,9 +369,7 @@ function zodConversionOptions( // properties, no toJSON) while zod emits string/binary — accept the // actual wire form too. loosened.value = true; - const emitted = { ...ctx.jsonSchema }; - for (const key of Object.keys(ctx.jsonSchema)) delete ctx.jsonSchema[key]; - ctx.jsonSchema.anyOf = [emitted, { type: 'object' }]; + wrapConstraintsInAnyOf(ctx.jsonSchema, { type: 'object' }); return; } if (def.type !== 'object') return; @@ -436,6 +432,24 @@ function rewriteOneOfToAnyOf(node: unknown, seen: Set = new Set()): voi } } +/** + * Moves a node's constraint keywords (schema-carrying + enforced) into `anyOf[0]`, + * adding `alternative` as `anyOf[1]`, while leaving annotation-opaque keys (and + * `default`) at the node top level where consumers read them — an annotation buried + * inside an applicator branch the wire payload never matches applies to nothing + * under 2020-12 annotation-collection semantics. + */ +function wrapConstraintsInAnyOf(node: Record, alternative: Record): void { + const constrained: Record = {}; + for (const key of Object.keys(node)) { + if (SCHEMA_CARRYING_JSON_SCHEMA_KEYWORDS.has(key) || ENFORCED_JSON_SCHEMA_KEYWORDS.has(key)) { + constrained[key] = node[key]; + delete node[key]; + } + } + node.anyOf = [constrained, alternative]; +} + /** * Reduces a degraded node's composition member to its type skeleton — `type` plus * recursively-skeletonized nested compositions, nothing else — so the output @@ -741,7 +755,7 @@ export function standardSchemaToJsonSchema( // serialization; Map/Set ship `{}` garbage). Representable non-object roots // (legal per SEP-2106) carry an explicit `type` and returned above; quiet // shapes keep listing. - const outputVerdict = nonObjectTypelessRootVerdict(schema); + const outputVerdict = nonObjectTypelessRootVerdict(schema, 'output'); if (outputVerdict !== undefined && outputVerdict.loud) { throw new Error( `MCP tool and prompt schemas must describe objects (got a non-object ${outputVerdict.type} schema). ` + @@ -761,7 +775,7 @@ export function standardSchemaToJsonSchema( // so misregistered roots keep failing loudly instead of being advertised as // permanently-uncallable `{type: 'object'}` tools. if (result.type === undefined) { - const verdict = nonObjectTypelessRootVerdict(schema); + const verdict = nonObjectTypelessRootVerdict(schema, 'input'); // Quiet verdicts (compositions of only non-finite number literals) listed // silently pre-#2464 and keep the unconditional stamp below. if (verdict !== undefined && verdict.loud) { @@ -786,15 +800,16 @@ export function standardSchemaToJsonSchema( * roots converted before #2464. * * The verdict carries loudness for the loudness-parity contract: `loud` shapes made - * pre-#2464 conversion throw (bigint/symbol/date/…, literals with undefined/bigint/ - * symbol values) and must keep throwing; `quiet` shapes (non-finite number literals, - * which zod silently emits as `{type: 'number', const: null}`) listed silently - * pre-#2464 and must keep listing — the guard throws only for a loud-containing - * verdict. `ancestors` tracks only the current traversal path, so a shared instance + * pre-#2464 conversion throw (bigint/symbol/date/…, literals with undefined or + * bigint values) and must keep throwing; `quiet` shapes (non-finite number and + * symbol literals, which zod silently emitted without a conversion error) listed + * silently pre-#2464 and must keep listing — the guard throws only for a + * loud-containing verdict. `ancestors` tracks only the current traversal path, so a shared instance * gets a real verdict at every occurrence while recursive lazies stay bounded. */ function nonObjectTypelessRootVerdict( schema: unknown, + io: 'input' | 'output', ancestors: ReadonlySet = new Set() ): { type: string; loud: boolean } | undefined { if (typeof schema !== 'object' || schema === null || ancestors.has(schema)) return undefined; @@ -820,33 +835,44 @@ function nonObjectTypelessRootVerdict( path.add(schema); if (def.type === 'lazy' && typeof def.getter === 'function') { try { - return nonObjectTypelessRootVerdict((def.getter as () => unknown)(), path); + return nonObjectTypelessRootVerdict((def.getter as () => unknown)(), io, path); } catch { return undefined; } } - if (def.type === 'pipe' && def.in !== undefined) { - const inVerdict = nonObjectTypelessRootVerdict(def.in, path); - if (inVerdict !== undefined) return inVerdict; - // `z.preprocess(fn, inner)` builds the opposite pipe — the transform sits at - // `def.in` (verdict undefined by design) and the real schema at `def.out`. - const inDef = (def.in as { _zod?: { def?: { type?: string } } })._zod?.def; - if (inDef?.type === 'transform' && def.out !== undefined) { - return nonObjectTypelessRootVerdict(def.out, path); + if (def.type === 'pipe') { + // Loudness parity must anchor to the side zod's conversion actually + // processed: input conversion reads `def.in`, output conversion `def.out` — + // pre-#2464, `z.date().transform(d => d.toISOString()).pipe(z.string().nullable())` + // converted fine on the output path (only its OUT side was visited). + const primary = io === 'output' ? def.out : def.in; + const secondary = io === 'output' ? def.in : def.out; + if (primary !== undefined) { + const verdict = nonObjectTypelessRootVerdict(primary, io, path); + if (verdict !== undefined) return verdict; + // A bare transform on the processed side has no verdict of its own — the + // real schema sits on the other side (`z.preprocess(fn, inner)` on input; + // on output, zod threw 'Transforms cannot be represented' pre-#2464, so + // consulting `def.in` keeps genuinely-unrepresentable inputs loud while + // representable ones degrade gracefully). + const primaryDef = (primary as { _zod?: { def?: { type?: string } } })._zod?.def; + if (primaryDef?.type === 'transform' && secondary !== undefined) { + return nonObjectTypelessRootVerdict(secondary, io, path); + } } return undefined; } if (WRAPPER_ZOD_DEF_TYPES.has(def.type) && def.innerType !== undefined) { - return nonObjectTypelessRootVerdict(def.innerType, path); + return nonObjectTypelessRootVerdict(def.innerType, io, path); } if (def.type === 'union' && Array.isArray(def.options) && def.options.length > 0) { - const verdicts = def.options.map(option => nonObjectTypelessRootVerdict(option, path)); + const verdicts = def.options.map(option => nonObjectTypelessRootVerdict(option, io, path)); if (verdicts.includes(undefined)) return undefined; return { type: 'union', loud: verdicts.some(verdict => verdict?.loud === true) }; } if (def.type === 'intersection' && def.left !== undefined && def.right !== undefined) { - const left = nonObjectTypelessRootVerdict(def.left, path); - const right = nonObjectTypelessRootVerdict(def.right, path); + const left = nonObjectTypelessRootVerdict(def.left, io, path); + const right = nonObjectTypelessRootVerdict(def.right, io, path); if (left === undefined && right === undefined) return undefined; return { type: 'intersection', loud: left?.loud === true || right?.loud === true }; } @@ -875,11 +901,12 @@ function nonObjectTypelessRootVerdict( /** * The non-object loudness of a literal's values, or `undefined` when the literal may * be JSON-satisfiable. `'loud'`: a value zod's own converter threw on pre-#2464 - * (`undefined`, bigint, symbol) — such roots must keep failing loudly. `'quiet'`: - * only non-finite numbers (`Infinity`/`-Infinity`/`NaN`), which zod silently emits - * as `{type: 'number', const: null}` — non-object, but such roots listed silently - * pre-#2464 and must keep listing. Any representable value (string, finite number, - * boolean, null) makes the literal satisfiable via JSON: `undefined` + * (`undefined`, bigint) — such roots must keep failing loudly. `'quiet'`: non-finite + * numbers (`Infinity`/`-Infinity`/`NaN`, silently emitted as + * `{type: 'number', const: null}`) and symbol values (silently emitted as + * `{type: 'symbol'}`) — non-object, but such roots listed silently pre-#2464 and + * must keep listing. Any representable value (string, finite number, boolean, null) + * makes the literal satisfiable via JSON: `undefined` * (`z.union([z.literal('admin'), z.literal('member')])`, zod's idiomatic enum * spelling, and mixed-type lists like `z.literal(['a', 1])` all keep converting * exactly as they did pre-#2464). @@ -887,24 +914,35 @@ function nonObjectTypelessRootVerdict( function nonObjectLiteralLoudness(values: unknown): 'loud' | 'quiet' | undefined { if (!Array.isArray(values) || values.length === 0) return 'loud'; let representable = false; - let nonFinite = false; + let quiet = false; for (const value of values) { if (value === null) { representable = true; continue; } const valueType = typeof value; - if (valueType !== 'string' && valueType !== 'number' && valueType !== 'boolean') { - return 'loud'; // undefined / bigint / symbol values threw pre-#2464 + if (valueType === 'undefined' || valueType === 'bigint') { + // These literal VALUES threw pre-#2464 regardless of co-values. + return 'loud'; + } + if (valueType === 'symbol') { + // Not JSON-satisfiable, but zod silently emitted {type: 'symbol'} for it + // pre-#2464 — quiet, like non-finite numbers. + quiet = true; + continue; } if (valueType === 'number' && !Number.isFinite(value as number)) { - nonFinite = true; - } else { + quiet = true; + continue; + } + if (valueType === 'string' || valueType === 'number' || valueType === 'boolean') { representable = true; + continue; } + return 'loud'; // unknown value kind — conservative } if (representable) return undefined; - return nonFinite ? 'quiet' : undefined; + return quiet ? 'quiet' : undefined; } /** diff --git a/packages/core-internal/test/util/standardSchema.test.ts b/packages/core-internal/test/util/standardSchema.test.ts index fa1e09b6c1..fe0dc7e022 100644 --- a/packages/core-internal/test/util/standardSchema.test.ts +++ b/packages/core-internal/test/util/standardSchema.test.ts @@ -490,6 +490,82 @@ describe('zod conversion options (#2464)', () => { expect(standardSchemaToJsonSchema(z.never(), 'output').type).toBeUndefined(); }); + test('output pipes anchor loudness to their OUT side (codec roots keep listing)', () => { + // Pre-#2464, output conversion only visited a pipe's OUT side — these codecs + // (raw Date/bigint in, representable declared output) listed and worked + // wire-truthfully; anchoring the verdict to def.in threw 'non-object date' + // and killed the whole tools/list. + const dateCodec = z + .date() + .transform(d => d.toISOString()) + .pipe(z.string().nullable() as unknown as z.ZodType); + expect(standardSchemaToJsonSchema(dateCodec, 'output').anyOf).toBeDefined(); + const bigintCodec = z + .bigint() + .transform(String) + .pipe(z.string().nullable() as unknown as z.ZodType); + expect(standardSchemaToJsonSchema(bigintCodec, 'output').anyOf).toBeDefined(); + // A bare-transform OUT side falls back to def.in: pre-#2464 zod threw + // 'Transforms cannot be represented' on output, so genuinely-unrepresentable + // inputs stay loud ... + expect(() => + standardSchemaToJsonSchema( + z.bigint().transform(x => Number(x)), + 'output' + ) + ).toThrow(/must describe objects/); + // ... and input-path pipe semantics are unchanged. + expect(() => + standardSchemaToJsonSchema( + z.bigint().transform(x => Number(x)), + 'input' + ) + ).toThrow(/must describe objects/); + }); + + test('quiet literal values (symbols) keep compositions listing', () => { + // Pre-#2464, z.literal(Symbol) silently emitted {type: 'symbol'} — only + // undefined/bigint literal VALUES threw. These compositions listed pre-#2464. + expect( + standardSchemaToJsonSchema( + z.union([z.literal(Symbol('a') as unknown as string), z.literal(Symbol('b') as unknown as string)]), + 'input' + ).type + ).toBe('object'); + expect( + standardSchemaToJsonSchema(z.intersection(z.object({ a: z.string() }), z.literal(Symbol('x') as unknown as string)), 'input') + .type + ).toBe('object'); + // A representable co-value keeps mixed lists satisfiable (no early loud return). + expect( + standardSchemaToJsonSchema(z.union([z.literal(['x', Symbol('s') as unknown as string]), z.literal(['y', 1])]), 'input').type + ).toBe('object'); + // Loud co-members and loud literal values still throw ... + expect(() => standardSchemaToJsonSchema(z.union([z.literal(Symbol('a') as unknown as string), z.bigint()]), 'input')).toThrow( + /must describe objects/ + ); + // ... and a bare symbol literal root keeps throwing via the explicit-type guard. + expect(() => standardSchemaToJsonSchema(z.literal(Symbol('x') as unknown as string), 'input')).toThrow(/got type/); + }); + + test('the anyOf constraint wrap keeps annotations at the node top level', () => { + const schema = z.object({ + f: z.file().describe('binary upload'), + inf: z.literal(Infinity).describe('sentinel'), + name: z.string() + }); + const result = standardSchemaToJsonSchema(schema, 'output'); + const properties = result.properties as Record>; + + // An annotation buried inside an applicator branch the wire payload never + // matches applies to nothing — consumers read the top-level description. + expect(properties.f!.description).toBe('binary upload'); + expect(properties.inf!.description).toBe('sentinel'); + // The wire forms still validate. + const validate = new AjvJsonSchemaValidator().getValidator(result); + expect(validate({ f: {}, inf: null, name: 'n' }).valid).toBe(true); + }); + test('z.file() union members are quiet non-object verdicts', () => { // A File can never ride JSON, so a loud co-member restores the pre-#2464 throw ... expect(() => standardSchemaToJsonSchema(z.union([z.file(), z.bigint()]), 'input')).toThrow(/must describe objects/); From f105c75537edbd8f094878466fab4082557bf4a0 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 13:36:10 +0000 Subject: [PATCH 21/35] fix(core-internal): conjunction-preserving oneOf rewrite; skip negated positions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The oneOf->anyOf rename bailed when the node already carried anyOf (zod merges .meta({anyOf: [...]}) verbatim), leaving a reject-everything oneOf after its members were loosened into mutual satisfiability. Instead of bailing, the loosened members now move under allOf as {anyOf: members} — keywords on one node combine with AND, so this is semantically equivalent, loosen-only, and does not clobber the user's anyOf. - The walker recursed into not/if, where oneOf->anyOf INVERTS polarity and tightens: a payload matching >=2 members passed not {oneOf} pre-#2464 but failed the rewritten not {anyOf} (and under if, the rename can flip which then/else branch applies). Negated/conditional positions are now skipped; then/else and the other schema-carrying keywords are positive-polarity and keep the rewrite. Co-Authored-By: Claude --- .../core-internal/src/util/standardSchema.ts | 16 +++++++-- .../test/util/standardSchema.test.ts | 36 +++++++++++++++++++ 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/packages/core-internal/src/util/standardSchema.ts b/packages/core-internal/src/util/standardSchema.ts index 419788081d..f5179c7dd3 100644 --- a/packages/core-internal/src/util/standardSchema.ts +++ b/packages/core-internal/src/util/standardSchema.ts @@ -409,8 +409,16 @@ function rewriteOneOfToAnyOf(node: unknown, seen: Set = new Set()): voi return; } const record = node as Record; - if (Array.isArray(record.oneOf) && record.anyOf === undefined) { - record.anyOf = record.oneOf; + if (Array.isArray(record.oneOf)) { + if (record.anyOf === undefined) { + record.anyOf = record.oneOf; + } else { + // A user `.meta({anyOf})` can coexist with the emitted `oneOf` — preserve + // conjunction semantics without clobbering it: keywords on one node + // combine with AND, so `{anyOf: members}` under `allOf` is equivalent. + const allOf = Array.isArray(record.allOf) ? record.allOf : (record.allOf = []); + allOf.push({ anyOf: record.oneOf }); + } delete record.oneOf; } for (const [key, value] of Object.entries(record)) { @@ -419,6 +427,10 @@ function rewriteOneOfToAnyOf(node: unknown, seen: Set = new Set()): voi // `.meta()` keys — carries user DATA whose literal `oneOf` keys must not be // renamed. if (!SCHEMA_CARRYING_JSON_SCHEMA_KEYWORDS.has(key)) continue; + // oneOf→anyOf is a loosening only in POSITIVE polarity: under `not` it + // inverts (a payload matching ≥2 members passed `not {oneOf}` but fails + // `not {anyOf}`), and under `if` it can flip which then/else branch applies. + if (key === 'not' || key === 'if') continue; // Schema MAPS hold schemas under user-chosen names that may collide with // annotation keywords (a property literally named `description` still // carries a schema) — recurse into every value unconditionally. diff --git a/packages/core-internal/test/util/standardSchema.test.ts b/packages/core-internal/test/util/standardSchema.test.ts index fe0dc7e022..1b4adb416b 100644 --- a/packages/core-internal/test/util/standardSchema.test.ts +++ b/packages/core-internal/test/util/standardSchema.test.ts @@ -622,6 +622,42 @@ describe('zod conversion options (#2464)', () => { expect(cfg.default).toEqual({ oneOf: [1, 2] }); }); + test('the oneOf rewrite preserves a coexisting user anyOf via allOf', () => { + // zod merges .meta({anyOf}) verbatim beside the emitted oneOf — bailing there + // left a reject-everything oneOf after the members were loosened. + const du = z + .discriminatedUnion('t', [ + z.object({ t: z.literal('a').catch('a'), x: z.string().optional() }), + z.object({ t: z.literal('b').catch('b'), y: z.string().optional() }) + ]) + .meta({ anyOf: [{ type: 'object' }] }); + const result = standardSchemaToJsonSchema(du, 'output'); + + expect(result.oneOf).toBeUndefined(); + expect(result.anyOf).toEqual([{ type: 'object' }]); // the user's anyOf, unclobbered + const allOf = result.allOf as Array<{ anyOf: Array> }>; + expect(allOf).toHaveLength(1); + expect(allOf[0]!.anyOf.map(member => member.type)).toEqual(['object', 'object']); + expect(new AjvJsonSchemaValidator().getValidator(result)({ t: 'a', x: 'v' }).valid).toBe(true); + }); + + test('the oneOf rewrite skips negated and conditional positions', () => { + // Under `not`, oneOf→anyOf inverts polarity and TIGHTENS: {v: 4} matches + // both members, so it passed `not {oneOf}` pre-#2464 but would fail the + // rewritten `not {anyOf}`. + const schema = z.object({ + v: z.number().meta({ not: { oneOf: [{ type: 'integer' }, { multipleOf: 2 }] } }), + counted: z.number().default(0), // trips the loosened flag + name: z.string() + }); + const result = standardSchemaToJsonSchema(schema, 'output'); + + expect((result.properties as Record>).v!.not).toEqual({ + oneOf: [{ type: 'integer' }, { multipleOf: 2 }] + }); + expect(new AjvJsonSchemaValidator().getValidator(result)({ v: 4, counted: 1, name: 'n' }).valid).toBe(true); + }); + test('custom .meta() keys are annotation-opaque at both carve-out sites', () => { // zod merges arbitrary .meta() keys verbatim and 2020-12 validators ignore // unknown keywords — they are annotations by construction, like x-*. From 6814683d826f515275c7dbc068431a66ae4e8738 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 14:22:55 +0000 Subject: [PATCH 22/35] fix(core-internal): all-key object proof; contains skip; io-aware bigint-literal/date output verdicts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - isProvablyObjectShapedRoot returned the FIRST composition key's verdict, so relocating loosened DU members under allOf beside a user .meta({anyOf: [object, null]}) defeated the proof and flipped the 2025-era legacy wrap. The proof now consults every composition key (any key proving objectness suffices — keywords AND-combine) and uses some-semantics for allOf (one all-object conjunct proves the value is an object). Stamp-only strengthening; also fixes user-authored .meta({allOf}) proofs. - 'contains' joins the oneOf-rewrite polarity skip: per element, anyOf matches a superset of oneOf, so the contains-count can only rise — tightening against a sibling maxContains. zod never emits contains, so skipping is regression-free. The remaining cross-polarity hole — a .meta({not: {$ref}}) aliasing a legitimately-renamed positive target — cannot be fixed by any lexical key-skip and is documented in Known residual gaps and the changeset instead. - The output loudness guard misfired in both directions: bigint-valued literal roots (z.literal(1n), also optional/readonly/lazy spellings) emit an explicit {type: 'number', const: 1} under unrepresentable: 'any' and bypassed the typeless guard via the early return, listing a tool whose every result fails JSON-RPC serialization (pre-#2464 they threw) — a new isBigintValuedLiteralRoot unwind now throws before the early return; and date roots were loud on BOTH io paths, so z.date().nullable() output roots (wire-truthful thanks to the date override, unlike the listing bare-date/optional/union-with-null spellings) threw and killed the whole tools/list — the date verdict is now io-aware (loud on input, quiet on output), with 'date' removed from the loud set in favor of the dedicated branch. Co-Authored-By: Claude --- .changeset/zod-tojsonschema-wire-truthful.md | 4 +- .../core-internal/src/util/standardSchema.ts | 95 +++++++++++++++---- .../test/util/standardSchema.test.ts | 65 +++++++++++++ 3 files changed, 143 insertions(+), 21 deletions(-) diff --git a/.changeset/zod-tojsonschema-wire-truthful.md b/.changeset/zod-tojsonschema-wire-truthful.md index 36cfbb0493..7e8819ff55 100644 --- a/.changeset/zod-tojsonschema-wire-truthful.md +++ b/.changeset/zod-tojsonschema-wire-truthful.md @@ -34,6 +34,8 @@ every JSON payload — use a JSON-representable type such as `z.iso.date()`/ optional. The same holds for required OUTPUT fields of such types: bigint results fail JSON-RPC serialization and Map/Set values serialize as `{}`. And a degraded object-`.catch()` node keeps `type: 'object'` for the 2025-era wrap proof even though -catch-validation does not enforce it on the raw value.) Elicitation is unaffected: +catch-validation does not enforce it on the raw value. A `.meta()`-injected +`not: {$ref: …}` aliasing a loosened subtree can also observe the `oneOf` → `anyOf` +rewrite cross-polarity.) Elicitation is unaffected: `inputRequired.elicit()` keeps throwing on schemas its restricted form grammar cannot round-trip, including `z.date()`. diff --git a/packages/core-internal/src/util/standardSchema.ts b/packages/core-internal/src/util/standardSchema.ts index f5179c7dd3..b48cb07557 100644 --- a/packages/core-internal/src/util/standardSchema.ts +++ b/packages/core-internal/src/util/standardSchema.ts @@ -238,6 +238,12 @@ export const JSON_SCHEMA_CONVERSION_TARGET = 'draft-2020-12'; * transport. `InMemoryTransport` passes messages by reference with no JSON * round-trip, so the raw `Date` the server must ship reaches a validating client * as a `Date` instance and fails the advertised schema there. + * - The loosen rewrite's `oneOf` → `anyOf` rename skips lexical `not`/`if`/`contains` + * positions, but a `.meta({not: {$ref: '#/properties/…'}})` aliasing a + * positive-position target observes the (legitimately) renamed schema, inverting + * polarity — the same node cannot read `anyOf` for its positive consumer and + * `oneOf` for a negated alias, so cross-polarity `$ref` aliasing into loosened + * subtrees stays untruthful. */ function zodConversionOptions( io: 'input' | 'output', @@ -429,8 +435,11 @@ function rewriteOneOfToAnyOf(node: unknown, seen: Set = new Set()): voi if (!SCHEMA_CARRYING_JSON_SCHEMA_KEYWORDS.has(key)) continue; // oneOf→anyOf is a loosening only in POSITIVE polarity: under `not` it // inverts (a payload matching ≥2 members passed `not {oneOf}` but fails - // `not {anyOf}`), and under `if` it can flip which then/else branch applies. - if (key === 'not' || key === 'if') continue; + // `not {anyOf}`), under `if` it can flip which then/else branch applies, + // and under `contains` it raises the contains-count, tightening against a + // sibling `maxContains` (zod never emits `contains`, so skipping is + // regression-free). + if (key === 'not' || key === 'if' || key === 'contains') continue; // Schema MAPS hold schemas under user-chosen names that may collide with // annotation keywords (a property literally named `description` still // carries a schema) — recurse into every value unconditionally. @@ -758,6 +767,16 @@ export function standardSchemaToJsonSchema( // as-is — stamping there would be self-contradictory. Anything that does not end up // `type:'object'` is wrapped as `{type:'object', properties:{result:…}}` by the 2025 // codec's legacy projection (see `wire/rev2025-11-25/legacyWrap.ts`). + // Loudness parity BEFORE the explicit-type early return: a bigint-valued + // literal root emits an explicit `{type: 'number', const: …}` under + // `unrepresentable: 'any'`, yet no such result can ever be serialized + // (JSON.stringify throws on BigInt) — pre-#2464 these threw loudly. + if (isBigintValuedLiteralRoot(schema)) { + throw new Error( + `MCP tool and prompt schemas must describe objects (got a non-object literal schema). ` + + `Wrap your schema in z.object({...}) or equivalent.` + ); + } if (result.type !== undefined) return result; if (isProvablyObjectShapedRoot(result)) return { type: 'object', ...result }; // Loudness parity for misregistered OUTPUT roots too: an unrepresentable @@ -800,6 +819,32 @@ export function standardSchemaToJsonSchema( return { type: 'object', ...result }; } +/** + * Whether the root unwinds (through transparent wrappers and lazies) to a literal + * carrying a bigint value. Its emission under `unrepresentable: 'any'` is an explicit + * `{type: 'number', const: …}` that bypasses the typeless-root guard via the + * explicit-type early return — yet server-side validation accepts only bigints, which + * JSON.stringify can never serialize. + */ +function isBigintValuedLiteralRoot(schema: unknown, ancestors: ReadonlySet = new Set()): boolean { + if (typeof schema !== 'object' || schema === null || ancestors.has(schema)) return false; + const def = (schema as { _zod?: { def?: { type?: string; innerType?: unknown; getter?: unknown; values?: unknown } } })._zod?.def; + if (def === undefined || typeof def.type !== 'string') return false; + const path = new Set(ancestors); + path.add(schema); + if (def.type === 'lazy' && typeof def.getter === 'function') { + try { + return isBigintValuedLiteralRoot((def.getter as () => unknown)(), path); + } catch { + return false; + } + } + if (WRAPPER_ZOD_DEF_TYPES.has(def.type) && def.innerType !== undefined) { + return isBigintValuedLiteralRoot(def.innerType, path); + } + return def.type === 'literal' && Array.isArray(def.values) && def.values.some(value => typeof value === 'bigint'); +} + /** * The verdict for a zod root that emits a typeless node yet provably cannot describe * an object, or `undefined` when the root may. Unwraps transparent wrappers, lazies, @@ -892,6 +937,14 @@ function nonObjectTypelessRootVerdict( const loudness = nonObjectLiteralLoudness(def.values); return loudness === undefined ? undefined : { type: 'literal', loud: loudness === 'loud' }; } + if (def.type === 'date') { + // The date override rewrites every date node to its true wire form + // (string/date-time), so a date-rooted OUTPUT emission is wire-truthful and + // must keep listing (`z.date().nullable()` is a working 'timestamp or null' + // tool); on INPUT no JSON payload satisfies raw z.date() validation, so date + // members stay loud there. + return { type: 'date', loud: io === 'input' }; + } if (def.type === 'never') { // z.never() matches no value: it can never make a union satisfiable or // object-shaped. Quiet, though — bare/all-never shapes emitted `{not: {}}` @@ -958,14 +1011,13 @@ function nonObjectLiteralLoudness(values: unknown): 'loud' | 'quiet' | undefined } /** - * Zod def types that are genuinely unrepresentable in JSON Schema and whose values can - * never be a JSON object — the input-root guard must keep rejecting roots and - * compositions built from them. Most degrade to a typeless `{}` under - * `unrepresentable: 'any'`; `date` is instead rewritten to `string`/`date-time` (so a - * bare date ROOT throws via the explicit-type guard), but a `Date` value can never be - * a JSON object either, so date composition MEMBERS classify as non-object here. - * `custom` and bare `transform` are deliberately excluded (they can legitimately - * accept objects), and typeless literals are classified separately by value in + * Zod def types that are genuinely unrepresentable in JSON Schema (they degrade to a + * typeless `{}` under `unrepresentable: 'any'`) and whose values can never be a JSON + * object — the root guards must keep rejecting roots and compositions built from + * them. `custom` and bare `transform` are deliberately excluded (they can + * legitimately accept objects); `date` gets a dedicated io-aware branch (loud on + * input, quiet on output where the override makes it wire-truthful); and typeless + * literals are classified separately by value in * {@linkcode nonObjectLiteralLoudness}. */ const NON_OBJECT_UNREPRESENTABLE_TYPES: ReadonlySet = new Set([ @@ -976,8 +1028,7 @@ const NON_OBJECT_UNREPRESENTABLE_TYPES: ReadonlySet = new Set([ 'void', 'undefined', 'nan', - 'function', - 'date' + 'function' ]); /** Transparent wrapper def types whose `innerType` carries the real root semantics. */ @@ -1003,16 +1054,20 @@ function isProvablyObjectShapedRoot(schema: Record): boolean { if ('properties' in schema || 'patternProperties' in schema || 'additionalProperties' in schema || 'required' in schema) { return true; } + const isObjectMember = (member: unknown): boolean => + member !== null && + typeof member === 'object' && + ((member as Record).type === 'object' || isProvablyObjectShapedRoot(member as Record)); + // Keywords on one node AND-combine, so ANY present composition key proving + // objectness suffices (a first-key-wins rule breaks when the loosen rewrite + // relocates all-object members under `allOf` beside a user `.meta({anyOf})`). for (const key of ['oneOf', 'anyOf', 'allOf'] as const) { const members = schema[key]; - if (Array.isArray(members) && members.length > 0) { - return members.every( - m => - m !== null && - typeof m === 'object' && - ((m as Record).type === 'object' || isProvablyObjectShapedRoot(m as Record)) - ); - } + if (!Array.isArray(members) || members.length === 0) continue; + // A disjunction proves objectness only when EVERY member is an object; a + // conjunction already does when ONE conjunct is (the value must satisfy it). + const proves = key === 'allOf' ? members.some(member => isObjectMember(member)) : members.every(member => isObjectMember(member)); + if (proves) return true; } return false; } diff --git a/packages/core-internal/test/util/standardSchema.test.ts b/packages/core-internal/test/util/standardSchema.test.ts index 1b4adb416b..9ccd4c5ac3 100644 --- a/packages/core-internal/test/util/standardSchema.test.ts +++ b/packages/core-internal/test/util/standardSchema.test.ts @@ -658,6 +658,71 @@ describe('zod conversion options (#2464)', () => { expect(new AjvJsonSchemaValidator().getValidator(result)({ v: 4, counted: 1, name: 'n' }).valid).toBe(true); }); + test('the oneOf rewrite skips contains (count-tightening under maxContains)', () => { + // Per element, anyOf matches a superset of oneOf, so the contains-count can + // only rise — under a sibling maxContains the rename would TIGHTEN: 4 and 6 + // match both branches (excluded by oneOf, counted by anyOf). + const schema = z.object({ + arr: z.array(z.number()).meta({ contains: { oneOf: [{ type: 'integer' }, { multipleOf: 2 }] }, maxContains: 2 }), + counted: z.number().default(0), // trips the loosened flag + name: z.string() + }); + const result = standardSchemaToJsonSchema(schema, 'output'); + + expect((result.properties as Record>).arr!.contains).toEqual({ + oneOf: [{ type: 'integer' }, { multipleOf: 2 }] + }); + expect(new AjvJsonSchemaValidator().getValidator(result)({ arr: [3, 5, 4, 6], counted: 1, name: 'n' }).valid).toBe(true); + }); + + test('the object proof consults every composition key (allOf uses some-semantics)', () => { + // The loosen rewrite relocates the DU members under allOf beside the user's + // .meta({anyOf}) — a first-key-wins proof would read the user's anyOf, see + // the null member, and flip the 2025-era legacy wrap. + const du = z + .discriminatedUnion('t', [ + z.object({ t: z.literal('a').catch('a'), x: z.string().optional() }), + z.object({ t: z.literal('b').catch('b'), y: z.string().optional() }) + ]) + .meta({ anyOf: [{ type: 'object' }, { type: 'null' }] }); + const result = standardSchemaToJsonSchema(du, 'output'); + + expect(result.type).toBe('object'); + expect(isNonObjectJsonSchemaRoot(result)).toBe(false); + }); + + test('bigint-valued literal output roots throw across spellings', () => { + // These emit an explicit {type: 'number', const: …} under + // unrepresentable: 'any' (bypassing the typeless guard), yet no result can + // ever be serialized — JSON.stringify throws on BigInt. Pre-#2464 they threw. + expect(() => standardSchemaToJsonSchema(z.literal(1n), 'output')).toThrow(/must describe objects/); + expect(() => standardSchemaToJsonSchema(z.literal(1n).optional(), 'output')).toThrow(/must describe objects/); + expect(() => standardSchemaToJsonSchema(z.literal(1n).readonly(), 'output')).toThrow(/must describe objects/); + expect(() => + standardSchemaToJsonSchema( + z.lazy(() => z.literal(1n)), + 'output' + ) + ).toThrow(/must describe objects/); + // The input spelling keeps throwing via the explicit-type guard. + expect(() => standardSchemaToJsonSchema(z.literal(1n), 'input')).toThrow(/got type/); + }); + + test('nullable date output roots list and validate their wire forms', () => { + // The date override makes date-rooted OUTPUT emissions wire-truthful (a raw + // Date ships as an ISO string), so 'timestamp or null' must list — classifying + // date as loud on output killed the whole tools/list for it. + const result = standardSchemaToJsonSchema(z.date().nullable(), 'output'); + + expect(Array.isArray(result.anyOf)).toBe(true); + const validate = new AjvJsonSchemaValidator().getValidator(result); + expect(validate(new Date().toISOString()).valid).toBe(true); + expect(validate(null).valid).toBe(true); + // Input-path date members stay loud (pinned elsewhere: union(date,date) throws) + // and output unions still turn loud with a genuinely-loud co-member. + expect(() => standardSchemaToJsonSchema(z.union([z.date(), z.bigint()]), 'output')).toThrow(/must describe objects/); + }); + test('custom .meta() keys are annotation-opaque at both carve-out sites', () => { // zod merges arbitrary .meta() keys verbatim and 2020-12 validators ignore // unknown keywords — they are annotations by construction, like x-*. From 3b72567e2af98557b697b18af5a8a8498f7f84c7 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 15:10:53 +0000 Subject: [PATCH 23/35] fix(core-internal): restore every()-proof for untouched allOf; extend loud-literal guard; keep anchors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The round-21 some()-semantics for allOf in isProvablyObjectShapedRoot fired on UNTOUCHED intersection emissions: z.intersection(z.object, z.bigint()) output stamped-and-listed before the loudness guard ran (pre-#2464 it threw), and z.intersection(z.object, z.any()) — a working registration — got stamped, silently flipping the 2025-era wire shape. Every composition key now uses EVERY-member semantics again (main's decision for untouched emissions); the all-keys iteration — the actual round-21 repair — stays, and the loosen rewrite's relocated {anyOf: members} conjunct is itself provably object-shaped, so its proof survives. Additionally the output epilogue consults the loud verdict BEFORE the stamp, so a loud conjunct throws even when an object conjunct could prove the root. - isBigintValuedLiteralRoot -> isLoudLiteralOutputRoot: adds the io-appropriate pipe branch (recurse def.out with the bare-transform fallback to def.in, mirroring the verdict walk) and replaces the bigint-only typeof check with nonObjectLiteralLoudness(values) === 'loud' — catching codec spellings (z.number().transform(() => 1n) .pipe(z.literal(1n)) emits {type: 'number', const: 1}) and z.literal([undefined, 'a']) (zod filters the undefined value and emits an explicit type), both of which threw pre-#2464. - The catch degrade no longer deletes $anchor/$dynamicAnchor/$id: reference TARGETS constrain no instance value, and deleting them dangles inbound $refs — the advertisement failed Ajv COMPILE and every callTool failed pre-send. They stay on the node (and remain movable by wrapConstraintsInAnyOf). Co-Authored-By: Claude --- .../core-internal/src/util/standardSchema.ts | 65 +++++++++++++------ .../test/util/standardSchema.test.ts | 57 ++++++++++++++++ 2 files changed, 102 insertions(+), 20 deletions(-) diff --git a/packages/core-internal/src/util/standardSchema.ts b/packages/core-internal/src/util/standardSchema.ts index b48cb07557..d673b8ce41 100644 --- a/packages/core-internal/src/util/standardSchema.ts +++ b/packages/core-internal/src/util/standardSchema.ts @@ -300,6 +300,9 @@ function zodConversionOptions( ctx.jsonSchema[key === 'oneOf' ? 'anyOf' : key] = skeletons; continue; } + // Reference TARGETS constrain no instance value — deleting them only + // dangles inbound `$ref`s and makes the advertisement uncompilable. + if (key === '$anchor' || key === '$dynamicAnchor' || key === '$id') continue; // Only schema-carrying and enforced keywords constrain validation; // everything else — vocabulary annotations, `x-*` extensions, custom // `.meta()` keys — is annotation-opaque and kept. @@ -767,18 +770,17 @@ export function standardSchemaToJsonSchema( // as-is — stamping there would be self-contradictory. Anything that does not end up // `type:'object'` is wrapped as `{type:'object', properties:{result:…}}` by the 2025 // codec's legacy projection (see `wire/rev2025-11-25/legacyWrap.ts`). - // Loudness parity BEFORE the explicit-type early return: a bigint-valued - // literal root emits an explicit `{type: 'number', const: …}` under - // `unrepresentable: 'any'`, yet no such result can ever be serialized - // (JSON.stringify throws on BigInt) — pre-#2464 these threw loudly. - if (isBigintValuedLiteralRoot(schema)) { + // Loudness parity BEFORE the explicit-type early return: a loud-valued + // literal root (bigint, or undefined filtered from the value list) emits an + // explicit type under `unrepresentable: 'any'`, yet no such result can ever + // ride JSON truthfully — pre-#2464 these threw loudly. + if (isLoudLiteralOutputRoot(schema)) { throw new Error( `MCP tool and prompt schemas must describe objects (got a non-object literal schema). ` + `Wrap your schema in z.object({...}) or equivalent.` ); } if (result.type !== undefined) return result; - if (isProvablyObjectShapedRoot(result)) return { type: 'object', ...result }; // Loudness parity for misregistered OUTPUT roots too: an unrepresentable // non-object root (z.bigint(), z.map(), unions of them) threw at tools/list // pre-#2464 and must keep doing so — post-degrade it would list silently as @@ -793,6 +795,10 @@ export function standardSchemaToJsonSchema( `Wrap your schema in z.object({...}) or equivalent.` ); } + // The stamp runs AFTER the guard: a loud conjunct (e.g. + // `z.intersection(z.object(...), z.bigint())`) must throw even when an object + // conjunct could prove the root. + if (isProvablyObjectShapedRoot(result)) return { type: 'object', ...result }; return result; } if (result.type !== undefined && result.type !== 'object') { @@ -820,29 +826,46 @@ export function standardSchemaToJsonSchema( } /** - * Whether the root unwinds (through transparent wrappers and lazies) to a literal - * carrying a bigint value. Its emission under `unrepresentable: 'any'` is an explicit - * `{type: 'number', const: …}` that bypasses the typeless-root guard via the - * explicit-type early return — yet server-side validation accepts only bigints, which - * JSON.stringify can never serialize. + * Whether an OUTPUT root unwinds (through transparent wrappers, lazies, and pipe OUT + * sides) to a literal with loud values (bigint, or `undefined` beside representable + * co-values that zod filters out). Such emissions carry an explicit `type` under + * `unrepresentable: 'any'` — bypassing the typeless-root guard via the explicit-type + * early return — yet the raw values server-side validation accepts can never ride + * JSON truthfully. Pre-#2464 all of them threw loudly. */ -function isBigintValuedLiteralRoot(schema: unknown, ancestors: ReadonlySet = new Set()): boolean { +function isLoudLiteralOutputRoot(schema: unknown, ancestors: ReadonlySet = new Set()): boolean { if (typeof schema !== 'object' || schema === null || ancestors.has(schema)) return false; - const def = (schema as { _zod?: { def?: { type?: string; innerType?: unknown; getter?: unknown; values?: unknown } } })._zod?.def; + const def = ( + schema as { + _zod?: { def?: { type?: string; innerType?: unknown; getter?: unknown; in?: unknown; out?: unknown; values?: unknown } }; + } + )._zod?.def; if (def === undefined || typeof def.type !== 'string') return false; const path = new Set(ancestors); path.add(schema); if (def.type === 'lazy' && typeof def.getter === 'function') { try { - return isBigintValuedLiteralRoot((def.getter as () => unknown)(), path); + return isLoudLiteralOutputRoot((def.getter as () => unknown)(), path); } catch { return false; } } + if (def.type === 'pipe') { + // Output conversion processes the OUT side; a bare transform there has no + // literal of its own, so fall back to the IN side (mirroring the verdict walk). + if (def.out !== undefined) { + if (isLoudLiteralOutputRoot(def.out, path)) return true; + const outDef = (def.out as { _zod?: { def?: { type?: string } } })._zod?.def; + if (outDef?.type === 'transform' && def.in !== undefined) { + return isLoudLiteralOutputRoot(def.in, path); + } + } + return false; + } if (WRAPPER_ZOD_DEF_TYPES.has(def.type) && def.innerType !== undefined) { - return isBigintValuedLiteralRoot(def.innerType, path); + return isLoudLiteralOutputRoot(def.innerType, path); } - return def.type === 'literal' && Array.isArray(def.values) && def.values.some(value => typeof value === 'bigint'); + return def.type === 'literal' && nonObjectLiteralLoudness(def.values) === 'loud'; } /** @@ -1061,13 +1084,15 @@ function isProvablyObjectShapedRoot(schema: Record): boolean { // Keywords on one node AND-combine, so ANY present composition key proving // objectness suffices (a first-key-wins rule breaks when the loosen rewrite // relocates all-object members under `allOf` beside a user `.meta({anyOf})`). + // Every key uses EVERY-member semantics, preserving the pre-#2464 stamp/wrap + // decision for untouched emissions (e.g. `z.intersection(z.object(...), z.any())` + // stays typeless and 2025-era-wrapped); the loosen rewrite's relocated + // `{anyOf: members}` conjunct is itself provably object-shaped, so its proof + // survives the stricter rule. for (const key of ['oneOf', 'anyOf', 'allOf'] as const) { const members = schema[key]; if (!Array.isArray(members) || members.length === 0) continue; - // A disjunction proves objectness only when EVERY member is an object; a - // conjunction already does when ONE conjunct is (the value must satisfy it). - const proves = key === 'allOf' ? members.some(member => isObjectMember(member)) : members.every(member => isObjectMember(member)); - if (proves) return true; + if (members.every(member => isObjectMember(member))) return true; } return false; } diff --git a/packages/core-internal/test/util/standardSchema.test.ts b/packages/core-internal/test/util/standardSchema.test.ts index 9ccd4c5ac3..d815258fa8 100644 --- a/packages/core-internal/test/util/standardSchema.test.ts +++ b/packages/core-internal/test/util/standardSchema.test.ts @@ -708,6 +708,63 @@ describe('zod conversion options (#2464)', () => { expect(() => standardSchemaToJsonSchema(z.literal(1n), 'input')).toThrow(/got type/); }); + test('untouched intersection output roots keep their pre-#2464 verdicts', () => { + // A loud conjunct must throw even though the object conjunct could prove the + // root (an intersection value can never satisfy both sides) ... + expect(() => standardSchemaToJsonSchema(z.intersection(z.object({ a: z.string() }), z.bigint()), 'output')).toThrow( + /must describe objects/ + ); + // ... and a quiet non-provable conjunct keeps the typeless root, preserving + // the 2025-era legacy wrap for a WORKING pre-#2464 registration. + const anyConjunct = standardSchemaToJsonSchema(z.intersection(z.object({ a: z.string() }), z.any()), 'output'); + expect(anyConjunct.type).toBeUndefined(); + expect(isNonObjectJsonSchemaRoot(anyConjunct)).toBe(true); + }); + + test('piped and undefined-filtered loud literal output roots throw', () => { + // A bigint literal on a pipe's OUT side emits {type: 'number', const: 1}, + // bypassing the typeless guard — pre-#2464 the conversion threw. + expect(() => + standardSchemaToJsonSchema( + z + .number() + .transform(() => 1n) + .pipe(z.literal(1n)), + 'output' + ) + ).toThrow(/must describe objects/); + expect(() => + standardSchemaToJsonSchema( + z + .number() + .transform(() => 1n) + .pipe(z.literal([1n, 2n])), + 'output' + ) + ).toThrow(/must describe objects/); + // zod filters undefined out of the value list, emitting {type: 'string', + // const: 'a'} — but the undefined value threw pre-#2464. + expect(() => standardSchemaToJsonSchema(z.literal([undefined, 'a']), 'output')).toThrow(/must describe objects/); + // Representable literal output roots keep listing. + expect(standardSchemaToJsonSchema(z.literal('a'), 'output').type).toBe('string'); + }); + + test('the catch degrade keeps reference targets ($anchor) compilable', () => { + // $anchor/$dynamicAnchor/$id constrain no instance value — deleting them only + // dangles inbound $refs, making the advertisement uncompilable (every + // callTool would fail at Ajv compile, before the request is sent). + const schema = z.object({ + cfg: z.object({ q: z.string() }).catch({ q: 'd' }).meta({ $anchor: 'cfgAnchor' }), + alias: z.unknown().meta({ $ref: '#cfgAnchor' }), + name: z.string() + }); + const result = standardSchemaToJsonSchema(schema, 'output'); + + expect((result.properties as Record>).cfg!.$anchor).toBe('cfgAnchor'); + const validate = new AjvJsonSchemaValidator().getValidator(result); + expect(validate({ cfg: { q: 'x' }, alias: { q: 'y' }, name: 'n' }).valid).toBe(true); + }); + test('nullable date output roots list and validate their wire forms', () => { // The date override makes date-rooted OUTPUT emissions wire-truthful (a raw // Date ships as an ISO string), so 'timestamp or null' must list — classifying From eaa15f2c138c2c99f291f51238e42a87a016f9e7 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 15:39:46 +0000 Subject: [PATCH 24/35] fix(core-internal): complete reference-target keep; loud output date intersections; allOf-push type stamp MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The reference-target keep was incomplete at two sibling sites: compositionTypeSkeleton now carries member $anchor/$dynamicAnchor/ $id into skeletons (a catch-of-union member's anchor with a sibling $ref alias dangled), and $defs joins the catch delete-loop skip (a pure reference container — deleting it dangled cross-subtree $refs). Both loosen-neutral; both previously made the advertisement fail Ajv COMPILE, so every callTool failed pre-send. - The quiet output-path date verdict flowed into INTERSECTION positions: z.intersection(z.object({...}), z.date()) output listed as a permanently-broken tool (no value satisfies both sides; pre-#2464 it threw, and the input path still throws). The intersection branch now upgrades a quiet date side to loud on the output path — parity-safe, since every date-containing intersection threw pre-#2464; bare/nullable/union-with-representable date outputs keep listing (pinned). - allOf-push residual: a .meta() carrying BOTH a non-all-object anyOf AND a non-object-provable allOf conjunct (e.g. {minProperties: 1}) defeated the every()-member proof on every composition key after the push, flipping the 2025-era wrap for a working registration. The push branch now stamps the sound explicit type: 'object' itself when every relocated member is object-provable (the value must satisfy the pushed conjunct), preserving the pre-#2464 stamp without touching the proof semantics — the round-22 untouched-intersection fixes stay intact. The stale '(allOf uses some-semantics)' test title is renamed to match. Co-Authored-By: Claude --- .../core-internal/src/util/standardSchema.ts | 26 ++++++-- .../test/util/standardSchema.test.ts | 59 ++++++++++++++++++- 2 files changed, 80 insertions(+), 5 deletions(-) diff --git a/packages/core-internal/src/util/standardSchema.ts b/packages/core-internal/src/util/standardSchema.ts index d673b8ce41..b90bf078ab 100644 --- a/packages/core-internal/src/util/standardSchema.ts +++ b/packages/core-internal/src/util/standardSchema.ts @@ -300,9 +300,10 @@ function zodConversionOptions( ctx.jsonSchema[key === 'oneOf' ? 'anyOf' : key] = skeletons; continue; } - // Reference TARGETS constrain no instance value — deleting them only - // dangles inbound `$ref`s and makes the advertisement uncompilable. - if (key === '$anchor' || key === '$dynamicAnchor' || key === '$id') continue; + // Reference TARGETS and containers constrain no instance value — + // deleting them only dangles inbound `$ref`s and makes the + // advertisement uncompilable. + if (key === '$anchor' || key === '$dynamicAnchor' || key === '$id' || key === '$defs') continue; // Only schema-carrying and enforced keywords constrain validation; // everything else — vocabulary annotations, `x-*` extensions, custom // `.meta()` keys — is annotation-opaque and kept. @@ -427,6 +428,14 @@ function rewriteOneOfToAnyOf(node: unknown, seen: Set = new Set()): voi // combine with AND, so `{anyOf: members}` under `allOf` is equivalent. const allOf = Array.isArray(record.allOf) ? record.allOf : (record.allOf = []); allOf.push({ anyOf: record.oneOf }); + // The relocated members' objectness becomes invisible to the wrap proof's + // every()-member rule once user conjuncts coexist (e.g. a .meta({allOf: + // [{minProperties: 1}]})) — stamp the sound explicit type here (the value + // must satisfy the pushed conjunct), preserving the pre-#2464 stamp these + // roots got via their emitted oneOf. + if (record.type === undefined && isProvablyObjectShapedRoot({ anyOf: record.oneOf })) { + record.type = 'object'; + } } delete record.oneOf; } @@ -486,6 +495,10 @@ function compositionTypeSkeleton(node: unknown): Record { const skeleton: Record = {}; // Only `type: 'object'` feeds the proof; any other type is an unenforced constraint. if (source.type === 'object') skeleton.type = 'object'; + // Reference TARGETS constrain nothing — dropping them would dangle inbound $refs. + for (const referenceKey of ['$anchor', '$dynamicAnchor', '$id'] as const) { + if (source[referenceKey] !== undefined) skeleton[referenceKey] = source[referenceKey]; + } for (const key of ['anyOf', 'oneOf', 'allOf'] as const) { if (Array.isArray(source[key])) { // Skeletonized `oneOf` members are indistinguishable, so exactly-one @@ -954,7 +967,12 @@ function nonObjectTypelessRootVerdict( const left = nonObjectTypelessRootVerdict(def.left, io, path); const right = nonObjectTypelessRootVerdict(def.right, io, path); if (left === undefined && right === undefined) return undefined; - return { type: 'intersection', loud: left?.loud === true || right?.loud === true }; + // A date side is quiet on the output path as a root or union member (the + // override makes those emissions wire-truthful), but no value can satisfy + // BOTH intersection sides when one is a Date — every date-containing + // intersection threw pre-#2464, so parity keeps them loud here. + const loudDateSide = io === 'output' && (left?.type === 'date' || right?.type === 'date'); + return { type: 'intersection', loud: left?.loud === true || right?.loud === true || loudDateSide }; } if (def.type === 'literal') { const loudness = nonObjectLiteralLoudness(def.values); diff --git a/packages/core-internal/test/util/standardSchema.test.ts b/packages/core-internal/test/util/standardSchema.test.ts index d815258fa8..d7999b7e87 100644 --- a/packages/core-internal/test/util/standardSchema.test.ts +++ b/packages/core-internal/test/util/standardSchema.test.ts @@ -675,7 +675,7 @@ describe('zod conversion options (#2464)', () => { expect(new AjvJsonSchemaValidator().getValidator(result)({ arr: [3, 5, 4, 6], counted: 1, name: 'n' }).valid).toBe(true); }); - test('the object proof consults every composition key (allOf uses some-semantics)', () => { + test('the object proof consults every composition key (any key may prove)', () => { // The loosen rewrite relocates the DU members under allOf beside the user's // .meta({anyOf}) — a first-key-wins proof would read the user's anyOf, see // the null member, and flip the 2025-era legacy wrap. @@ -765,6 +765,63 @@ describe('zod conversion options (#2464)', () => { expect(validate({ cfg: { q: 'x' }, alias: { q: 'y' }, name: 'n' }).valid).toBe(true); }); + test('skeletons and the catch degrade keep member anchors and $defs compilable', () => { + // (1) compositionTypeSkeleton must carry member reference targets — a + // catch-of-union member's $anchor with a sibling $ref alias dangled. + const unionSchema = z.object({ + cfg: z.union([z.object({ q: z.string() }).meta({ $anchor: 'cfgA' }), z.object({ r: z.number() })]).catch({ q: 'd' }), + alias: z.unknown().meta({ $ref: '#cfgA' }), + name: z.string() + }); + const unionResult = standardSchemaToJsonSchema(unionSchema, 'output'); + const unionValidate = new AjvJsonSchemaValidator().getValidator(unionResult); + expect(unionValidate({ cfg: { q: 'x' }, alias: { q: 'y' }, name: 'n' }).valid).toBe(true); + + // (2) $defs is a pure reference CONTAINER — deleting it from a catch node + // dangled cross-subtree $refs into it. + const defsSchema = z.object({ + cfg: z + .object({ q: z.string() }) + .catch({ q: 'd' }) + .meta({ $defs: { X: { type: 'string' } } }), + alias: z.unknown().meta({ $ref: '#/properties/cfg/$defs/X' }), + name: z.string() + }); + const defsResult = standardSchemaToJsonSchema(defsSchema, 'output'); + const defsValidate = new AjvJsonSchemaValidator().getValidator(defsResult); + expect(defsValidate({ cfg: { q: 'x' }, alias: 's', name: 'n' }).valid).toBe(true); + }); + + test('date-containing intersections stay loud on the output path', () => { + // No value satisfies both an object side and a Date, and every + // date-containing intersection threw pre-#2464 — the quiet output-path date + // verdict must not flow into intersection positions. + expect(() => standardSchemaToJsonSchema(z.intersection(z.object({ a: z.string() }), z.date()), 'output')).toThrow( + /must describe objects/ + ); + // Wire-truthful date output shapes keep listing (pinned elsewhere too). + expect(Array.isArray(standardSchemaToJsonSchema(z.date().nullable(), 'output').anyOf)).toBe(true); + }); + + test('the allOf-push stamps the sound type when user conjuncts defeat the proof', () => { + // A .meta() carrying BOTH a non-all-object anyOf AND a non-object-provable + // allOf conjunct defeats every() on every composition key after the push — + // the pushed members still prove the value is an object, so the rewrite + // stamps the explicit type itself (pre-#2464 these roots were stamped via + // their emitted oneOf). + const du = z + .discriminatedUnion('t', [ + z.object({ t: z.literal('a').catch('a'), x: z.string().optional() }), + z.object({ t: z.literal('b').catch('b'), y: z.string().optional() }) + ]) + .meta({ anyOf: [{ type: 'object' }, { type: 'null' }], allOf: [{ minProperties: 1 }] }); + const result = standardSchemaToJsonSchema(du, 'output'); + + expect(result.type).toBe('object'); + expect(isNonObjectJsonSchemaRoot(result)).toBe(false); + expect(new AjvJsonSchemaValidator().getValidator(result)({ t: 'a', x: 'v' }).valid).toBe(true); + }); + test('nullable date output roots list and validate their wire forms', () => { // The date override makes date-rooted OUTPUT emissions wire-truthful (a raw // Date ships as an ISO string), so 'timestamp or null' must list — classifying From 8fdacf4185dacb3bdb82d8b6a4cf84d88007b5ab Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 16:28:20 +0000 Subject: [PATCH 25/35] fix(core-internal): first-present-key proof; pointer rewrite for moved oneOf; date-union parity; $defs keeps - Restore first-present-key-wins in isProvablyObjectShapedRoot: untouched multi-key roots (e.g. a nullable union with a user .meta({allOf})) keep their typeless root and 2025-era legacy wrap; the loosen rewrite's allOf-push stamps its own type and no longer relies on the epilogue proof. - Redirect same-document $ref/$dynamicRef JSON Pointers through segments the oneOf->anyOf rewrite relocates (rename: /oneOf/ -> /anyOf/; push: /oneOf/ -> /allOf//anyOf/), mirroring the legacy wrap's position-aware, $id-scoped pointer rewrite - a dangling alias pointer made the document uncompilable. - Treat an all-date union as a date side at output intersections: no value satisfies both conjuncts, so parity keeps them loud (direct-side and root verdicts unchanged). - Keep $defs path-addressable: the catch degrade's type skeleton carries it like the name-resolved anchors, and wrapConstraintsInAnyOf leaves it at the wrap site instead of relocating it into anyOf[0]. Co-Authored-By: Claude --- .../core-internal/src/util/standardSchema.ts | 150 +++++++++++++++--- .../test/util/standardSchema.test.ts | 124 ++++++++++++++- 2 files changed, 253 insertions(+), 21 deletions(-) diff --git a/packages/core-internal/src/util/standardSchema.ts b/packages/core-internal/src/util/standardSchema.ts index b90bf078ab..f5360f94e6 100644 --- a/packages/core-internal/src/util/standardSchema.ts +++ b/packages/core-internal/src/util/standardSchema.ts @@ -404,30 +404,55 @@ function zodConversionOptions( }; } +/** + * A JSON-Pointer prefix relocation performed by {@linkcode rewriteOneOfToAnyOf}: + * every same-document pointer that traverses `from` must be redirected to `to`. + * `from`'s ancestor segments are spelled in the coordinates that held AFTER the + * moves recorded before it, so applying an ordered move list sequentially walks a + * pointer from original coordinates to final ones. + */ +interface RelocatedPointerPrefix { + from: string; + to: string; +} + +/** RFC 6901 escaping for a single JSON-Pointer reference token. */ +function escapeJsonPointerSegment(segment: string): string { + return segment.replaceAll('~', '~0').replaceAll('/', '~1'); +} + /** * Recursively moves every `oneOf` in an emitted (already-loosened) JSON Schema tree * to `anyOf`. Used when a conversion's catch degrade or required-filter fired: the * loosened members of an untouched parent `oneOf` (e.g. a discriminated union whose * catch-wrapped discriminators were degraded) may have become mutually satisfiable, * inverting exactly-one semantics into reject-everything. + * + * Each relocation is appended to `moves` (as a JSON-Pointer prefix pair rooted at + * `path`) so {@linkcode rewriteRelocatedJsonPointers} can redirect same-document + * `$ref`/`$dynamicRef` pointers that traverse the moved segment — a user + * `.meta({$ref: '#/properties/du/oneOf/0'})` alias would otherwise dangle and fail + * schema compilation outright. */ -function rewriteOneOfToAnyOf(node: unknown, seen: Set = new Set()): void { +function rewriteOneOfToAnyOf(node: unknown, path = '', moves: RelocatedPointerPrefix[] = [], seen: Set = new Set()): void { if (typeof node !== 'object' || node === null || seen.has(node)) return; seen.add(node); if (Array.isArray(node)) { - for (const item of node) rewriteOneOfToAnyOf(item, seen); + for (const [index, item] of node.entries()) rewriteOneOfToAnyOf(item, `${path}/${index}`, moves, seen); return; } const record = node as Record; if (Array.isArray(record.oneOf)) { if (record.anyOf === undefined) { record.anyOf = record.oneOf; + moves.push({ from: `${path}/oneOf`, to: `${path}/anyOf` }); } else { // A user `.meta({anyOf})` can coexist with the emitted `oneOf` — preserve // conjunction semantics without clobbering it: keywords on one node // combine with AND, so `{anyOf: members}` under `allOf` is equivalent. const allOf = Array.isArray(record.allOf) ? record.allOf : (record.allOf = []); allOf.push({ anyOf: record.oneOf }); + moves.push({ from: `${path}/oneOf`, to: `${path}/allOf/${allOf.length - 1}/anyOf` }); // The relocated members' objectness becomes invisible to the wrap proof's // every()-member rule once user conjuncts coexist (e.g. a .meta({allOf: // [{minProperties: 1}]})) — stamp the sound explicit type here (the value @@ -452,19 +477,90 @@ function rewriteOneOfToAnyOf(node: unknown, seen: Set = new Set()): voi // sibling `maxContains` (zod never emits `contains`, so skipping is // regression-free). if (key === 'not' || key === 'if' || key === 'contains') continue; + const childPath = `${path}/${escapeJsonPointerSegment(key)}`; // Schema MAPS hold schemas under user-chosen names that may collide with // annotation keywords (a property literally named `description` still // carries a schema) — recurse into every value unconditionally. if (SCHEMA_MAP_JSON_SCHEMA_KEYWORDS.has(key) && typeof value === 'object' && value !== null && !Array.isArray(value)) { if (seen.has(value)) continue; seen.add(value); - for (const subschema of Object.values(value as Record)) rewriteOneOfToAnyOf(subschema, seen); + for (const [name, subschema] of Object.entries(value as Record)) { + rewriteOneOfToAnyOf(subschema, `${childPath}/${escapeJsonPointerSegment(name)}`, moves, seen); + } + continue; + } + rewriteOneOfToAnyOf(value, childPath, moves, seen); + } +} + +/** + * Redirects same-document `$ref`/`$dynamicRef` JSON Pointers through the prefix + * relocations {@linkcode rewriteOneOfToAnyOf} performed, mirroring the legacy-wrap + * envelope's pointer rewrite (`wire/rev2025-11-25/legacyWrap.ts`): a pointer that + * traverses a moved `oneOf` segment (`#/…/oneOf//…`) is rewritten to the + * segment's new home (`#/…/anyOf//…`, or `#/…/allOf//anyOf//…` on the + * allOf-push branch) so it keeps resolving to the same subschema. Position-aware + * like the rename walk itself: only schema-carrying keywords are descended into + * (data-valued `const`/`enum`/`default`/`examples` and annotations are opaque), + * schema-map VALUES are always descended into while their keys stay names, and — + * unlike the rename walk — `not`/`if`/`contains` subtrees ARE descended into + * (redirecting a pointer is resolution-preserving, never a polarity-sensitive + * loosening). Subtrees establishing a new `$id` base are skipped: their + * same-document pointers resolve against the embedded base, not the document root + * the move prefixes are rooted at. Cross-document refs (not starting with `#/`) + * are left untouched. + */ +function rewriteRelocatedJsonPointers(node: unknown, moves: readonly RelocatedPointerPrefix[], seen: Set = new Set()): void { + if (typeof node !== 'object' || node === null || seen.has(node)) return; + seen.add(node); + if (Array.isArray(node)) { + for (const item of node) rewriteRelocatedJsonPointers(item, moves, seen); + return; + } + const record = node as Record; + // Base-establishing `$id` guards sit at the recursion sites below, not here: a + // ROOT `$id` names the document itself, so `#/…` pointers under it still + // address these very coordinates and must be rewritten, while NESTED bases own + // their subtrees' resolution and are never descended into. + for (const refKey of ['$ref', '$dynamicRef'] as const) { + const value = record[refKey]; + if (typeof value !== 'string' || !value.startsWith('#/')) continue; + let pointer = value.slice(1); + for (const move of moves) { + if (pointer === move.from || pointer.startsWith(`${move.from}/`)) { + pointer = move.to + pointer.slice(move.from.length); + } + } + record[refKey] = `#${pointer}`; + } + for (const [key, value] of Object.entries(record)) { + if (!SCHEMA_CARRYING_JSON_SCHEMA_KEYWORDS.has(key)) continue; + if (SCHEMA_MAP_JSON_SCHEMA_KEYWORDS.has(key) && typeof value === 'object' && value !== null && !Array.isArray(value)) { + if (seen.has(value)) continue; + seen.add(value); + for (const subschema of Object.values(value as Record)) { + if (subtreeEstablishesNewIdBase(subschema)) continue; + rewriteRelocatedJsonPointers(subschema, moves, seen); + } continue; } - rewriteOneOfToAnyOf(value, seen); + if (subtreeEstablishesNewIdBase(value)) continue; + rewriteRelocatedJsonPointers(value, moves, seen); } } +/** + * Whether a subtree's `$id` establishes a new RFC 3986 resolution base (mirrors + * the legacy wrap's guard): same-document pointers inside such a subtree resolve + * against the embedded base, so the document-rooted pointer rewrite must not + * touch them. Arrays are containers, not schemas — they never carry `$id`. + */ +function subtreeEstablishesNewIdBase(node: unknown): boolean { + if (typeof node !== 'object' || node === null || Array.isArray(node)) return false; + const id = (node as Record).$id; + return id !== undefined && !(typeof id === 'string' && id.startsWith('#')); +} + /** * Moves a node's constraint keywords (schema-carrying + enforced) into `anyOf[0]`, * adding `alternative` as `anyOf[1]`, while leaving annotation-opaque keys (and @@ -475,6 +571,10 @@ function rewriteOneOfToAnyOf(node: unknown, seen: Set = new Set()): voi function wrapConstraintsInAnyOf(node: Record, alternative: Record): void { const constrained: Record = {}; for (const key of Object.keys(node)) { + // `$defs` constrains nothing and is PATH-addressed: inbound `#/…/$defs/X` + // pointers spell out its exact position, so unlike the name-resolved + // anchors (which relocate safely inside `anyOf[0]`) it must stay put. + if (key === '$defs') continue; if (SCHEMA_CARRYING_JSON_SCHEMA_KEYWORDS.has(key) || ENFORCED_JSON_SCHEMA_KEYWORDS.has(key)) { constrained[key] = node[key]; delete node[key]; @@ -495,8 +595,11 @@ function compositionTypeSkeleton(node: unknown): Record { const skeleton: Record = {}; // Only `type: 'object'` feeds the proof; any other type is an unenforced constraint. if (source.type === 'object') skeleton.type = 'object'; - // Reference TARGETS constrain nothing — dropping them would dangle inbound $refs. - for (const referenceKey of ['$anchor', '$dynamicAnchor', '$id'] as const) { + // Reference TARGETS constrain nothing — dropping them would dangle inbound + // $refs. `$defs` rides along verbatim: its entries are path-addressed + // (`#/…/$defs/X`), so unlike the name-resolved anchors they must stay at the + // exact position inbound pointers spell out. + for (const referenceKey of ['$anchor', '$dynamicAnchor', '$id', '$defs'] as const) { if (source[referenceKey] !== undefined) skeleton[referenceKey] = source[referenceKey]; } for (const key of ['anyOf', 'oneOf', 'allOf'] as const) { @@ -768,8 +871,13 @@ export function standardSchemaToJsonSchema( // satisfiable — e.g. catch-wrapped discriminators — and Ajv would reject // every payload with "must match exactly one schema in oneOf". Rewrite to // the honest `anyOf` (loosen-only and wrap-neutral: - // `isProvablyObjectShapedRoot` treats the composition keywords identically). - rewriteOneOfToAnyOf(result); + // `isProvablyObjectShapedRoot` treats the composition keywords identically), + // then redirect any same-document pointers that traversed a moved segment + // so they keep resolving (a dangling `$ref` is uncompilable — worse than + // any loosening). + const moves: RelocatedPointerPrefix[] = []; + rewriteOneOfToAnyOf(result, '', moves); + if (moves.length > 0) rewriteRelocatedJsonPointers(result, moves); } if (io === 'output') { // SEP-2106: outputSchema may have any JSON Schema root. An explicit `type` (object or @@ -961,7 +1069,13 @@ function nonObjectTypelessRootVerdict( if (def.type === 'union' && Array.isArray(def.options) && def.options.length > 0) { const verdicts = def.options.map(option => nonObjectTypelessRootVerdict(option, io, path)); if (verdicts.includes(undefined)) return undefined; - return { type: 'union', loud: verdicts.some(verdict => verdict?.loud === true) }; + const loud = verdicts.some(verdict => verdict?.loud === true); + // An all-date union IS a date side: `z.union([z.date(), z.date()])` admits + // only Dates, so the intersection branch's date-parity rule below must see + // it (pre-#2464 the conversion threw on the date either way). A root or + // union-member position reads only `loud`, so the type is inert there. + if (verdicts.every(verdict => verdict?.type === 'date')) return { type: 'date', loud }; + return { type: 'union', loud }; } if (def.type === 'intersection' && def.left !== undefined && def.right !== undefined) { const left = nonObjectTypelessRootVerdict(def.left, io, path); @@ -1099,18 +1213,18 @@ function isProvablyObjectShapedRoot(schema: Record): boolean { member !== null && typeof member === 'object' && ((member as Record).type === 'object' || isProvablyObjectShapedRoot(member as Record)); - // Keywords on one node AND-combine, so ANY present composition key proving - // objectness suffices (a first-key-wins rule breaks when the loosen rewrite - // relocates all-object members under `allOf` beside a user `.meta({anyOf})`). - // Every key uses EVERY-member semantics, preserving the pre-#2464 stamp/wrap - // decision for untouched emissions (e.g. `z.intersection(z.object(...), z.any())` - // stays typeless and 2025-era-wrapped); the loosen rewrite's relocated - // `{anyOf: members}` conjunct is itself provably object-shaped, so its proof - // survives the stricter rule. + // FIRST-PRESENT-KEY-WINS with EVERY-member semantics, matching the pre-#2464 + // stamp/wrap decision for untouched emissions: a multi-key root whose first + // present composition key has a non-object member (e.g. an anyOf-emitting + // nullable union carrying a user `.meta({allOf: [{type: 'object'}]})`) stayed + // typeless and 2025-era-wrapped on main, so a later key must not prove what + // the first cannot. The loosen rewrite's allOf-push does not rely on this + // proof seeing its relocated conjunct: it stamps `type: 'object'` itself, and + // its internal proof argument carries only `anyOf`. for (const key of ['oneOf', 'anyOf', 'allOf'] as const) { const members = schema[key]; if (!Array.isArray(members) || members.length === 0) continue; - if (members.every(member => isObjectMember(member))) return true; + return members.every(member => isObjectMember(member)); } return false; } diff --git a/packages/core-internal/test/util/standardSchema.test.ts b/packages/core-internal/test/util/standardSchema.test.ts index d7999b7e87..3a10892408 100644 --- a/packages/core-internal/test/util/standardSchema.test.ts +++ b/packages/core-internal/test/util/standardSchema.test.ts @@ -675,10 +675,101 @@ describe('zod conversion options (#2464)', () => { expect(new AjvJsonSchemaValidator().getValidator(result)({ arr: [3, 5, 4, 6], counted: 1, name: 'n' }).valid).toBe(true); }); - test('the object proof consults every composition key (any key may prove)', () => { + test('the oneOf rewrite redirects pointers through the renamed segment', () => { + // The members moved to anyOf — a pointer still spelling oneOf would DANGLE, + // making the whole document uncompilable (callTool fails before sending). + const du = z.discriminatedUnion('t', [ + z.object({ t: z.literal('a').catch('a'), x: z.string().optional() }), + z.object({ t: z.literal('b').catch('b'), y: z.string().optional() }) + ]); + const schema = z.object({ + du, + alias: z.unknown().meta({ $ref: '#/properties/du/oneOf/0' }), + dynAlias: z.unknown().meta({ $dynamicRef: '#/properties/du/oneOf/1' }), + name: z.string() + }); + const result = standardSchemaToJsonSchema(schema, 'output'); + + const properties = result.properties as Record>; + expect(properties.du!.oneOf).toBeUndefined(); + expect(properties.alias!.$ref).toBe('#/properties/du/anyOf/0'); + expect(properties.dynAlias!.$dynamicRef).toBe('#/properties/du/anyOf/1'); + expect(new AjvJsonSchemaValidator().getValidator(result)({ du: { t: 'a' }, name: 'n' }).valid).toBe(true); + }); + + test('the oneOf rewrite redirects pointers into the allOf-pushed segment', () => { + // On the push branch the members land under `allOf//anyOf`, not `anyOf`. + const du = z + .discriminatedUnion('t', [ + z.object({ t: z.literal('a').catch('a'), x: z.string().optional() }), + z.object({ t: z.literal('b').catch('b'), y: z.string().optional() }) + ]) + .meta({ anyOf: [{ type: 'object' }] }); + const schema = z.object({ + du, + alias: z.unknown().meta({ $ref: '#/properties/du/oneOf/1' }), + name: z.string() + }); + const result = standardSchemaToJsonSchema(schema, 'output'); + + expect((result.properties as Record>).alias!.$ref).toBe('#/properties/du/allOf/0/anyOf/1'); + expect(new AjvJsonSchemaValidator().getValidator(result)({ du: { t: 'b' }, name: 'n' }).valid).toBe(true); + }); + + test('pointers through an untouched oneOf stay put (no-loosening control)', () => { + const du = z.discriminatedUnion('t', [z.object({ t: z.literal('a') }), z.object({ t: z.literal('b') })]); + const schema = z.object({ + du, + alias: z.string().optional().meta({ $ref: '#/properties/du/oneOf/0' }), + name: z.string() + }); + const result = standardSchemaToJsonSchema(schema, 'output'); + + const properties = result.properties as Record>; + expect(properties.du!.oneOf).toBeDefined(); + expect(properties.alias!.$ref).toBe('#/properties/du/oneOf/0'); + expect(new AjvJsonSchemaValidator().getValidator(result)({ du: { t: 'a' }, name: 'n' }).valid).toBe(true); + }); + + test('a degraded member $defs survives the skeleton at its pointer-addressed spot', () => { + // `$defs` entries are PATH-addressed (`#/…/$defs/X`) — the catch degrade's + // type skeleton must carry them (like the name-resolved anchors) or inbound + // pointers dangle and the document stops compiling. + const schema = z.object({ + cfg: z + .union([z.object({ v: z.string() }).meta({ $defs: { X: { type: 'string' } } }), z.object({ w: z.number() })]) + .catch({ v: 'd' }), + alias: z.unknown().meta({ $ref: '#/properties/cfg/anyOf/0/$defs/X' }), + name: z.string() + }); + const result = standardSchemaToJsonSchema(schema, 'output'); + + const cfg = (result.properties as Record>).cfg!; + expect((cfg.anyOf as Array>)[0]!.$defs).toEqual({ X: { type: 'string' } }); + expect(new AjvJsonSchemaValidator().getValidator(result)({ cfg: { v: 'd' }, name: 'n' }).valid).toBe(true); + }); + + test('the constraint wrap leaves $defs at the wrap site', () => { + // wrapConstraintsInAnyOf moves constraints into anyOf[0]; `$defs` is + // path-addressed, so relocating it (unlike the name-resolved anchors, which + // stay resolvable from anywhere) dangles inbound `#/…/$defs/X` pointers. + const schema = z.object({ + f: z.file().meta({ $defs: { X: { type: 'string' } } }), + alias: z.unknown().meta({ $ref: '#/properties/f/$defs/X' }), + name: z.string() + }); + const result = standardSchemaToJsonSchema(schema, 'output'); + + const f = (result.properties as Record>).f!; + expect(f.$defs).toEqual({ X: { type: 'string' } }); + expect(new AjvJsonSchemaValidator().getValidator(result)({ f: {}, name: 'n' }).valid).toBe(true); + }); + + test('relocated members keep their stamp beside a null-membered user anyOf', () => { // The loosen rewrite relocates the DU members under allOf beside the user's - // .meta({anyOf}) — a first-key-wins proof would read the user's anyOf, see - // the null member, and flip the 2025-era legacy wrap. + // .meta({anyOf}) and stamps `type: 'object'` itself — the epilogue proof + // reads the user's anyOf first (first-present-key-wins), sees the null + // member, and could not prove the root on its own. const du = z .discriminatedUnion('t', [ z.object({ t: z.literal('a').catch('a'), x: z.string().optional() }), @@ -691,6 +782,19 @@ describe('zod conversion options (#2464)', () => { expect(isNonObjectJsonSchemaRoot(result)).toBe(false); }); + test('untouched multi-key composition roots keep the typeless root (first-present-key-wins)', () => { + // No loosening fires here, so the emission reaches the epilogue proof + // untouched: its first present composition key (the union's anyOf) has a + // null member, so main never stamped — an any-key-may-prove rule reading + // the user's allOf instead would flip the 2025-era legacy wrap for a + // WORKING pre-#2464 registration. + const schema = z.union([z.object({ a: z.string() }), z.null()]).meta({ allOf: [{ type: 'object' }] }); + const result = standardSchemaToJsonSchema(schema, 'output'); + + expect(result.type).toBeUndefined(); + expect(isNonObjectJsonSchemaRoot(result)).toBe(true); + }); + test('bigint-valued literal output roots throw across spellings', () => { // These emit an explicit {type: 'number', const: …} under // unrepresentable: 'any' (bypassing the typeless guard), yet no result can @@ -721,6 +825,20 @@ describe('zod conversion options (#2464)', () => { expect(isNonObjectJsonSchemaRoot(anyConjunct)).toBe(true); }); + test('an all-date union side keeps date intersections loud on output', () => { + // `z.union([z.date(), z.date()])` admits only Dates — no value satisfies + // both it and the object side, and pre-#2464 the conversion threw on the + // date. The union spelling must not slip past the direct-side parity check. + expect(() => + standardSchemaToJsonSchema(z.intersection(z.object({ a: z.string() }), z.union([z.date(), z.date()])), 'output') + ).toThrow(/must describe objects/); + // A date union ROOT stays quiet — the date override makes those emissions + // wire-truthful, so 'timestamp or one of these' style tools keep listing. + const root = standardSchemaToJsonSchema(z.union([z.date(), z.date()]), 'output'); + expect(root.type).toBeUndefined(); + expect(isNonObjectJsonSchemaRoot(root)).toBe(true); + }); + test('piped and undefined-filtered loud literal output roots throw', () => { // A bigint literal on a pipe's OUT side emits {type: 'number', const: 1}, // bypassing the typeless guard — pre-#2464 the conversion threw. From 8494566d89e75cc8da9ce4066a55688d2cf53365 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 17:04:21 +0000 Subject: [PATCH 26/35] fix(core-internal): override-time pointer integrity; dangling-ref neutralization; mixed date-union parity - Defer the catch degrade's (and the skeleton's) oneOf->anyOf rename to the epilogue's rewriteOneOfToAnyOf: the override hook has no document path, so renames performed there could not record the pointer move that keeps inbound $ref aliases resolvable. Members skeletonize under their emitted key; the epilogue - which always runs once the degrade set loosened - renames them at a known path with move bookkeeping. - Record the array/tuple null-tolerance wraps' relocations (items -> items/anyOf/0, prefixItems/ equivalents) via the override's ctx.path so the existing redirect pass covers them; on zod versions without ctx.path the wrap still ships and dangling pointers fall through to neutralization. - Neutralize same-document pointers left unresolvable by the degrade's constraint deletes (no move target exists for a deleted subtree): loosen-only removal of the reference keyword, scoped to loosened conversions, run after the redirect pass so relocated-but-surviving targets stay referenced. - Extend the union date-side rule to mixed quiet members: a union whose every member is a date or a quiet never/file/non-finite-literal verdict admits at most Dates, so output intersections with it stay loud (a date-free union of nevers keeps listing). Co-Authored-By: Claude --- .../core-internal/src/util/standardSchema.ts | 217 +++++++++++++----- .../test/util/standardSchema.test.ts | 93 ++++++++ 2 files changed, 257 insertions(+), 53 deletions(-) diff --git a/packages/core-internal/src/util/standardSchema.ts b/packages/core-internal/src/util/standardSchema.ts index f5360f94e6..c32b61cb6f 100644 --- a/packages/core-internal/src/util/standardSchema.ts +++ b/packages/core-internal/src/util/standardSchema.ts @@ -247,7 +247,8 @@ export const JSON_SCHEMA_CONVERSION_TARGET = 'draft-2020-12'; */ function zodConversionOptions( io: 'input' | 'output', - loosened: { value: boolean } + loosened: { value: boolean }, + moves: RelocatedPointerPrefix[] ): Pick { return { unrepresentable: 'any', @@ -290,14 +291,15 @@ function zodConversionOptions( for (const key of Object.keys(ctx.jsonSchema)) { if (key === 'type' && ctx.jsonSchema.type === 'object') continue; if ((key === 'anyOf' || key === 'oneOf' || key === 'allOf') && Array.isArray(ctx.jsonSchema[key])) { - const skeletons = (ctx.jsonSchema[key] as unknown[]).map(member => compositionTypeSkeleton(member)); - // `oneOf` means EXACTLY one: with member constraints stripped, the - // skeletons are indistinguishable and every payload would match all - // of them — advertise the honest loosening `anyOf` instead - // (wrap-neutral: `isProvablyObjectShapedRoot` treats the - // composition keywords identically). - if (key === 'oneOf') delete ctx.jsonSchema[key]; - ctx.jsonSchema[key === 'oneOf' ? 'anyOf' : key] = skeletons; + // Members reduce to skeletons UNDER THEIR EMITTED KEY. An + // exactly-one `oneOf` of indistinguishable skeletons would + // reject every payload, but the rename to the honest `anyOf` + // is DEFERRED to the epilogue's `rewriteOneOfToAnyOf` — it + // always runs once the degrade set `loosened`, and unlike + // this override hook it knows the node's document path, so + // the rename records the pointer move that keeps inbound + // `$ref: '#/…/oneOf/'` aliases resolvable. + ctx.jsonSchema[key] = (ctx.jsonSchema[key] as unknown[]).map(member => compositionTypeSkeleton(member)); continue; } // Reference TARGETS and containers constrain no instance value — @@ -326,16 +328,20 @@ function zodConversionOptions( // JSON.stringify turns an undefined array ELEMENT into `null` (unlike an // undefined-valued object key, which it drops), so a tolerant element // (`.default()`/`.prefault()`, …) may ship as null — the advertised item - // subschema must accept it. + // subschema must accept it. The wrap relocates the element subschema to + // `items/anyOf/0`, so the move is recorded (via `ctx.path`) for the + // epilogue's pointer redirect — an inbound `$ref: '#/…/items/…'` alias + // would otherwise dangle. const items = ctx.jsonSchema.items; if (typeof items === 'object' && items !== null && !Array.isArray(items) && hasStructuralMissingKeyTolerance(def.element)) { loosened.value = true; ctx.jsonSchema.items = { anyOf: [items, { type: 'null' }] }; + recordNullToleranceWrapMove(moves, ctx.path, 'items'); } return; } if (def.type === 'tuple') { - // Same wire mechanism per prefix position. + // Same wire mechanism (and same recorded move) per prefix position. const prefixItems = ctx.jsonSchema.prefixItems; if (Array.isArray(def.items) && Array.isArray(prefixItems)) { for (const [index, item] of def.items.entries()) { @@ -343,6 +349,7 @@ function zodConversionOptions( if (typeof emitted === 'object' && emitted !== null && hasStructuralMissingKeyTolerance(item)) { loosened.value = true; prefixItems[index] = { anyOf: [emitted, { type: 'null' }] }; + recordNullToleranceWrapMove(moves, ctx.path, 'prefixItems', index); } } } @@ -358,6 +365,7 @@ function zodConversionOptions( ) { loosened.value = true; ctx.jsonSchema.items = { anyOf: [restItems, { type: 'null' }] }; + recordNullToleranceWrapMove(moves, ctx.path, 'items'); } return; } @@ -404,6 +412,27 @@ function zodConversionOptions( }; } +/** + * Records the pointer move of an override-time null-tolerance wrap: the wrapped + * element subschema relocated from `/` to `//anyOf/0`. + * `path` is zod's emission path for the node being overridden (`ctx.path`, raw + * segments — RFC 6901-escaped here). On zod versions that predate `ctx.path` the + * move is skipped: the wrap itself still ships, and a pointer left dangling by it + * is neutralized (loosen-only) by the epilogue's last-resort pass instead of + * redirected. The same fallback covers reused zod instances — the override runs + * once per instance, so only the first emission position's copy gets a recorded + * move — and registry-referenced nodes, whose emitted home is `#/$defs/` + * while `ctx.path` names the referencing position (a prefix that matches no + * resolvable pointer, so the stray move can redirect nothing wrongly). + */ +function recordNullToleranceWrapMove(moves: RelocatedPointerPrefix[], path: unknown, ...slot: ReadonlyArray): void { + if (!Array.isArray(path)) return; + const from = [...(path as ReadonlyArray), ...slot] + .map(segment => `/${escapeJsonPointerSegment(String(segment))}`) + .join(''); + moves.push({ from, to: `${from}/anyOf/0` }); +} + /** * A JSON-Pointer prefix relocation performed by {@linkcode rewriteOneOfToAnyOf}: * every same-document pointer that traverses `from` must be redirected to `to`. @@ -426,7 +455,9 @@ function escapeJsonPointerSegment(segment: string): string { * to `anyOf`. Used when a conversion's catch degrade or required-filter fired: the * loosened members of an untouched parent `oneOf` (e.g. a discriminated union whose * catch-wrapped discriminators were degraded) may have become mutually satisfiable, - * inverting exactly-one semantics into reject-everything. + * inverting exactly-one semantics into reject-everything. Also performs the + * oneOf→anyOf renames the catch degrade deferred: its override hook cannot place + * the rename in the document, while this walk knows every node's path. * * Each relocation is appended to `moves` (as a JSON-Pointer prefix pair rooted at * `path`) so {@linkcode rewriteRelocatedJsonPointers} can redirect same-document @@ -497,42 +528,93 @@ function rewriteOneOfToAnyOf(node: unknown, path = '', moves: RelocatedPointerPr * Redirects same-document `$ref`/`$dynamicRef` JSON Pointers through the prefix * relocations {@linkcode rewriteOneOfToAnyOf} performed, mirroring the legacy-wrap * envelope's pointer rewrite (`wire/rev2025-11-25/legacyWrap.ts`): a pointer that - * traverses a moved `oneOf` segment (`#/…/oneOf//…`) is rewritten to the - * segment's new home (`#/…/anyOf//…`, or `#/…/allOf//anyOf//…` on the - * allOf-push branch) so it keeps resolving to the same subschema. Position-aware - * like the rename walk itself: only schema-carrying keywords are descended into - * (data-valued `const`/`enum`/`default`/`examples` and annotations are opaque), - * schema-map VALUES are always descended into while their keys stay names, and — - * unlike the rename walk — `not`/`if`/`contains` subtrees ARE descended into - * (redirecting a pointer is resolution-preserving, never a polarity-sensitive - * loosening). Subtrees establishing a new `$id` base are skipped: their - * same-document pointers resolve against the embedded base, not the document root - * the move prefixes are rooted at. Cross-document refs (not starting with `#/`) - * are left untouched. + * traverses a moved segment — a renamed/pushed `oneOf` (`#/…/oneOf//…` → + * `#/…/anyOf//…` or `#/…/allOf//anyOf//…`) or a null-tolerance-wrapped + * element (`#/…/items/…` → `#/…/items/anyOf/0/…`) — is rewritten to the segment's + * new home so it keeps resolving to the same subschema. Cross-document refs (not + * starting with `#/`) are left untouched; see + * {@linkcode visitLocalRefBearingNodes} for the walk's position/`$id` rules. + */ +function rewriteRelocatedJsonPointers(root: unknown, moves: readonly RelocatedPointerPrefix[]): void { + visitLocalRefBearingNodes(root, record => { + for (const refKey of ['$ref', '$dynamicRef'] as const) { + const value = record[refKey]; + if (typeof value !== 'string' || !value.startsWith('#/')) continue; + let pointer = value.slice(1); + for (const move of moves) { + if (pointer === move.from || pointer.startsWith(`${move.from}/`)) { + pointer = move.to + pointer.slice(move.from.length); + } + } + record[refKey] = `#${pointer}`; + } + }); +} + +/** + * Last-resort pointer integrity for LOOSENED conversions: any same-document + * `$ref`/`$dynamicRef` JSON Pointer that no longer resolves — its target subtree + * was deleted by the catch degrade's constraint drop or the skeleton's member + * reduction, where nothing survives for a move to redirect to — is neutralized by + * deleting the reference keyword. Loosen-only (an absent `$ref` constrains + * nothing) and strictly better than shipping it: a dangling pointer makes the + * whole advertisement uncompilable, so the tool would list but every `callTool` + * would fail client-side before the request is sent. Anchor-form fragments + * (`#name`) are name-resolved and cannot dangle by position; cross-document refs + * cannot be verified locally — both are left untouched. Runs after + * {@linkcode rewriteRelocatedJsonPointers} so pointers that were redirected to a + * surviving location are recognized as resolvable and kept. + */ +function neutralizeDanglingLocalRefs(root: Record): void { + visitLocalRefBearingNodes(root, record => { + for (const refKey of ['$ref', '$dynamicRef'] as const) { + const value = record[refKey]; + if (typeof value !== 'string' || !value.startsWith('#/')) continue; + if (!localJsonPointerResolves(root, value.slice(1))) delete record[refKey]; + } + }); +} + +/** Whether an RFC 6901 pointer (leading `/…`, tokens still escaped) resolves in `root`. */ +function localJsonPointerResolves(root: unknown, pointer: string): boolean { + let node: unknown = root; + for (const token of pointer.split('/').slice(1)) { + const segment = token.replaceAll('~1', '/').replaceAll('~0', '~'); + if (Array.isArray(node)) { + const index = Number(segment); + if (!Number.isInteger(index) || String(index) !== segment || index < 0 || index >= node.length) return false; + node = node[index]; + } else if (typeof node === 'object' && node !== null && Object.hasOwn(node, segment)) { + node = (node as Record)[segment]; + } else { + return false; + } + } + return true; +} + +/** + * Position-aware walk over every schema node whose same-document references are + * resolvable against the DOCUMENT ROOT, invoking `visit` once per node. Only + * schema-carrying keywords are descended into (data-valued + * `const`/`enum`/`default`/`examples` and annotations are opaque); schema-map + * VALUES are always descended into while their keys stay names; and — + * unlike the oneOf rename walk — `not`/`if`/`contains` subtrees ARE descended + * into (pointer fixups are resolution-preserving, never polarity-sensitive + * loosenings). Subtrees establishing a new `$id` base are skipped: their + * same-document pointers resolve against the embedded base, not the document + * root. A ROOT `$id` names the document itself, so the root node is always + * visited. */ -function rewriteRelocatedJsonPointers(node: unknown, moves: readonly RelocatedPointerPrefix[], seen: Set = new Set()): void { +function visitLocalRefBearingNodes(node: unknown, visit: (record: Record) => void, seen: Set = new Set()): void { if (typeof node !== 'object' || node === null || seen.has(node)) return; seen.add(node); if (Array.isArray(node)) { - for (const item of node) rewriteRelocatedJsonPointers(item, moves, seen); + for (const item of node) visitLocalRefBearingNodes(item, visit, seen); return; } const record = node as Record; - // Base-establishing `$id` guards sit at the recursion sites below, not here: a - // ROOT `$id` names the document itself, so `#/…` pointers under it still - // address these very coordinates and must be rewritten, while NESTED bases own - // their subtrees' resolution and are never descended into. - for (const refKey of ['$ref', '$dynamicRef'] as const) { - const value = record[refKey]; - if (typeof value !== 'string' || !value.startsWith('#/')) continue; - let pointer = value.slice(1); - for (const move of moves) { - if (pointer === move.from || pointer.startsWith(`${move.from}/`)) { - pointer = move.to + pointer.slice(move.from.length); - } - } - record[refKey] = `#${pointer}`; - } + visit(record); for (const [key, value] of Object.entries(record)) { if (!SCHEMA_CARRYING_JSON_SCHEMA_KEYWORDS.has(key)) continue; if (SCHEMA_MAP_JSON_SCHEMA_KEYWORDS.has(key) && typeof value === 'object' && value !== null && !Array.isArray(value)) { @@ -540,12 +622,12 @@ function rewriteRelocatedJsonPointers(node: unknown, moves: readonly RelocatedPo seen.add(value); for (const subschema of Object.values(value as Record)) { if (subtreeEstablishesNewIdBase(subschema)) continue; - rewriteRelocatedJsonPointers(subschema, moves, seen); + visitLocalRefBearingNodes(subschema, visit, seen); } continue; } if (subtreeEstablishesNewIdBase(value)) continue; - rewriteRelocatedJsonPointers(value, moves, seen); + visitLocalRefBearingNodes(value, visit, seen); } } @@ -604,9 +686,13 @@ function compositionTypeSkeleton(node: unknown): Record { } for (const key of ['anyOf', 'oneOf', 'allOf'] as const) { if (Array.isArray(source[key])) { - // Skeletonized `oneOf` members are indistinguishable, so exactly-one - // semantics would reject every payload — emit `anyOf` instead. - skeleton[key === 'oneOf' ? 'anyOf' : key] = (source[key] as unknown[]).map(member => compositionTypeSkeleton(member)); + // Kept under the emitted key — including `oneOf`, whose skeletonized + // members are indistinguishable and would reject every payload under + // exactly-one semantics. The oneOf→anyOf rename is deferred to the + // epilogue's `rewriteOneOfToAnyOf` (the degrade that skeletonizes + // always sets `loosened`), which performs it at a known document path + // WITH pointer-move bookkeeping. + skeleton[key] = (source[key] as unknown[]).map(member => compositionTypeSkeleton(member)); } } return skeleton; @@ -827,7 +913,12 @@ export function standardSchemaToJsonSchema( ): Record { const std = schema['~standard']; const loosened = { value: false }; - const zodOptions = options?.unrepresentable === 'throw' ? undefined : zodConversionOptions(io, loosened); + // Pointer-prefix relocations, in chronological order: the override's + // null-tolerance wraps record theirs during emission (via `ctx.path`), the + // epilogue's oneOf rewrite appends its renames — sequential application then + // walks every inbound pointer from its authored coordinates to the final ones. + const moves: RelocatedPointerPrefix[] = []; + const zodOptions = options?.unrepresentable === 'throw' ? undefined : zodConversionOptions(io, loosened, moves); let result: Record; if (std.jsonSchema) { result = std.jsonSchema[io]({ @@ -875,9 +966,15 @@ export function standardSchemaToJsonSchema( // then redirect any same-document pointers that traversed a moved segment // so they keep resolving (a dangling `$ref` is uncompilable — worse than // any loosening). - const moves: RelocatedPointerPrefix[] = []; rewriteOneOfToAnyOf(result, '', moves); if (moves.length > 0) rewriteRelocatedJsonPointers(result, moves); + // Targets the degrade DELETED (a catch node's `properties`, a skeleton + // member's constraints) have no new home for a move to redirect to — + // neutralize any pointer left dangling so the advertisement stays + // compilable. Scoped to loosened conversions: an untouched emission's + // dangling ref (a user typo) failed compile identically pre-#2464 and is + // not this rewrite's to repair. + neutralizeDanglingLocalRefs(result); } if (io === 'output') { // SEP-2106: outputSchema may have any JSON Schema root. An explicit `type` (object or @@ -1070,11 +1167,25 @@ function nonObjectTypelessRootVerdict( const verdicts = def.options.map(option => nonObjectTypelessRootVerdict(option, io, path)); if (verdicts.includes(undefined)) return undefined; const loud = verdicts.some(verdict => verdict?.loud === true); - // An all-date union IS a date side: `z.union([z.date(), z.date()])` admits - // only Dates, so the intersection branch's date-parity rule below must see - // it (pre-#2464 the conversion threw on the date either way). A root or - // union-member position reads only `loud`, so the type is inert there. - if (verdicts.every(verdict => verdict?.type === 'date')) return { type: 'date', loud }; + // A union that admits AT MOST Dates is a date side: every member is a date + // or a quiet kind that adds no JSON-satisfiable value (`never` matches + // nothing; a File or a non-finite/symbol literal is never a JSON object), + // so the intersection branch's date-parity rule below must see it — mixed + // spellings like `z.union([z.date(), z.never()])` threw on the date + // pre-#2464 exactly like the all-date union. At least one member must BE a + // date (a date-free union of nevers listed pre-#2464 and must keep doing + // so). A root or union-member position reads only `loud`, so the type is + // inert there. + if ( + verdicts.some(verdict => verdict?.type === 'date') && + verdicts.every( + verdict => + verdict?.type === 'date' || + (verdict?.loud === false && (verdict.type === 'never' || verdict.type === 'file' || verdict.type === 'literal')) + ) + ) { + return { type: 'date', loud }; + } return { type: 'union', loud }; } if (def.type === 'intersection' && def.left !== undefined && def.right !== undefined) { diff --git a/packages/core-internal/test/util/standardSchema.test.ts b/packages/core-internal/test/util/standardSchema.test.ts index 3a10892408..a03e00a7e0 100644 --- a/packages/core-internal/test/util/standardSchema.test.ts +++ b/packages/core-internal/test/util/standardSchema.test.ts @@ -731,6 +731,82 @@ describe('zod conversion options (#2464)', () => { expect(new AjvJsonSchemaValidator().getValidator(result)({ du: { t: 'a' }, name: 'n' }).valid).toBe(true); }); + test('pointers through a catch-degraded oneOf follow the deferred rename', () => { + // The catch degrade skeletonizes composition members at override time, + // where no document path exists — the oneOf→anyOf rename is deferred to + // the epilogue so the pointer move is recorded, both for the catch node's + // own oneOf and for a skeletonized member's nested one. + const schema = z.object({ + cfg: z + .discriminatedUnion('t', [z.object({ t: z.literal('a'), x: z.string() }), z.object({ t: z.literal('b'), y: z.string() })]) + .catch({ t: 'a', x: 'd' }), + nested: z + .union([ + z.discriminatedUnion('t', [z.object({ t: z.literal('a') }), z.object({ t: z.literal('b') })]), + z.object({ w: z.number() }) + ]) + .catch({ w: 1 }), + alias: z.unknown().meta({ $ref: '#/properties/cfg/oneOf/0' }), + nestedAlias: z.unknown().meta({ $ref: '#/properties/nested/anyOf/0/oneOf/1' }), + name: z.string() + }); + const result = standardSchemaToJsonSchema(schema, 'output'); + + const properties = result.properties as Record>; + expect(properties.cfg!.oneOf).toBeUndefined(); + expect(properties.alias!.$ref).toBe('#/properties/cfg/anyOf/0'); + expect(properties.nestedAlias!.$ref).toBe('#/properties/nested/anyOf/0/anyOf/1'); + expect(new AjvJsonSchemaValidator().getValidator(result)({ cfg: { t: 'a' }, nested: { w: 1 }, name: 'n' }).valid).toBe(true); + }); + + test('pointers into wrapped array and tuple element subschemas follow the wrap', () => { + // The null-tolerance wrap relocates the element subschema to + // `…/items/anyOf/0` (and the prefixItems/rest equivalents) — the move is + // recorded via the override's `ctx.path` so inbound aliases keep resolving. + const schema = z.object({ + arr: z.array(z.object({ x: z.string() }).optional()), + t: z.tuple([z.object({ x: z.string() }).optional(), z.string()], z.object({ y: z.number() }).optional()), + itemsAlias: z.unknown().meta({ $ref: '#/properties/arr/items/properties/x' }), + prefixAlias: z.unknown().meta({ $ref: '#/properties/t/prefixItems/0/properties/x' }), + restAlias: z.unknown().meta({ $ref: '#/properties/t/items/properties/y' }), + name: z.string() + }); + const result = standardSchemaToJsonSchema(schema, 'output'); + + const properties = result.properties as Record>; + expect(properties.itemsAlias!.$ref).toBe('#/properties/arr/items/anyOf/0/properties/x'); + expect(properties.prefixAlias!.$ref).toBe('#/properties/t/prefixItems/0/anyOf/0/properties/x'); + expect(properties.restAlias!.$ref).toBe('#/properties/t/items/anyOf/0/properties/y'); + const validate = new AjvJsonSchemaValidator().getValidator(result); + expect(validate({ arr: [{ x: 'v' }, null], t: [null, 's', null], name: 'n' }).valid).toBe(true); + }); + + test('pointers into degrade-deleted subtrees are neutralized, resolvable ones kept', () => { + // The catch degrade DELETES a catch node's `properties` — no move target + // exists, so a pointer into it is neutralized (loosen-only: an absent $ref + // constrains nothing) instead of shipping an uncompilable advertisement. + const schema = z.object({ + cfg: z.object({ q: z.string() }).catch({ q: 'd' }), + deletedAlias: z.unknown().meta({ $ref: '#/properties/cfg/properties/q' }), + keptAlias: z.unknown().meta({ $ref: '#/properties/name' }), + name: z.string() + }); + const result = standardSchemaToJsonSchema(schema, 'output'); + + const properties = result.properties as Record>; + expect(properties.deletedAlias!.$ref).toBeUndefined(); + expect(properties.keptAlias!.$ref).toBe('#/properties/name'); // resolvable — untouched + expect(new AjvJsonSchemaValidator().getValidator(result)({ cfg: {}, name: 'n' }).valid).toBe(true); + }); + + test('untouched conversions keep a dangling ref (neutralization is loosen-scoped)', () => { + // No degrade fired here: a ref the user broke themselves failed compile + // identically pre-#2464 and is not the loosen machinery's to repair. + const result = standardSchemaToJsonSchema(z.object({ alias: z.string().meta({ $ref: '#/nowhere' }), name: z.string() }), 'output'); + + expect((result.properties as Record>).alias!.$ref).toBe('#/nowhere'); + }); + test('a degraded member $defs survives the skeleton at its pointer-addressed spot', () => { // `$defs` entries are PATH-addressed (`#/…/$defs/X`) — the catch degrade's // type skeleton must carry them (like the name-resolved anchors) or inbound @@ -839,6 +915,23 @@ describe('zod conversion options (#2464)', () => { expect(isNonObjectJsonSchemaRoot(root)).toBe(true); }); + test('mixed quiet-member unions carrying a date keep intersections loud on output', () => { + // A quiet co-member that adds no JSON-satisfiable value (never matches + // nothing; Files and non-finite literals are never JSON objects) must not + // launder the union's date-sidedness: all three spellings threw on the + // date pre-#2464. + for (const coMember of [z.never(), z.literal(Infinity), z.file()]) { + expect(() => + standardSchemaToJsonSchema(z.intersection(z.object({ a: z.string() }), z.union([z.date(), coMember])), 'output') + ).toThrow(/must describe objects/); + } + // No date member, no date side: a union of nevers listed pre-#2464 (it + // emitted {not: {}} members without throwing) and must keep doing so. + const noDate = standardSchemaToJsonSchema(z.intersection(z.object({ a: z.string() }), z.union([z.never(), z.never()])), 'output'); + expect(noDate.type).toBeUndefined(); + expect(isNonObjectJsonSchemaRoot(noDate)).toBe(true); + }); + test('piped and undefined-filtered loud literal output roots throw', () => { // A bigint literal on a pipe's OUT side emits {type: 'number', const: 1}, // bypassing the typeless guard — pre-#2464 the conversion threw. From 66559b9246bdac8663bdb633edd3a378e26e3b56 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 17:48:44 +0000 Subject: [PATCH 27/35] fix(core-internal): anchor/base-addressed dangle repair; polarity-aware neutralization; structural date-intersection parity - Extend the dangling-ref neutralization to the two remaining locally-verifiable forms: anchor-form '#name' refs whose $anchor/$dynamicAnchor rode a subtree the degrade deleted (anchors are relocation-immune but dangle by deletion - surviving names are collected document-wide, a keep-erring superset of the resource-scoped lookup), and base-addressed '#/...' refs into embedded $id resources the loosen rewrite mutated (verified against the embedded resource and dropped; document-rooted moves cannot redirect them). Truly cross-document refs stay untouched. - Make neutralization polarity-aware: a dangling ref under not/if/contains no longer has its $ref deleted (not: {} rejects everything; if: {} force-fires then; a looser contains tightens against maxContains) - the enclosing boundary keyword is removed at its outermost occurrence instead, which only ever loosens since top-level keywords AND-combine. The redirect pass stays polarity-insensitive by design. - Replace the thrice-patched date-intersection enumeration with the structural rule: an atIntersection flag threads through the verdict recursion so a date leaf reports loud at any nesting depth under an intersection side, and representable non-object types (string/number/boolean/null/enum/ template_literal, representable literals) return DEFINED quiet 'representable' verdicts instead of undefined - reserving the may-be-object bail for genuinely satisfiable members (z.union([z.date(), z.object({...})]) intersections keep listing) while a representable member still discharges union loudness outside intersections (z.union([z.date(), z.string()]) stays a working schema on both io paths). Co-Authored-By: Claude --- .../core-internal/src/util/standardSchema.ts | 312 ++++++++++++++---- .../test/util/standardSchema.test.ts | 100 ++++++ 2 files changed, 346 insertions(+), 66 deletions(-) diff --git a/packages/core-internal/src/util/standardSchema.ts b/packages/core-internal/src/util/standardSchema.ts index c32b61cb6f..a89a68224e 100644 --- a/packages/core-internal/src/util/standardSchema.ts +++ b/packages/core-internal/src/util/standardSchema.ts @@ -552,27 +552,166 @@ function rewriteRelocatedJsonPointers(root: unknown, moves: readonly RelocatedPo } /** - * Last-resort pointer integrity for LOOSENED conversions: any same-document - * `$ref`/`$dynamicRef` JSON Pointer that no longer resolves — its target subtree - * was deleted by the catch degrade's constraint drop or the skeleton's member - * reduction, where nothing survives for a move to redirect to — is neutralized by - * deleting the reference keyword. Loosen-only (an absent `$ref` constrains - * nothing) and strictly better than shipping it: a dangling pointer makes the - * whole advertisement uncompilable, so the tool would list but every `callTool` - * would fail client-side before the request is sent. Anchor-form fragments - * (`#name`) are name-resolved and cannot dangle by position; cross-document refs - * cannot be verified locally — both are left untouched. Runs after - * {@linkcode rewriteRelocatedJsonPointers} so pointers that were redirected to a - * surviving location are recognized as resolvable and kept. + * Last-resort pointer integrity for LOOSENED conversions: any locally-verifiable + * `$ref`/`$dynamicRef` that no longer resolves is neutralized. Three dangle + * sources, all produced by the loosen machinery itself: + * - pointer-form `#/…` targets deleted by the catch degrade's constraint drop or + * the skeleton's member reduction (no move target survives to redirect to); + * - anchor-form `#name` whose `$anchor`/`$dynamicAnchor` rode a deleted subtree — + * anchors are name-resolved and immune to RELOCATION, but they dangle by + * DELETION; + * - base-addressed `#/…` into an embedded-`$id` resource whose insides the + * loosen rewrite moved: the recorded moves are document-rooted, so these are + * verified against the embedded resource and dropped rather than redirected. + * Neutralization is loosen-only by construction: + * - in POSITIVE polarity the reference keyword is deleted (an absent `$ref` + * constrains nothing); + * - inside `not`/`if`/`contains`, deleting the ref would TIGHTEN (`not: {}` + * rejects everything; `if: {}` makes a sibling `then` always apply; a looser + * `contains` subschema raises the match count against `maxContains`) — the + * ENCLOSING boundary keyword is deleted instead, at its outermost occurrence: + * top-level keywords AND-combine, so removing the whole conjunct can only + * loosen, while any per-ref repair deeper inside is unsound once polarity + * re-inverts (`then`/`else` without `if` are ignored per 2020-12, and + * `maxContains` without `contains` likewise). + * Strictly better than shipping the dangling ref, which makes the whole + * advertisement uncompilable — the tool would list but every `callTool` would + * fail client-side before the request is sent. Truly cross-document refs (base + * URI matching no embedded `$id`) cannot be verified locally and are left + * untouched. Runs after {@linkcode rewriteRelocatedJsonPointers} so pointers + * redirected to a surviving location are recognized as resolvable and kept. */ function neutralizeDanglingLocalRefs(root: Record): void { - visitLocalRefBearingNodes(root, record => { - for (const refKey of ['$ref', '$dynamicRef'] as const) { - const value = record[refKey]; - if (typeof value !== 'string' || !value.startsWith('#/')) continue; - if (!localJsonPointerResolves(root, value.slice(1))) delete record[refKey]; + const targets = collectLocalReferenceTargets(root); + const dangles = (value: unknown): boolean => { + if (typeof value !== 'string' || value === '' || value === '#') return false; + if (value.startsWith('#/')) return !localJsonPointerResolves(root, value.slice(1)); + // Anchor-form fragment: resolvable iff SOME surviving anchor bears the + // name. Document-wide collection is a superset of 2020-12's + // resource-scoped lookup, erring toward keeping: a resolvable ref is never + // neutralized, while a scope-mismatched dangle the superset hides was + // already broken by its author, not by the loosen machinery. + if (value.startsWith('#')) return !targets.anchors.has(value.slice(1)); + const hashIndex = value.indexOf('#'); + const base = hashIndex === -1 ? value : value.slice(0, hashIndex); + const resource = targets.resources.get(base); + if (resource === undefined) return false; // truly cross-document — unverifiable + const fragment = hashIndex === -1 ? '' : value.slice(hashIndex + 1); + if (fragment === '') return false; // the embedded resource root itself + if (fragment.startsWith('/')) return !localJsonPointerResolves(resource, fragment); + return !targets.anchors.has(fragment); + }; + neutralizeWalk(root, dangles); +} + +/** + * Keywords whose subschemas invert or conditionalize validation polarity: a + * loosen-only ref repair inside them must remove the whole enclosing conjunct + * instead of the reference keyword. + */ +const POLARITY_BOUNDARY_JSON_SCHEMA_KEYWORDS: ReadonlySet = new Set(['not', 'if', 'contains']); + +/** The polarity-aware neutralization walk — see {@linkcode neutralizeDanglingLocalRefs}. */ +function neutralizeWalk(node: unknown, dangles: (value: unknown) => boolean, seen: Set = new Set()): void { + if (typeof node !== 'object' || node === null || seen.has(node)) return; + seen.add(node); + if (Array.isArray(node)) { + for (const item of node) neutralizeWalk(item, dangles, seen); + return; + } + const record = node as Record; + for (const refKey of ['$ref', '$dynamicRef'] as const) { + if (dangles(record[refKey])) delete record[refKey]; + } + for (const [key, value] of Object.entries(record)) { + if (!SCHEMA_CARRYING_JSON_SCHEMA_KEYWORDS.has(key)) continue; + if (POLARITY_BOUNDARY_JSON_SCHEMA_KEYWORDS.has(key)) { + // Outermost-boundary rule: a dangling ref ANYWHERE inside removes this + // whole conjunct — deeper nesting may re-invert polarity, so no per-ref + // repair inside is sound in both directions, while conjunct removal at + // a positive position only ever loosens. + if (subtreeHasDanglingLocalRef(value, dangles)) delete record[key]; + continue; } - }); + if (SCHEMA_MAP_JSON_SCHEMA_KEYWORDS.has(key) && typeof value === 'object' && value !== null && !Array.isArray(value)) { + if (seen.has(value)) continue; + seen.add(value); + for (const subschema of Object.values(value as Record)) { + if (subtreeEstablishesNewIdBase(subschema)) continue; + neutralizeWalk(subschema, dangles, seen); + } + continue; + } + if (subtreeEstablishesNewIdBase(value)) continue; + neutralizeWalk(value, dangles, seen); + } +} + +/** Whether any `$ref`/`$dynamicRef` in the subtree dangles — the boundary-keyword scan. */ +function subtreeHasDanglingLocalRef(node: unknown, dangles: (value: unknown) => boolean, seen: Set = new Set()): boolean { + if (typeof node !== 'object' || node === null || seen.has(node)) return false; + seen.add(node); + if (Array.isArray(node)) return node.some(item => subtreeHasDanglingLocalRef(item, dangles, seen)); + const record = node as Record; + if (dangles(record.$ref) || dangles(record.$dynamicRef)) return true; + for (const [key, value] of Object.entries(record)) { + if (!SCHEMA_CARRYING_JSON_SCHEMA_KEYWORDS.has(key)) continue; + if (SCHEMA_MAP_JSON_SCHEMA_KEYWORDS.has(key) && typeof value === 'object' && value !== null && !Array.isArray(value)) { + if (seen.has(value)) continue; + seen.add(value); + for (const subschema of Object.values(value as Record)) { + if (subtreeEstablishesNewIdBase(subschema)) continue; + if (subtreeHasDanglingLocalRef(subschema, dangles, seen)) return true; + } + continue; + } + if (subtreeEstablishesNewIdBase(value)) continue; + if (subtreeHasDanglingLocalRef(value, dangles, seen)) return true; + } + return false; +} + +/** + * Collects the document's locally-resolvable reference targets: every surviving + * `$anchor`/`$dynamicAnchor` name (plus draft-07's fragment-form `$id: '#name'` + * anchor spelling) and every embedded non-fragment `$id` resource mapped to its + * subtree. Position-aware like the other walks, but deliberately descends into + * embedded `$id` resources — their anchors and nested resources are still in + * this document. + */ +function collectLocalReferenceTargets(root: Record): { + anchors: Set; + resources: Map>; +} { + const anchors = new Set(); + const resources = new Map>(); + const walk = (node: unknown, seen: Set): void => { + if (typeof node !== 'object' || node === null || seen.has(node)) return; + seen.add(node); + if (Array.isArray(node)) { + for (const item of node) walk(item, seen); + return; + } + const record = node as Record; + if (typeof record.$anchor === 'string') anchors.add(record.$anchor); + if (typeof record.$dynamicAnchor === 'string') anchors.add(record.$dynamicAnchor); + if (typeof record.$id === 'string' && record.$id !== '') { + if (record.$id.startsWith('#')) anchors.add(record.$id.slice(1)); + else resources.set(record.$id, record); + } + for (const [key, value] of Object.entries(record)) { + if (!SCHEMA_CARRYING_JSON_SCHEMA_KEYWORDS.has(key)) continue; + if (SCHEMA_MAP_JSON_SCHEMA_KEYWORDS.has(key) && typeof value === 'object' && value !== null && !Array.isArray(value)) { + if (seen.has(value)) continue; + seen.add(value); + for (const subschema of Object.values(value as Record)) walk(subschema, seen); + continue; + } + walk(value, seen); + } + }; + walk(root, new Set()); + return { anchors, resources }; } /** Whether an RFC 6901 pointer (leading `/…`, tokens still escaped) resolves in `root`. */ @@ -595,16 +734,17 @@ function localJsonPointerResolves(root: unknown, pointer: string): boolean { /** * Position-aware walk over every schema node whose same-document references are - * resolvable against the DOCUMENT ROOT, invoking `visit` once per node. Only - * schema-carrying keywords are descended into (data-valued - * `const`/`enum`/`default`/`examples` and annotations are opaque); schema-map - * VALUES are always descended into while their keys stay names; and — + * resolvable against the DOCUMENT ROOT, invoking `visit` once per node — the + * REDIRECT pass's walk. Only schema-carrying keywords are descended into + * (data-valued `const`/`enum`/`default`/`examples` and annotations are opaque); + * schema-map VALUES are always descended into while their keys stay names; and — * unlike the oneOf rename walk — `not`/`if`/`contains` subtrees ARE descended - * into (pointer fixups are resolution-preserving, never polarity-sensitive - * loosenings). Subtrees establishing a new `$id` base are skipped: their - * same-document pointers resolve against the embedded base, not the document - * root. A ROOT `$id` names the document itself, so the root node is always - * visited. + * into (REDIRECTING a pointer is resolution-preserving, never a + * polarity-sensitive loosening; the polarity-sensitive NEUTRALIZE pass uses its + * own walk, {@linkcode neutralizeWalk}). Subtrees establishing a new `$id` base + * are skipped: their same-document pointers resolve against the embedded base, + * not the document root. A ROOT `$id` names the document itself, so the root + * node is always visited. */ function visitLocalRefBearingNodes(node: unknown, visit: (record: Record) => void, seen: Set = new Set()): void { if (typeof node !== 'object' || node === null || seen.has(node)) return; @@ -1104,11 +1244,22 @@ function isLoudLiteralOutputRoot(schema: unknown, ancestors: ReadonlySet = new Set() + ancestors: ReadonlySet = new Set(), + atIntersection = false ): { type: string; loud: boolean } | undefined { if (typeof schema !== 'object' || schema === null || ancestors.has(schema)) return undefined; const def = ( @@ -1133,7 +1284,7 @@ function nonObjectTypelessRootVerdict( path.add(schema); if (def.type === 'lazy' && typeof def.getter === 'function') { try { - return nonObjectTypelessRootVerdict((def.getter as () => unknown)(), io, path); + return nonObjectTypelessRootVerdict((def.getter as () => unknown)(), io, path, atIntersection); } catch { return undefined; } @@ -1146,7 +1297,7 @@ function nonObjectTypelessRootVerdict( const primary = io === 'output' ? def.out : def.in; const secondary = io === 'output' ? def.in : def.out; if (primary !== undefined) { - const verdict = nonObjectTypelessRootVerdict(primary, io, path); + const verdict = nonObjectTypelessRootVerdict(primary, io, path, atIntersection); if (verdict !== undefined) return verdict; // A bare transform on the processed side has no verdict of its own — the // real schema sits on the other side (`z.preprocess(fn, inner)` on input; @@ -1155,61 +1306,66 @@ function nonObjectTypelessRootVerdict( // representable ones degrade gracefully). const primaryDef = (primary as { _zod?: { def?: { type?: string } } })._zod?.def; if (primaryDef?.type === 'transform' && secondary !== undefined) { - return nonObjectTypelessRootVerdict(secondary, io, path); + return nonObjectTypelessRootVerdict(secondary, io, path, atIntersection); } } return undefined; } if (WRAPPER_ZOD_DEF_TYPES.has(def.type) && def.innerType !== undefined) { - return nonObjectTypelessRootVerdict(def.innerType, io, path); + return nonObjectTypelessRootVerdict(def.innerType, io, path, atIntersection); } if (def.type === 'union' && Array.isArray(def.options) && def.options.length > 0) { - const verdicts = def.options.map(option => nonObjectTypelessRootVerdict(option, io, path)); + const verdicts = def.options.map(option => nonObjectTypelessRootVerdict(option, io, path, atIntersection)); + // The may-be-object bail: an `undefined` member (z.object, z.any, + // z.custom, …) can make the union satisfiable in ANY position — including + // against an object conjunct at an intersection, which is exactly when a + // loud at-intersection date member must be discarded rather than + // propagated (`z.union([z.date(), z.object({…})])` intersections are + // satisfiable and keep listing). Representable non-object members return + // DEFINED quiet verdicts and do not trigger this bail. if (verdicts.includes(undefined)) return undefined; - const loud = verdicts.some(verdict => verdict?.loud === true); - // A union that admits AT MOST Dates is a date side: every member is a date - // or a quiet kind that adds no JSON-satisfiable value (`never` matches - // nothing; a File or a non-finite/symbol literal is never a JSON object), - // so the intersection branch's date-parity rule below must see it — mixed - // spellings like `z.union([z.date(), z.never()])` threw on the date - // pre-#2464 exactly like the all-date union. At least one member must BE a - // date (a date-free union of nevers listed pre-#2464 and must keep doing - // so). A root or union-member position reads only `loud`, so the type is - // inert there. - if ( - verdicts.some(verdict => verdict?.type === 'date') && - verdicts.every( - verdict => - verdict?.type === 'date' || - (verdict?.loud === false && (verdict.type === 'never' || verdict.type === 'file' || verdict.type === 'literal')) - ) - ) { - return { type: 'date', loud }; - } - return { type: 'union', loud }; + const satisfiable = verdicts.some(verdict => verdict?.type === 'representable'); + // Union semantics discharge loudness OUTSIDE intersections: one + // representable member makes the whole union a working schema + // (`z.union([z.date(), z.string()])` accepts strings on input — the date + // member is redundant, not fatal), preserving the established + // representable-member-keeps-the-composition-accepted policy. AT an + // intersection nothing representable can satisfy the object conjunct, so + // a loud member's verdict stands. + const loud = verdicts.some(verdict => verdict?.loud === true) && (atIntersection || !satisfiable); + // A satisfiable union is itself representable-satisfiable for OUTER + // compositions (`z.union([z.date(), z.union([z.string(), z.number()])])` + // must discharge exactly like its flattened spelling). + return { type: satisfiable ? 'representable' : 'union', loud }; } if (def.type === 'intersection' && def.left !== undefined && def.right !== undefined) { - const left = nonObjectTypelessRootVerdict(def.left, io, path); - const right = nonObjectTypelessRootVerdict(def.right, io, path); + // Sides recurse in intersection context: the value must satisfy BOTH + // sides, so a date reached anywhere under either side (directly, or nested + // in unions whose every co-member is provably non-object) is fatal on the + // output path and reports loud from the date leaf itself. + const left = nonObjectTypelessRootVerdict(def.left, io, path, true); + const right = nonObjectTypelessRootVerdict(def.right, io, path, true); if (left === undefined && right === undefined) return undefined; - // A date side is quiet on the output path as a root or union member (the - // override makes those emissions wire-truthful), but no value can satisfy - // BOTH intersection sides when one is a Date — every date-containing - // intersection threw pre-#2464, so parity keeps them loud here. - const loudDateSide = io === 'output' && (left?.type === 'date' || right?.type === 'date'); - return { type: 'intersection', loud: left?.loud === true || right?.loud === true || loudDateSide }; + return { type: 'intersection', loud: left?.loud === true || right?.loud === true }; } if (def.type === 'literal') { + // The verdict stays DEFINED whatever the values: literal values are + // primitives, so the node can never satisfy an object conjunct. A + // representable value (string/finite number/boolean/null) wins over quiet + // ones and marks the literal satisfiable. const loudness = nonObjectLiteralLoudness(def.values); - return loudness === undefined ? undefined : { type: 'literal', loud: loudness === 'loud' }; + if (loudness === undefined) return { type: 'representable', loud: false }; + return { type: 'literal', loud: loudness === 'loud' }; } if (def.type === 'date') { // The date override rewrites every date node to its true wire form // (string/date-time), so a date-rooted OUTPUT emission is wire-truthful and // must keep listing (`z.date().nullable()` is a working 'timestamp or null' - // tool); on INPUT no JSON payload satisfies raw z.date() validation, so date - // members stay loud there. - return { type: 'date', loud: io === 'input' }; + // tool); on INPUT no JSON payload satisfies raw z.date() validation, and at + // an intersection no value satisfies both a Date side and the other side — + // every date-containing intersection threw pre-#2464, so parity keeps + // those loud. + return { type: 'date', loud: io === 'input' || atIntersection }; } if (def.type === 'never') { // z.never() matches no value: it can never make a union satisfiable or @@ -1226,9 +1382,33 @@ function nonObjectTypelessRootVerdict( // loud co-member restores the pre-#2464 throw. return { type: 'file', loud: false }; } + if (REPRESENTABLE_NON_OBJECT_ZOD_DEF_TYPES.has(def.type)) { + // Representable and provably non-object: quiet in every position (these + // all converted and listed pre-#2464), but DEFINED — unlike the + // may-be-object `undefined` verdict — so a union pairing one with a date + // cannot launder the intersection date-parity rule through the bail above + // (`z.union([z.date(), z.string()])` against an object conjunct admits no + // satisfying value and threw pre-#2464). + return { type: 'representable', loud: false }; + } return NON_OBJECT_UNREPRESENTABLE_TYPES.has(def.type) ? { type: def.type, loud: true } : undefined; } +/** + * Zod def types that always emit a representable NON-OBJECT JSON type. Their + * verdicts are quiet but DEFINED: `undefined` is reserved for may-be-object + * shapes (`z.object`, `z.any`, `z.custom`, unrecognized defs), which is what the + * union bail keys on to keep possibly-satisfiable intersections listing. + */ +const REPRESENTABLE_NON_OBJECT_ZOD_DEF_TYPES: ReadonlySet = new Set([ + 'string', + 'number', + 'boolean', + 'null', + 'enum', + 'template_literal' +]); + /** * The non-object loudness of a literal's values, or `undefined` when the literal may * be JSON-satisfiable. `'loud'`: a value zod's own converter threw on pre-#2464 diff --git a/packages/core-internal/test/util/standardSchema.test.ts b/packages/core-internal/test/util/standardSchema.test.ts index a03e00a7e0..465bfa1b8b 100644 --- a/packages/core-internal/test/util/standardSchema.test.ts +++ b/packages/core-internal/test/util/standardSchema.test.ts @@ -799,6 +799,73 @@ describe('zod conversion options (#2464)', () => { expect(new AjvJsonSchemaValidator().getValidator(result)({ cfg: {}, name: 'n' }).valid).toBe(true); }); + test('anchor-form refs whose anchors rode deleted subtrees are neutralized', () => { + // Anchors are name-resolved and immune to relocation, but they dangle by + // DELETION: the catch degrade drops a catch node's `properties` (and the + // skeleton drops member constraints) along with any anchors nested inside. + const schema = z.object({ + cfg: z.object({ q: z.string().meta({ $anchor: 'innerA' }) }).catch({ q: 'd' }), + skel: z.union([z.object({ q: z.string().meta({ $anchor: 'deepA' }) }), z.object({ w: z.number() })]).catch({ q: 'd' }), + live: z.object({ q: z.string() }).meta({ $dynamicAnchor: 'liveD' }), + gone: z.unknown().meta({ $ref: '#innerA' }), + goneDeep: z.unknown().meta({ $ref: '#deepA' }), + kept: z.unknown().meta({ $dynamicRef: '#liveD' }), + name: z.string() + }); + const result = standardSchemaToJsonSchema(schema, 'output'); + + const properties = result.properties as Record>; + expect(properties.gone!.$ref).toBeUndefined(); + expect(properties.goneDeep!.$ref).toBeUndefined(); + expect(properties.kept!.$dynamicRef).toBe('#liveD'); // surviving anchor — untouched + expect(new AjvJsonSchemaValidator().getValidator(result)({ cfg: {}, skel: {}, live: { q: 'x' }, name: 'n' }).valid).toBe(true); + }); + + test('base-addressed refs into a mutated embedded-$id resource are neutralized', () => { + // The recorded moves are document-rooted, so a `#/…` ref into an + // embedded `$id` resource whose oneOf the loosen rewrite renamed cannot be + // redirected — it is verified against the embedded resource and dropped. + const schema = z.object({ + du: z + .discriminatedUnion('t', [z.object({ t: z.literal('a').catch('a') }), z.object({ t: z.literal('b') })]) + .meta({ $id: 'https://example.com/du' }), + cfg: z.object({ q: z.string() }).meta({ $id: 'https://example.com/cfg' }), + gone: z.unknown().meta({ $ref: 'https://example.com/du#/oneOf/0' }), + kept: z.unknown().meta({ $ref: 'https://example.com/cfg#/properties/q' }), + name: z.string() + }); + const result = standardSchemaToJsonSchema(schema, 'output'); + + const properties = result.properties as Record>; + expect(properties.gone!.$ref).toBeUndefined(); + // A ref into a SURVIVING part of an embedded resource still resolves — kept. + expect(properties.kept!.$ref).toBe('https://example.com/cfg#/properties/q'); + expect(new AjvJsonSchemaValidator().getValidator(result)({ du: { t: 'a' }, cfg: { q: 'x' }, name: 'n' }).valid).toBe(true); + }); + + test('dangling refs under negative or conditional keywords remove the enclosing conjunct', () => { + // Deleting the ref itself would TIGHTEN: `not: {}` rejects everything and + // `if: {}` makes a sibling `then` always apply — the enclosing boundary + // keyword is removed instead (then/else without if are ignored). + const schema = z.object({ + cfg: z.object({ q: z.string() }).catch({ q: 'd' }), + negated: z.number().meta({ not: { $ref: '#/properties/cfg/properties/q' } }), + conditional: z.number().meta({ if: { $ref: '#/properties/cfg/properties/q' }, then: { multipleOf: 2 } }), + control: z.number().meta({ not: { $ref: '#/properties/name' } }), + name: z.string() + }); + const result = standardSchemaToJsonSchema(schema, 'output'); + + const properties = result.properties as Record>; + expect(properties.negated!.not).toBeUndefined(); + expect(properties.conditional!.if).toBeUndefined(); + expect(properties.control!.not).toEqual({ $ref: '#/properties/name' }); // resolvable — untouched + // Pre-PR both payloads validated: `not` never matched a number, and `if` + // never fired its `then`. + const validate = new AjvJsonSchemaValidator().getValidator(result); + expect(validate({ cfg: {}, negated: 4, conditional: 3, control: 5, name: 'n' }).valid).toBe(true); + }); + test('untouched conversions keep a dangling ref (neutralization is loosen-scoped)', () => { // No degrade fired here: a ref the user broke themselves failed compile // identically pre-#2464 and is not the loosen machinery's to repair. @@ -932,6 +999,39 @@ describe('zod conversion options (#2464)', () => { expect(isNonObjectJsonSchemaRoot(noDate)).toBe(true); }); + test('date-carrying non-object unions keep intersections loud on output (structural)', () => { + // Every co-member spelling that provably cannot satisfy the object side — + // representable non-objects (short-circuited the old may-be-object bail) + // and quiet compositions of unsatisfiable kinds (escaped the old leaf-kind + // enumeration) — must not launder the date: all seven threw pre-#2464. + const coMembers = [ + z.string(), + z.number(), + z.null(), + z.literal('x'), + z.union([z.never(), z.never()]), + z.intersection(z.never(), z.never()), + z.union([z.file(), z.literal(Infinity)]) + ]; + for (const coMember of coMembers) { + expect(() => + standardSchemaToJsonSchema(z.intersection(z.object({ a: z.string() }), z.union([z.date(), coMember])), 'output') + ).toThrow(/must describe objects/); + } + // A may-be-object member makes the intersection satisfiable — keeps listing. + const satisfiable = standardSchemaToJsonSchema( + z.intersection(z.object({ a: z.string() }), z.union([z.date(), z.object({ b: z.number() })])), + 'output' + ); + expect(satisfiable.type).toBeUndefined(); + expect(isNonObjectJsonSchemaRoot(satisfiable)).toBe(true); + // Root/union positions are untouched: outside intersections a + // representable member discharges the union (a working + // 'timestamp-or-string' tool on either io path). + expect(standardSchemaToJsonSchema(z.union([z.date(), z.string()]), 'output').type).toBeUndefined(); + expect(standardSchemaToJsonSchema(z.union([z.date(), z.string()]), 'input').type).toBe('object'); + }); + test('piped and undefined-filtered loud literal output roots throw', () => { // A bigint literal on a pipe's OUT side emits {type: 'number', const: 1}, // bypassing the typeless guard — pre-#2464 the conversion threw. From 8007798f029bbb54adce0cd9ff80b01e45d5e57b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 18:37:09 +0000 Subject: [PATCH 28/35] fix(core-internal): array/tuple representable verdicts; per-resource ref repair; neutralization fixpoint + annotation siblings; probe-based wrap tolerance - Add 'array' and 'tuple' to REPRESENTABLE_NON_OBJECT_ZOD_DEF_TYPES: a JSON array is never a JSON object, so array/tuple union members must return the defined quiet verdict instead of falling to the may-be-object bail that laundered loud date/bigint co-members at intersections (the bigint spelling on both io paths). Plain object-with-array intersections stay quiet. - Treat embedded $id resources as sub-documents in the neutralize pass: the loosen machinery mutates freely inside them while the document-rooted repair walks skipped them, leaving base-relative refs inside a resource dangling after a rename/wrap/degrade. The walk now recurses with the resource node as resolution root (renames inside a resource are neutralized rather than redirected - moves are document-rooted), and the boundary scan checks inside resources with per-resource predicates. - Iterate neutralization to a fixpoint: a boundary-conjunct deletion is itself a fresh dangle source (deleted $anchor judged against a stale snapshot; pointer-form refs into a conjunct deleted later in the walk), so each round re-derives targets until nothing is deleted. When deleting a conjunct, also drop its annotation-consuming siblings (contains -> unevaluatedItems; if -> then/else + unevaluated*), closing the 2020-12 annotation channel where the bare deletion was a tightening. - Use fieldAcceptsMissingKey (structural walk + validate(undefined) probe) at the array/tuple wrap sites, matching the object required-filter and record branches, so probe-only-tolerant elements (z.preprocess fallbacks) get the null wrap too. Co-Authored-By: Claude --- .../core-internal/src/util/standardSchema.ts | 141 ++++++++++++++---- .../test/util/standardSchema.test.ts | 139 ++++++++++++++++- 2 files changed, 252 insertions(+), 28 deletions(-) diff --git a/packages/core-internal/src/util/standardSchema.ts b/packages/core-internal/src/util/standardSchema.ts index a89a68224e..d29b155f08 100644 --- a/packages/core-internal/src/util/standardSchema.ts +++ b/packages/core-internal/src/util/standardSchema.ts @@ -333,7 +333,11 @@ function zodConversionOptions( // epilogue's pointer redirect — an inbound `$ref: '#/…/items/…'` alias // would otherwise dangle. const items = ctx.jsonSchema.items; - if (typeof items === 'object' && items !== null && !Array.isArray(items) && hasStructuralMissingKeyTolerance(def.element)) { + // Tolerance uses the same predicate as the object required-filter + // and record branches — structural walk PLUS the validate(undefined) + // probe — so probe-only-tolerant elements (e.g. a z.preprocess whose + // fn itself maps undefined to a value) get the wrap too. + if (typeof items === 'object' && items !== null && !Array.isArray(items) && fieldAcceptsMissingKey(def.element)) { loosened.value = true; ctx.jsonSchema.items = { anyOf: [items, { type: 'null' }] }; recordNullToleranceWrapMove(moves, ctx.path, 'items'); @@ -346,7 +350,7 @@ function zodConversionOptions( if (Array.isArray(def.items) && Array.isArray(prefixItems)) { for (const [index, item] of def.items.entries()) { const emitted = prefixItems[index]; - if (typeof emitted === 'object' && emitted !== null && hasStructuralMissingKeyTolerance(item)) { + if (typeof emitted === 'object' && emitted !== null && fieldAcceptsMissingKey(item)) { loosened.value = true; prefixItems[index] = { anyOf: [emitted, { type: 'null' }] }; recordNullToleranceWrapMove(moves, ctx.path, 'prefixItems', index); @@ -361,7 +365,7 @@ function zodConversionOptions( typeof restItems === 'object' && restItems !== null && !Array.isArray(restItems) && - hasStructuralMissingKeyTolerance(def.rest) + fieldAcceptsMissingKey(def.rest ?? undefined) ) { loosened.value = true; ctx.jsonSchema.items = { anyOf: [restItems, { type: 'null' }] }; @@ -581,16 +585,38 @@ function rewriteRelocatedJsonPointers(root: unknown, moves: readonly RelocatedPo * untouched. Runs after {@linkcode rewriteRelocatedJsonPointers} so pointers * redirected to a surviving location are recognized as resolvable and kept. */ -function neutralizeDanglingLocalRefs(root: Record): void { +function neutralizeDanglingLocalRefs(root: Record): boolean { + // Iterated to a FIXPOINT: a boundary-conjunct deletion is itself a fresh + // dangle source — the deleted subtree may have carried an `$anchor` a + // surviving `#name` ref was judged against (stale snapshot), or been the + // target of a pointer-form ref checked earlier in the same walk (visit-order + // dependence). Each round re-derives the target snapshot and re-examines + // every ref; per-ref deletions create no new dangles, so only rounds that + // deleted a conjunct (here or in an embedded resource) continue. Terminates: + // every continuing round deletes at least one of finitely many conjuncts. + let mutatedEver = false; + let mutated = true; + while (mutated) { + mutated = neutralizeWalk(root, makeDanglesPredicate(root)); + mutatedEver ||= mutated; + } + return mutatedEver; +} + +/** + * The dangle test for refs whose resolution root is `root` — the document, or an + * embedded `$id` resource treated as a sub-document. + */ +function makeDanglesPredicate(root: Record): (value: unknown) => boolean { const targets = collectLocalReferenceTargets(root); - const dangles = (value: unknown): boolean => { + return (value: unknown): boolean => { if (typeof value !== 'string' || value === '' || value === '#') return false; if (value.startsWith('#/')) return !localJsonPointerResolves(root, value.slice(1)); // Anchor-form fragment: resolvable iff SOME surviving anchor bears the - // name. Document-wide collection is a superset of 2020-12's - // resource-scoped lookup, erring toward keeping: a resolvable ref is never - // neutralized, while a scope-mismatched dangle the superset hides was - // already broken by its author, not by the loosen machinery. + // name. Root-wide collection is a superset of 2020-12's resource-scoped + // lookup, erring toward keeping: a resolvable ref is never neutralized, + // while a scope-mismatched dangle the superset hides was already broken + // by its author, not by the loosen machinery. if (value.startsWith('#')) return !targets.anchors.has(value.slice(1)); const hashIndex = value.indexOf('#'); const base = hashIndex === -1 ? value : value.slice(0, hashIndex); @@ -601,7 +627,6 @@ function neutralizeDanglingLocalRefs(root: Record): void { if (fragment.startsWith('/')) return !localJsonPointerResolves(resource, fragment); return !targets.anchors.has(fragment); }; - neutralizeWalk(root, dangles); } /** @@ -611,13 +636,23 @@ function neutralizeDanglingLocalRefs(root: Record): void { */ const POLARITY_BOUNDARY_JSON_SCHEMA_KEYWORDS: ReadonlySet = new Set(['not', 'if', 'contains']); -/** The polarity-aware neutralization walk — see {@linkcode neutralizeDanglingLocalRefs}. */ -function neutralizeWalk(node: unknown, dangles: (value: unknown) => boolean, seen: Set = new Set()): void { - if (typeof node !== 'object' || node === null || seen.has(node)) return; +/** + * The polarity-aware neutralization walk — see + * {@linkcode neutralizeDanglingLocalRefs}. Returns whether it deleted a boundary + * conjunct (directly or inside an embedded resource) — the signal that a new + * fixpoint round is needed. An embedded `$id` subtree is not skipped but treated + * as a SUB-DOCUMENT: the loosen machinery mutates freely inside such resources, + * so their base-relative refs are repaired by a nested neutralization whose + * resolution root is the resource node itself (renames inside a resource are + * neutralized rather than redirected — the recorded moves are document-rooted). + */ +function neutralizeWalk(node: unknown, dangles: (value: unknown) => boolean, seen: Set = new Set()): boolean { + if (typeof node !== 'object' || node === null || seen.has(node)) return false; seen.add(node); + let mutated = false; if (Array.isArray(node)) { - for (const item of node) neutralizeWalk(item, dangles, seen); - return; + for (const item of node) mutated = neutralizeWalk(item, dangles, seen) || mutated; + return mutated; } const record = node as Record; for (const refKey of ['$ref', '$dynamicRef'] as const) { @@ -630,20 +665,52 @@ function neutralizeWalk(node: unknown, dangles: (value: unknown) => boolean, see // whole conjunct — deeper nesting may re-invert polarity, so no per-ref // repair inside is sound in both directions, while conjunct removal at // a positive position only ever loosens. - if (subtreeHasDanglingLocalRef(value, dangles)) delete record[key]; + if (subtreeHasDanglingLocalRef(value, dangles)) { + delete record[key]; + deleteDeadAnnotationSiblings(record, key); + mutated = true; + } continue; } if (SCHEMA_MAP_JSON_SCHEMA_KEYWORDS.has(key) && typeof value === 'object' && value !== null && !Array.isArray(value)) { if (seen.has(value)) continue; seen.add(value); for (const subschema of Object.values(value as Record)) { - if (subtreeEstablishesNewIdBase(subschema)) continue; - neutralizeWalk(subschema, dangles, seen); + if (subtreeEstablishesNewIdBase(subschema)) { + mutated = neutralizeDanglingLocalRefs(subschema as Record) || mutated; + continue; + } + mutated = neutralizeWalk(subschema, dangles, seen) || mutated; } continue; } - if (subtreeEstablishesNewIdBase(value)) continue; - neutralizeWalk(value, dangles, seen); + if (subtreeEstablishesNewIdBase(value)) { + mutated = neutralizeDanglingLocalRefs(value as Record) || mutated; + continue; + } + mutated = neutralizeWalk(value, dangles, seen) || mutated; + } + return mutated; +} + +/** + * Companion to a boundary-conjunct deletion, closing the 2020-12 ANNOTATION + * channel: `contains` contributes the item-evaluation annotations a sibling + * `unevaluatedItems: false` consumed, and `if`'s branches contribute property- + * and item-evaluation annotations for `unevaluatedProperties`/`unevaluatedItems` + * (while `then`/`else` are dead without `if`) — deleting the conjunct alone + * would TIGHTEN those siblings against previously-evaluated members. Deleting + * `unevaluated*` is genuinely loosen-only. `not` needs no companion: only + * successful subschemas contribute annotations, and `not` requires failure. + */ +function deleteDeadAnnotationSiblings(record: Record, deletedKey: string): void { + if (deletedKey === 'contains') { + delete record.unevaluatedItems; + } else if (deletedKey === 'if') { + delete record.then; + delete record.else; + delete record.unevaluatedProperties; + delete record.unevaluatedItems; } } @@ -660,17 +727,30 @@ function subtreeHasDanglingLocalRef(node: unknown, dangles: (value: unknown) => if (seen.has(value)) continue; seen.add(value); for (const subschema of Object.values(value as Record)) { - if (subtreeEstablishesNewIdBase(subschema)) continue; - if (subtreeHasDanglingLocalRef(subschema, dangles, seen)) return true; + if (subtreeHasDanglingLocalRefScoped(subschema, dangles, seen)) return true; } continue; } - if (subtreeEstablishesNewIdBase(value)) continue; - if (subtreeHasDanglingLocalRef(value, dangles, seen)) return true; + if (subtreeHasDanglingLocalRefScoped(value, dangles, seen)) return true; } return false; } +/** + * Scope dispatch for the boundary scan: an embedded `$id` resource is scanned + * with its OWN dangle predicate (base-relative refs inside it resolve against + * the resource, not the enclosing root) — a dangle anywhere inside still + * condemns the enclosing boundary conjunct, since no per-ref repair inside a + * negative/conditional position is sound. + */ +function subtreeHasDanglingLocalRefScoped(node: unknown, dangles: (value: unknown) => boolean, seen: Set): boolean { + if (subtreeEstablishesNewIdBase(node)) { + const resource = node as Record; + return subtreeHasDanglingLocalRef(resource, makeDanglesPredicate(resource)); + } + return subtreeHasDanglingLocalRef(node, dangles, seen); +} + /** * Collects the document's locally-resolvable reference targets: every surviving * `$anchor`/`$dynamicAnchor` name (plus draft-07's fragment-form `$id: '#name'` @@ -743,8 +823,10 @@ function localJsonPointerResolves(root: unknown, pointer: string): boolean { * polarity-sensitive loosening; the polarity-sensitive NEUTRALIZE pass uses its * own walk, {@linkcode neutralizeWalk}). Subtrees establishing a new `$id` base * are skipped: their same-document pointers resolve against the embedded base, - * not the document root. A ROOT `$id` names the document itself, so the root - * node is always visited. + * not the document root the moves are rooted at — refs inside them broken by the + * loosen machinery are repaired by the neutralize pass's per-resource recursion + * instead. A ROOT `$id` names the document itself, so the root node is always + * visited. */ function visitLocalRefBearingNodes(node: unknown, visit: (record: Record) => void, seen: Set = new Set()): void { if (typeof node !== 'object' || node === null || seen.has(node)) return; @@ -1406,7 +1488,12 @@ const REPRESENTABLE_NON_OBJECT_ZOD_DEF_TYPES: ReadonlySet = new Set([ 'boolean', 'null', 'enum', - 'template_literal' + 'template_literal', + // A JSON array is never a JSON object (z.object rejects arrays at parse + // time), so array/tuple members are provably non-object exactly like the + // primitive types above. + 'array', + 'tuple' ]); /** diff --git a/packages/core-internal/test/util/standardSchema.test.ts b/packages/core-internal/test/util/standardSchema.test.ts index 465bfa1b8b..3a7c241dbf 100644 --- a/packages/core-internal/test/util/standardSchema.test.ts +++ b/packages/core-internal/test/util/standardSchema.test.ts @@ -866,6 +866,127 @@ describe('zod conversion options (#2464)', () => { expect(validate({ cfg: {}, negated: 4, conditional: 3, control: 5, name: 'n' }).valid).toBe(true); }); + test('refs inside embedded-$id resources are repaired per resource', () => { + // The loosen machinery mutates freely inside embedded `$id` resources, so + // their base-relative refs must be repaired with the RESOURCE as the + // resolution root — the document-rooted walks skip these subtrees. + // Rename source: the member alias pointed at the resource's own oneOf. + const renamed = standardSchemaToJsonSchema( + z.object({ + du: z + .discriminatedUnion('t', [ + z.object({ t: z.literal('a').catch('a'), x: z.string().optional() }), + z.object({}).meta({ $ref: '#/oneOf/0' }) + ]) + .meta({ $id: 'https://example.com/du' }) + }), + 'output' + ); + const du = (renamed.properties as Record>).du!; + expect((du.anyOf as Array>)[1]!.$ref).toBeUndefined(); + expect(new AjvJsonSchemaValidator().getValidator(renamed)({ du: { t: 'a' } }).valid).toBe(true); + + // Wrap and delete sources inside one resource; a ref to a surviving + // position stays enforced. + const inner = standardSchemaToJsonSchema( + z.object({ + inner: z + .object({ + arr: z.array(z.object({ x: z.string() }).optional()), + cfg: z.object({ q: z.string() }).catch({ q: 'd' }), + wrapAlias: z.unknown().meta({ $ref: '#/properties/arr/items/properties/x' }), + delAlias: z.unknown().meta({ $ref: '#/properties/cfg/properties/q' }), + keptAlias: z.unknown().meta({ $ref: '#/properties/arr' }) + }) + .meta({ $id: 'https://example.com/inner' }), + name: z.string() + }), + 'output' + ); + const innerProperties = ((inner.properties as Record>).inner!.properties ?? {}) as Record< + string, + Record + >; + expect(innerProperties.wrapAlias!.$ref).toBeUndefined(); + expect(innerProperties.delAlias!.$ref).toBeUndefined(); + expect(innerProperties.keptAlias!.$ref).toBe('#/properties/arr'); + expect(new AjvJsonSchemaValidator().getValidator(inner)({ inner: { arr: [{ x: 'v' }, null] }, name: 'n' }).valid).toBe(true); + }); + + test('boundary-conjunct deletions feed back into the neutralization fixpoint', () => { + // Deleting `guard.not` removes both the `$anchor` a surviving `#A` ref was + // judged against (stale snapshot) and the target of a pointer-form ref + // declared EARLIER in the walk (visit-order dependence) — a second round + // must re-examine both. + const schema = z.object({ + cfg: z.object({ q: z.string() }).catch({ q: 'd' }), + early: z.unknown().meta({ $ref: '#/properties/guard/not' }), + guard: z.number().meta({ not: { $anchor: 'A', $ref: '#/properties/cfg/properties/q' } }), + anchorAlias: z.unknown().meta({ $ref: '#A' }), + name: z.string() + }); + const result = standardSchemaToJsonSchema(schema, 'output'); + + const properties = result.properties as Record>; + expect(properties.guard!.not).toBeUndefined(); + expect(properties.early!.$ref).toBeUndefined(); + expect(properties.anchorAlias!.$ref).toBeUndefined(); + expect(new AjvJsonSchemaValidator().getValidator(result)({ cfg: {}, guard: 4, name: 'n' }).valid).toBe(true); + }); + + test('deleting a boundary conjunct also drops its annotation-consuming siblings', () => { + // `contains` feeds the item-evaluation annotations `unevaluatedItems: + // false` consumes — deleting the conjunct alone would reject the + // previously-contains-evaluated 'tail' item (a tightening). Likewise + // `if`'s branches feed `unevaluatedProperties`, and `then`/`else` are + // dead without `if`. + const schema = z.object({ + cfg: z.object({ q: z.string() }).catch({ q: 'd' }), + arr: z.unknown().meta({ + type: 'array', + prefixItems: [{ type: 'string' }], + contains: { $ref: '#/properties/cfg/properties/q' }, + unevaluatedItems: false + }), + obj: z.unknown().meta({ + type: 'object', + if: { $ref: '#/properties/cfg/properties/q' }, + then: { required: ['z'] }, + unevaluatedProperties: false + }), + name: z.string() + }); + const result = standardSchemaToJsonSchema(schema, 'output'); + + const properties = result.properties as Record>; + expect(properties.arr!.contains).toBeUndefined(); + expect(properties.arr!.unevaluatedItems).toBeUndefined(); + expect(properties.obj!.if).toBeUndefined(); + expect(properties.obj!.then).toBeUndefined(); + expect(properties.obj!.unevaluatedProperties).toBeUndefined(); + // Pre-PR this payload validated: 'tail' was evaluated by the contains ref. + const validate = new AjvJsonSchemaValidator().getValidator(result); + expect(validate({ cfg: {}, arr: ['head', 'tail'], obj: {}, name: 'n' }).valid).toBe(true); + }); + + test('probe-only-tolerant array and tuple elements get the null wrap', () => { + // The preprocess fn itself maps undefined to a value, so tolerance is + // invisible structurally and only the validate(undefined) probe sees it — + // the wrap sites must use the same predicate as the object/record + // branches. + const element = z.preprocess((value: unknown) => value ?? 0, z.number()); + const result = standardSchemaToJsonSchema( + z.object({ a: z.array(element), t: z.tuple([element, z.string()], element), name: z.string() }), + 'output' + ); + + const properties = result.properties as Record>; + expect((properties.a!.items as Record).anyOf).toEqual([{ type: 'number' }, { type: 'null' }]); + expect((properties.t!.prefixItems as Array>)[0]!.anyOf).toEqual([{ type: 'number' }, { type: 'null' }]); + expect((properties.t!.items as Record).anyOf).toEqual([{ type: 'number' }, { type: 'null' }]); + expect(new AjvJsonSchemaValidator().getValidator(result)({ a: [1, null, 3], t: [null, 's', null], name: 'n' }).valid).toBe(true); + }); + test('untouched conversions keep a dangling ref (neutralization is loosen-scoped)', () => { // No degrade fired here: a ref the user broke themselves failed compile // identically pre-#2464 and is not the loosen machinery's to repair. @@ -1011,13 +1132,29 @@ describe('zod conversion options (#2464)', () => { z.literal('x'), z.union([z.never(), z.never()]), z.intersection(z.never(), z.never()), - z.union([z.file(), z.literal(Infinity)]) + z.union([z.file(), z.literal(Infinity)]), + // A JSON array is never a JSON object — array/tuple members are as + // provably non-object as the primitives above. + z.array(z.string()), + z.tuple([z.string()]) ]; for (const coMember of coMembers) { expect(() => standardSchemaToJsonSchema(z.intersection(z.object({ a: z.string() }), z.union([z.date(), coMember])), 'output') ).toThrow(/must describe objects/); } + // Loud non-date co-member verdicts survive the bail too, on BOTH io paths + // (the input spelling used to list as a stamped phantom tool). + for (const io of ['output', 'input'] as const) { + expect(() => + standardSchemaToJsonSchema(z.intersection(z.object({ a: z.string() }), z.union([z.bigint(), z.array(z.string())])), io) + ).toThrow(/must describe objects/); + } + // A PLAIN array side (no union, no date) was no-throw pre-#2464 — the + // defined-but-quiet verdict keeps it listing. + const plainArray = standardSchemaToJsonSchema(z.intersection(z.object({ a: z.string() }), z.array(z.string())), 'output'); + expect(plainArray.type).toBeUndefined(); + expect(isNonObjectJsonSchemaRoot(plainArray)).toBe(true); // A may-be-object member makes the intersection satisfiable — keeps listing. const satisfiable = standardSchemaToJsonSchema( z.intersection(z.object({ a: z.string() }), z.union([z.date(), z.object({ b: z.number() })])), From 21ae868501b02d18369911359d80b705eb064bc1 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 19:31:03 +0000 Subject: [PATCH 29/35] refactor(core-internal): replace ref-repair machinery with a reference-construct guard; may-be-object verdicts Reverse the repair-after-mutation approach for reference keywords: instead of redirecting/neutralizing every reference form the loosen family can break (pointer redirects, anchor collection, embedded-$id sub-documents, RFC 3986 base resolution, polarity-aware fixpoint neutralization), DETECT hand-authored reference constructs up front and skip the loosen family for those conversions. - The zod output conversion now runs a strict pass first (date rewrite + draft-04 id strip only) and inspects the natural emission: any $ref/$dynamicRef beyond zod's own registry shapes ('#', '#/$defs/'), any $anchor/$dynamicAnchor, any $id, or a non-root $defs ships that strict pre-#2464-shaped emission - compilable and working by construction, at the cost of the loosening (documented as a Known residual gap). Reference-free documents (and zod's own registry/recursion refs, stable under every loosen mutation - $defs stays at the root, entry names are untouched, entries mutate in place) convert again with the loosen family active, exactly as before. - Delete the entire repair machinery: move recording, pointer redirect pass, dangling-ref neutralization with fixpoint iteration, polarity boundaries, annotation-sibling cleanup, per-resource recursion, anchor/resource collection. This closes the residual classes reported against it (per-ref deletion vs unevaluated* annotations, embedded-$id resources at array positions, verbatim-string $id base matching and boundary-deleted resources) by construction. - Fix the may-be-object union bail laundering loud members past provably non-object intersection conjuncts: a union with an object/any/custom member now returns a distinguishable quiet 'mayBeObject' verdict carrying the members' inner loudness, which the intersection branch surfaces only when the sibling side is provably non-object (object-conjunct intersections keep listing; array/string-conjunct spellings with a date or bigint member throw as pre-#2464, on both io paths for bigint). Co-Authored-By: Claude --- .changeset/zod-tojsonschema-wire-truthful.md | 10 +- .../core-internal/src/util/standardSchema.ts | 625 +++++------------- .../test/util/standardSchema.test.ts | 363 +++++----- 3 files changed, 341 insertions(+), 657 deletions(-) diff --git a/.changeset/zod-tojsonschema-wire-truthful.md b/.changeset/zod-tojsonschema-wire-truthful.md index 7e8819ff55..f713d7966f 100644 --- a/.changeset/zod-tojsonschema-wire-truthful.md +++ b/.changeset/zod-tojsonschema-wire-truthful.md @@ -34,8 +34,12 @@ every JSON payload — use a JSON-representable type such as `z.iso.date()`/ optional. The same holds for required OUTPUT fields of such types: bigint results fail JSON-RPC serialization and Map/Set values serialize as `{}`. And a degraded object-`.catch()` node keeps `type: 'object'` for the 2025-era wrap proof even though -catch-validation does not enforce it on the raw value. A `.meta()`-injected -`not: {$ref: …}` aliasing a loosened subtree can also observe the `oneOf` → `anyOf` -rewrite cross-polarity.) Elicitation is unaffected: +catch-validation does not enforce it on the raw value. +Hand-authored reference keywords disable the loosening: a `.meta()`/registry-injected +`$ref`/`$dynamicRef` beyond zod's own registry shapes (`#`, `#/$defs/`), any +`$anchor`/`$dynamicAnchor` or `$id`, or a non-root `$defs` makes the conversion ship the +strict pre-fix-shaped emission instead — such constructs would observe the loosening's +rewrites as dangling pointers or stale anchors, so those schemas keep pre-fix strictness, +compilable and working by construction.) Elicitation is unaffected: `inputRequired.elicit()` keeps throwing on schemas its restricted form grammar cannot round-trip, including `z.date()`. diff --git a/packages/core-internal/src/util/standardSchema.ts b/packages/core-internal/src/util/standardSchema.ts index d29b155f08..57fb9ecd34 100644 --- a/packages/core-internal/src/util/standardSchema.ts +++ b/packages/core-internal/src/util/standardSchema.ts @@ -238,17 +238,28 @@ export const JSON_SCHEMA_CONVERSION_TARGET = 'draft-2020-12'; * transport. `InMemoryTransport` passes messages by reference with no JSON * round-trip, so the raw `Date` the server must ship reaches a validating client * as a `Date` instance and fails the advertised schema there. + * - Hand-authored reference keywords disable the wire-truthfulness loosening + * entirely: when the emission carries any `$ref`/`$dynamicRef` beyond zod's own + * registry shapes (`#`, `#/$defs/`), any `$anchor`/`$dynamicAnchor`, any + * `$id`, or a non-root `$defs`, the conversion ships the strict pre-#2464-shaped + * emission instead (see {@linkcode hasHandAuthoredReferenceConstructs}) — the + * loosen family relocates and deletes document positions, which such constructs + * observe as dangling pointers, stale anchors, or shifted base-relative paths. + * Those conversions keep pre-#2464 strictness (e.g. `additionalProperties: false` + * stays advertised, tolerant fields stay `required`), trading loosening for + * compilability-by-construction. * - The loosen rewrite's `oneOf` → `anyOf` rename skips lexical `not`/`if`/`contains` - * positions, but a `.meta({not: {$ref: '#/properties/…'}})` aliasing a - * positive-position target observes the (legitimately) renamed schema, inverting - * polarity — the same node cannot read `anyOf` for its positive consumer and - * `oneOf` for a negated alias, so cross-polarity `$ref` aliasing into loosened - * subtrees stays untruthful. + * positions, but a `.meta({not: {oneOf: […]}})` cloning a positive-position + * composition observes the (legitimately) renamed sibling while keeping its own + * `oneOf` — the same composition cannot read `anyOf` for its positive consumer + * and `oneOf` for a negated clone, so cross-polarity duplication of loosened + * compositions stays untruthful (reference-keyword aliasing itself disables + * loosening per the previous bullet). */ function zodConversionOptions( io: 'input' | 'output', loosened: { value: boolean }, - moves: RelocatedPointerPrefix[] + loosen: boolean ): Pick { return { unrepresentable: 'any', @@ -270,7 +281,11 @@ function zodConversionOptions( ctx.jsonSchema.format = 'date-time'; return; } - if (io !== 'output') return; + // The loosen family below is gated twice: it rewrites only OUTPUT + // advertisements, and only when the caller established (via the + // strict detection pass) that no hand-authored reference construct + // could observe the rewrites — see standardSchemaToJsonSchema. + if (io !== 'output' || !loosen) return; if (def.type === 'catch') { // `.catch()` accepts any raw value — invalid input is replaced by the // fallback only in the parsed result, which the server never ships — so @@ -328,10 +343,7 @@ function zodConversionOptions( // JSON.stringify turns an undefined array ELEMENT into `null` (unlike an // undefined-valued object key, which it drops), so a tolerant element // (`.default()`/`.prefault()`, …) may ship as null — the advertised item - // subschema must accept it. The wrap relocates the element subschema to - // `items/anyOf/0`, so the move is recorded (via `ctx.path`) for the - // epilogue's pointer redirect — an inbound `$ref: '#/…/items/…'` alias - // would otherwise dangle. + // subschema must accept it. const items = ctx.jsonSchema.items; // Tolerance uses the same predicate as the object required-filter // and record branches — structural walk PLUS the validate(undefined) @@ -340,12 +352,11 @@ function zodConversionOptions( if (typeof items === 'object' && items !== null && !Array.isArray(items) && fieldAcceptsMissingKey(def.element)) { loosened.value = true; ctx.jsonSchema.items = { anyOf: [items, { type: 'null' }] }; - recordNullToleranceWrapMove(moves, ctx.path, 'items'); } return; } if (def.type === 'tuple') { - // Same wire mechanism (and same recorded move) per prefix position. + // Same wire mechanism per prefix position. const prefixItems = ctx.jsonSchema.prefixItems; if (Array.isArray(def.items) && Array.isArray(prefixItems)) { for (const [index, item] of def.items.entries()) { @@ -353,7 +364,6 @@ function zodConversionOptions( if (typeof emitted === 'object' && emitted !== null && fieldAcceptsMissingKey(item)) { loosened.value = true; prefixItems[index] = { anyOf: [emitted, { type: 'null' }] }; - recordNullToleranceWrapMove(moves, ctx.path, 'prefixItems', index); } } } @@ -369,7 +379,6 @@ function zodConversionOptions( ) { loosened.value = true; ctx.jsonSchema.items = { anyOf: [restItems, { type: 'null' }] }; - recordNullToleranceWrapMove(moves, ctx.path, 'items'); } return; } @@ -416,78 +425,36 @@ function zodConversionOptions( }; } -/** - * Records the pointer move of an override-time null-tolerance wrap: the wrapped - * element subschema relocated from `/` to `//anyOf/0`. - * `path` is zod's emission path for the node being overridden (`ctx.path`, raw - * segments — RFC 6901-escaped here). On zod versions that predate `ctx.path` the - * move is skipped: the wrap itself still ships, and a pointer left dangling by it - * is neutralized (loosen-only) by the epilogue's last-resort pass instead of - * redirected. The same fallback covers reused zod instances — the override runs - * once per instance, so only the first emission position's copy gets a recorded - * move — and registry-referenced nodes, whose emitted home is `#/$defs/` - * while `ctx.path` names the referencing position (a prefix that matches no - * resolvable pointer, so the stray move can redirect nothing wrongly). - */ -function recordNullToleranceWrapMove(moves: RelocatedPointerPrefix[], path: unknown, ...slot: ReadonlyArray): void { - if (!Array.isArray(path)) return; - const from = [...(path as ReadonlyArray), ...slot] - .map(segment => `/${escapeJsonPointerSegment(String(segment))}`) - .join(''); - moves.push({ from, to: `${from}/anyOf/0` }); -} - -/** - * A JSON-Pointer prefix relocation performed by {@linkcode rewriteOneOfToAnyOf}: - * every same-document pointer that traverses `from` must be redirected to `to`. - * `from`'s ancestor segments are spelled in the coordinates that held AFTER the - * moves recorded before it, so applying an ordered move list sequentially walks a - * pointer from original coordinates to final ones. - */ -interface RelocatedPointerPrefix { - from: string; - to: string; -} - -/** RFC 6901 escaping for a single JSON-Pointer reference token. */ -function escapeJsonPointerSegment(segment: string): string { - return segment.replaceAll('~', '~0').replaceAll('/', '~1'); -} - /** * Recursively moves every `oneOf` in an emitted (already-loosened) JSON Schema tree * to `anyOf`. Used when a conversion's catch degrade or required-filter fired: the * loosened members of an untouched parent `oneOf` (e.g. a discriminated union whose * catch-wrapped discriminators were degraded) may have become mutually satisfiable, * inverting exactly-one semantics into reject-everything. Also performs the - * oneOf→anyOf renames the catch degrade deferred: its override hook cannot place - * the rename in the document, while this walk knows every node's path. + * oneOf→anyOf renames the catch degrade deferred to keep the rename in one place. * - * Each relocation is appended to `moves` (as a JSON-Pointer prefix pair rooted at - * `path`) so {@linkcode rewriteRelocatedJsonPointers} can redirect same-document - * `$ref`/`$dynamicRef` pointers that traverse the moved segment — a user - * `.meta({$ref: '#/properties/du/oneOf/0'})` alias would otherwise dangle and fail - * schema compilation outright. + * The rename relocates document positions, but no reference can observe it: the + * loosen family runs only when {@linkcode hasHandAuthoredReferenceConstructs} found + * none, and zod's own registry refs (`#`, `#/$defs/`) never traverse a + * `oneOf` segment. */ -function rewriteOneOfToAnyOf(node: unknown, path = '', moves: RelocatedPointerPrefix[] = [], seen: Set = new Set()): void { +function rewriteOneOfToAnyOf(node: unknown, seen: Set = new Set()): void { if (typeof node !== 'object' || node === null || seen.has(node)) return; seen.add(node); if (Array.isArray(node)) { - for (const [index, item] of node.entries()) rewriteOneOfToAnyOf(item, `${path}/${index}`, moves, seen); + for (const item of node) rewriteOneOfToAnyOf(item, seen); return; } const record = node as Record; if (Array.isArray(record.oneOf)) { if (record.anyOf === undefined) { record.anyOf = record.oneOf; - moves.push({ from: `${path}/oneOf`, to: `${path}/anyOf` }); } else { // A user `.meta({anyOf})` can coexist with the emitted `oneOf` — preserve // conjunction semantics without clobbering it: keywords on one node // combine with AND, so `{anyOf: members}` under `allOf` is equivalent. const allOf = Array.isArray(record.allOf) ? record.allOf : (record.allOf = []); allOf.push({ anyOf: record.oneOf }); - moves.push({ from: `${path}/oneOf`, to: `${path}/allOf/${allOf.length - 1}/anyOf` }); // The relocated members' objectness becomes invisible to the wrap proof's // every()-member rule once user conjuncts coexist (e.g. a .meta({allOf: // [{minProperties: 1}]})) — stamp the sound explicit type here (the value @@ -507,362 +474,83 @@ function rewriteOneOfToAnyOf(node: unknown, path = '', moves: RelocatedPointerPr if (!SCHEMA_CARRYING_JSON_SCHEMA_KEYWORDS.has(key)) continue; // oneOf→anyOf is a loosening only in POSITIVE polarity: under `not` it // inverts (a payload matching ≥2 members passed `not {oneOf}` but fails - // `not {anyOf}`), under `if` it can flip which then/else branch applies, - // and under `contains` it raises the contains-count, tightening against a - // sibling `maxContains` (zod never emits `contains`, so skipping is - // regression-free). + // the rewritten `not {anyOf}`), under `if` it can flip which then/else + // branch applies, and under `contains` it raises the contains-count, + // tightening against a sibling `maxContains` (zod never emits `contains`, + // so skipping is regression-free). if (key === 'not' || key === 'if' || key === 'contains') continue; - const childPath = `${path}/${escapeJsonPointerSegment(key)}`; // Schema MAPS hold schemas under user-chosen names that may collide with // annotation keywords (a property literally named `description` still // carries a schema) — recurse into every value unconditionally. if (SCHEMA_MAP_JSON_SCHEMA_KEYWORDS.has(key) && typeof value === 'object' && value !== null && !Array.isArray(value)) { if (seen.has(value)) continue; seen.add(value); - for (const [name, subschema] of Object.entries(value as Record)) { - rewriteOneOfToAnyOf(subschema, `${childPath}/${escapeJsonPointerSegment(name)}`, moves, seen); - } - continue; - } - rewriteOneOfToAnyOf(value, childPath, moves, seen); - } -} - -/** - * Redirects same-document `$ref`/`$dynamicRef` JSON Pointers through the prefix - * relocations {@linkcode rewriteOneOfToAnyOf} performed, mirroring the legacy-wrap - * envelope's pointer rewrite (`wire/rev2025-11-25/legacyWrap.ts`): a pointer that - * traverses a moved segment — a renamed/pushed `oneOf` (`#/…/oneOf//…` → - * `#/…/anyOf//…` or `#/…/allOf//anyOf//…`) or a null-tolerance-wrapped - * element (`#/…/items/…` → `#/…/items/anyOf/0/…`) — is rewritten to the segment's - * new home so it keeps resolving to the same subschema. Cross-document refs (not - * starting with `#/`) are left untouched; see - * {@linkcode visitLocalRefBearingNodes} for the walk's position/`$id` rules. - */ -function rewriteRelocatedJsonPointers(root: unknown, moves: readonly RelocatedPointerPrefix[]): void { - visitLocalRefBearingNodes(root, record => { - for (const refKey of ['$ref', '$dynamicRef'] as const) { - const value = record[refKey]; - if (typeof value !== 'string' || !value.startsWith('#/')) continue; - let pointer = value.slice(1); - for (const move of moves) { - if (pointer === move.from || pointer.startsWith(`${move.from}/`)) { - pointer = move.to + pointer.slice(move.from.length); - } - } - record[refKey] = `#${pointer}`; - } - }); -} - -/** - * Last-resort pointer integrity for LOOSENED conversions: any locally-verifiable - * `$ref`/`$dynamicRef` that no longer resolves is neutralized. Three dangle - * sources, all produced by the loosen machinery itself: - * - pointer-form `#/…` targets deleted by the catch degrade's constraint drop or - * the skeleton's member reduction (no move target survives to redirect to); - * - anchor-form `#name` whose `$anchor`/`$dynamicAnchor` rode a deleted subtree — - * anchors are name-resolved and immune to RELOCATION, but they dangle by - * DELETION; - * - base-addressed `#/…` into an embedded-`$id` resource whose insides the - * loosen rewrite moved: the recorded moves are document-rooted, so these are - * verified against the embedded resource and dropped rather than redirected. - * Neutralization is loosen-only by construction: - * - in POSITIVE polarity the reference keyword is deleted (an absent `$ref` - * constrains nothing); - * - inside `not`/`if`/`contains`, deleting the ref would TIGHTEN (`not: {}` - * rejects everything; `if: {}` makes a sibling `then` always apply; a looser - * `contains` subschema raises the match count against `maxContains`) — the - * ENCLOSING boundary keyword is deleted instead, at its outermost occurrence: - * top-level keywords AND-combine, so removing the whole conjunct can only - * loosen, while any per-ref repair deeper inside is unsound once polarity - * re-inverts (`then`/`else` without `if` are ignored per 2020-12, and - * `maxContains` without `contains` likewise). - * Strictly better than shipping the dangling ref, which makes the whole - * advertisement uncompilable — the tool would list but every `callTool` would - * fail client-side before the request is sent. Truly cross-document refs (base - * URI matching no embedded `$id`) cannot be verified locally and are left - * untouched. Runs after {@linkcode rewriteRelocatedJsonPointers} so pointers - * redirected to a surviving location are recognized as resolvable and kept. - */ -function neutralizeDanglingLocalRefs(root: Record): boolean { - // Iterated to a FIXPOINT: a boundary-conjunct deletion is itself a fresh - // dangle source — the deleted subtree may have carried an `$anchor` a - // surviving `#name` ref was judged against (stale snapshot), or been the - // target of a pointer-form ref checked earlier in the same walk (visit-order - // dependence). Each round re-derives the target snapshot and re-examines - // every ref; per-ref deletions create no new dangles, so only rounds that - // deleted a conjunct (here or in an embedded resource) continue. Terminates: - // every continuing round deletes at least one of finitely many conjuncts. - let mutatedEver = false; - let mutated = true; - while (mutated) { - mutated = neutralizeWalk(root, makeDanglesPredicate(root)); - mutatedEver ||= mutated; - } - return mutatedEver; -} - -/** - * The dangle test for refs whose resolution root is `root` — the document, or an - * embedded `$id` resource treated as a sub-document. - */ -function makeDanglesPredicate(root: Record): (value: unknown) => boolean { - const targets = collectLocalReferenceTargets(root); - return (value: unknown): boolean => { - if (typeof value !== 'string' || value === '' || value === '#') return false; - if (value.startsWith('#/')) return !localJsonPointerResolves(root, value.slice(1)); - // Anchor-form fragment: resolvable iff SOME surviving anchor bears the - // name. Root-wide collection is a superset of 2020-12's resource-scoped - // lookup, erring toward keeping: a resolvable ref is never neutralized, - // while a scope-mismatched dangle the superset hides was already broken - // by its author, not by the loosen machinery. - if (value.startsWith('#')) return !targets.anchors.has(value.slice(1)); - const hashIndex = value.indexOf('#'); - const base = hashIndex === -1 ? value : value.slice(0, hashIndex); - const resource = targets.resources.get(base); - if (resource === undefined) return false; // truly cross-document — unverifiable - const fragment = hashIndex === -1 ? '' : value.slice(hashIndex + 1); - if (fragment === '') return false; // the embedded resource root itself - if (fragment.startsWith('/')) return !localJsonPointerResolves(resource, fragment); - return !targets.anchors.has(fragment); - }; -} - -/** - * Keywords whose subschemas invert or conditionalize validation polarity: a - * loosen-only ref repair inside them must remove the whole enclosing conjunct - * instead of the reference keyword. - */ -const POLARITY_BOUNDARY_JSON_SCHEMA_KEYWORDS: ReadonlySet = new Set(['not', 'if', 'contains']); - -/** - * The polarity-aware neutralization walk — see - * {@linkcode neutralizeDanglingLocalRefs}. Returns whether it deleted a boundary - * conjunct (directly or inside an embedded resource) — the signal that a new - * fixpoint round is needed. An embedded `$id` subtree is not skipped but treated - * as a SUB-DOCUMENT: the loosen machinery mutates freely inside such resources, - * so their base-relative refs are repaired by a nested neutralization whose - * resolution root is the resource node itself (renames inside a resource are - * neutralized rather than redirected — the recorded moves are document-rooted). - */ -function neutralizeWalk(node: unknown, dangles: (value: unknown) => boolean, seen: Set = new Set()): boolean { - if (typeof node !== 'object' || node === null || seen.has(node)) return false; - seen.add(node); - let mutated = false; - if (Array.isArray(node)) { - for (const item of node) mutated = neutralizeWalk(item, dangles, seen) || mutated; - return mutated; - } - const record = node as Record; - for (const refKey of ['$ref', '$dynamicRef'] as const) { - if (dangles(record[refKey])) delete record[refKey]; - } - for (const [key, value] of Object.entries(record)) { - if (!SCHEMA_CARRYING_JSON_SCHEMA_KEYWORDS.has(key)) continue; - if (POLARITY_BOUNDARY_JSON_SCHEMA_KEYWORDS.has(key)) { - // Outermost-boundary rule: a dangling ref ANYWHERE inside removes this - // whole conjunct — deeper nesting may re-invert polarity, so no per-ref - // repair inside is sound in both directions, while conjunct removal at - // a positive position only ever loosens. - if (subtreeHasDanglingLocalRef(value, dangles)) { - delete record[key]; - deleteDeadAnnotationSiblings(record, key); - mutated = true; - } - continue; - } - if (SCHEMA_MAP_JSON_SCHEMA_KEYWORDS.has(key) && typeof value === 'object' && value !== null && !Array.isArray(value)) { - if (seen.has(value)) continue; - seen.add(value); - for (const subschema of Object.values(value as Record)) { - if (subtreeEstablishesNewIdBase(subschema)) { - mutated = neutralizeDanglingLocalRefs(subschema as Record) || mutated; - continue; - } - mutated = neutralizeWalk(subschema, dangles, seen) || mutated; - } - continue; - } - if (subtreeEstablishesNewIdBase(value)) { - mutated = neutralizeDanglingLocalRefs(value as Record) || mutated; - continue; - } - mutated = neutralizeWalk(value, dangles, seen) || mutated; - } - return mutated; -} - -/** - * Companion to a boundary-conjunct deletion, closing the 2020-12 ANNOTATION - * channel: `contains` contributes the item-evaluation annotations a sibling - * `unevaluatedItems: false` consumed, and `if`'s branches contribute property- - * and item-evaluation annotations for `unevaluatedProperties`/`unevaluatedItems` - * (while `then`/`else` are dead without `if`) — deleting the conjunct alone - * would TIGHTEN those siblings against previously-evaluated members. Deleting - * `unevaluated*` is genuinely loosen-only. `not` needs no companion: only - * successful subschemas contribute annotations, and `not` requires failure. - */ -function deleteDeadAnnotationSiblings(record: Record, deletedKey: string): void { - if (deletedKey === 'contains') { - delete record.unevaluatedItems; - } else if (deletedKey === 'if') { - delete record.then; - delete record.else; - delete record.unevaluatedProperties; - delete record.unevaluatedItems; - } -} - -/** Whether any `$ref`/`$dynamicRef` in the subtree dangles — the boundary-keyword scan. */ -function subtreeHasDanglingLocalRef(node: unknown, dangles: (value: unknown) => boolean, seen: Set = new Set()): boolean { - if (typeof node !== 'object' || node === null || seen.has(node)) return false; - seen.add(node); - if (Array.isArray(node)) return node.some(item => subtreeHasDanglingLocalRef(item, dangles, seen)); - const record = node as Record; - if (dangles(record.$ref) || dangles(record.$dynamicRef)) return true; - for (const [key, value] of Object.entries(record)) { - if (!SCHEMA_CARRYING_JSON_SCHEMA_KEYWORDS.has(key)) continue; - if (SCHEMA_MAP_JSON_SCHEMA_KEYWORDS.has(key) && typeof value === 'object' && value !== null && !Array.isArray(value)) { - if (seen.has(value)) continue; - seen.add(value); - for (const subschema of Object.values(value as Record)) { - if (subtreeHasDanglingLocalRefScoped(subschema, dangles, seen)) return true; - } + for (const subschema of Object.values(value as Record)) rewriteOneOfToAnyOf(subschema, seen); continue; } - if (subtreeHasDanglingLocalRefScoped(value, dangles, seen)) return true; + rewriteOneOfToAnyOf(value, seen); } - return false; } -/** - * Scope dispatch for the boundary scan: an embedded `$id` resource is scanned - * with its OWN dangle predicate (base-relative refs inside it resolve against - * the resource, not the enclosing root) — a dangle anywhere inside still - * condemns the enclosing boundary conjunct, since no per-ref repair inside a - * negative/conditional position is sound. - */ -function subtreeHasDanglingLocalRefScoped(node: unknown, dangles: (value: unknown) => boolean, seen: Set): boolean { - if (subtreeEstablishesNewIdBase(node)) { - const resource = node as Record; - return subtreeHasDanglingLocalRef(resource, makeDanglesPredicate(resource)); - } - return subtreeHasDanglingLocalRef(node, dangles, seen); -} +/** The only `$ref` shape zod's emitter produces besides bare `#`: a top-level `$defs` entry. */ +const ZOD_REGISTRY_REF_PATTERN = /^#\/\$defs\/[^/]+$/; /** - * Collects the document's locally-resolvable reference targets: every surviving - * `$anchor`/`$dynamicAnchor` name (plus draft-07's fragment-form `$id: '#name'` - * anchor spelling) and every embedded non-fragment `$id` resource mapped to its - * subtree. Position-aware like the other walks, but deliberately descends into - * embedded `$id` resources — their anchors and nested resources are still in - * this document. + * Whether the emitted document carries any reference construct beyond what zod's + * own emitter produces: a `$ref`/`$dynamicRef` that is not `#` or + * `#/$defs/` (the only shapes zod emits, for recursive, deduplicated, and + * registered schemas), any `$anchor`/`$dynamicAnchor`, any `$id`, or a `$defs` + * container anywhere but the conversion root. All of these can only enter an + * emission through `.meta()`/registry metadata — i.e. they are hand-authored. + * + * The wire-truthfulness loosen family relocates and deletes document positions + * (required-filter drops, catch degrades, null-tolerance element wraps, the + * oneOf→anyOf rename), which hand-authored references observe as dangling + * pointers, stale anchors, or shifted base-relative paths — an uncompilable or + * silently tightened advertisement, strictly worse than pre-#2464 strictness. + * Rather than repairing every reference form after the fact (pointer redirects, + * anchor collection, embedded-`$id` sub-documents, RFC 3986 base resolution, + * polarity-aware neutralization, …), a conversion carrying any such construct + * SKIPS the loosen family and ships the strict pre-#2464-shaped emission: + * compilable and working by construction. Registry refs are stable under every + * loosen mutation — `$defs` stays at the conversion root (the catch degrade and + * the constraint wrap both keep it), entry names are never touched, and entries + * are only mutated in place — so registry-only documents loosen freely. + * + * Position-aware: only schema-carrying keywords are inspected (data-valued + * `const`/`enum`/`default`/`examples` and annotation values may contain these key + * names as plain user data), schema-map VALUES are schemas while their keys stay + * names, and `not`/`if`/`contains` subtrees ARE inspected — their references + * break all the same. */ -function collectLocalReferenceTargets(root: Record): { - anchors: Set; - resources: Map>; -} { - const anchors = new Set(); - const resources = new Map>(); - const walk = (node: unknown, seen: Set): void => { - if (typeof node !== 'object' || node === null || seen.has(node)) return; +function hasHandAuthoredReferenceConstructs(document: Record): boolean { + const walk = (node: unknown, isRoot: boolean, seen: Set): boolean => { + if (typeof node !== 'object' || node === null || seen.has(node)) return false; seen.add(node); - if (Array.isArray(node)) { - for (const item of node) walk(item, seen); - return; - } + if (Array.isArray(node)) return node.some(item => walk(item, false, seen)); const record = node as Record; - if (typeof record.$anchor === 'string') anchors.add(record.$anchor); - if (typeof record.$dynamicAnchor === 'string') anchors.add(record.$dynamicAnchor); - if (typeof record.$id === 'string' && record.$id !== '') { - if (record.$id.startsWith('#')) anchors.add(record.$id.slice(1)); - else resources.set(record.$id, record); + for (const refKey of ['$ref', '$dynamicRef'] as const) { + const value = record[refKey]; + if (value === undefined) continue; + if (typeof value !== 'string' || (value !== '#' && !ZOD_REGISTRY_REF_PATTERN.test(value))) return true; } + if (record.$anchor !== undefined || record.$dynamicAnchor !== undefined || record.$id !== undefined) return true; + if (!isRoot && record.$defs !== undefined) return true; for (const [key, value] of Object.entries(record)) { if (!SCHEMA_CARRYING_JSON_SCHEMA_KEYWORDS.has(key)) continue; if (SCHEMA_MAP_JSON_SCHEMA_KEYWORDS.has(key) && typeof value === 'object' && value !== null && !Array.isArray(value)) { if (seen.has(value)) continue; seen.add(value); - for (const subschema of Object.values(value as Record)) walk(subschema, seen); + for (const subschema of Object.values(value as Record)) { + if (walk(subschema, false, seen)) return true; + } continue; } - walk(value, seen); + if (walk(value, false, seen)) return true; } + return false; }; - walk(root, new Set()); - return { anchors, resources }; -} - -/** Whether an RFC 6901 pointer (leading `/…`, tokens still escaped) resolves in `root`. */ -function localJsonPointerResolves(root: unknown, pointer: string): boolean { - let node: unknown = root; - for (const token of pointer.split('/').slice(1)) { - const segment = token.replaceAll('~1', '/').replaceAll('~0', '~'); - if (Array.isArray(node)) { - const index = Number(segment); - if (!Number.isInteger(index) || String(index) !== segment || index < 0 || index >= node.length) return false; - node = node[index]; - } else if (typeof node === 'object' && node !== null && Object.hasOwn(node, segment)) { - node = (node as Record)[segment]; - } else { - return false; - } - } - return true; -} - -/** - * Position-aware walk over every schema node whose same-document references are - * resolvable against the DOCUMENT ROOT, invoking `visit` once per node — the - * REDIRECT pass's walk. Only schema-carrying keywords are descended into - * (data-valued `const`/`enum`/`default`/`examples` and annotations are opaque); - * schema-map VALUES are always descended into while their keys stay names; and — - * unlike the oneOf rename walk — `not`/`if`/`contains` subtrees ARE descended - * into (REDIRECTING a pointer is resolution-preserving, never a - * polarity-sensitive loosening; the polarity-sensitive NEUTRALIZE pass uses its - * own walk, {@linkcode neutralizeWalk}). Subtrees establishing a new `$id` base - * are skipped: their same-document pointers resolve against the embedded base, - * not the document root the moves are rooted at — refs inside them broken by the - * loosen machinery are repaired by the neutralize pass's per-resource recursion - * instead. A ROOT `$id` names the document itself, so the root node is always - * visited. - */ -function visitLocalRefBearingNodes(node: unknown, visit: (record: Record) => void, seen: Set = new Set()): void { - if (typeof node !== 'object' || node === null || seen.has(node)) return; - seen.add(node); - if (Array.isArray(node)) { - for (const item of node) visitLocalRefBearingNodes(item, visit, seen); - return; - } - const record = node as Record; - visit(record); - for (const [key, value] of Object.entries(record)) { - if (!SCHEMA_CARRYING_JSON_SCHEMA_KEYWORDS.has(key)) continue; - if (SCHEMA_MAP_JSON_SCHEMA_KEYWORDS.has(key) && typeof value === 'object' && value !== null && !Array.isArray(value)) { - if (seen.has(value)) continue; - seen.add(value); - for (const subschema of Object.values(value as Record)) { - if (subtreeEstablishesNewIdBase(subschema)) continue; - visitLocalRefBearingNodes(subschema, visit, seen); - } - continue; - } - if (subtreeEstablishesNewIdBase(value)) continue; - visitLocalRefBearingNodes(value, visit, seen); - } -} - -/** - * Whether a subtree's `$id` establishes a new RFC 3986 resolution base (mirrors - * the legacy wrap's guard): same-document pointers inside such a subtree resolve - * against the embedded base, so the document-rooted pointer rewrite must not - * touch them. Arrays are containers, not schemas — they never carry `$id`. - */ -function subtreeEstablishesNewIdBase(node: unknown): boolean { - if (typeof node !== 'object' || node === null || Array.isArray(node)) return false; - const id = (node as Record).$id; - return id !== undefined && !(typeof id === 'string' && id.startsWith('#')); + return walk(document, true, new Set()); } /** @@ -1135,47 +823,63 @@ export function standardSchemaToJsonSchema( ): Record { const std = schema['~standard']; const loosened = { value: false }; - // Pointer-prefix relocations, in chronological order: the override's - // null-tolerance wraps record theirs during emission (via `ctx.path`), the - // epilogue's oneOf rewrite appends its renames — sequential application then - // walks every inbound pointer from its authored coordinates to the final ones. - const moves: RelocatedPointerPrefix[] = []; - const zodOptions = options?.unrepresentable === 'throw' ? undefined : zodConversionOptions(io, loosened, moves); - let result: Record; - if (std.jsonSchema) { - result = std.jsonSchema[io]({ - target: JSON_SCHEMA_CONVERSION_TARGET, - // Non-zod vendors receive no libraryOptions, so their behavior is unchanged. - libraryOptions: std.vendor === 'zod' ? zodOptions : undefined - }); - } else if (std.vendor === 'zod') { - // zod 4.0–4.1 implements StandardSchemaV1 but not StandardJSONSchemaV1 (`~standard.jsonSchema`). - // The SDK already bundles zod 4, so fall back to its converter rather than crashing on tools/list. - // zod 3 schemas (which also report vendor 'zod') have `_def` but not `_zod`; the SDK-bundled - // zod 4 `z.toJSONSchema()` cannot introspect them, so throw a clear error instead of crashing. - if (!('_zod' in (schema as object))) { - throw new Error( - 'Schema appears to be from zod 3, which the SDK cannot convert to JSON Schema. ' + - 'Upgrade to zod >=4.2.0, or wrap your JSON Schema with fromJsonSchema().' - ); + const convert = (zodOptions: Pick | undefined): Record => { + if (std.jsonSchema) { + return std.jsonSchema[io]({ + target: JSON_SCHEMA_CONVERSION_TARGET, + // Non-zod vendors receive no libraryOptions, so their behavior is unchanged. + libraryOptions: std.vendor === 'zod' ? zodOptions : undefined + }); } - if (!warnedZodFallback) { - warnedZodFallback = true; - console.warn( - '[mcp-sdk] Your zod version does not implement `~standard.jsonSchema` (added in zod 4.2.0). ' + - 'Falling back to z.toJSONSchema(). Upgrade to zod >=4.2.0 to silence this warning.' - ); + if (std.vendor === 'zod') { + // zod 4.0–4.1 implements StandardSchemaV1 but not StandardJSONSchemaV1 (`~standard.jsonSchema`). + // The SDK already bundles zod 4, so fall back to its converter rather than crashing on tools/list. + // zod 3 schemas (which also report vendor 'zod') have `_def` but not `_zod`; the SDK-bundled + // zod 4 `z.toJSONSchema()` cannot introspect them, so throw a clear error instead of crashing. + if (!('_zod' in (schema as object))) { + throw new Error( + 'Schema appears to be from zod 3, which the SDK cannot convert to JSON Schema. ' + + 'Upgrade to zod >=4.2.0, or wrap your JSON Schema with fromJsonSchema().' + ); + } + if (!warnedZodFallback) { + warnedZodFallback = true; + console.warn( + '[mcp-sdk] Your zod version does not implement `~standard.jsonSchema` (added in zod 4.2.0). ' + + 'Falling back to z.toJSONSchema(). Upgrade to zod >=4.2.0 to silence this warning.' + ); + } + return z.toJSONSchema(schema as unknown as z.ZodType, { + target: JSON_SCHEMA_CONVERSION_TARGET, + io, + ...zodOptions + }) as Record; } - result = z.toJSONSchema(schema as unknown as z.ZodType, { - target: JSON_SCHEMA_CONVERSION_TARGET, - io, - ...zodOptions - }) as Record; - } else { throw new Error( `Schema library "${std.vendor}" does not implement StandardJSONSchemaV1 (\`~standard.jsonSchema\`). ` + `Upgrade to a version that does, or wrap your JSON Schema with fromJsonSchema().` ); + }; + let result: Record; + if (options?.unrepresentable === 'throw') { + result = convert(undefined); + } else if (io !== 'output' || std.vendor !== 'zod') { + // The loosen family rewrites only zod OUTPUT advertisements — every other + // conversion runs once, with the sanitizing overrides (date rewrite, + // draft-04 `id` strip) alone for zod inputs. + result = convert(zodConversionOptions(io, loosened, false)); + } else { + // Wire-truthfulness loosening is guarded by reference-construct detection: + // first emit STRICTLY (sanitizing overrides only) and inspect the natural + // document — the authoritative view of everything the user authored, + // including constructs a loosen pass would itself delete. Hand-authored + // reference keywords ship that strict, pre-#2464-shaped emission + // (compilable and working by construction — see + // `hasHandAuthoredReferenceConstructs`); reference-free documents (and + // zod's own registry refs, stable under every mutation) convert again with + // the loosen family active. + const strict = convert(zodConversionOptions(io, loosened, false)); + result = hasHandAuthoredReferenceConstructs(strict) ? strict : convert(zodConversionOptions(io, loosened, true)); } if (io === 'output' && loosened.value) { // Exactly-one semantics cannot survive member loosening: once the catch @@ -1184,19 +888,9 @@ export function standardSchemaToJsonSchema( // satisfiable — e.g. catch-wrapped discriminators — and Ajv would reject // every payload with "must match exactly one schema in oneOf". Rewrite to // the honest `anyOf` (loosen-only and wrap-neutral: - // `isProvablyObjectShapedRoot` treats the composition keywords identically), - // then redirect any same-document pointers that traversed a moved segment - // so they keep resolving (a dangling `$ref` is uncompilable — worse than - // any loosening). - rewriteOneOfToAnyOf(result, '', moves); - if (moves.length > 0) rewriteRelocatedJsonPointers(result, moves); - // Targets the degrade DELETED (a catch node's `properties`, a skeleton - // member's constraints) have no new home for a move to redirect to — - // neutralize any pointer left dangling so the advertisement stays - // compilable. Scoped to loosened conversions: an untouched emission's - // dangling ref (a user typo) failed compile identically pre-#2464 and is - // not this rewrite's to repair. - neutralizeDanglingLocalRefs(result); + // `isProvablyObjectShapedRoot` treats the composition keywords identically; + // no reference can observe the rename — the guard above vouched for it). + rewriteOneOfToAnyOf(result); } if (io === 'output') { // SEP-2106: outputSchema may have any JSON Schema root. An explicit `type` (object or @@ -1342,7 +1036,7 @@ function nonObjectTypelessRootVerdict( io: 'input' | 'output', ancestors: ReadonlySet = new Set(), atIntersection = false -): { type: string; loud: boolean } | undefined { +): { type: string; loud: boolean; loudInside?: boolean } | undefined { if (typeof schema !== 'object' || schema === null || ancestors.has(schema)) return undefined; const def = ( schema as { @@ -1398,14 +1092,20 @@ function nonObjectTypelessRootVerdict( } if (def.type === 'union' && Array.isArray(def.options) && def.options.length > 0) { const verdicts = def.options.map(option => nonObjectTypelessRootVerdict(option, io, path, atIntersection)); - // The may-be-object bail: an `undefined` member (z.object, z.any, - // z.custom, …) can make the union satisfiable in ANY position — including - // against an object conjunct at an intersection, which is exactly when a - // loud at-intersection date member must be discarded rather than - // propagated (`z.union([z.date(), z.object({…})])` intersections are - // satisfiable and keep listing). Representable non-object members return - // DEFINED quiet verdicts and do not trigger this bail. - if (verdicts.includes(undefined)) return undefined; + // A may-be-object member (z.object, z.any, z.custom, … — `undefined` + // verdicts — or a nested may-be-object composition) can make the union + // satisfiable in ANY position, so the union reports the quiet + // 'mayBeObject' verdict — but it CARRIES the members' inner loudness: + // an intersection sibling that is provably non-object kills the object + // possibility, and only then does the inner loudness surface + // (`z.union([z.date(), z.object({…})])` against an OBJECT conjunct keeps + // listing; against an ARRAY conjunct it threw pre-#2464 and keeps + // throwing). Representable non-object members return DEFINED quiet + // verdicts and do not trigger this branch. + if (verdicts.includes(undefined) || verdicts.some(verdict => verdict?.type === 'mayBeObject')) { + const loudInside = verdicts.some(verdict => verdict !== undefined && (verdict.loud || verdict.loudInside === true)); + return { type: 'mayBeObject', loud: false, loudInside }; + } const satisfiable = verdicts.some(verdict => verdict?.type === 'representable'); // Union semantics discharge loudness OUTSIDE intersections: one // representable member makes the whole union a working schema @@ -1427,8 +1127,29 @@ function nonObjectTypelessRootVerdict( // output path and reports loud from the date leaf itself. const left = nonObjectTypelessRootVerdict(def.left, io, path, true); const right = nonObjectTypelessRootVerdict(def.right, io, path, true); - if (left === undefined && right === undefined) return undefined; - return { type: 'intersection', loud: left?.loud === true || right?.loud === true }; + const leftMayBeObject = left === undefined || left.type === 'mayBeObject'; + const rightMayBeObject = right === undefined || right.type === 'mayBeObject'; + if (leftMayBeObject && rightMayBeObject) { + if (left === undefined && right === undefined) return undefined; + // Both sides may be object, so the intersection may be satisfiable — + // quiet here, but the inner loudness rides along for an OUTER + // provably-non-object conjunct to surface. + return { + type: 'mayBeObject', + loud: false, + loudInside: left?.loudInside === true || right?.loudInside === true + }; + } + if (leftMayBeObject || rightMayBeObject) { + // One side is provably non-object: the other side's object possibility + // is dead — no value satisfies both — so its carried inner loudness + // surfaces (`z.intersection(z.array(…), z.union([z.date(), z.object(…)]))` + // threw pre-#2464). + const provablyNonObject = leftMayBeObject ? right! : left!; + const mayBeObject = leftMayBeObject ? left : right; + return { type: 'intersection', loud: provablyNonObject.loud || mayBeObject?.loudInside === true }; + } + return { type: 'intersection', loud: left!.loud || right!.loud }; } if (def.type === 'literal') { // The verdict stays DEFINED whatever the values: literal values are diff --git a/packages/core-internal/test/util/standardSchema.test.ts b/packages/core-internal/test/util/standardSchema.test.ts index 3a7c241dbf..074aa80675 100644 --- a/packages/core-internal/test/util/standardSchema.test.ts +++ b/packages/core-internal/test/util/standardSchema.test.ts @@ -675,9 +675,11 @@ describe('zod conversion options (#2464)', () => { expect(new AjvJsonSchemaValidator().getValidator(result)({ arr: [3, 5, 4, 6], counted: 1, name: 'n' }).valid).toBe(true); }); - test('the oneOf rewrite redirects pointers through the renamed segment', () => { - // The members moved to anyOf — a pointer still spelling oneOf would DANGLE, - // making the whole document uncompilable (callTool fails before sending). + test('positional pointer aliases ship the strict emission (reference guard)', () => { + // A hand-authored positional $ref could observe the loosen family's + // relocations (the oneOf→anyOf rename here) as a dangling pointer — the + // guard skips the loosen family instead, shipping the raw pre-#2464 + // emission: compilable and working by construction, strict. const du = z.discriminatedUnion('t', [ z.object({ t: z.literal('a').catch('a'), x: z.string().optional() }), z.object({ t: z.literal('b').catch('b'), y: z.string().optional() }) @@ -685,20 +687,22 @@ describe('zod conversion options (#2464)', () => { const schema = z.object({ du, alias: z.unknown().meta({ $ref: '#/properties/du/oneOf/0' }), - dynAlias: z.unknown().meta({ $dynamicRef: '#/properties/du/oneOf/1' }), + dynAlias: z.unknown().optional().meta({ $dynamicRef: '#/properties/du/oneOf/1' }), name: z.string() }); const result = standardSchemaToJsonSchema(schema, 'output'); + // Byte-equal to zod's own emission (nothing here for the date/id + // sanitizers to touch). + expect(result).toEqual(z.toJSONSchema(schema, { target: 'draft-2020-12', io: 'output', unrepresentable: 'any' })); const properties = result.properties as Record>; - expect(properties.du!.oneOf).toBeUndefined(); - expect(properties.alias!.$ref).toBe('#/properties/du/anyOf/0'); - expect(properties.dynAlias!.$dynamicRef).toBe('#/properties/du/anyOf/1'); - expect(new AjvJsonSchemaValidator().getValidator(result)({ du: { t: 'a' }, name: 'n' }).valid).toBe(true); + expect(properties.du!.oneOf).toBeDefined(); + expect(properties.alias!.$ref).toBe('#/properties/du/oneOf/0'); + expect(properties.dynAlias!.$dynamicRef).toBe('#/properties/du/oneOf/1'); + expect(new AjvJsonSchemaValidator().getValidator(result)({ du: { t: 'a' }, alias: { t: 'a' }, name: 'n' }).valid).toBe(true); }); - test('the oneOf rewrite redirects pointers into the allOf-pushed segment', () => { - // On the push branch the members land under `allOf//anyOf`, not `anyOf`. + test('a pointer alias beside a user anyOf ships strict (reference guard)', () => { const du = z .discriminatedUnion('t', [ z.object({ t: z.literal('a').catch('a'), x: z.string().optional() }), @@ -712,8 +716,11 @@ describe('zod conversion options (#2464)', () => { }); const result = standardSchemaToJsonSchema(schema, 'output'); - expect((result.properties as Record>).alias!.$ref).toBe('#/properties/du/allOf/0/anyOf/1'); - expect(new AjvJsonSchemaValidator().getValidator(result)({ du: { t: 'b' }, name: 'n' }).valid).toBe(true); + const properties = result.properties as Record>; + expect(properties.du!.oneOf).toBeDefined(); + expect(properties.du!.anyOf).toEqual([{ type: 'object' }]); // the user's, unmoved + expect(properties.alias!.$ref).toBe('#/properties/du/oneOf/1'); + expect(new AjvJsonSchemaValidator().getValidator(result)({ du: { t: 'b' }, alias: { t: 'b' }, name: 'n' }).valid).toBe(true); }); test('pointers through an untouched oneOf stay put (no-loosening control)', () => { @@ -731,242 +738,137 @@ describe('zod conversion options (#2464)', () => { expect(new AjvJsonSchemaValidator().getValidator(result)({ du: { t: 'a' }, name: 'n' }).valid).toBe(true); }); - test('pointers through a catch-degraded oneOf follow the deferred rename', () => { - // The catch degrade skeletonizes composition members at override time, - // where no document path exists — the oneOf→anyOf rename is deferred to - // the epilogue so the pointer move is recorded, both for the catch node's - // own oneOf and for a skeletonized member's nested one. + test('catch nodes with pointer aliases ship strict (reference guard)', () => { + // Pre-guard, the catch degrade would have deleted the members' constraints + // and renamed the oneOf out from under the alias. const schema = z.object({ cfg: z .discriminatedUnion('t', [z.object({ t: z.literal('a'), x: z.string() }), z.object({ t: z.literal('b'), y: z.string() })]) .catch({ t: 'a', x: 'd' }), - nested: z - .union([ - z.discriminatedUnion('t', [z.object({ t: z.literal('a') }), z.object({ t: z.literal('b') })]), - z.object({ w: z.number() }) - ]) - .catch({ w: 1 }), alias: z.unknown().meta({ $ref: '#/properties/cfg/oneOf/0' }), - nestedAlias: z.unknown().meta({ $ref: '#/properties/nested/anyOf/0/oneOf/1' }), name: z.string() }); const result = standardSchemaToJsonSchema(schema, 'output'); const properties = result.properties as Record>; - expect(properties.cfg!.oneOf).toBeUndefined(); - expect(properties.alias!.$ref).toBe('#/properties/cfg/anyOf/0'); - expect(properties.nestedAlias!.$ref).toBe('#/properties/nested/anyOf/0/anyOf/1'); - expect(new AjvJsonSchemaValidator().getValidator(result)({ cfg: { t: 'a' }, nested: { w: 1 }, name: 'n' }).valid).toBe(true); + expect(properties.cfg!.oneOf).toBeDefined(); // no degrade, no rename + expect(properties.alias!.$ref).toBe('#/properties/cfg/oneOf/0'); + expect( + new AjvJsonSchemaValidator().getValidator(result)({ cfg: { t: 'a', x: 'v' }, alias: { t: 'a', x: 'w' }, name: 'n' }).valid + ).toBe(true); }); - test('pointers into wrapped array and tuple element subschemas follow the wrap', () => { - // The null-tolerance wrap relocates the element subschema to - // `…/items/anyOf/0` (and the prefixItems/rest equivalents) — the move is - // recorded via the override's `ctx.path` so inbound aliases keep resolving. + test('array and tuple element aliases ship strict — no null wrap (reference guard)', () => { const schema = z.object({ arr: z.array(z.object({ x: z.string() }).optional()), t: z.tuple([z.object({ x: z.string() }).optional(), z.string()], z.object({ y: z.number() }).optional()), itemsAlias: z.unknown().meta({ $ref: '#/properties/arr/items/properties/x' }), - prefixAlias: z.unknown().meta({ $ref: '#/properties/t/prefixItems/0/properties/x' }), - restAlias: z.unknown().meta({ $ref: '#/properties/t/items/properties/y' }), name: z.string() }); const result = standardSchemaToJsonSchema(schema, 'output'); const properties = result.properties as Record>; - expect(properties.itemsAlias!.$ref).toBe('#/properties/arr/items/anyOf/0/properties/x'); - expect(properties.prefixAlias!.$ref).toBe('#/properties/t/prefixItems/0/anyOf/0/properties/x'); - expect(properties.restAlias!.$ref).toBe('#/properties/t/items/anyOf/0/properties/y'); + expect((properties.arr!.items as Record).anyOf).toBeUndefined(); // strict: no null tolerance advertised + expect(properties.itemsAlias!.$ref).toBe('#/properties/arr/items/properties/x'); const validate = new AjvJsonSchemaValidator().getValidator(result); - expect(validate({ arr: [{ x: 'v' }, null], t: [null, 's', null], name: 'n' }).valid).toBe(true); + expect(validate({ arr: [{ x: 'v' }], t: [{ x: 'v' }, 's'], itemsAlias: 'w', name: 'n' }).valid).toBe(true); + // Pre-#2464 strictness kept: a null element is rejected by the + // advertisement even though the raw payload may carry one. + expect(validate({ arr: [{ x: 'v' }, null], t: [{ x: 'v' }, 's'], itemsAlias: 'w', name: 'n' }).valid).toBe(false); }); - test('pointers into degrade-deleted subtrees are neutralized, resolvable ones kept', () => { - // The catch degrade DELETES a catch node's `properties` — no move target - // exists, so a pointer into it is neutralized (loosen-only: an absent $ref - // constrains nothing) instead of shipping an uncompilable advertisement. + test('refs into catch subtrees stay resolvable under the guard', () => { const schema = z.object({ cfg: z.object({ q: z.string() }).catch({ q: 'd' }), - deletedAlias: z.unknown().meta({ $ref: '#/properties/cfg/properties/q' }), + alias: z.unknown().meta({ $ref: '#/properties/cfg/properties/q' }), keptAlias: z.unknown().meta({ $ref: '#/properties/name' }), name: z.string() }); const result = standardSchemaToJsonSchema(schema, 'output'); const properties = result.properties as Record>; - expect(properties.deletedAlias!.$ref).toBeUndefined(); - expect(properties.keptAlias!.$ref).toBe('#/properties/name'); // resolvable — untouched - expect(new AjvJsonSchemaValidator().getValidator(result)({ cfg: {}, name: 'n' }).valid).toBe(true); + expect((properties.cfg!.properties as Record).q).toEqual({ type: 'string' }); // no degrade + expect(properties.alias!.$ref).toBe('#/properties/cfg/properties/q'); + expect(new AjvJsonSchemaValidator().getValidator(result)({ cfg: { q: 'x' }, alias: 'y', keptAlias: 'k', name: 'n' }).valid).toBe( + true + ); }); - test('anchor-form refs whose anchors rode deleted subtrees are neutralized', () => { - // Anchors are name-resolved and immune to relocation, but they dangle by - // DELETION: the catch degrade drops a catch node's `properties` (and the - // skeleton drops member constraints) along with any anchors nested inside. + test('anchors and anchor-form refs ship strict (reference guard)', () => { const schema = z.object({ cfg: z.object({ q: z.string().meta({ $anchor: 'innerA' }) }).catch({ q: 'd' }), skel: z.union([z.object({ q: z.string().meta({ $anchor: 'deepA' }) }), z.object({ w: z.number() })]).catch({ q: 'd' }), live: z.object({ q: z.string() }).meta({ $dynamicAnchor: 'liveD' }), - gone: z.unknown().meta({ $ref: '#innerA' }), - goneDeep: z.unknown().meta({ $ref: '#deepA' }), - kept: z.unknown().meta({ $dynamicRef: '#liveD' }), + aliasA: z.unknown().meta({ $ref: '#innerA' }), + aliasB: z.unknown().meta({ $ref: '#deepA' }), + aliasC: z.unknown().meta({ $dynamicRef: '#liveD' }), name: z.string() }); const result = standardSchemaToJsonSchema(schema, 'output'); const properties = result.properties as Record>; - expect(properties.gone!.$ref).toBeUndefined(); - expect(properties.goneDeep!.$ref).toBeUndefined(); - expect(properties.kept!.$dynamicRef).toBe('#liveD'); // surviving anchor — untouched - expect(new AjvJsonSchemaValidator().getValidator(result)({ cfg: {}, skel: {}, live: { q: 'x' }, name: 'n' }).valid).toBe(true); - }); - - test('base-addressed refs into a mutated embedded-$id resource are neutralized', () => { - // The recorded moves are document-rooted, so a `#/…` ref into an - // embedded `$id` resource whose oneOf the loosen rewrite renamed cannot be - // redirected — it is verified against the embedded resource and dropped. + expect(properties.aliasA!.$ref).toBe('#innerA'); // the anchors survive — no degrade ran + expect(properties.aliasB!.$ref).toBe('#deepA'); + expect( + new AjvJsonSchemaValidator().getValidator(result)({ + cfg: { q: 'x' }, + skel: { q: 'y' }, + live: { q: 'z' }, + aliasA: 'a', + aliasB: 'b', + aliasC: { q: 'c' }, + name: 'n' + }).valid + ).toBe(true); + }); + + test('embedded-$id resources ship strict (reference guard)', () => { + // Outside-in base-addressed and inside base-relative refs both resolve + // against the untouched emission — including inside the resource, where + // the rename walk used to fire $id-blind. const schema = z.object({ du: z - .discriminatedUnion('t', [z.object({ t: z.literal('a').catch('a') }), z.object({ t: z.literal('b') })]) + .discriminatedUnion('t', [ + z.object({ t: z.literal('a').catch('a'), x: z.string().optional() }), + z.object({}).meta({ $ref: '#/oneOf/0' }) + ]) .meta({ $id: 'https://example.com/du' }), - cfg: z.object({ q: z.string() }).meta({ $id: 'https://example.com/cfg' }), - gone: z.unknown().meta({ $ref: 'https://example.com/du#/oneOf/0' }), - kept: z.unknown().meta({ $ref: 'https://example.com/cfg#/properties/q' }), + outer: z.unknown().meta({ $ref: 'https://example.com/du#/oneOf/0' }), name: z.string() }); const result = standardSchemaToJsonSchema(schema, 'output'); const properties = result.properties as Record>; - expect(properties.gone!.$ref).toBeUndefined(); - // A ref into a SURVIVING part of an embedded resource still resolves — kept. - expect(properties.kept!.$ref).toBe('https://example.com/cfg#/properties/q'); - expect(new AjvJsonSchemaValidator().getValidator(result)({ du: { t: 'a' }, cfg: { q: 'x' }, name: 'n' }).valid).toBe(true); + expect(properties.du!.oneOf).toBeDefined(); // rename skipped inside the resource too + expect(properties.outer!.$ref).toBe('https://example.com/du#/oneOf/0'); + expect(new AjvJsonSchemaValidator().getValidator(result)({ du: { t: 'a' }, outer: { t: 'a' }, name: 'n' }).valid).toBe(true); }); - test('dangling refs under negative or conditional keywords remove the enclosing conjunct', () => { - // Deleting the ref itself would TIGHTEN: `not: {}` rejects everything and - // `if: {}` makes a sibling `then` always apply — the enclosing boundary - // keyword is removed instead (then/else without if are ignored). - const schema = z.object({ - cfg: z.object({ q: z.string() }).catch({ q: 'd' }), - negated: z.number().meta({ not: { $ref: '#/properties/cfg/properties/q' } }), - conditional: z.number().meta({ if: { $ref: '#/properties/cfg/properties/q' }, then: { multipleOf: 2 } }), - control: z.number().meta({ not: { $ref: '#/properties/name' } }), - name: z.string() - }); - const result = standardSchemaToJsonSchema(schema, 'output'); - - const properties = result.properties as Record>; - expect(properties.negated!.not).toBeUndefined(); - expect(properties.conditional!.if).toBeUndefined(); - expect(properties.control!.not).toEqual({ $ref: '#/properties/name' }); // resolvable — untouched - // Pre-PR both payloads validated: `not` never matched a number, and `if` - // never fired its `then`. - const validate = new AjvJsonSchemaValidator().getValidator(result); - expect(validate({ cfg: {}, negated: 4, conditional: 3, control: 5, name: 'n' }).valid).toBe(true); - }); - - test('refs inside embedded-$id resources are repaired per resource', () => { - // The loosen machinery mutates freely inside embedded `$id` resources, so - // their base-relative refs must be repaired with the RESOURCE as the - // resolution root — the document-rooted walks skip these subtrees. - // Rename source: the member alias pointed at the resource's own oneOf. - const renamed = standardSchemaToJsonSchema( - z.object({ - du: z - .discriminatedUnion('t', [ - z.object({ t: z.literal('a').catch('a'), x: z.string().optional() }), - z.object({}).meta({ $ref: '#/oneOf/0' }) - ]) - .meta({ $id: 'https://example.com/du' }) - }), - 'output' - ); - const du = (renamed.properties as Record>).du!; - expect((du.anyOf as Array>)[1]!.$ref).toBeUndefined(); - expect(new AjvJsonSchemaValidator().getValidator(renamed)({ du: { t: 'a' } }).valid).toBe(true); - - // Wrap and delete sources inside one resource; a ref to a surviving - // position stays enforced. - const inner = standardSchemaToJsonSchema( - z.object({ - inner: z - .object({ - arr: z.array(z.object({ x: z.string() }).optional()), - cfg: z.object({ q: z.string() }).catch({ q: 'd' }), - wrapAlias: z.unknown().meta({ $ref: '#/properties/arr/items/properties/x' }), - delAlias: z.unknown().meta({ $ref: '#/properties/cfg/properties/q' }), - keptAlias: z.unknown().meta({ $ref: '#/properties/arr' }) - }) - .meta({ $id: 'https://example.com/inner' }), - name: z.string() - }), - 'output' - ); - const innerProperties = ((inner.properties as Record>).inner!.properties ?? {}) as Record< - string, - Record - >; - expect(innerProperties.wrapAlias!.$ref).toBeUndefined(); - expect(innerProperties.delAlias!.$ref).toBeUndefined(); - expect(innerProperties.keptAlias!.$ref).toBe('#/properties/arr'); - expect(new AjvJsonSchemaValidator().getValidator(inner)({ inner: { arr: [{ x: 'v' }, null] }, name: 'n' }).valid).toBe(true); - }); - - test('boundary-conjunct deletions feed back into the neutralization fixpoint', () => { - // Deleting `guard.not` removes both the `$anchor` a surviving `#A` ref was - // judged against (stale snapshot) and the target of a pointer-form ref - // declared EARLIER in the walk (visit-order dependence) — a second round - // must re-examine both. - const schema = z.object({ - cfg: z.object({ q: z.string() }).catch({ q: 'd' }), - early: z.unknown().meta({ $ref: '#/properties/guard/not' }), - guard: z.number().meta({ not: { $anchor: 'A', $ref: '#/properties/cfg/properties/q' } }), - anchorAlias: z.unknown().meta({ $ref: '#A' }), - name: z.string() - }); - const result = standardSchemaToJsonSchema(schema, 'output'); - - const properties = result.properties as Record>; - expect(properties.guard!.not).toBeUndefined(); - expect(properties.early!.$ref).toBeUndefined(); - expect(properties.anchorAlias!.$ref).toBeUndefined(); - expect(new AjvJsonSchemaValidator().getValidator(result)({ cfg: {}, guard: 4, name: 'n' }).valid).toBe(true); - }); - - test('deleting a boundary conjunct also drops its annotation-consuming siblings', () => { - // `contains` feeds the item-evaluation annotations `unevaluatedItems: - // false` consumes — deleting the conjunct alone would reject the - // previously-contains-evaluated 'tail' item (a tightening). Likewise - // `if`'s branches feed `unevaluatedProperties`, and `then`/`else` are - // dead without `if`. + test('refs under negative and conditional keywords ship strict (reference guard)', () => { const schema = z.object({ cfg: z.object({ q: z.string() }).catch({ q: 'd' }), + negated: z.number().meta({ not: { $anchor: 'A', $ref: '#/properties/cfg/properties/q' } }), + early: z.unknown().meta({ $ref: '#/properties/negated/not' }), arr: z.unknown().meta({ type: 'array', prefixItems: [{ type: 'string' }], contains: { $ref: '#/properties/cfg/properties/q' }, unevaluatedItems: false }), - obj: z.unknown().meta({ - type: 'object', - if: { $ref: '#/properties/cfg/properties/q' }, - then: { required: ['z'] }, - unevaluatedProperties: false - }), + conditional: z.number().meta({ if: { $ref: '#/properties/cfg/properties/q' }, then: { multipleOf: 2 } }), name: z.string() }); const result = standardSchemaToJsonSchema(schema, 'output'); const properties = result.properties as Record>; - expect(properties.arr!.contains).toBeUndefined(); - expect(properties.arr!.unevaluatedItems).toBeUndefined(); - expect(properties.obj!.if).toBeUndefined(); - expect(properties.obj!.then).toBeUndefined(); - expect(properties.obj!.unevaluatedProperties).toBeUndefined(); - // Pre-PR this payload validated: 'tail' was evaluated by the contains ref. + expect(properties.negated!.not).toBeDefined(); // conjuncts survive — their refs resolve + expect(properties.arr!.contains).toBeDefined(); + expect(properties.arr!.unevaluatedItems).toBe(false); + expect(properties.conditional!.if).toBeDefined(); + // Pre-PR-valid payload: `not` never matches a number, `contains` + // evaluates 'tail' for unevaluatedItems, `if` never fires its then-branch. const validate = new AjvJsonSchemaValidator().getValidator(result); - expect(validate({ cfg: {}, arr: ['head', 'tail'], obj: {}, name: 'n' }).valid).toBe(true); + expect(validate({ cfg: { q: 'x' }, negated: 4, early: 'e', arr: ['head', 'tail'], conditional: 3, name: 'n' }).valid).toBe(true); }); test('probe-only-tolerant array and tuple elements get the null wrap', () => { @@ -995,38 +897,60 @@ describe('zod conversion options (#2464)', () => { expect((result.properties as Record>).alias!.$ref).toBe('#/nowhere'); }); - test('a degraded member $defs survives the skeleton at its pointer-addressed spot', () => { - // `$defs` entries are PATH-addressed (`#/…/$defs/X`) — the catch degrade's - // type skeleton must carry them (like the name-resolved anchors) or inbound - // pointers dangle and the document stops compiling. + test('non-root $defs ships strict (reference guard)', () => { const schema = z.object({ cfg: z .union([z.object({ v: z.string() }).meta({ $defs: { X: { type: 'string' } } }), z.object({ w: z.number() })]) .catch({ v: 'd' }), + f: z.file().meta({ $defs: { Y: { type: 'string' } } }), alias: z.unknown().meta({ $ref: '#/properties/cfg/anyOf/0/$defs/X' }), + wrapAlias: z.unknown().meta({ $ref: '#/properties/f/$defs/Y' }), name: z.string() }); const result = standardSchemaToJsonSchema(schema, 'output'); - const cfg = (result.properties as Record>).cfg!; - expect((cfg.anyOf as Array>)[0]!.$defs).toEqual({ X: { type: 'string' } }); - expect(new AjvJsonSchemaValidator().getValidator(result)({ cfg: { v: 'd' }, name: 'n' }).valid).toBe(true); - }); - - test('the constraint wrap leaves $defs at the wrap site', () => { - // wrapConstraintsInAnyOf moves constraints into anyOf[0]; `$defs` is - // path-addressed, so relocating it (unlike the name-resolved anchors, which - // stay resolvable from anywhere) dangles inbound `#/…/$defs/X` pointers. - const schema = z.object({ - f: z.file().meta({ $defs: { X: { type: 'string' } } }), - alias: z.unknown().meta({ $ref: '#/properties/f/$defs/X' }), - name: z.string() - }); + const properties = result.properties as Record>; + expect((properties.cfg!.anyOf as Array>)[0]!.$defs).toEqual({ X: { type: 'string' } }); + expect(properties.f!.$defs).toEqual({ Y: { type: 'string' } }); + expect(properties.f!.anyOf).toBeUndefined(); // strict: no file wrap + expect( + new AjvJsonSchemaValidator().getValidator(result)({ cfg: { v: 'd' }, f: 'bytes', alias: 'a', wrapAlias: 'w', name: 'n' }).valid + ).toBe(true); + }); + + test('zod registry refs keep the loosening (stable under every mutation)', () => { + // Registry emissions — `$defs` at the conversion root, refs of the exact + // shape `#/$defs/`, bare `#` — are zod's own output: the guard must + // NOT fire, and the loosen family may mutate `$defs` entries freely IN + // PLACE without breaking the refs. + const reg = z + .object({ + q: z.string(), + du: z.discriminatedUnion('t', [z.object({ t: z.literal('a').catch('a') }), z.object({ t: z.literal('b') })]), + c: z.number().catch(0) + }) + .meta({ id: 'Reg' }); + const schema = z.object({ x: reg, y: reg, name: z.string() }); const result = standardSchemaToJsonSchema(schema, 'output'); - const f = (result.properties as Record>).f!; - expect(f.$defs).toEqual({ X: { type: 'string' } }); - expect(new AjvJsonSchemaValidator().getValidator(result)({ f: {}, name: 'n' }).valid).toBe(true); + const properties = result.properties as Record>; + expect(properties.x!.$ref).toBe('#/$defs/Reg'); + const entry = (result.$defs as Record>).Reg!; + // Loosening fired INSIDE the entry — the tolerant field left `required` + // and the DU's oneOf was renamed — while the registry ref still resolves. + expect(entry.required).not.toContain('c'); + expect((entry.properties as Record>).du!.oneOf).toBeUndefined(); + expect( + new AjvJsonSchemaValidator().getValidator(result)({ x: { q: 'a', du: { t: 'a' } }, y: { q: 'b', du: { t: 'b' } }, name: 'n' }) + .valid + ).toBe(true); + + // Recursive schemas emit registry-shaped cycle refs — loosening stays on. + type Tree = { v: string; kids?: Tree[] }; + const tree: z.ZodType = z.lazy(() => z.object({ v: z.string(), kids: z.array(tree).optional() })); + const rec = standardSchemaToJsonSchema(z.object({ tree, c: z.number().catch(0), name: z.string() }), 'output'); + expect(rec.required).toEqual(['tree', 'name']); + expect(new AjvJsonSchemaValidator().getValidator(rec)({ tree: { v: 'r', kids: [{ v: 'k' }] }, name: 'n' }).valid).toBe(true); }); test('relocated members keep their stamp beside a null-membered user anyOf', () => { @@ -1169,6 +1093,41 @@ describe('zod conversion options (#2464)', () => { expect(standardSchemaToJsonSchema(z.union([z.date(), z.string()]), 'input').type).toBe('object'); }); + test('a may-be-object union member cannot launder loudness past a non-object conjunct', () => { + // The union's object member makes the union satisfiable against an OBJECT + // conjunct — but against a provably non-object conjunct (array/string) the + // object possibility is dead, and the loud date/bigint member must + // surface: all of these threw pre-#2464. + expect(() => + standardSchemaToJsonSchema(z.intersection(z.array(z.string()), z.union([z.date(), z.object({ b: z.number() })])), 'output') + ).toThrow(/must describe objects/); + expect(() => + standardSchemaToJsonSchema(z.intersection(z.string(), z.union([z.date(), z.object({ b: z.number() })])), 'output') + ).toThrow(/must describe objects/); + for (const io of ['output', 'input'] as const) { + expect(() => + standardSchemaToJsonSchema(z.intersection(z.array(z.string()), z.union([z.bigint(), z.object({ b: z.number() })])), io) + ).toThrow(/must describe objects/); + } + // Against an OBJECT conjunct the same union keeps listing (satisfiable) — + // the pinned control — and its inner loudness rides through NESTED + // intersections until a non-object conjunct kills the object possibility. + const satisfiable = standardSchemaToJsonSchema( + z.intersection(z.object({ a: z.string() }), z.union([z.date(), z.object({ b: z.number() })])), + 'output' + ); + expect(satisfiable.type).toBeUndefined(); + expect(() => + standardSchemaToJsonSchema( + z.intersection( + z.intersection(z.object({ a: z.string() }), z.union([z.date(), z.object({ b: z.number() })])), + z.array(z.string()) + ), + 'output' + ) + ).toThrow(/must describe objects/); + }); + test('piped and undefined-filtered loud literal output roots throw', () => { // A bigint literal on a pipe's OUT side emits {type: 'number', const: 1}, // bypassing the typeless guard — pre-#2464 the conversion threw. From 36c5ea3edca11e7f0efb05ddf773c26e55553ab1 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 19:59:57 +0000 Subject: [PATCH 30/35] fix(core-internal): genuine object proof for the allOf-push stamp; polarity and unevaluated* clauses in the reference guard - The allOf-push branch stamps its enforced type: 'object' only on a genuine instance-typing proof (every relocated member carries an explicit type: 'object', recursively through nested compositions - EVERY oneOf/anyOf branch, ANY allOf conjunct) instead of isProvablyObjectShapedRoot's keyword-presence rule: properties/required are vacuous for non-object instances, so a hand-authored type-less member satisfiable by 42 must not be stamped away. Zod-emitted DU members and catch skeletons always carry the explicit type, so the 2025-era legacy-wrap protection is unaffected. - The reference-construct guard flags two more hand-authored spellings that observe in-place loosening as a tightening: any $ref/$dynamicRef - registry-shaped included - consumed under a not/if/contains polarity boundary (the lexical polarity skip cannot see through the ref indirection, so a negated consumer of a loosened $defs entry inverts the loosening), and any unevaluatedProperties/unevaluatedItems (annotation consumers that lose contributions when a catch degrade or required-filter strips their contributors; zod never emits these keywords). Positive-polarity registry-shaped aliases keep loosening, pinned. Co-Authored-By: Claude --- .../core-internal/src/util/standardSchema.ts | 82 +++++++++++++++--- .../test/util/standardSchema.test.ts | 83 +++++++++++++++++++ 2 files changed, 152 insertions(+), 13 deletions(-) diff --git a/packages/core-internal/src/util/standardSchema.ts b/packages/core-internal/src/util/standardSchema.ts index 57fb9ecd34..ee538e85b6 100644 --- a/packages/core-internal/src/util/standardSchema.ts +++ b/packages/core-internal/src/util/standardSchema.ts @@ -457,10 +457,16 @@ function rewriteOneOfToAnyOf(node: unknown, seen: Set = new Set()): voi allOf.push({ anyOf: record.oneOf }); // The relocated members' objectness becomes invisible to the wrap proof's // every()-member rule once user conjuncts coexist (e.g. a .meta({allOf: - // [{minProperties: 1}]})) — stamp the sound explicit type here (the value - // must satisfy the pushed conjunct), preserving the pre-#2464 stamp these - // roots got via their emitted oneOf. - if (record.type === undefined && isProvablyObjectShapedRoot({ anyOf: record.oneOf })) { + // [{minProperties: 1}]})) — stamp the explicit type here, preserving the + // pre-#2464 stamp these roots got via their emitted oneOf. The stamp is + // ENFORCED, so it requires a genuine instance-typing proof — every + // relocated member explicitly `type: 'object'` — NOT the wrap + // heuristic's keyword-presence rule (`properties`/`required`/… are + // vacuous for non-object instances, so a hand-authored type-less member + // is satisfiable by `42` and must not be stamped away). Zod-emitted DU + // members and catch skeletons always carry the explicit type, so the + // legacy-wrap protection is unaffected. + if (record.type === undefined && membersAreExplicitlyObjectTyped(record.oneOf)) { record.type = 'object'; } } @@ -492,6 +498,35 @@ function rewriteOneOfToAnyOf(node: unknown, seen: Set = new Set()): voi } } +/** + * A genuine instance-typing proof for a composition's members: every member — + * recursively through nested compositions — carries an explicit enforced + * `type: 'object'`. Sound per 2020-12 semantics: keywords on one node + * AND-combine, so a member forces objectness when it carries the type directly, + * when an exhaustive nested composition does (EVERY `oneOf`/`anyOf` branch a + * value could satisfy), or when ANY `allOf` conjunct does. Unlike + * {@linkcode isProvablyObjectShapedRoot}'s keyword-PRESENCE rule (a 2025-era + * wrap-decision heuristic, parity-sound only at roots), mere `properties`/ + * `required` carriage proves nothing — those keywords are vacuous for + * non-object instances. + */ +function membersAreExplicitlyObjectTyped(members: readonly unknown[]): boolean { + return ( + members.length > 0 && + members.every(member => { + if (typeof member !== 'object' || member === null || Array.isArray(member)) return false; + const record = member as Record; + if (record.type === 'object') return true; + for (const key of ['oneOf', 'anyOf'] as const) { + const nested = record[key]; + if (Array.isArray(nested) && membersAreExplicitlyObjectTyped(nested)) return true; + } + const allOf = record.allOf; + return Array.isArray(allOf) && allOf.some(conjunct => membersAreExplicitlyObjectTyped([conjunct])); + }) + ); +} + /** The only `$ref` shape zod's emitter produces besides bare `#`: a top-level `$defs` entry. */ const ZOD_REGISTRY_REF_PATTERN = /^#\/\$defs\/[^/]+$/; @@ -499,9 +534,13 @@ const ZOD_REGISTRY_REF_PATTERN = /^#\/\$defs\/[^/]+$/; * Whether the emitted document carries any reference construct beyond what zod's * own emitter produces: a `$ref`/`$dynamicRef` that is not `#` or * `#/$defs/` (the only shapes zod emits, for recursive, deduplicated, and - * registered schemas), any `$anchor`/`$dynamicAnchor`, any `$id`, or a `$defs` - * container anywhere but the conversion root. All of these can only enter an - * emission through `.meta()`/registry metadata — i.e. they are hand-authored. + * registered schemas), ANY ref — registry-shaped included — consumed under a + * `not`/`if`/`contains` polarity boundary, any `$anchor`/`$dynamicAnchor`, any + * `$id`, a `$defs` container anywhere but the conversion root, or any + * `unevaluatedProperties`/`unevaluatedItems` (annotation consumers that observe + * in-place loosening of their contributors as a tightening). All of these can + * only enter an emission through `.meta()`/registry metadata — i.e. they are + * hand-authored. * * The wire-truthfulness loosen family relocates and deletes document positions * (required-filter drops, catch degrades, null-tolerance element wraps, the @@ -524,33 +563,50 @@ const ZOD_REGISTRY_REF_PATTERN = /^#\/\$defs\/[^/]+$/; * break all the same. */ function hasHandAuthoredReferenceConstructs(document: Record): boolean { - const walk = (node: unknown, isRoot: boolean, seen: Set): boolean => { + const walk = (node: unknown, isRoot: boolean, underPolarityBoundary: boolean, seen: Set): boolean => { if (typeof node !== 'object' || node === null || seen.has(node)) return false; seen.add(node); - if (Array.isArray(node)) return node.some(item => walk(item, false, seen)); + if (Array.isArray(node)) return node.some(item => walk(item, false, underPolarityBoundary, seen)); const record = node as Record; for (const refKey of ['$ref', '$dynamicRef'] as const) { const value = record[refKey]; if (value === undefined) continue; - if (typeof value !== 'string' || (value !== '#' && !ZOD_REGISTRY_REF_PATTERN.test(value))) return true; + if (typeof value !== 'string') return true; + // Registry refs are loosen-safe only for POSITIVE-polarity consumers: + // the loosen family mutates `$defs` entries IN PLACE, so a consumer + // reading the target under `not`/`if`/`contains` observes every + // loosening as a tightening the rename walk's lexical polarity skip + // cannot see through the ref indirection. + if (underPolarityBoundary) return true; + if (value !== '#' && !ZOD_REGISTRY_REF_PATTERN.test(value)) return true; } if (record.$anchor !== undefined || record.$dynamicAnchor !== undefined || record.$id !== undefined) return true; if (!isRoot && record.$defs !== undefined) return true; + // `unevaluatedProperties`/`unevaluatedItems` consume the 2020-12 + // evaluation annotations their sibling (and `$ref`-mediated) subschemas + // contribute — in-place loosening of any contributor (a catch-degraded + // union member, a loosened `$defs` target) silently strips annotations + // and TIGHTENS the surviving consumer. zod never emits these keywords, + // so their presence is hand-authored by definition. + if (record.unevaluatedProperties !== undefined || record.unevaluatedItems !== undefined) return true; for (const [key, value] of Object.entries(record)) { if (!SCHEMA_CARRYING_JSON_SCHEMA_KEYWORDS.has(key)) continue; + // Sticky through nesting: anything beneath a negative/conditional + // keyword stays polarity-suspect (deeper `not`s only re-invert). + const childUnderBoundary = underPolarityBoundary || key === 'not' || key === 'if' || key === 'contains'; if (SCHEMA_MAP_JSON_SCHEMA_KEYWORDS.has(key) && typeof value === 'object' && value !== null && !Array.isArray(value)) { if (seen.has(value)) continue; seen.add(value); for (const subschema of Object.values(value as Record)) { - if (walk(subschema, false, seen)) return true; + if (walk(subschema, false, childUnderBoundary, seen)) return true; } continue; } - if (walk(value, false, seen)) return true; + if (walk(value, false, childUnderBoundary, seen)) return true; } return false; }; - return walk(document, true, new Set()); + return walk(document, true, false, new Set()); } /** diff --git a/packages/core-internal/test/util/standardSchema.test.ts b/packages/core-internal/test/util/standardSchema.test.ts index 074aa80675..d7b6f6a2bf 100644 --- a/packages/core-internal/test/util/standardSchema.test.ts +++ b/packages/core-internal/test/util/standardSchema.test.ts @@ -951,6 +951,89 @@ describe('zod conversion options (#2464)', () => { const rec = standardSchemaToJsonSchema(z.object({ tree, c: z.number().catch(0), name: z.string() }), 'output'); expect(rec.required).toEqual(['tree', 'name']); expect(new AjvJsonSchemaValidator().getValidator(rec)({ tree: { v: 'r', kids: [{ v: 'k' }] }, name: 'n' }).valid).toBe(true); + + // A POSITIVE-polarity hand-authored registry-shaped alias is loosen-safe + // too: entries mutate in place, so the alias observes the loosened entry. + const aliased = standardSchemaToJsonSchema( + z.object({ x: reg, alias: z.unknown().meta({ $ref: '#/$defs/Reg' }), name: z.string() }), + 'output' + ); + expect(((aliased.$defs as Record>).Reg!.required as string[]) ?? []).not.toContain('c'); + expect( + new AjvJsonSchemaValidator().getValidator(aliased)({ + x: { q: 'a', du: { t: 'a' } }, + alias: { q: 'b', du: { t: 'b' } }, + name: 'n' + }).valid + ).toBe(true); + }); + + test('the allOf-push stamp requires explicit member types (vacuous keywords prove nothing)', () => { + // `properties`/`required` are vacuous for non-object instances — a + // hand-authored type-less oneOf member is satisfiable by 42, so stamping + // an enforced `type: 'object'` would reject pre-PR-valid payloads. + const schema = z.object({ + counted: z.number().default(0), // unrelated loosening trigger + poly: z.unknown().meta({ oneOf: [{ properties: { a: { type: 'string' } } }], anyOf: [{}] }), + name: z.string() + }); + const result = standardSchemaToJsonSchema(schema, 'output'); + + const poly = (result.properties as Record>).poly!; + expect(poly.type).toBeUndefined(); + expect(new AjvJsonSchemaValidator().getValidator(result)({ counted: 1, poly: 42, name: 'n' }).valid).toBe(true); + // Explicitly-typed members (zod DU emissions) keep the stamp — the + // 2025-era legacy-wrap protection is unaffected. + const du = standardSchemaToJsonSchema( + z + .discriminatedUnion('t', [ + z.object({ t: z.literal('a').catch('a'), x: z.string().optional() }), + z.object({ t: z.literal('b').catch('b'), y: z.string().optional() }) + ]) + .meta({ anyOf: [{ type: 'object' }, { type: 'null' }] }), + 'output' + ); + expect(du.type).toBe('object'); + }); + + test('polarity-consumed registry refs and unevaluated* keywords disable loosening', () => { + // (1) A registry-shaped ref under `not`: the entry loosens IN PLACE, so + // the negated consumer would observe every loosening as a tightening — + // the guard ships strict instead and `{}` keeps failing the target. + const regY = z.object({ q: z.string().default('d') }).meta({ id: 'Y' }); + const negated = standardSchemaToJsonSchema( + z.object({ cfg: regY, guarded: z.unknown().meta({ not: { $ref: '#/$defs/Y' } }), name: z.string() }), + 'output' + ); + expect(((negated.$defs as Record>).Y!.required as string[]) ?? []).toContain('q'); // strict + expect(new AjvJsonSchemaValidator().getValidator(negated)({ cfg: { q: 'x' }, guarded: {}, name: 'n' }).valid).toBe(true); + + // (2) A registry ref beside `unevaluatedProperties: false`: the catch + // degrade would strip the target's `properties`, so the still-resolving + // ref would stop contributing evaluation annotations. + const regX = z.object({ p: z.string() }).catch({ p: 'd' }).meta({ id: 'X' }); + const annotated = standardSchemaToJsonSchema( + z.object({ cfg: regX, guarded: z.unknown().meta({ $ref: '#/$defs/X', unevaluatedProperties: false }), name: z.string() }), + 'output' + ); + expect(((annotated.$defs as Record>).X!.properties as Record).p).toBeDefined(); + expect(new AjvJsonSchemaValidator().getValidator(annotated)({ cfg: { p: 'v' }, guarded: { p: 'w' }, name: 'n' }).valid).toBe(true); + + // (3) No ref at all: a hand-authored `unevaluatedProperties: false` on a + // zod-emitted union whose member the catch degrade would skeletonize + // loses that member's annotation contributions identically. + const refFree = standardSchemaToJsonSchema( + z.object({ + x: z + .union([z.looseObject({ p: z.string() }).catch({ p: 'd' }), z.looseObject({ q: z.number() })]) + .meta({ unevaluatedProperties: false }), + name: z.string() + }), + 'output' + ); + const member = ((refFree.properties as Record>).x!.anyOf as Array>)[0]!; + expect(member.properties).toBeDefined(); // strict: no skeleton + expect(new AjvJsonSchemaValidator().getValidator(refFree)({ x: { p: 'v' }, name: 'n' }).valid).toBe(true); }); test('relocated members keep their stamp beside a null-membered user anyOf', () => { From 31b67463bf0d277f25cbb8824daa846da13d5d30 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 20:39:15 +0000 Subject: [PATCH 31/35] fix(core-internal): copy-on-wrap tuple prefixItems; legacy-draft guard vocabulary; root-aware allOf-push stamp; prose sync - The tuple null-tolerance wrap reassigns a FRESH prefixItems array on the emitted node instead of writing elements in place: a .meta({prefixItems}) array is the user's registry-owned object (zod's Object.assign meta merge shares it by reference and the override runs before the terminal deep clone), so the in-place write permanently corrupted registry metadata, nested one more anyOf per tools/list conversion, and leaked false null-tolerance into input advertisements. - The guard's vocabulary now covers the legacy-draft spellings the SDK's Ajv2020 (strict:false) engine still enforces: draft-07 'dependencies' joins both keyword sets (walked as a name->schema map like dependentSchemas; the array-of-strings form walks harmlessly), and any $recursiveRef/$recursiveAnchor occurrence is flagged as a hand-authored reference construct (zod never emits them; a negated {$recursiveRef: '#'} observes root loosening as a tightening exactly like its $dynamicRef successor). - The allOf-push stamp is root-aware: at the conversion ROOT it stamps on the keyword-presence heuristic (byte-parity with main's epilogue stamp, whose first-present-key-wins read the emitted oneOf - declining flipped the 2025-era legacy wrap whenever an unrelated .default() fired the loosen pass), while nested nodes keep the genuine explicit-type proof. - Sync the changeset paragraph and the Known-residual-gaps bullet with the full set of loosening-disabling conditions (polarity-consumed refs, unevaluated*, $recursiveRef/$recursiveAnchor). Co-Authored-By: Claude --- .changeset/zod-tojsonschema-wire-truthful.md | 13 ++- .../core-internal/src/util/standardSchema.ts | 94 +++++++++++++------ .../test/util/standardSchema.test.ts | 71 ++++++++++++++ 3 files changed, 146 insertions(+), 32 deletions(-) diff --git a/.changeset/zod-tojsonschema-wire-truthful.md b/.changeset/zod-tojsonschema-wire-truthful.md index f713d7966f..a242575799 100644 --- a/.changeset/zod-tojsonschema-wire-truthful.md +++ b/.changeset/zod-tojsonschema-wire-truthful.md @@ -36,10 +36,13 @@ JSON-RPC serialization and Map/Set values serialize as `{}`. And a degraded object-`.catch()` node keeps `type: 'object'` for the 2025-era wrap proof even though catch-validation does not enforce it on the raw value. Hand-authored reference keywords disable the loosening: a `.meta()`/registry-injected -`$ref`/`$dynamicRef` beyond zod's own registry shapes (`#`, `#/$defs/`), any -`$anchor`/`$dynamicAnchor` or `$id`, or a non-root `$defs` makes the conversion ship the -strict pre-fix-shaped emission instead — such constructs would observe the loosening's -rewrites as dangling pointers or stale anchors, so those schemas keep pre-fix strictness, -compilable and working by construction.) Elicitation is unaffected: +`$ref`/`$dynamicRef` beyond zod's own registry shapes (`#`, `#/$defs/`), ANY ref — +registry-shaped included — consumed under a `not`/`if`/`contains` keyword, any +`$anchor`/`$dynamicAnchor` or `$id`, any `$recursiveRef`/`$recursiveAnchor`, a non-root +`$defs`, or any hand-authored `unevaluatedProperties`/`unevaluatedItems` makes the +conversion ship the strict pre-fix-shaped emission instead — such constructs would observe +the loosening's rewrites as dangling pointers, stale anchors, polarity-inverted negations, +or stripped evaluation annotations, so those schemas keep pre-fix strictness, compilable +and working by construction.) Elicitation is unaffected: `inputRequired.elicit()` keeps throwing on schemas its restricted form grammar cannot round-trip, including `z.date()`. diff --git a/packages/core-internal/src/util/standardSchema.ts b/packages/core-internal/src/util/standardSchema.ts index ee538e85b6..7304693970 100644 --- a/packages/core-internal/src/util/standardSchema.ts +++ b/packages/core-internal/src/util/standardSchema.ts @@ -240,13 +240,19 @@ export const JSON_SCHEMA_CONVERSION_TARGET = 'draft-2020-12'; * as a `Date` instance and fails the advertised schema there. * - Hand-authored reference keywords disable the wire-truthfulness loosening * entirely: when the emission carries any `$ref`/`$dynamicRef` beyond zod's own - * registry shapes (`#`, `#/$defs/`), any `$anchor`/`$dynamicAnchor`, any - * `$id`, or a non-root `$defs`, the conversion ships the strict pre-#2464-shaped - * emission instead (see {@linkcode hasHandAuthoredReferenceConstructs}) — the - * loosen family relocates and deletes document positions, which such constructs - * observe as dangling pointers, stale anchors, or shifted base-relative paths. - * Those conversions keep pre-#2464 strictness (e.g. `additionalProperties: false` - * stays advertised, tolerant fields stay `required`), trading loosening for + * registry shapes (`#`, `#/$defs/`), ANY ref — registry-shaped included — + * consumed under a `not`/`if`/`contains` polarity boundary, any + * `$anchor`/`$dynamicAnchor`, any `$id`, any `$recursiveRef`/`$recursiveAnchor` + * (2019-09 spellings the SDK's Ajv2020 engine still enforces), a non-root + * `$defs`, or any `unevaluatedProperties`/`unevaluatedItems` (annotation + * consumers that observe in-place loosening of their contributors as a + * tightening), the conversion ships the strict pre-#2464-shaped emission + * instead (see {@linkcode hasHandAuthoredReferenceConstructs}) — the loosen + * family relocates and deletes document positions, which such constructs + * observe as dangling pointers, stale anchors, shifted base-relative paths, or + * polarity-inverted/annotation-stripped tightenings. Those conversions keep + * pre-#2464 strictness (e.g. `additionalProperties: false` stays advertised, + * tolerant fields stay `required`), trading loosening for * compilability-by-construction. * - The loosen rewrite's `oneOf` → `anyOf` rename skips lexical `not`/`if`/`contains` * positions, but a `.meta({not: {oneOf: […]}})` cloning a positive-position @@ -356,15 +362,28 @@ function zodConversionOptions( return; } if (def.type === 'tuple') { - // Same wire mechanism per prefix position. + // Same wire mechanism per prefix position. The wrap REASSIGNS a + // fresh array on the node's top-level key — never writes into the + // existing one: a `.meta({prefixItems})` array is the user's + // registry-owned object (zod's Object.assign meta merge shares it + // by reference, and this hook runs before the terminal deep + // clone), so an in-place element write would permanently corrupt + // registry metadata, nest one more anyOf per conversion, and leak + // false null-tolerance into input advertisements. const prefixItems = ctx.jsonSchema.prefixItems; if (Array.isArray(def.items) && Array.isArray(prefixItems)) { - for (const [index, item] of def.items.entries()) { - const emitted = prefixItems[index]; - if (typeof emitted === 'object' && emitted !== null && fieldAcceptsMissingKey(item)) { - loosened.value = true; - prefixItems[index] = { anyOf: [emitted, { type: 'null' }] }; + let wrappedAny = false; + const wrapped = prefixItems.map((emitted, index) => { + const item = index < (def.items as unknown[]).length ? (def.items as z.core.$ZodType[])[index] : undefined; + if (item !== undefined && typeof emitted === 'object' && emitted !== null && fieldAcceptsMissingKey(item)) { + wrappedAny = true; + return { anyOf: [emitted, { type: 'null' }] }; } + return emitted as unknown; + }); + if (wrappedAny) { + loosened.value = true; + ctx.jsonSchema.prefixItems = wrapped as typeof ctx.jsonSchema.prefixItems; } } // ... and for the REST element, whose emitted subschema lands under @@ -438,11 +457,11 @@ function zodConversionOptions( * none, and zod's own registry refs (`#`, `#/$defs/`) never traverse a * `oneOf` segment. */ -function rewriteOneOfToAnyOf(node: unknown, seen: Set = new Set()): void { +function rewriteOneOfToAnyOf(node: unknown, seen: Set = new Set(), isRoot = true): void { if (typeof node !== 'object' || node === null || seen.has(node)) return; seen.add(node); if (Array.isArray(node)) { - for (const item of node) rewriteOneOfToAnyOf(item, seen); + for (const item of node) rewriteOneOfToAnyOf(item, seen, false); return; } const record = node as Record; @@ -458,15 +477,20 @@ function rewriteOneOfToAnyOf(node: unknown, seen: Set = new Set()): voi // The relocated members' objectness becomes invisible to the wrap proof's // every()-member rule once user conjuncts coexist (e.g. a .meta({allOf: // [{minProperties: 1}]})) — stamp the explicit type here, preserving the - // pre-#2464 stamp these roots got via their emitted oneOf. The stamp is - // ENFORCED, so it requires a genuine instance-typing proof — every - // relocated member explicitly `type: 'object'` — NOT the wrap - // heuristic's keyword-presence rule (`properties`/`required`/… are - // vacuous for non-object instances, so a hand-authored type-less member - // is satisfiable by `42` and must not be stamped away). Zod-emitted DU - // members and catch skeletons always carry the explicit type, so the - // legacy-wrap protection is unaffected. - if (record.type === undefined && membersAreExplicitlyObjectTyped(record.oneOf)) { + // pre-#2464 stamp these roots got via their emitted oneOf. The proof is + // POSITION-AWARE: at the conversion ROOT the keyword-presence heuristic + // is byte-parity with main's epilogue stamp (first-present-key-wins + // would have read this oneOf and stamped, deciding the 2025-era legacy + // wrap), so declining it would flip the wire shape; at NESTED nodes — + // which never received any stamp pre-#2464 — the stamp is a fresh + // ENFORCED constraint and requires the genuine instance-typing proof + // (every relocated member explicitly `type: 'object'`; mere + // `properties`/`required` carriage is vacuous for non-object + // instances, so a hand-authored type-less member satisfiable by `42` + // must not be stamped away). Zod-emitted DU members and catch + // skeletons always carry the explicit type either way. + const proven = isRoot ? isProvablyObjectShapedRoot({ anyOf: record.oneOf }) : membersAreExplicitlyObjectTyped(record.oneOf); + if (record.type === undefined && proven) { record.type = 'object'; } } @@ -491,10 +515,10 @@ function rewriteOneOfToAnyOf(node: unknown, seen: Set = new Set()): voi if (SCHEMA_MAP_JSON_SCHEMA_KEYWORDS.has(key) && typeof value === 'object' && value !== null && !Array.isArray(value)) { if (seen.has(value)) continue; seen.add(value); - for (const subschema of Object.values(value as Record)) rewriteOneOfToAnyOf(subschema, seen); + for (const subschema of Object.values(value as Record)) rewriteOneOfToAnyOf(subschema, seen, false); continue; } - rewriteOneOfToAnyOf(value, seen); + rewriteOneOfToAnyOf(value, seen, false); } } @@ -581,6 +605,11 @@ function hasHandAuthoredReferenceConstructs(document: Record): if (value !== '#' && !ZOD_REGISTRY_REF_PATTERN.test(value)) return true; } if (record.$anchor !== undefined || record.$dynamicAnchor !== undefined || record.$id !== undefined) return true; + // 2019-09 recursion keywords: zod never emits them, and the SDK's + // Ajv2020 engine enforces $recursiveRef in every draft mode — a negated + // `{$recursiveRef: '#'}` observes root loosening as a tightening exactly + // like its $dynamicRef successor (which the polarity clause catches). + if (record.$recursiveRef !== undefined || record.$recursiveAnchor !== undefined) return true; if (!isRoot && record.$defs !== undefined) return true; // `unevaluatedProperties`/`unevaluatedItems` consume the 2020-12 // evaluation annotations their sibling (and `$ref`-mediated) subschemas @@ -677,6 +706,11 @@ const SCHEMA_CARRYING_JSON_SCHEMA_KEYWORDS: ReadonlySet = new Set([ 'patternProperties', '$defs', 'dependentSchemas', + // Draft-07 spelling of dependentSchemas (name→schema map; the + // array-of-strings property-dependency form walks harmlessly) — Ajv2020 + // with strict:false still compiles and ENFORCES it, so the guard and the + // rename walk must see inside. + 'dependencies', 'items', 'prefixItems', 'anyOf', @@ -694,7 +728,13 @@ const SCHEMA_CARRYING_JSON_SCHEMA_KEYWORDS: ReadonlySet = new Set([ ]); /** Schema-map keywords among the above: their KEYS are user-chosen names, their values schemas. */ -const SCHEMA_MAP_JSON_SCHEMA_KEYWORDS: ReadonlySet = new Set(['properties', 'patternProperties', '$defs', 'dependentSchemas']); +const SCHEMA_MAP_JSON_SCHEMA_KEYWORDS: ReadonlySet = new Set([ + 'properties', + 'patternProperties', + '$defs', + 'dependentSchemas', + 'dependencies' +]); /** * Non-schema-carrying keywords that validators ENFORCE — the `.catch()` degrade must diff --git a/packages/core-internal/test/util/standardSchema.test.ts b/packages/core-internal/test/util/standardSchema.test.ts index d7b6f6a2bf..586909c358 100644 --- a/packages/core-internal/test/util/standardSchema.test.ts +++ b/packages/core-internal/test/util/standardSchema.test.ts @@ -1036,6 +1036,77 @@ describe('zod conversion options (#2464)', () => { expect(new AjvJsonSchemaValidator().getValidator(refFree)({ x: { p: 'v' }, name: 'n' }).valid).toBe(true); }); + test('the tuple wrap never mutates a registry-owned prefixItems array', () => { + // zod's Object.assign meta merge shares the user's array by reference and + // the override runs before the terminal deep clone — an in-place element + // write would corrupt registry metadata, nest one more anyOf per + // conversion, and leak false null-tolerance into input advertisements. + const userPrefixItems = [{ type: 'string' }, { type: 'number' }]; + const schema = z.object({ + t: z.tuple([z.string().default('x'), z.number()]).meta({ prefixItems: userPrefixItems }), + name: z.string() + }); + const first = standardSchemaToJsonSchema(schema, 'output'); + const second = standardSchemaToJsonSchema(schema, 'output'); + + expect(userPrefixItems).toEqual([{ type: 'string' }, { type: 'number' }]); // registry untouched + // No nesting growth: both conversions advertise the identical single wrap. + expect(second).toEqual(first); + const prefixItems = (first.properties as Record>).t!.prefixItems as Array>; + expect(prefixItems[0]).toEqual({ anyOf: [{ type: 'string' }, { type: 'null' }] }); + }); + + test('legacy-draft reference spellings disable loosening (dependencies, $recursiveRef)', () => { + // Ajv2020 with strict:false still compiles and ENFORCES draft-07 + // `dependencies` and 2019-09 `$recursiveRef` — both must ship strict. + const gated = standardSchemaToJsonSchema( + z.object({ + du: z.discriminatedUnion('t', [ + z.object({ t: z.literal('a').catch('a'), x: z.string().optional() }), + z.object({ t: z.literal('b').catch('b'), y: z.string().optional() }) + ]), + gated: z.unknown().meta({ type: 'object', dependencies: { a: { $ref: '#/properties/du/oneOf/0' } } }), + name: z.string() + }), + 'output' + ); + expect((gated.properties as Record>).du!.oneOf).toBeDefined(); // strict: no rename + expect(new AjvJsonSchemaValidator().getValidator(gated)({ du: { t: 'a' }, gated: { b: 1 }, name: 'n' }).valid).toBe(true); + + const recursive = standardSchemaToJsonSchema( + z.object({ + cfg: z.number().default(0), + guarded: z.unknown().meta({ not: { $recursiveRef: '#' } }), + name: z.string().default('n') + }), + 'output' + ); + // Strict: the root keeps required/additionalProperties, so `{}` still + // fails the root and the negation keeps passing (pre-PR behavior). + expect(recursive.required).toEqual(['cfg', 'guarded', 'name']); + expect(new AjvJsonSchemaValidator().getValidator(recursive)({ cfg: 1, guarded: {}, name: 'n' }).valid).toBe(true); + }); + + test('the allOf-push stamp is root-aware (keyword-presence parity at roots only)', () => { + // At the conversion ROOT, main's epilogue stamped via the + // keyword-presence rule (first-present-key-wins read the emitted oneOf) — + // declining there would flip the 2025-era legacy wrap when an unrelated + // .default() fires the loosen pass. + const root = z + .union([z.object({ c: z.number().default(0), a: z.string() }), z.null()]) + .meta({ oneOf: [{ properties: { a: { type: 'string' } } }] }); + const loosenedRoot = standardSchemaToJsonSchema(root, 'output'); + expect(loosenedRoot.type).toBe('object'); + expect(isNonObjectJsonSchemaRoot(loosenedRoot)).toBe(false); + // Without the trigger the epilogue proof stamps the untouched root — the + // wire shape must not depend on an unrelated .default(). + const untouchedRoot = standardSchemaToJsonSchema( + z.union([z.object({ a: z.string() }), z.null()]).meta({ oneOf: [{ properties: { a: { type: 'string' } } }] }), + 'output' + ); + expect(untouchedRoot.type).toBe('object'); + }); + test('relocated members keep their stamp beside a null-membered user anyOf', () => { // The loosen rewrite relocates the DU members under allOf beside the user's // .meta({anyOf}) and stamps `type: 'object'` itself — the epilogue proof From c105ae738aa64c6cf7efec487d71b87a4a639d3d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 21:22:21 +0000 Subject: [PATCH 32/35] fix(core-internal): wrap stamp reads the strict pre-loosen snapshot; additionalProperties drop sets loosened; additionalItems in guard vocabulary - The 2025-era legacy-wrap stamp decision now reads the STRICT pre-loosen emission the guard pass already computes (snapshot isProvablyObjectShapedRoot(strict); the output epilogue consults it, falling back to the post-loosen proof only when no strict pass ran - guard-shipped results and non-zod vendors). Main decided the wrap on the raw emission, so this is byte-parity by construction: loosen mutations that destroy proof-relevant keys (the allOf-push relocating a root oneOf, the catch degrade deleting meta-authored required/properties) or expose keys main's first-present-key-wins rule never read can no longer flip the SEP-2106 wire shape on an unrelated trigger, in either direction. The allOf-push branch's stamping arm (both the root keyword-presence and nested explicit-type proofs) is deleted - the snapshot covers the root, and nested nodes never received any stamp pre-#2464. - The plain-object additionalProperties: false drop now sets loosened.value: it was the only loosen mutation that skipped the flag, so a solo drop left a hand-authored exactly-one oneOf over registry-hoisted plain objects reject-everything (both $defs entries become mutually satisfiable without the rename). Plain-object DU emissions now rename to anyOf - harmless, discriminator consts keep members exclusive - pinned, with a strictObject control keeping its oneOf. - Add draft-07 'additionalItems' to SCHEMA_CARRYING_JSON_SCHEMA_KEYWORDS: the SDK's cfworker provider enforces it in every draft mode and collects refs inside it, so a hand-authored ref there must trip the guard - parallel to the round-30 'dependencies' fix. Co-Authored-By: Claude --- .../core-internal/src/util/standardSchema.ts | 112 ++++++-------- .../test/util/standardSchema.test.ts | 143 +++++++++++++++--- 2 files changed, 172 insertions(+), 83 deletions(-) diff --git a/packages/core-internal/src/util/standardSchema.ts b/packages/core-internal/src/util/standardSchema.ts index 7304693970..74ac71cc28 100644 --- a/packages/core-internal/src/util/standardSchema.ts +++ b/packages/core-internal/src/util/standardSchema.ts @@ -425,6 +425,12 @@ function zodConversionOptions( if (def.type !== 'object') return; const isStrict = def.catchall?._zod.def.type === 'never'; if (!isStrict && ctx.jsonSchema.additionalProperties === false) { + // A member-visible loosening like every other mutation here: two + // registry-hoisted plain objects distinguished only by their extra + // keys become mutually satisfiable, so a hand-authored exactly-one + // `oneOf` over registry refs turns reject-everything unless the + // epilogue rename fires — the flag must be set. + loosened.value = true; delete ctx.jsonSchema.additionalProperties; } const required = ctx.jsonSchema.required; @@ -457,11 +463,11 @@ function zodConversionOptions( * none, and zod's own registry refs (`#`, `#/$defs/`) never traverse a * `oneOf` segment. */ -function rewriteOneOfToAnyOf(node: unknown, seen: Set = new Set(), isRoot = true): void { +function rewriteOneOfToAnyOf(node: unknown, seen: Set = new Set()): void { if (typeof node !== 'object' || node === null || seen.has(node)) return; seen.add(node); if (Array.isArray(node)) { - for (const item of node) rewriteOneOfToAnyOf(item, seen, false); + for (const item of node) rewriteOneOfToAnyOf(item, seen); return; } const record = node as Record; @@ -472,27 +478,12 @@ function rewriteOneOfToAnyOf(node: unknown, seen: Set = new Set(), isRo // A user `.meta({anyOf})` can coexist with the emitted `oneOf` — preserve // conjunction semantics without clobbering it: keywords on one node // combine with AND, so `{anyOf: members}` under `allOf` is equivalent. + // No type stamp here: the relocation destroys evidence the 2025-era + // wrap proof would read, so the epilogue decides the root stamp from + // the STRICT pre-loosen snapshot instead (byte-parity with main), and + // nested nodes never received any stamp pre-#2464. const allOf = Array.isArray(record.allOf) ? record.allOf : (record.allOf = []); allOf.push({ anyOf: record.oneOf }); - // The relocated members' objectness becomes invisible to the wrap proof's - // every()-member rule once user conjuncts coexist (e.g. a .meta({allOf: - // [{minProperties: 1}]})) — stamp the explicit type here, preserving the - // pre-#2464 stamp these roots got via their emitted oneOf. The proof is - // POSITION-AWARE: at the conversion ROOT the keyword-presence heuristic - // is byte-parity with main's epilogue stamp (first-present-key-wins - // would have read this oneOf and stamped, deciding the 2025-era legacy - // wrap), so declining it would flip the wire shape; at NESTED nodes — - // which never received any stamp pre-#2464 — the stamp is a fresh - // ENFORCED constraint and requires the genuine instance-typing proof - // (every relocated member explicitly `type: 'object'`; mere - // `properties`/`required` carriage is vacuous for non-object - // instances, so a hand-authored type-less member satisfiable by `42` - // must not be stamped away). Zod-emitted DU members and catch - // skeletons always carry the explicit type either way. - const proven = isRoot ? isProvablyObjectShapedRoot({ anyOf: record.oneOf }) : membersAreExplicitlyObjectTyped(record.oneOf); - if (record.type === undefined && proven) { - record.type = 'object'; - } } delete record.oneOf; } @@ -515,42 +506,13 @@ function rewriteOneOfToAnyOf(node: unknown, seen: Set = new Set(), isRo if (SCHEMA_MAP_JSON_SCHEMA_KEYWORDS.has(key) && typeof value === 'object' && value !== null && !Array.isArray(value)) { if (seen.has(value)) continue; seen.add(value); - for (const subschema of Object.values(value as Record)) rewriteOneOfToAnyOf(subschema, seen, false); + for (const subschema of Object.values(value as Record)) rewriteOneOfToAnyOf(subschema, seen); continue; } - rewriteOneOfToAnyOf(value, seen, false); + rewriteOneOfToAnyOf(value, seen); } } -/** - * A genuine instance-typing proof for a composition's members: every member — - * recursively through nested compositions — carries an explicit enforced - * `type: 'object'`. Sound per 2020-12 semantics: keywords on one node - * AND-combine, so a member forces objectness when it carries the type directly, - * when an exhaustive nested composition does (EVERY `oneOf`/`anyOf` branch a - * value could satisfy), or when ANY `allOf` conjunct does. Unlike - * {@linkcode isProvablyObjectShapedRoot}'s keyword-PRESENCE rule (a 2025-era - * wrap-decision heuristic, parity-sound only at roots), mere `properties`/ - * `required` carriage proves nothing — those keywords are vacuous for - * non-object instances. - */ -function membersAreExplicitlyObjectTyped(members: readonly unknown[]): boolean { - return ( - members.length > 0 && - members.every(member => { - if (typeof member !== 'object' || member === null || Array.isArray(member)) return false; - const record = member as Record; - if (record.type === 'object') return true; - for (const key of ['oneOf', 'anyOf'] as const) { - const nested = record[key]; - if (Array.isArray(nested) && membersAreExplicitlyObjectTyped(nested)) return true; - } - const allOf = record.allOf; - return Array.isArray(allOf) && allOf.some(conjunct => membersAreExplicitlyObjectTyped([conjunct])); - }) - ); -} - /** The only `$ref` shape zod's emitter produces besides bare `#`: a top-level `$defs` entry. */ const ZOD_REGISTRY_REF_PATTERN = /^#\/\$defs\/[^/]+$/; @@ -724,7 +686,11 @@ const SCHEMA_CARRYING_JSON_SCHEMA_KEYWORDS: ReadonlySet = new Set([ 'propertyNames', 'contains', 'unevaluatedProperties', - 'unevaluatedItems' + 'unevaluatedItems', + // Draft-07 tail-items spelling: the SDK's cfworker provider enforces it in + // every draft mode (and collects refs inside it for resolution), so the + // guard must walk inside — parallel to `dependencies` above. + 'additionalItems' ]); /** Schema-map keywords among the above: their KEYS are user-chosen names, their values schemas. */ @@ -957,6 +923,7 @@ export function standardSchemaToJsonSchema( ); }; let result: Record; + let strictRootProven: boolean | undefined; if (options?.unrepresentable === 'throw') { result = convert(undefined); } else if (io !== 'output' || std.vendor !== 'zod') { @@ -975,17 +942,31 @@ export function standardSchemaToJsonSchema( // zod's own registry refs, stable under every mutation) convert again with // the loosen family active. const strict = convert(zodConversionOptions(io, loosened, false)); - result = hasHandAuthoredReferenceConstructs(strict) ? strict : convert(zodConversionOptions(io, loosened, true)); + if (hasHandAuthoredReferenceConstructs(strict)) { + result = strict; + } else { + // The 2025-era wrap-stamp decision must match main, which read the RAW + // emission: loosen mutations can destroy proof-relevant keys (the + // allOf-push relocates a root `oneOf`; the catch degrade deletes + // meta-authored `required`/`properties`) or expose ones main's + // first-present-key-wins rule never read — flipping the SEP-2106 + // legacy wrap either direction on an unrelated trigger. Snapshot the + // proof on the strict emission; the output epilogue consults it + // instead of the post-loosen document. + strictRootProven = isProvablyObjectShapedRoot(strict); + result = convert(zodConversionOptions(io, loosened, true)); + } } if (io === 'output' && loosened.value) { - // Exactly-one semantics cannot survive member loosening: once the catch - // degrade or the required-filter fired anywhere in this conversion, `oneOf` - // members (zod's discriminated-union emission) may have become mutually - // satisfiable — e.g. catch-wrapped discriminators — and Ajv would reject - // every payload with "must match exactly one schema in oneOf". Rewrite to - // the honest `anyOf` (loosen-only and wrap-neutral: - // `isProvablyObjectShapedRoot` treats the composition keywords identically; - // no reference can observe the rename — the guard above vouched for it). + // Exactly-one semantics cannot survive member loosening: once ANY loosen + // mutation fired anywhere in this conversion — a catch degrade, the + // required-filter, or the additionalProperties drop — `oneOf` members + // (zod's discriminated-union emission, or a hand-authored oneOf over + // registry refs) may have become mutually satisfiable, and Ajv would + // reject every payload with "must match exactly one schema in oneOf". + // Rewrite to the honest `anyOf` (loosen-only; wrap-neutral because the + // 2025-era stamp decision reads the strict pre-loosen snapshot; no + // reference can observe the rename — the guard above vouched for it). rewriteOneOfToAnyOf(result); } if (io === 'output') { @@ -1027,8 +1008,11 @@ export function standardSchemaToJsonSchema( } // The stamp runs AFTER the guard: a loud conjunct (e.g. // `z.intersection(z.object(...), z.bigint())`) must throw even when an object - // conjunct could prove the root. - if (isProvablyObjectShapedRoot(result)) return { type: 'object', ...result }; + // conjunct could prove the root. When a loosen pass ran, the proof reads the + // STRICT pre-loosen snapshot — byte-parity with main, immune to loosen + // mutations of proof-relevant keys; otherwise (guard-shipped strict result, + // non-zod vendors) the document itself is the un-mutated emission. + if (strictRootProven ?? isProvablyObjectShapedRoot(result)) return { type: 'object', ...result }; return result; } if (result.type !== undefined && result.type !== 'object') { diff --git a/packages/core-internal/test/util/standardSchema.test.ts b/packages/core-internal/test/util/standardSchema.test.ts index 586909c358..f6bcb2ed6c 100644 --- a/packages/core-internal/test/util/standardSchema.test.ts +++ b/packages/core-internal/test/util/standardSchema.test.ts @@ -2,6 +2,7 @@ import * as z from 'zod/v4'; import { standardSchemaToJsonSchema } from '../../src/util/standardSchema'; import { AjvJsonSchemaValidator } from '../../src/validators/ajvProvider'; +import { CfWorkerJsonSchemaValidator } from '../../src/validators/cfWorkerProvider'; import { isNonObjectJsonSchemaRoot } from '../../src/wire/rev2025-11-25/legacyWrap'; describe('standardSchemaToJsonSchema', () => { @@ -436,18 +437,31 @@ describe('zod conversion options (#2464)', () => { expect(new AjvJsonSchemaValidator().getValidator(nested)({ res: { t: 'a', x: 'v' }, name: 'n' }).valid).toBe(true); }); - test('a discriminated union without degraded nodes keeps its oneOf', () => { - // Untouched schemas must not be loosened: exactly-one semantics stay - // truthful while the members keep their discriminating constraints. + test('a discriminated union of plain objects renames to anyOf (additionalProperties drop is loosening)', () => { + // The members' additionalProperties: false drop is member-visible + // loosening (two objects distinguished only by their extra keys become + // mutually satisfiable), so the rename gate must fire. Harmless for zod + // DUs — the discriminator consts keep members exclusive under anyOf. const du = z.discriminatedUnion('t', [ z.object({ t: z.literal('a'), x: z.string() }), z.object({ t: z.literal('b'), y: z.string() }) ]); const result = standardSchemaToJsonSchema(du, 'output'); - expect(Array.isArray(result.oneOf)).toBe(true); - expect(result.anyOf).toBeUndefined(); - expect(new AjvJsonSchemaValidator().getValidator(result)({ t: 'a', x: 'v' }).valid).toBe(true); + expect(result.oneOf).toBeUndefined(); + expect(Array.isArray(result.anyOf)).toBe(true); + expect(result.type).toBe('object'); // wrap parity: the strict snapshot proves the oneOf + const validate = new AjvJsonSchemaValidator().getValidator(result); + expect(validate({ t: 'a', x: 'v' }).valid).toBe(true); + expect(validate({ t: 'c' }).valid).toBe(false); // discriminators still enforce + // STRICT objects keep additionalProperties: false — nothing loosens, the + // exactly-one oneOf survives. + const strictDu = standardSchemaToJsonSchema( + z.discriminatedUnion('t', [z.strictObject({ t: z.literal('a'), x: z.string() }), z.strictObject({ t: z.literal('b') })]), + 'output' + ); + expect(Array.isArray(strictDu.oneOf)).toBe(true); + expect(new AjvJsonSchemaValidator().getValidator(strictDu)({ t: 'a', x: 'v' }).valid).toBe(true); }); test('tolerant array/tuple elements also accept null in output schemas', () => { @@ -968,10 +982,11 @@ describe('zod conversion options (#2464)', () => { ).toBe(true); }); - test('the allOf-push stamp requires explicit member types (vacuous keywords prove nothing)', () => { + test('nested nodes are never stamped by the loosen rewrite (vacuous keywords prove nothing)', () => { // `properties`/`required` are vacuous for non-object instances — a // hand-authored type-less oneOf member is satisfiable by 42, so stamping - // an enforced `type: 'object'` would reject pre-PR-valid payloads. + // an enforced `type: 'object'` would reject pre-PR-valid payloads. The + // root's wrap stamp comes from the strict pre-loosen snapshot instead. const schema = z.object({ counted: z.number().default(0), // unrelated loosening trigger poly: z.unknown().meta({ oneOf: [{ properties: { a: { type: 'string' } } }], anyOf: [{}] }), @@ -1087,31 +1102,121 @@ describe('zod conversion options (#2464)', () => { expect(new AjvJsonSchemaValidator().getValidator(recursive)({ cfg: 1, guarded: {}, name: 'n' }).valid).toBe(true); }); - test('the allOf-push stamp is root-aware (keyword-presence parity at roots only)', () => { - // At the conversion ROOT, main's epilogue stamped via the - // keyword-presence rule (first-present-key-wins read the emitted oneOf) — - // declining there would flip the 2025-era legacy wrap when an unrelated - // .default() fires the loosen pass. + test('the wrap stamp reads the strict pre-loosen emission (parity with main at roots)', () => { + // Main decided the 2025-era wrap on the RAW emission — the epilogue now + // snapshots the proof on the strict pass, so loosen mutations of + // proof-relevant keys cannot flip the wire shape either direction. + // Keyword-presence-provable root oneOf: stamped with AND without an + // unrelated .default() trigger. const root = z .union([z.object({ c: z.number().default(0), a: z.string() }), z.null()]) .meta({ oneOf: [{ properties: { a: { type: 'string' } } }] }); const loosenedRoot = standardSchemaToJsonSchema(root, 'output'); expect(loosenedRoot.type).toBe('object'); expect(isNonObjectJsonSchemaRoot(loosenedRoot)).toBe(false); - // Without the trigger the epilogue proof stamps the untouched root — the - // wire shape must not depend on an unrelated .default(). const untouchedRoot = standardSchemaToJsonSchema( z.union([z.object({ a: z.string() }), z.null()]).meta({ oneOf: [{ properties: { a: { type: 'string' } } }] }), 'output' ); expect(untouchedRoot.type).toBe('object'); + + // FAILING root oneOf (no object keyword): the allOf-push deletes the + // oneOf, but the snapshot keeps reading it — typeless and wrapped, with + // and without the trigger. + const failing = z + .union([z.object({ c: z.number().default(0), a: z.string() }), z.object({ b: z.string() })]) + .meta({ oneOf: [{ minimum: 1 }] }); + const failingLoosened = standardSchemaToJsonSchema(failing, 'output'); + expect(failingLoosened.type).toBeUndefined(); + expect(isNonObjectJsonSchemaRoot(failingLoosened)).toBe(true); + const failingUntouched = standardSchemaToJsonSchema( + z.union([z.object({ a: z.string() }), z.object({ b: z.string() })]).meta({ oneOf: [{ minimum: 1 }] }), + 'output' + ); + expect(failingUntouched.type).toBeUndefined(); + + // NESTED member whose provability evidence the push relocates: the + // snapshot still proves it through the member's oneOf. + const nested = z.union([ + z.object({ c: z.number().default(0), a: z.string() }), + z.unknown().meta({ oneOf: [{ properties: { q: { type: 'string' } } }], anyOf: [{}] }) + ]); + expect(standardSchemaToJsonSchema(nested, 'output').type).toBe('object'); + + // Catch degrade deleting meta-authored proof keys (`required`): the + // snapshot read them before the degrade. + const catchRoot = z + .union([z.object({ x: z.number() }), z.null()]) + .catch({ x: 1 }) + .meta({ required: ['x'] }); + expect(standardSchemaToJsonSchema(catchRoot, 'output').type).toBe('object'); + }); + + test('a solo additionalProperties drop still fires the oneOf rename', () => { + // The drop is member-visible loosening through positive-polarity registry + // refs: both $defs entries lose additionalProperties: false, the payload + // matches both, and a surviving exactly-one oneOf would reject everything. + const A = z.object({ kind: z.string() }).meta({ id: 'ReproA' }); + const B = z.object({ kind: z.string(), extra: z.number() }).meta({ id: 'ReproB' }); + const schema = z.object({ + x: A, + y: B, + // A string-keyed record carrier: not missing-key-tolerant, so no other + // loosen mutation fires — the drop must set the flag itself. + choice: z.record(z.string(), z.unknown()).meta({ oneOf: [{ $ref: '#/$defs/ReproA' }, { $ref: '#/$defs/ReproB' }] }), + name: z.string() + }); + const result = standardSchemaToJsonSchema(schema, 'output'); + + const choice = (result.properties as Record>).choice!; + expect(choice.oneOf).toBeUndefined(); + expect(Array.isArray(choice.anyOf)).toBe(true); + const payload = { x: { kind: 'k' }, y: { kind: 'k', extra: 1 }, choice: { kind: 'k', extra: 1 }, name: 'n' }; + expect(new AjvJsonSchemaValidator().getValidator(result)(payload).valid).toBe(true); + }); + + test('refs inside draft-07 additionalItems disable loosening (cfworker enforces it)', () => { + // @cfworker/json-schema validates additionalItems in every draft mode and + // collects refs inside it — a dangling one throws at validator build. + const schema = z.object({ + du: z.discriminatedUnion('t', [ + z.object({ t: z.literal('a').catch('a'), x: z.string().optional() }), + z.object({ t: z.literal('b').catch('b'), y: z.string().optional() }) + ]), + arr: z.unknown().meta({ type: 'array', items: [{ type: 'string' }], additionalItems: { $ref: '#/properties/du/oneOf/0' } }), + name: z.string() + }); + const result = standardSchemaToJsonSchema(schema, 'output'); + + expect((result.properties as Record>).du!.oneOf).toBeDefined(); // strict shipped + const validate = new CfWorkerJsonSchemaValidator().getValidator(result); + expect(validate({ du: { t: 'a' }, arr: ['head', { t: 'a', x: 'v' }], name: 'n' }).valid).toBe(true); + expect(validate({ du: { t: 'a' }, arr: ['head', { z: 1 }], name: 'n' }).valid).toBe(false); // ref still enforces + + // Catch-degrade spelling: the ref's target would be deleted. + const degraded = standardSchemaToJsonSchema( + z.object({ + cfg: z.object({ q: z.string() }).catch({ q: 'd' }), + arr: z + .unknown() + .meta({ type: 'array', items: [{ type: 'string' }], additionalItems: { $ref: '#/properties/cfg/properties/q' } }), + name: z.string() + }), + 'output' + ); + const cfg = (degraded.properties as Record>).cfg!; + expect((cfg.properties as Record).q).toEqual({ type: 'string' }); // strict: no degrade + expect(new CfWorkerJsonSchemaValidator().getValidator(degraded)({ cfg: { q: 'x' }, arr: ['head', 'tail'], name: 'n' }).valid).toBe( + true + ); }); test('relocated members keep their stamp beside a null-membered user anyOf', () => { - // The loosen rewrite relocates the DU members under allOf beside the user's - // .meta({anyOf}) and stamps `type: 'object'` itself — the epilogue proof - // reads the user's anyOf first (first-present-key-wins), sees the null - // member, and could not prove the root on its own. + // The loosen rewrite relocates the DU members under allOf beside the + // user's .meta({anyOf}) — the epilogue's strict pre-loosen snapshot read + // the emitted oneOf (first-present-key-wins) and stamps the root, where + // the post-loosen document alone could not prove it (the user's anyOf has + // a null member). const du = z .discriminatedUnion('t', [ z.object({ t: z.literal('a').catch('a'), x: z.string().optional() }), From 37d8eb46525b49b7cdb1deab5c72cb637bb944b7 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 22:07:56 +0000 Subject: [PATCH 33/35] fix(core-internal): nonoptional probe deferral; deferred id strip on the guard path; snapshot honors main's decision order; prose sync - hasStructuralMissingKeyTolerance no longer unwinds through 'nonoptional' as transparent: z.nonoptional() RE-FORBIDS undefined, so acceptance-tolerance inside it (.optional(), z.any(), ...) does not survive the wrapper while filling-tolerance (default/prefault/static catch) does - the structural walk claims nothing and the validate(undefined) probe in fieldAcceptsMissingKey decides, keeping .required() fields truthfully advertised as required and no longer spuriously setting loosened. - The zod output flow defers the draft-04 id strip on its strict pass: when the reference guard fires, the shipped emission keeps the id keys a URI-form hand-authored ref resolves through on the cfworker engine (schema.$id || schema.id base registration) - stripping made a working pre-#2464 registration permanently uncallable. Fragment-only guard documents still get a post-hoc strip so Ajv keeps compiling registry-id documents; Ajv rejected URI-form-ref documents pre-#2464 too, so keeping id there is pre-fix parity. (Bare draft-04 id occurrences deliberately do NOT trip the guard: every registered schema emits one, and the dangerous combination - an id-base URI-form ref - already trips it via the ref shape.) - strictRootProven honors main's decision order: an explicit non-object type on the strict root short-circuits before the object proof, so meta-authored object keywords on a scalar catch root can no longer flip the SEP-2106 wrap after the degrade deletes the type. - Changeset: document the oneOf->anyOf rename (near-universal for plain-object DU emissions now that the additionalProperties drop sets the flag) and the serialized-wire-form wraps (array/tuple null, file {}, non-finite null); rewrite two stale inline comments still describing the deleted pointer-move bookkeeping. Co-Authored-By: Claude --- .changeset/zod-tojsonschema-wire-truthful.md | 9 +- .../core-internal/src/util/standardSchema.ts | 127 +++++++++++++++--- .../test/util/standardSchema.test.ts | 57 ++++++++ 3 files changed, 174 insertions(+), 19 deletions(-) diff --git a/.changeset/zod-tojsonschema-wire-truthful.md b/.changeset/zod-tojsonschema-wire-truthful.md index a242575799..b919956f40 100644 --- a/.changeset/zod-tojsonschema-wire-truthful.md +++ b/.changeset/zod-tojsonschema-wire-truthful.md @@ -18,7 +18,14 @@ Output schemas no longer advertise constraints the server doesn't enforce on the undefined-accepting types) are dropped from `required` — on objects and enum-keyed records — and `additionalProperties: false` is dropped for plain `z.object()` (kept for `z.strictObject()`), so validating clients no longer reject legitimate tool results for -these schema shapes. (Output schemas containing `.transform()`/`.pipe()`/`z.coerce` still +these schema shapes. Once any such loosening applies, exactly-one `oneOf` compositions — +including zod's discriminated-union emissions over plain objects, i.e. most of them — are +advertised as `anyOf` (the loosened members may overlap; discriminator consts keep them +distinguishable), and the serialized wire forms of tolerant values are additionally +accepted: tolerant array/tuple elements also allow `null` (what `JSON.stringify` makes of +an undefined element), `z.file()` fields also allow `{}` (a `File` has no JSON form), and +non-finite number literals also allow `null`. (Output schemas containing +`.transform()`/`.pipe()`/`z.coerce` still advertise the post-transform shape while the server ships the raw pre-transform value — a pre-existing gap this change does not address. And on zod 4.0–4.2.x, `toJSONSchema` skips the sanitization hook on a schema reused both bare and via a `.describe()`/`.meta()` clone diff --git a/packages/core-internal/src/util/standardSchema.ts b/packages/core-internal/src/util/standardSchema.ts index 74ac71cc28..df7855fa44 100644 --- a/packages/core-internal/src/util/standardSchema.ts +++ b/packages/core-internal/src/util/standardSchema.ts @@ -265,19 +265,24 @@ export const JSON_SCHEMA_CONVERSION_TARGET = 'draft-2020-12'; function zodConversionOptions( io: 'input' | 'output', loosened: { value: boolean }, - loosen: boolean + loosen: boolean, + stripLegacyId = true ): Pick { return { unrepresentable: 'any', override: ctx => { const def = ctx.zodSchema._zod.def; - if ('id' in ctx.jsonSchema) { + if (stripLegacyId && 'id' in ctx.jsonSchema) { // zod copies registry metadata (`.meta({id: 'X'})`) verbatim, emitting a // literal draft-04 `id` keyword that Ajv v8 hard-rejects at COMPILE time // ('NOT SUPPORTED: keyword "id", use "$id"' — strict: false does not // help), so the SDK's own client could never validate the advertisement. - // `$ref`s are path-based (#/$defs/Name) and cannot dangle; renaming to - // `$id` would change base-URI resolution, so plain removal. + // Registry `$ref`s are path-based (#/$defs/Name) and cannot dangle; + // renaming to `$id` would change base-URI resolution, so plain removal. + // The zod OUTPUT flow defers this strip on its strict pass — a + // guard-shipped document with URI-form refs needs the `id` base the + // cfworker engine resolves them through (see the guard branch in + // standardSchemaToJsonSchema). delete ctx.jsonSchema.id; } if (def.type === 'date') { @@ -316,10 +321,10 @@ function zodConversionOptions( // exactly-one `oneOf` of indistinguishable skeletons would // reject every payload, but the rename to the honest `anyOf` // is DEFERRED to the epilogue's `rewriteOneOfToAnyOf` — it - // always runs once the degrade set `loosened`, and unlike - // this override hook it knows the node's document path, so - // the rename records the pointer move that keeps inbound - // `$ref: '#/…/oneOf/'` aliases resolvable. + // always runs once the degrade set `loosened`, keeping the + // rename logic in one place; no reference can observe the + // relocation, since any hand-authored ref disables the + // loosen family entirely (the reference guard vouched). ctx.jsonSchema[key] = (ctx.jsonSchema[key] as unknown[]).map(member => compositionTypeSkeleton(member)); continue; } @@ -516,6 +521,67 @@ function rewriteOneOfToAnyOf(node: unknown, seen: Set = new Set()): voi /** The only `$ref` shape zod's emitter produces besides bare `#`: a top-level `$defs` entry. */ const ZOD_REGISTRY_REF_PATTERN = /^#\/\$defs\/[^/]+$/; +/** + * Whether any reference keyword in the document carries a non-fragment value — a + * ref that may resolve through a base URI (`$id`, or the draft-04 `id` the + * cfworker engine also registers) rather than by same-document pointer/anchor. + * Position-aware like the guard walk. + */ +function hasNonFragmentRefs(document: Record): boolean { + return someSchemaNode(document, record => + ['$ref', '$dynamicRef', '$recursiveRef'].some(refKey => { + const value = record[refKey]; + return typeof value === 'string' && !value.startsWith('#'); + }) + ); +} + +/** + * Deletes every keyword-position draft-04 `id` in the document — the post-hoc + * spelling of the override hook's strip, for the guard-strict path where the + * override runs with the strip deferred. Only safe when + * {@linkcode hasNonFragmentRefs} is false: with fragment-only refs the `id` keys + * are inert bases nothing resolves through, while Ajv v8 hard-rejects the + * keyword at compile time. + */ +function stripLegacyIdKeywords(document: Record): void { + someSchemaNode(document, record => { + if ('id' in record) delete record.id; + return false; + }); +} + +/** + * Position-aware some() over the document's schema nodes: only schema-carrying + * keywords are descended into (data-valued `const`/`enum`/`default`/`examples` + * and annotation values stay opaque), and schema-map VALUES are schemas while + * their keys stay names — a property literally named `id` or `$ref` is user + * data, not a keyword. Stops at the first node where `predicate` returns true. + */ +function someSchemaNode(document: Record, predicate: (record: Record) => boolean): boolean { + const walk = (node: unknown, seen: Set): boolean => { + if (typeof node !== 'object' || node === null || seen.has(node)) return false; + seen.add(node); + if (Array.isArray(node)) return node.some(item => walk(item, seen)); + const record = node as Record; + if (predicate(record)) return true; + for (const [key, value] of Object.entries(record)) { + if (!SCHEMA_CARRYING_JSON_SCHEMA_KEYWORDS.has(key)) continue; + if (SCHEMA_MAP_JSON_SCHEMA_KEYWORDS.has(key) && typeof value === 'object' && value !== null && !Array.isArray(value)) { + if (seen.has(value)) continue; + seen.add(value); + for (const subschema of Object.values(value as Record)) { + if (walk(subschema, seen)) return true; + } + continue; + } + if (walk(value, seen)) return true; + } + return false; + }; + return walk(document, new Set()); +} + /** * Whether the emitted document carries any reference construct beyond what zod's * own emitter produces: a `$ref`/`$dynamicRef` that is not `#` or @@ -647,8 +713,8 @@ function compositionTypeSkeleton(node: unknown): Record { // members are indistinguishable and would reject every payload under // exactly-one semantics. The oneOf→anyOf rename is deferred to the // epilogue's `rewriteOneOfToAnyOf` (the degrade that skeletonizes - // always sets `loosened`), which performs it at a known document path - // WITH pointer-move bookkeeping. + // always sets `loosened`), keeping the rename in one place — safe + // because the reference guard vouched no ref can observe it. skeleton[key] = (source[key] as unknown[]).map(member => compositionTypeSkeleton(member)); } } @@ -843,6 +909,18 @@ function hasStructuralMissingKeyTolerance(field: unknown, ancestors: ReadonlySet // filling its default) for zod to merge the results. return hasStructuralMissingKeyTolerance(def.left, path) && hasStructuralMissingKeyTolerance(def.right, path); } + if (def.type === 'nonoptional') { + // z.nonoptional() RE-FORBIDS undefined, so tolerance by ACCEPTANCE inside + // it (an inner optional, any/unknown, undefined-valued literals) does NOT + // survive the wrapper — only tolerance by FILLING does (default/prefault/ + // static catch replace undefined before nonoptional's check runs). The + // structural walk cannot tell the two apart, so it claims nothing and the + // validate(undefined) probe in fieldAcceptsMissingKey decides: it returns + // issues for `.optional().nonoptional()` (stays required) and success for + // `.default(1).nonoptional()` (stays droppable). The generic unwind below + // would wrongly propagate acceptance-tolerance through the re-forbid. + return false; + } if (WRAPPER_ZOD_DEF_TYPES.has(def.type) && def.innerType !== undefined) { return hasStructuralMissingKeyTolerance(def.innerType, path); } @@ -941,19 +1019,32 @@ export function standardSchemaToJsonSchema( // `hasHandAuthoredReferenceConstructs`); reference-free documents (and // zod's own registry refs, stable under every mutation) convert again with // the loosen family active. - const strict = convert(zodConversionOptions(io, loosened, false)); + // The strict pass defers the draft-04 `id` strip: when the guard fires, + // the emission ships verbatim, and a URI-form hand-authored ref (itself a + // guard trigger) may resolve through an `id` base on the cfworker engine + // — stripping would break a working pre-#2464 registration. + const strict = convert(zodConversionOptions(io, loosened, false, false)); if (hasHandAuthoredReferenceConstructs(strict)) { + // With only fragment-form refs the `id` keys are inert bases — strip + // them post-hoc so Ajv keeps compiling registry-id documents (its v8 + // engine hard-rejects the keyword; it rejected URI-form-ref documents + // pre-#2464 too, so keeping `id` for those is pre-fix parity). + if (!hasNonFragmentRefs(strict)) stripLegacyIdKeywords(strict); result = strict; } else { // The 2025-era wrap-stamp decision must match main, which read the RAW - // emission: loosen mutations can destroy proof-relevant keys (the + // emission in ITS decision order: an explicit root `type` short-circuited + // before the object proof ever ran (`{type: 'number', + // properties: …}` stayed a wrapped non-object root — the proof's + // keyword-presence rule never saw it), and only typeless roots were + // proven. Loosen mutations can destroy proof-relevant keys (the // allOf-push relocates a root `oneOf`; the catch degrade deletes - // meta-authored `required`/`properties`) or expose ones main's - // first-present-key-wins rule never read — flipping the SEP-2106 - // legacy wrap either direction on an unrelated trigger. Snapshot the - // proof on the strict emission; the output epilogue consults it - // instead of the post-loosen document. - strictRootProven = isProvablyObjectShapedRoot(strict); + // meta-authored `required`/`properties` and non-object `type`s) or + // expose ones main never read — flipping the SEP-2106 legacy wrap + // either direction on an unrelated trigger. Snapshot the decision on + // the strict emission; the output epilogue consults it instead of the + // post-loosen document. + strictRootProven = strict.type === undefined ? isProvablyObjectShapedRoot(strict) : strict.type === 'object'; result = convert(zodConversionOptions(io, loosened, true)); } } diff --git a/packages/core-internal/test/util/standardSchema.test.ts b/packages/core-internal/test/util/standardSchema.test.ts index f6bcb2ed6c..a81354939e 100644 --- a/packages/core-internal/test/util/standardSchema.test.ts +++ b/packages/core-internal/test/util/standardSchema.test.ts @@ -1150,6 +1150,63 @@ describe('zod conversion options (#2464)', () => { .catch({ x: 1 }) .meta({ required: ['x'] }); expect(standardSchemaToJsonSchema(catchRoot, 'output').type).toBe('object'); + + // The snapshot honors main's DECISION ORDER too: an explicit non-object + // type on the strict root short-circuits before the proof, so + // meta-authored object keywords on a scalar catch root cannot prove it + // even though the degrade deletes the type before the epilogue runs. + const typedRoot = z + .number() + .catch(0) + .meta({ properties: { a: { type: 'string' } } }); + const typedResult = standardSchemaToJsonSchema(typedRoot, 'output'); + expect(typedResult.type).toBeUndefined(); + expect(isNonObjectJsonSchemaRoot(typedResult)).toBe(true); // wrap parity with main + }); + + test('nonoptional re-forbids undefined: .required() fields stay advertised required', () => { + // The structural walk must not propagate acceptance-tolerance through + // z.nonoptional() — the validate(undefined) probe decides instead. + const out = z.object({ a: z.string().optional(), b: z.number() }).required(); + const result = standardSchemaToJsonSchema(out, 'output'); + expect(result.required).toEqual(['a', 'b']); // zod's own truthful emission + // FILLING tolerance survives the wrapper: the default replaces undefined + // before nonoptional's check runs, so the probe keeps the field droppable. + const filled = standardSchemaToJsonSchema(z.object({ c: z.number().default(1).nonoptional(), b: z.number() }), 'output'); + expect(filled.required).toEqual(['b']); + }); + + test('guard-shipped documents keep draft-04 id when URI-form refs need its base', () => { + // cfworker resolves URI-form refs through `schema.$id || schema.id` base + // registration — the strict emission must keep the `id` the ref resolves + // through (Ajv rejected these documents pre-#2464 too, so nothing Ajv + // regresses). + const reg = z.object({ q: z.string() }).meta({ id: 'RegX' }); + const schema = z.object({ + x: reg, + alias: z.unknown().meta({ $ref: 'RegX' }), + name: z.string() + }); + const result = standardSchemaToJsonSchema(schema, 'output'); + + expect(result.required).toEqual(['x', 'alias', 'name']); // guard fired: strict + expect(((result.$defs as Record>).RegX ?? {}).id).toBe('RegX'); + const validate = new CfWorkerJsonSchemaValidator().getValidator(result); + expect(validate({ x: { q: 'a' }, alias: { q: 'b' }, name: 'n' }).valid).toBe(true); + expect(validate({ x: { q: 'a' }, alias: { nope: 1 }, name: 'n' }).valid).toBe(false); // ref still enforces + + // Fragment-only guard-fired documents still get the post-hoc strip, so + // Ajv keeps compiling registry-id documents with exotic constructs. + const fragmentOnly = standardSchemaToJsonSchema( + z.object({ + cfg: z.object({ q: z.string().default('d') }).meta({ id: 'Y' }), + guarded: z.unknown().meta({ not: { $ref: '#/$defs/Y' } }), + name: z.string() + }), + 'output' + ); + expect(((fragmentOnly.$defs as Record>).Y ?? {}).id).toBeUndefined(); + expect(new AjvJsonSchemaValidator().getValidator(fragmentOnly)({ cfg: { q: 'x' }, guarded: {}, name: 'n' }).valid).toBe(true); }); test('a solo additionalProperties drop still fires the oneOf rename', () => { From 74ab2bd66c38c51ac15faf916e28094decf50e19 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 22:46:42 +0000 Subject: [PATCH 34/35] fix(core-internal): pipe tolerance requires bare-transform OUT; uniform ref-aware id strip; prose sync - The pipe branch of hasStructuralMissingKeyTolerance claims IN-side tolerance only when the OUT side is a bare transform (nothing re-validates the filled value - the pinned async-transform shape): a validating OUT side may reject it (.default(0).pipe(z.number().min(1)); .optional().pipe(z.coerce.number()) coerces undefined to NaN), so the walk claims nothing there and the validate(undefined) probe decides - .required()-style truthful required advertisements, no spurious loosened flag. The preprocess direction keeps its structural claim (deferring to the probe would mis-require async-refined preprocess fields) - the undefined-unsafe-fn residual is documented in the Known residual gaps list. - The draft-04 id strip is now uniformly ref-aware: the INPUT path defers the strip like the output paths and applies it post-hoc, and the gate is tightened from fragment-ness to the guard's registry-shape test - cfworker resolves even fragment pointers INSIDE an id resource relative to that base, so ANY hand-authored ref (URI-form or fragment-form) keeps the id (exact pre-fix parity; Ajv rejected those documents pre-fix too), while registry-only documents keep getting the strip for Ajv compilability. - Prose sync: retitle the stale allOf-push-stamps test to the strict-snapshot mechanism, fix the matching isProvablyObjectShapedRoot comment, and document the id strip in the changeset (what disappears, why, the cfworker caveat). Co-Authored-By: Claude --- .changeset/zod-tojsonschema-wire-truthful.md | 7 ++ .../core-internal/src/util/standardSchema.ts | 86 +++++++++++++------ .../test/util/standardSchema.test.ts | 64 +++++++++++++- 3 files changed, 127 insertions(+), 30 deletions(-) diff --git a/.changeset/zod-tojsonschema-wire-truthful.md b/.changeset/zod-tojsonschema-wire-truthful.md index b919956f40..c6369de1cf 100644 --- a/.changeset/zod-tojsonschema-wire-truthful.md +++ b/.changeset/zod-tojsonschema-wire-truthful.md @@ -13,6 +13,13 @@ and so do dynamic catch values, `.catch(ctx => …)`; the `.catch()` degrade cov fallback values only. And a misregistered non-object ROOT — `z.bigint()` or `z.map()` as the whole `inputSchema`/`outputSchema` — still fails `tools/list` loudly by design, preserving the pre-fix error instead of listing a permanently-broken tool.) +Registry metadata (`.meta({id: 'X'})`) no longer emits the draft-04 `id` keyword on either +io path — Ajv v8 hard-rejects it at compile time ('NOT SUPPORTED: keyword "id"'), so the +SDK's own client could never validate such advertisements. The key is kept only when the +document carries a hand-authored ref beyond zod's registry shapes: those may resolve +through the `id` base-URI on the `@cfworker/json-schema` engine (URI-form refs, and +fragment pointers inside an `id` resource), so stripping would break them — such documents +ship with `id` intact, exactly as pre-fix. Output schemas no longer advertise constraints the server doesn't enforce on the raw `structuredContent` it ships: fields that may be legitimately absent (`.default()`, undefined-accepting types) are dropped from `required` — on objects and enum-keyed records — diff --git a/packages/core-internal/src/util/standardSchema.ts b/packages/core-internal/src/util/standardSchema.ts index df7855fa44..61bd002788 100644 --- a/packages/core-internal/src/util/standardSchema.ts +++ b/packages/core-internal/src/util/standardSchema.ts @@ -223,6 +223,12 @@ export const JSON_SCHEMA_CONVERSION_TARGET = 'draft-2020-12'; * catchProcessor before this hook runs ("Dynamic catch values are not supported * in JSON Schema"), so one such tool still fails the entire `tools/list` — the * degrade below covers static `.catch(value)` only. + * - An undefined-unsafe `z.preprocess` fn wrapping a tolerant inner (`z.preprocess( + * v => (v as string).length, z.number().default(7))`) still advertises the field + * as droppable even though a missing key throws in the fn: the structural walk + * cannot evaluate the fn, and deferring the preprocess spelling to the + * validate(undefined) probe would mis-require async-refined preprocess fields + * (the probe goes async and conservatively claims nothing). * - Output schemas containing `.transform()`/`.pipe()`/`z.coerce` still advertise the * post-transform shape (`io: 'output'`) even though the server validates and ships * the raw pre-transform value — rewriting pipe nodes to their input side per-node @@ -279,10 +285,12 @@ function zodConversionOptions( // help), so the SDK's own client could never validate the advertisement. // Registry `$ref`s are path-based (#/$defs/Name) and cannot dangle; // renaming to `$id` would change base-URI resolution, so plain removal. - // The zod OUTPUT flow defers this strip on its strict pass — a - // guard-shipped document with URI-form refs needs the `id` base the - // cfworker engine resolves them through (see the guard branch in - // standardSchemaToJsonSchema). + // The strict and input passes DEFER this strip and apply it + // post-hoc only when no hand-authored ref exists — refs beyond + // zod's registry shapes may resolve through an `id` base on the + // cfworker engine (see standardSchemaToJsonSchema). The loosen + // pass strips in-hook: the guard already vouched the document + // carries no hand-authored reference construct. delete ctx.jsonSchema.id; } if (def.type === 'date') { @@ -522,27 +530,34 @@ function rewriteOneOfToAnyOf(node: unknown, seen: Set = new Set()): voi const ZOD_REGISTRY_REF_PATTERN = /^#\/\$defs\/[^/]+$/; /** - * Whether any reference keyword in the document carries a non-fragment value — a - * ref that may resolve through a base URI (`$id`, or the draft-04 `id` the - * cfworker engine also registers) rather than by same-document pointer/anchor. - * Position-aware like the guard walk. + * Whether any reference keyword in the document carries a hand-authored value — + * anything but zod's own registry shapes (`#`, `#/$defs/`). Zod's registry + * refs are root-base JSON Pointers that never resolve through a draft-04 `id` + * base, so stripping `id` around them is safe; every OTHER ref may depend on an + * `id` base on the cfworker engine (URI-form refs resolve through + * `schema.$id || schema.id` registration, and even a fragment pointer INSIDE an + * `id` resource resolves relative to that base). Position-aware like the guard + * walk. */ -function hasNonFragmentRefs(document: Record): boolean { +function hasHandAuthoredRefValues(document: Record): boolean { return someSchemaNode(document, record => ['$ref', '$dynamicRef', '$recursiveRef'].some(refKey => { const value = record[refKey]; - return typeof value === 'string' && !value.startsWith('#'); + if (value === undefined) return false; + return typeof value !== 'string' || (value !== '#' && !ZOD_REGISTRY_REF_PATTERN.test(value)); }) ); } /** * Deletes every keyword-position draft-04 `id` in the document — the post-hoc - * spelling of the override hook's strip, for the guard-strict path where the + * spelling of the override hook's strip, for the conversion paths where the * override runs with the strip deferred. Only safe when - * {@linkcode hasNonFragmentRefs} is false: with fragment-only refs the `id` keys - * are inert bases nothing resolves through, while Ajv v8 hard-rejects the - * keyword at compile time. + * {@linkcode hasHandAuthoredRefValues} is false: with only zod-registry refs the + * `id` keys are inert bases nothing resolves through, while Ajv v8 hard-rejects + * the keyword at compile time. Any hand-authored ref — URI-form or + * fragment-form — keeps the `id` (exact pre-#2464 parity: Ajv rejected those + * documents then too, and the cfworker engine needs the base). */ function stripLegacyIdKeywords(document: Record): void { someSchemaNode(document, record => { @@ -892,10 +907,21 @@ function hasStructuralMissingKeyTolerance(field: unknown, ancestors: ReadonlySet } } if (def.type === 'pipe' && def.in !== undefined) { - if (hasStructuralMissingKeyTolerance(def.in, path)) return true; - // `z.preprocess(fn, inner)` builds the opposite pipe — the transform sits at - // `def.in` and the tolerant node (e.g. a default) at `def.out`. const inDef = (def.in as { _zod?: { def?: { type?: string } } })._zod?.def; + const outDef = (def.out as { _zod?: { def?: { type?: string } } } | undefined)?._zod?.def; + // IN-side tolerance survives the pipe only when the OUT side is a BARE + // TRANSFORM (nothing re-validates the filled/passed value — the pinned + // `.default(7).transform(async …)` shape, where the async stage is exactly + // why the probe cannot be used). A validating OUT side may reject the + // filled value (`.default(0).pipe(z.number().min(1))` rejects 0; + // `.optional().pipe(z.coerce.number())` coerces undefined to NaN), so the + // walk claims nothing there and the validate(undefined) probe decides. + if (outDef?.type === 'transform' && hasStructuralMissingKeyTolerance(def.in, path)) return true; + // `z.preprocess(fn, inner)` builds the opposite pipe — the transform sits at + // `def.in` and the tolerant node (e.g. a default) at `def.out`. The fn's + // own undefined-safety is NOT checked (a Known residual gap): deferring to + // the probe would mis-require async-refined preprocess fields the pinned + // tests keep droppable. if (inDef?.type === 'transform' && def.out !== undefined) { return hasStructuralMissingKeyTolerance(def.out, path); } @@ -1006,9 +1032,13 @@ export function standardSchemaToJsonSchema( result = convert(undefined); } else if (io !== 'output' || std.vendor !== 'zod') { // The loosen family rewrites only zod OUTPUT advertisements — every other - // conversion runs once, with the sanitizing overrides (date rewrite, - // draft-04 `id` strip) alone for zod inputs. - result = convert(zodConversionOptions(io, loosened, false)); + // conversion runs once, with the sanitizing overrides alone for zod + // inputs. The draft-04 `id` strip is deferred and applied post-hoc under + // the same hand-authored-ref gate as the output paths: an input document + // whose refs resolve through an `id` base must keep it for the cfworker + // engine. + result = convert(zodConversionOptions(io, loosened, false, false)); + if (std.vendor === 'zod' && !hasHandAuthoredRefValues(result)) stripLegacyIdKeywords(result); } else { // Wire-truthfulness loosening is guarded by reference-construct detection: // first emit STRICTLY (sanitizing overrides only) and inspect the natural @@ -1025,11 +1055,14 @@ export function standardSchemaToJsonSchema( // — stripping would break a working pre-#2464 registration. const strict = convert(zodConversionOptions(io, loosened, false, false)); if (hasHandAuthoredReferenceConstructs(strict)) { - // With only fragment-form refs the `id` keys are inert bases — strip + // With only zod-registry refs the `id` keys are inert bases — strip // them post-hoc so Ajv keeps compiling registry-id documents (its v8 - // engine hard-rejects the keyword; it rejected URI-form-ref documents - // pre-#2464 too, so keeping `id` for those is pre-fix parity). - if (!hasNonFragmentRefs(strict)) stripLegacyIdKeywords(strict); + // engine hard-rejects the keyword). ANY hand-authored ref keeps the + // `id`: URI-form refs resolve through it, and even a fragment pointer + // inside an `id` resource resolves relative to that base on the + // cfworker engine (Ajv rejected such documents pre-#2464 too, so + // keeping `id` is pre-fix parity). + if (!hasHandAuthoredRefValues(strict)) stripLegacyIdKeywords(strict); result = strict; } else { // The 2025-era wrap-stamp decision must match main, which read the RAW @@ -1489,8 +1522,9 @@ function isProvablyObjectShapedRoot(schema: Record): boolean { // nullable union carrying a user `.meta({allOf: [{type: 'object'}]})`) stayed // typeless and 2025-era-wrapped on main, so a later key must not prove what // the first cannot. The loosen rewrite's allOf-push does not rely on this - // proof seeing its relocated conjunct: it stamps `type: 'object'` itself, and - // its internal proof argument carries only `anyOf`. + // proof seeing its relocated conjunct: the output epilogue decides the root + // stamp from the STRICT pre-loosen snapshot, where the emitted oneOf is + // still the first present key. for (const key of ['oneOf', 'anyOf', 'allOf'] as const) { const members = schema[key]; if (!Array.isArray(members) || members.length === 0) continue; diff --git a/packages/core-internal/test/util/standardSchema.test.ts b/packages/core-internal/test/util/standardSchema.test.ts index a81354939e..891cb5bf90 100644 --- a/packages/core-internal/test/util/standardSchema.test.ts +++ b/packages/core-internal/test/util/standardSchema.test.ts @@ -1176,6 +1176,62 @@ describe('zod conversion options (#2464)', () => { expect(filled.required).toEqual(['b']); }); + test('pipe tolerance needs the OUT side to be a bare transform', () => { + // A validating OUT side may reject the filled/passed value, so the walk + // claims nothing and the validate(undefined) probe decides — the filled 0 + // fails min(1), and optional-through-coerce yields NaN. + const validatingOut = z.object({ p: z.number().default(0).pipe(z.number().min(1)), name: z.string() }); + expect(standardSchemaToJsonSchema(validatingOut, 'output').required).toEqual(['p', 'name']); + const coerced = z.object({ p: z.string().optional().pipe(z.coerce.number()), name: z.string() }); + expect(standardSchemaToJsonSchema(coerced, 'output').required).toEqual(['p', 'name']); + // A defaulted value satisfying the OUT side keeps the field droppable via + // the probe... + const satisfied = z.object({ p: z.number().default(5).pipe(z.number().min(1)), name: z.string() }); + expect(standardSchemaToJsonSchema(satisfied, 'output').required).toEqual(['name']); + // ...and the bare-transform OUT side keeps the structural shortcut (the + // async stage below is exactly why the probe cannot be used there). + const bareTransform = z.object({ + p: z + .number() + .default(7) + .transform(async value => value + 1), + name: z.string() + }); + expect(standardSchemaToJsonSchema(bareTransform, 'output').required).toEqual(['name']); + }); + + test('input conversions keep draft-04 id when hand-authored refs need its base', () => { + // The input branch defers the strip under the same gate as the output + // paths — no reference guard runs on input, but the hand-authored-ref + // test does. + const reg = z.object({ q: z.string() }).meta({ id: 'RegIn' }); + const inResult = standardSchemaToJsonSchema( + z.object({ x: reg, alias: z.unknown().meta({ $ref: 'RegIn' }), name: z.string() }), + 'input' + ); + expect(((inResult.$defs as Record>).RegIn ?? {}).id).toBe('RegIn'); + const validate = new CfWorkerJsonSchemaValidator().getValidator(inResult); + expect(validate({ x: { q: 'a' }, alias: { q: 'b' }, name: 'n' }).valid).toBe(true); + expect(validate({ x: { q: 'a' }, alias: { nope: 1 }, name: 'n' }).valid).toBe(false); // ref enforces + // Registry-only inputs still get the strip (Ajv compilability). + const registryOnly = standardSchemaToJsonSchema(z.object({ x: reg, y: reg, name: z.string() }), 'input'); + expect(((registryOnly.$defs as Record>).RegIn ?? {}).id).toBeUndefined(); + expect(new AjvJsonSchemaValidator().getValidator(registryOnly)({ x: { q: 'a' }, y: { q: 'b' }, name: 'n' }).valid).toBe(true); + }); + + test('fragment refs inside an id resource keep the id (base-relative resolution)', () => { + // cfworker resolves a fragment pointer inside a draft-04 id resource + // relative to THAT base — stripping the id would retarget it to the root + // and dangle. Any hand-authored ref, fragment-form included, keeps the id. + const reg = z.object({ q: z.string(), alias: z.unknown().meta({ $ref: '#/properties/q' }) }).meta({ id: 'RegY' }); + const result = standardSchemaToJsonSchema(z.object({ x: reg, name: z.string() }), 'output'); + + expect(((result.$defs as Record>).RegY ?? {}).id).toBe('RegY'); + const validate = new CfWorkerJsonSchemaValidator().getValidator(result); + expect(validate({ x: { q: 'a', alias: 'b' }, name: 'n' }).valid).toBe(true); + expect(validate({ x: { q: 'a', alias: 1 }, name: 'n' }).valid).toBe(false); // resolves against the RegY base + }); + test('guard-shipped documents keep draft-04 id when URI-form refs need its base', () => { // cfworker resolves URI-form refs through `schema.$id || schema.id` base // registration — the strict emission must keep the `id` the ref resolves @@ -1526,12 +1582,12 @@ describe('zod conversion options (#2464)', () => { expect(Array.isArray(standardSchemaToJsonSchema(z.date().nullable(), 'output').anyOf)).toBe(true); }); - test('the allOf-push stamps the sound type when user conjuncts defeat the proof', () => { + test('user conjuncts defeating the post-loosen proof still get the root stamp (strict snapshot)', () => { // A .meta() carrying BOTH a non-all-object anyOf AND a non-object-provable // allOf conjunct defeats every() on every composition key after the push — - // the pushed members still prove the value is an object, so the rewrite - // stamps the explicit type itself (pre-#2464 these roots were stamped via - // their emitted oneOf). + // but the epilogue's strictRootProven snapshot read the STRICT emission, + // whose first present key was the DU's all-object oneOf, so the root is + // stamped exactly as pre-#2464 (the push branch itself stamps nothing). const du = z .discriminatedUnion('t', [ z.object({ t: z.literal('a').catch('a'), x: z.string().optional() }), From a0a0252d64b62a591cbbec56d99e2a904cc8e7ac Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 23:22:56 +0000 Subject: [PATCH 35/35] 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 --- .../core-internal/src/util/standardSchema.ts | 153 ++++++++++++++---- .../test/util/standardSchema.test.ts | 47 ++++++ 2 files changed, 170 insertions(+), 30 deletions(-) diff --git a/packages/core-internal/src/util/standardSchema.ts b/packages/core-internal/src/util/standardSchema.ts index 61bd002788..6c1946fc0d 100644 --- a/packages/core-internal/src/util/standardSchema.ts +++ b/packages/core-internal/src/util/standardSchema.ts @@ -229,6 +229,12 @@ export const JSON_SCHEMA_CONVERSION_TARGET = 'draft-2020-12'; * cannot evaluate the fn, and deferring the preprocess spelling to the * validate(undefined) probe would mis-require async-refined preprocess fields * (the probe goes async and conservatively claims nothing). + * - The converse trade for async-staged VALIDATING pipe OUT sides: a genuinely + * tolerant `.default(5).pipe(z.number().min(1).refine(async () => true))` stays + * advertised as required — the probe goes async, and claiming IN-side tolerance + * structurally would wrongly drop `.default(0).pipe(z.number().min(1).refine( + * async …))`. Neither structural direction is sound there, so the conservative + * stay-required posture (byte-parity with the pre-#2464 emission) wins. * - Output schemas containing `.transform()`/`.pipe()`/`z.coerce` still advertise the * post-transform shape (`io: 'output'`) even though the server validates and ships * the raw pre-transform value — rewriting pipe nodes to their input side per-node @@ -530,21 +536,25 @@ function rewriteOneOfToAnyOf(node: unknown, seen: Set = new Set()): voi const ZOD_REGISTRY_REF_PATTERN = /^#\/\$defs\/[^/]+$/; /** - * Whether any reference keyword in the document carries a hand-authored value — - * anything but zod's own registry shapes (`#`, `#/$defs/`). Zod's registry - * refs are root-base JSON Pointers that never resolve through a draft-04 `id` - * base, so stripping `id` around them is safe; every OTHER ref may depend on an - * `id` base on the cfworker engine (URI-form refs resolve through - * `schema.$id || schema.id` registration, and even a fragment pointer INSIDE an - * `id` resource resolves relative to that base). Position-aware like the guard - * walk. + * Whether any reference keyword in the document carries a hand-authored value. + * Hand-authored-ness is decided by value SHAPE and lexical CONTEXT together: + * outside `id` resources, anything but zod's own registry shapes (`#`, + * `#/$defs/` — root-base JSON Pointers that never resolve through a + * draft-04 `id` base) is hand-authored; INSIDE a draft-04 `id`-carrying + * resource, EVERY ref counts — resolution there is base-relative on the + * cfworker engine (`schema.$id || schema.id` registration), so even a + * registry-shaped `$ref: '#'` addresses the resource, a spelling zod's emitter + * never produces at that position. `$recursiveRef` counts regardless of value + * or position — zod never emits the keyword at all. */ function hasHandAuthoredRefValues(document: Record): boolean { - return someSchemaNode(document, record => + return someSchemaNode(document, (record, insideIdResource) => ['$ref', '$dynamicRef', '$recursiveRef'].some(refKey => { const value = record[refKey]; if (value === undefined) return false; - return typeof value !== 'string' || (value !== '#' && !ZOD_REGISTRY_REF_PATTERN.test(value)); + if (refKey === '$recursiveRef') return true; + if (typeof value !== 'string' || insideIdResource) return true; + return value !== '#' && !ZOD_REGISTRY_REF_PATTERN.test(value); }) ); } @@ -571,30 +581,37 @@ function stripLegacyIdKeywords(document: Record): void { * keywords are descended into (data-valued `const`/`enum`/`default`/`examples` * and annotation values stay opaque), and schema-map VALUES are schemas while * their keys stay names — a property literally named `id` or `$ref` is user - * data, not a keyword. Stops at the first node where `predicate` returns true. + * data, not a keyword. The predicate also receives whether the node sits + * lexically inside (or at) a draft-04 `id`-carrying resource, where ref + * resolution is base-relative. Stops at the first node where `predicate` + * returns true. */ -function someSchemaNode(document: Record, predicate: (record: Record) => boolean): boolean { - const walk = (node: unknown, seen: Set): boolean => { +function someSchemaNode( + document: Record, + predicate: (record: Record, insideIdResource: boolean) => boolean +): boolean { + const walk = (node: unknown, seen: Set, insideIdResource: boolean): boolean => { if (typeof node !== 'object' || node === null || seen.has(node)) return false; seen.add(node); - if (Array.isArray(node)) return node.some(item => walk(item, seen)); + if (Array.isArray(node)) return node.some(item => walk(item, seen, insideIdResource)); const record = node as Record; - if (predicate(record)) return true; + const inIdResource = insideIdResource || typeof record.id === 'string'; + if (predicate(record, inIdResource)) return true; for (const [key, value] of Object.entries(record)) { if (!SCHEMA_CARRYING_JSON_SCHEMA_KEYWORDS.has(key)) continue; if (SCHEMA_MAP_JSON_SCHEMA_KEYWORDS.has(key) && typeof value === 'object' && value !== null && !Array.isArray(value)) { if (seen.has(value)) continue; seen.add(value); for (const subschema of Object.values(value as Record)) { - if (walk(subschema, seen)) return true; + if (walk(subschema, seen, inIdResource)) return true; } continue; } - if (walk(value, seen)) return true; + if (walk(value, seen, inIdResource)) return true; } return false; }; - return walk(document, new Set()); + return walk(document, new Set(), false); } /** @@ -630,11 +647,18 @@ function someSchemaNode(document: Record, predicate: (record: R * break all the same. */ function hasHandAuthoredReferenceConstructs(document: Record): boolean { - const walk = (node: unknown, isRoot: boolean, underPolarityBoundary: boolean, seen: Set): boolean => { + const walk = ( + node: unknown, + isRoot: boolean, + underPolarityBoundary: boolean, + insideIdResource: boolean, + seen: Set + ): boolean => { if (typeof node !== 'object' || node === null || seen.has(node)) return false; seen.add(node); - if (Array.isArray(node)) return node.some(item => walk(item, false, underPolarityBoundary, seen)); + if (Array.isArray(node)) return node.some(item => walk(item, false, underPolarityBoundary, insideIdResource, seen)); const record = node as Record; + const inIdResource = insideIdResource || typeof record.id === 'string'; for (const refKey of ['$ref', '$dynamicRef'] as const) { const value = record[refKey]; if (value === undefined) continue; @@ -645,6 +669,11 @@ function hasHandAuthoredReferenceConstructs(document: Record): // loosening as a tightening the rename walk's lexical polarity skip // cannot see through the ref indirection. if (underPolarityBoundary) return true; + // And they are loosen-safe only OUTSIDE draft-04 `id` resources: the + // cfworker engine resolves refs base-relatively there, and zod never + // emits a bare `#` inside an id-carrying entry — such refs are + // hand-authored and observe the strip/loosening. + if (inIdResource) return true; if (value !== '#' && !ZOD_REGISTRY_REF_PATTERN.test(value)) return true; } if (record.$anchor !== undefined || record.$dynamicAnchor !== undefined || record.$id !== undefined) return true; @@ -670,15 +699,15 @@ function hasHandAuthoredReferenceConstructs(document: Record): if (seen.has(value)) continue; seen.add(value); for (const subschema of Object.values(value as Record)) { - if (walk(subschema, false, childUnderBoundary, seen)) return true; + if (walk(subschema, false, childUnderBoundary, inIdResource, seen)) return true; } continue; } - if (walk(value, false, childUnderBoundary, seen)) return true; + if (walk(value, false, childUnderBoundary, inIdResource, seen)) return true; } return false; }; - return walk(document, true, false, new Set()); + return walk(document, true, false, false, new Set()); } /** @@ -893,7 +922,15 @@ function hasStructuralMissingKeyTolerance(field: unknown, ancestors: ReadonlySet // would step past the very node granting tolerance (bare `.optional()` fields // are already excluded from `required` by zod's emitter, but one inside a pipe // — `z.string().optional().transform(async ...)` — is not). - if (def.type === 'default' || def.type === 'prefault' || def.type === 'catch' || def.type === 'optional') return true; + if (def.type === 'default' || def.type === 'catch' || def.type === 'optional') return true; + if (def.type === 'prefault') { + // UNLIKE `.default()`, `.prefault(v)` feeds v THROUGH the inner schema — + // `z.number().min(1).prefault(0)` rejects a missing key. Filling-then- + // revalidating is not filling: claim nothing and let the probe decide + // (sync verdicts are correct both ways; an async-refined valid-prefault + // field conservatively stays required, matching the documented posture). + return false; + } if (def.type === 'any' || def.type === 'unknown' || def.type === 'undefined' || def.type === 'void') return true; if (def.type === 'symbol' || def.type === 'function') return true; if (def.type === 'literal' && Array.isArray(def.values) && def.values.includes(undefined)) return true; @@ -931,21 +968,37 @@ function hasStructuralMissingKeyTolerance(field: unknown, ancestors: ReadonlySet return def.options.some(option => hasStructuralMissingKeyTolerance(option, path)); } if (def.type === 'intersection' && def.left !== undefined && def.right !== undefined) { - // EVERY-side semantics: `undefined` must parse through BOTH sides (each - // filling its default) for zod to merge the results. - return hasStructuralMissingKeyTolerance(def.left, path) && hasStructuralMissingKeyTolerance(def.right, path); + // `undefined` must parse through BOTH sides AND the two filled results + // must MERGE — zod throws 'Unmergable intersection' otherwise (two scalar + // defaults with different values reject every payload omitting the key). + // Merging is provable structurally only for the distinct-key + // plain-object-defaults shape (the pinned async-refined spelling, where + // the probe cannot be used); everything else defers to the probe. + return intersectionSidesFillDisjointObjects(def.left, def.right); + } + if (def.type === 'promise') { + // zod 4's promise parse rejects `undefined` outright regardless of the + // inner type — there is no undefined-tolerant z.promise spelling. Claim + // nothing (the generic unwind below would wrongly grant the inner's + // tolerance); `promise` stays in WRAPPER_ZOD_DEF_TYPES for the + // root-TYPE-verdict walks, where transparency is correct. + return false; } if (def.type === 'nonoptional') { // z.nonoptional() RE-FORBIDS undefined, so tolerance by ACCEPTANCE inside // it (an inner optional, any/unknown, undefined-valued literals) does NOT - // survive the wrapper — only tolerance by FILLING does (default/prefault/ - // static catch replace undefined before nonoptional's check runs). The + // survive the wrapper — only tolerance by FILLING does (default/static + // catch replace undefined before nonoptional's check runs). The // structural walk cannot tell the two apart, so it claims nothing and the // validate(undefined) probe in fieldAcceptsMissingKey decides: it returns // issues for `.optional().nonoptional()` (stays required) and success for // `.default(1).nonoptional()` (stays droppable). The generic unwind below // would wrongly propagate acceptance-tolerance through the re-forbid. - return false; + // SERIALIZATION-drop tolerance is the exception: a symbol/function leaf + // can never appear on the wire (JSON.stringify drops the key) no matter + // what validation demands, and the probe cannot see that — it survives + // the re-forbid. + return hasSerializationDroppedLeaf(def.innerType); } if (WRAPPER_ZOD_DEF_TYPES.has(def.type) && def.innerType !== undefined) { return hasStructuralMissingKeyTolerance(def.innerType, path); @@ -953,6 +1006,46 @@ function hasStructuralMissingKeyTolerance(field: unknown, ancestors: ReadonlySet return false; } +/** + * Whether the field unwinds (through transparent wrappers) to a symbol- or + * function-typed leaf — values `JSON.stringify` drops from the payload entirely, + * so the key can never appear on the wire regardless of what validation demands. + * Used where VALIDATION-based tolerance must not propagate but + * SERIALIZATION-based tolerance still applies (the `nonoptional` re-forbid). + */ +function hasSerializationDroppedLeaf(field: unknown): boolean { + if (typeof field !== 'object' || field === null) return false; + const def = (field as { _zod?: { def?: { type?: string; innerType?: unknown } } })._zod?.def; + if (def === undefined || typeof def.type !== 'string') return false; + if (def.type === 'symbol' || def.type === 'function') return true; + if (WRAPPER_ZOD_DEF_TYPES.has(def.type) && def.innerType !== undefined) return hasSerializationDroppedLeaf(def.innerType); + return false; +} + +/** + * The one structurally-provable mergeable-intersection shape: BOTH sides are + * `.default()`s whose fill values are plain objects with disjoint key sets, so + * zod's merge of the two fills cannot throw. Function-form defaults + * (`.default(() => …)`) and every other spelling defer to the probe. + */ +function intersectionSidesFillDisjointObjects(left: unknown, right: unknown): boolean { + const leftFill = plainObjectDefaultFill(left); + if (leftFill === undefined) return false; + const rightFill = plainObjectDefaultFill(right); + if (rightFill === undefined) return false; + return Object.keys(leftFill).every(key => !Object.hasOwn(rightFill, key)); +} + +/** The side's `.default()` fill value, when it is a plain (non-array) object. */ +function plainObjectDefaultFill(side: unknown): Record | undefined { + if (typeof side !== 'object' || side === null) return undefined; + const def = (side as { _zod?: { def?: { type?: string; defaultValue?: unknown } } })._zod?.def; + if (def?.type !== 'default') return undefined; + const fill = def.defaultValue; + if (typeof fill !== 'object' || fill === null || Array.isArray(fill)) return undefined; + return fill as Record; +} + /** Options for {@linkcode standardSchemaToJsonSchema}. */ export interface StandardSchemaToJsonSchemaOptions { /** diff --git a/packages/core-internal/test/util/standardSchema.test.ts b/packages/core-internal/test/util/standardSchema.test.ts index 891cb5bf90..a148eb0eee 100644 --- a/packages/core-internal/test/util/standardSchema.test.ts +++ b/packages/core-internal/test/util/standardSchema.test.ts @@ -1200,6 +1200,53 @@ describe('zod conversion options (#2464)', () => { expect(standardSchemaToJsonSchema(bareTransform, 'output').required).toEqual(['name']); }); + test('prefault, unmergeable intersections, and promise fields stay advertised required', () => { + // .prefault(v) feeds v THROUGH the inner schema — filling-then-revalidating + // is not filling, so the probe decides: the rejected fill keeps the field + // required, a valid fill keeps it droppable. + const prefaulted = z.object({ p: z.number().min(1).prefault(0), name: z.string() }); + expect(standardSchemaToJsonSchema(prefaulted, 'output').required).toEqual(['p', 'name']); + const validPrefault = z.object({ p: z.number().min(1).prefault(5), name: z.string() }); + expect(standardSchemaToJsonSchema(validPrefault, 'output').required).toEqual(['name']); + // Two scalar defaults with different values make zod throw 'Unmergable + // intersection' on every payload omitting the key. + const unmergeable = z.object({ m: z.intersection(z.number().default(0), z.number().default(1)), name: z.string() }); + expect(standardSchemaToJsonSchema(unmergeable, 'output').required).toEqual(['m', 'name']); + // zod 4's promise parse rejects undefined regardless of the inner type. + const promised = z.object({ p: z.promise(z.number().default(0)), name: z.string() }); + expect(standardSchemaToJsonSchema(promised, 'output').required).toEqual(['p', 'name']); + }); + + test('symbol leaves keep their wire-drop tolerance through nonoptional', () => { + // JSON.stringify drops symbol-valued keys regardless of what validation + // demands — serialization tolerance survives the re-forbid even though + // the probe (validation-only) cannot see it. + const required = z.object({ s: z.symbol().optional(), name: z.string() }).required(); + expect(standardSchemaToJsonSchema(required, 'output').required).toEqual(['name']); + // Acceptance tolerance still must NOT survive. + const acceptance = z.object({ a: z.string().optional(), name: z.string() }).required(); + expect(standardSchemaToJsonSchema(acceptance, 'output').required).toEqual(['a', 'name']); + }); + + test('registry-shaped refs inside an id resource keep the id (context-aware gate)', () => { + // cfworker resolves a `$ref: '#'` inside a draft-04 id resource + // base-relatively to THAT resource — zod never emits that spelling there, + // so it is hand-authored and the strip would invert every verdict. + const reg = z.object({ q: z.string(), self: z.unknown().optional().meta({ $ref: '#' }) }).meta({ id: 'RegSelf' }); + const schema = z.object({ x: reg, name: z.string() }); + for (const io of ['output', 'input'] as const) { + const result = standardSchemaToJsonSchema(schema, io); + expect(((result.$defs as Record>).RegSelf ?? {}).id).toBe('RegSelf'); + const validate = new CfWorkerJsonSchemaValidator().getValidator(result); + expect(validate({ x: { q: 'a', self: { q: 'b' } }, name: 'n' }).valid).toBe(true); // resource-shaped self + expect(validate({ x: { q: 'a', self: { x: { q: 'c' }, name: 'm' } }, name: 'n' }).valid).toBe(false); // root-shaped self + } + // Registry-only documents (no refs inside entries) still get the strip. + const plain = z.object({ q: z.string() }).meta({ id: 'RegPlain' }); + const stripped = standardSchemaToJsonSchema(z.object({ x: plain, y: plain, name: z.string() }), 'output'); + expect(((stripped.$defs as Record>).RegPlain ?? {}).id).toBeUndefined(); + }); + test('input conversions keep draft-04 id when hand-authored refs need its base', () => { // The input branch defers the strip under the same gate as the output // paths — no reference guard runs on input, but the hand-authored-ref