diff --git a/packages/ai/src/utils/schema/index.ts b/packages/ai/src/utils/schema/index.ts index b89a511b8..971e88208 100644 --- a/packages/ai/src/utils/schema/index.ts +++ b/packages/ai/src/utils/schema/index.ts @@ -10,3 +10,4 @@ export * from "./normalize"; export * from "./spill"; export * from "./types"; export * from "./wire"; +export * from "./zod-decontaminate"; diff --git a/packages/ai/src/utils/schema/normalize.ts b/packages/ai/src/utils/schema/normalize.ts index befaf81a0..9983ae9a7 100644 --- a/packages/ai/src/utils/schema/normalize.ts +++ b/packages/ai/src/utils/schema/normalize.ts @@ -21,8 +21,8 @@ import { import { isValidJsonSchema } from "./meta-validator"; import { type DescriptionSpillFormat, spillToDescription } from "./spill"; import { enter, epochNext, exit, once, stamp } from "./stamps"; -import type { JsonObject } from "./types"; -import { isJsonObject } from "./types"; +import { isJsonObject, type JsonObject } from "./types"; +import { decontaminateZodInstance } from "./zod-decontaminate"; export type ResidualSchemaIncompatibility = "type-array" | "type-null" | "nullable" | "combiners"; @@ -768,7 +768,8 @@ function hasResidualSchemaIncompatibilities( } export function normalizeSchema(value: unknown, options: NormalizeSchemaOptions): unknown { - const upgraded = upgradeJsonSchemaTo202012(value); + const detoxified = decontaminateZodInstance(value); + const upgraded = upgradeJsonSchemaTo202012(detoxified); const dereferenced = dereferenceJsonSchema(upgraded); let normalized = normalizeSchemaNode(dereferenced, { ...options, diff --git a/packages/ai/src/utils/schema/wire.ts b/packages/ai/src/utils/schema/wire.ts index 03ee7a2c7..d6e604856 100644 --- a/packages/ai/src/utils/schema/wire.ts +++ b/packages/ai/src/utils/schema/wire.ts @@ -18,7 +18,19 @@ import type { Tool, TSchema } from "../../types"; import { upgradeJsonSchemaTo202012 } from "./draft"; import { stamp } from "./stamps"; -/** True when `value` is a Zod schema instance. */ +/** + * True when `value` is a live Zod schema instance. + * + * The check is stricter than "has a `_zod` property" because a JSON + * round-trip preserves the `_zod` key as a plain object and would otherwise + * fool the predicate — see issue #1101, where MCP servers ship + * `JSON.stringify(zodSchemaInstance)` as a tool's `inputSchema` and the + * resulting plain object then explodes `z.toJSONSchema` because the prototype + * (and every Zod parsing method) is gone. + * + * Live Zod instances always carry a `.parse` function on the prototype; + * impostors do not. + */ export function isZodSchema(value: unknown): value is ZodType { return ( typeof value === "object" && @@ -30,7 +42,10 @@ export function isZodSchema(value: unknown): value is ZodType { // (`ZodObject`, `ZodOptional`, etc.) and a tagged-union style check would // have to enumerate them all. "_zod" in value && - typeof (value as { _zod?: { def?: unknown } })._zod === "object" + typeof (value as { _zod?: { def?: unknown } })._zod === "object" && + // Reject JSON-roundtripped objects that kept the `_zod` key but lost the + // prototype. Real instances have `.parse` on the prototype chain. + typeof (value as { parse?: unknown }).parse === "function" ); } diff --git a/packages/ai/src/utils/schema/zod-decontaminate.ts b/packages/ai/src/utils/schema/zod-decontaminate.ts new file mode 100644 index 000000000..09491c18c --- /dev/null +++ b/packages/ai/src/utils/schema/zod-decontaminate.ts @@ -0,0 +1,322 @@ +/** + * Defensive rewrite for nodes that look like `JSON.stringify(zodSchemaInstance)` + * output rather than JSON Schema. MCP servers using Zod 4 sometimes ship a + * serialised schema instance directly as a tool's `inputSchema`, because the + * fields Zod surfaces on its instances (`type`, `enum`, `options`, `def`) shadow + * (and clash with) JSON Schema keywords. The resulting payload is neither valid + * Zod nor valid JSON Schema 2020-12 and Anthropic's strict validator rejects + * the whole tool list. + * + * Symptoms we've observed (gitnexus_impact.direction): + * { + * def: { type: "enum", entries: { upstream: "upstream", ... } }, + * type: "enum", // <- invalid `type` value + * enum: { upstream: "upstream", ... }, // <- `enum` MUST be an array + * options: ["upstream", "downstream"], + * } + * + * This module recognises the shape (`def.type === node.type` and `def.type` is + * a known Zod kind) and rewrites it to clean JSON Schema where deterministic. + * For Zod kinds we don't fully model, we strip the toxic siblings (`def`, + * `options`, object-shaped `enum`) and drop an invalid `type` so the remainder + * passes meta-schema validation as a permissive node. + * + * Pure / identity-preserving: returns the input reference when nothing changes. + */ + +import { isJsonObject, type JsonObject } from "./types"; + +const VALID_JSON_SCHEMA_TYPES: Record = { + string: true, + number: true, + integer: true, + boolean: true, + object: true, + array: true, + null: true, +}; + +/** + * Known Zod 4 schema kinds as surfaced on `_def.type` / `.type`. Matching this + * set (rather than just "has `def`") is what keeps us from rewriting legitimate + * JSON Schemas that happen to use `def` as a property name. + */ +const ZOD_KINDS: Record = { + string: true, + number: true, + int: true, + boolean: true, + bigint: true, + null: true, + undefined: true, + void: true, + any: true, + unknown: true, + never: true, + date: true, + symbol: true, + nan: true, + enum: true, + literal: true, + object: true, + array: true, + tuple: true, + record: true, + map: true, + set: true, + union: true, + discriminatedUnion: true, + intersection: true, + lazy: true, + promise: true, + function: true, + file: true, + custom: true, + template_literal: true, + optional: true, + nullable: true, + default: true, + prefault: true, + catch: true, + pipe: true, + transform: true, + brand: true, + readonly: true, + success: true, + nonoptional: true, +}; + +const ZOD_SCALAR_TO_JSON_TYPE: Record = { + string: "string", + number: "number", + int: "integer", + boolean: "boolean", + null: "null", + bigint: "string", + date: "string", + nan: "number", +}; + +const ZOD_NOISE_KEYS: Record = { + def: true, + options: true, + _zod: true, + checks: true, +}; + +/** + * JSON Schema keywords where `null` is a legal value (literal payload positions). + * Anywhere else, a `null`-valued key is a meta-schema violation — Zod scalars + * leak `format: null`, `minLength: null`, etc. that we have to scrub. + */ +const KEYS_THAT_ACCEPT_NULL: Record = { + default: true, + const: true, + examples: true, +}; + +function isZodLeak(node: JsonObject): boolean { + const def = node.def; + if (!isJsonObject(def)) return false; + const defType = def.type; + if (typeof defType !== "string" || !ZOD_KINDS[defType]) return false; + // Both surface and inner `.type` must agree — Zod always mirrors `_def.type` + // onto the instance, so this is a near-zero false-positive guard. + return node.type === defType; +} + +function inferTypeFromValues(values: readonly unknown[]): string { + if (values.length === 0) return "string"; + const first = values[0]; + if (typeof first === "number") return Number.isInteger(first) ? "integer" : "number"; + if (typeof first === "boolean") return "boolean"; + if (first === null) return "null"; + return "string"; +} + +function unwrapInnerSchema(def: JsonObject): unknown { + // Zod uses different fields depending on the wrapper: + // optional/nullable/readonly/brand/default → `innerType` + // pipe → `in` (or `out`) + // lazy → `getter` (a function — gone after JSON.stringify); fall back to {} + return def.innerType ?? def.in ?? def.out ?? def.schema ?? def.element ?? {}; +} + +function copyWithoutNoise(node: JsonObject): JsonObject { + const out: JsonObject = {}; + for (const key in node) { + if (ZOD_NOISE_KEYS[key]) continue; + const value = node[key]; + if (value === null && !KEYS_THAT_ACCEPT_NULL[key]) continue; + out[key] = value; + } + return out; +} + +function rewriteZodNode(node: JsonObject, seen: WeakSet): unknown { + const def = node.def as JsonObject; + const kind = def.type as string; + + switch (kind) { + case "enum": { + // Prefer node.options (array form Zod exposes) → def.entries values → + // object-shaped node.enum values. All three carry the same data. + const optionsArray = Array.isArray(node.options) ? (node.options as unknown[]) : null; + const entries = isJsonObject(def.entries) ? Object.values(def.entries) : null; + const enumObj = isJsonObject(node.enum) ? Object.values(node.enum) : null; + const values = optionsArray ?? entries ?? enumObj ?? []; + return { type: inferTypeFromValues(values), enum: values }; + } + + case "literal": { + const values = Array.isArray(def.values) ? (def.values as unknown[]) : []; + if (values.length === 1) { + return { const: values[0] }; + } + if (values.length > 1) { + return { type: inferTypeFromValues(values), enum: values }; + } + return {}; + } + + case "union": + case "discriminatedUnion": { + const arms = Array.isArray(def.options) + ? (def.options as unknown[]) + : Array.isArray(node.options) + ? (node.options as unknown[]) + : []; + return { anyOf: arms.map(x => walk(x, seen)) }; + } + + case "intersection": { + return { + allOf: [walk(def.left, seen), walk(def.right, seen)], + }; + } + + case "array": { + return { type: "array", items: walk(def.element, seen) }; + } + + case "set": { + const element = def.valueType ?? def.element; + return { type: "array", uniqueItems: true, items: walk(element, seen) }; + } + + case "tuple": { + const items = Array.isArray(def.items) ? (def.items as unknown[]) : []; + const out: JsonObject = { type: "array", prefixItems: items.map(x => walk(x, seen)) }; + const rest = def.rest; + if (rest != null) out.items = walk(rest, seen); + return out; + } + + case "record": + case "map": { + return { type: "object", additionalProperties: walk(def.valueType, seen) }; + } + + case "object": { + const shape = isJsonObject(def.shape) ? def.shape : ({} as JsonObject); + const properties: JsonObject = {}; + const required: string[] = []; + for (const key in shape) { + const inner = walk(shape[key], seen); + properties[key] = inner; + if (!isOptionalEntry(shape[key])) required.push(key); + } + const out: JsonObject = { type: "object", properties }; + if (required.length > 0) out.required = required; + return out; + } + + case "nonoptional": + case "optional": + case "nullable": + case "default": + case "prefault": + case "catch": + case "readonly": + case "brand": + case "lazy": + case "pipe": + case "transform": { + const inner = walk(unwrapInnerSchema(def), seen); + if (kind === "nullable" && isJsonObject(inner) && typeof inner.type === "string") { + return { ...inner, type: [inner.type, "null"] }; + } + return inner; + } + + default: { + // Best-effort: drop the noise, map the kind to a JSON Schema type if + // we know one, otherwise drop `type` so the node validates as + // permissive. + const cleaned = copyWithoutNoise(node); + const mapped = ZOD_SCALAR_TO_JSON_TYPE[kind]; + if (mapped) { + cleaned.type = mapped; + } else if (typeof cleaned.type === "string" && !VALID_JSON_SCHEMA_TYPES[cleaned.type]) { + delete cleaned.type; + } + // Object-shaped `enum` survives as a noise field — remove if present. + if (cleaned.enum !== undefined && !Array.isArray(cleaned.enum)) { + delete cleaned.enum; + } + return cleaned; + } + } +} + +function isOptionalEntry(value: unknown): boolean { + if (!isJsonObject(value)) return false; + if (!isZodLeak(value)) return false; + const kind = (value.def as JsonObject).type; + return kind === "optional" || kind === "default" || kind === "prefault"; +} + +/** + * Walks a JSON value and rewrites every Zod-instance-shaped node into clean + * JSON Schema 2020-12. Identity-preserving when no rewrite fires. Tolerates + * self-referential graphs — a revisited node returns as-is. + */ +export function decontaminateZodInstance(value: unknown): unknown { + return walk(value, new WeakSet()); +} + +function walk(value: unknown, seen: WeakSet): unknown { + if (Array.isArray(value)) { + if (seen.has(value)) return value; + seen.add(value); + let changed = false; + const out = value.map(entry => { + const rewritten = walk(entry, seen); + if (rewritten !== entry) changed = true; + return rewritten; + }); + return changed ? out : value; + } + if (!isJsonObject(value)) return value; + if (seen.has(value)) return value; + seen.add(value); + + if (isZodLeak(value)) { + // Rewrite the node itself, then recurse into the rewrite so any nested + // Zod-instance children get cleaned in the same pass. + const rewritten = rewriteZodNode(value, seen); + return rewritten === value ? value : walk(rewritten, seen); + } + + // Plain JSON Schema node: recurse into children, preserving identity when + // nothing under us changed. + let changed = false; + const out: JsonObject = {}; + for (const key in value) { + const child = value[key]; + const rewritten = walk(child, seen); + if (rewritten !== child) changed = true; + out[key] = rewritten; + } + return changed ? out : value; +} diff --git a/packages/ai/test/schema-normalization.test.ts b/packages/ai/test/schema-normalization.test.ts index a1e871b81..6f5aec377 100644 --- a/packages/ai/test/schema-normalization.test.ts +++ b/packages/ai/test/schema-normalization.test.ts @@ -348,6 +348,99 @@ describe("normalizeSchemaForMCP", () => { description: "ID", }); }); + + // Regression: issue #1101. Some MCP servers ship `JSON.stringify(zodSchema)` + // directly as a tool's `inputSchema`. Zod 4 surfaces `.type`, `.enum`, + // `.options`, and `.def` on every schema instance — those keys collide with + // JSON Schema keywords, producing payloads that fail Anthropic's strict + // JSON Schema 2020-12 validator (`"type":"enum"`, `"enum":{...}` as object). + // `normalizeSchemaForMCP` must rewrite the offending nodes into clean JSON + // Schema so the tool list still ships. + it("rewrites a Zod-enum instance leaked as inputSchema", () => { + const leaked = { + def: { type: "enum", entries: { upstream: "upstream", downstream: "downstream" } }, + type: "enum", + enum: { upstream: "upstream", downstream: "downstream" }, + options: ["upstream", "downstream"], + }; + expect(normalizeSchemaForMCP(leaked)).toEqual({ + type: "string", + enum: ["upstream", "downstream"], + }); + }); + + it("rewrites a numeric Zod-enum (integer values keep integer type)", () => { + const leaked = { + def: { type: "enum", entries: { ONE: 1, TWO: 2 } }, + type: "enum", + enum: { ONE: 1, TWO: 2 }, + options: [1, 2], + }; + expect(normalizeSchemaForMCP(leaked)).toEqual({ + type: "integer", + enum: [1, 2], + }); + }); + + it("rewrites a Zod-literal instance to a single-element enum", () => { + const leaked = { + def: { type: "literal", values: ["only"] }, + type: "literal", + values: ["only"], + }; + // Decontamination emits `{const:"only"}`; downstream normalizer collapses + // it to the equivalent enum form. End-to-end contract is what callers see. + expect(normalizeSchemaForMCP(leaked)).toEqual({ type: "string", enum: ["only"] }); + }); + + it("rewrites a Zod-union of literals (downstream collapses anyOf-of-consts to enum)", () => { + const leaked = { + def: { + type: "union", + options: [ + { def: { type: "literal", values: ["on"] }, type: "literal", values: ["on"] }, + { def: { type: "literal", values: ["off"] }, type: "literal", values: ["off"] }, + ], + }, + type: "union", + }; + expect(normalizeSchemaForMCP(leaked)).toEqual({ + type: "string", + enum: ["on", "off"], + }); + }); + + it("strips null-valued JSON Schema keywords that Zod scalars leak (format: null, minLength: null)", () => { + const leaked = { + def: { type: "string", checks: [] }, + type: "string", + format: null, + minLength: null, + maxLength: null, + }; + expect(normalizeSchemaForMCP(leaked)).toEqual({ type: "string" }); + }); + + it("drops invalid `type` for unmodelled Zod kinds so the residue stays valid", () => { + const leaked = { + def: { type: "any" }, + type: "any", + description: "anything", + }; + expect(normalizeSchemaForMCP(leaked)).toEqual({ description: "anything" }); + }); + + it("leaves a genuine JSON Schema that happens to have a `def` property alone", () => { + // `def` is not a JSON Schema keyword but it's also not reserved. The + // detoxifier must only fire when `def.type` is a known Zod kind AND + // `node.type === def.type`, otherwise it would corrupt real schemas. + const schema = { + type: "object", + properties: { def: { type: "string" } }, + required: ["def"], + }; + expect(normalizeSchemaForMCP(schema)).toEqual(schema); + }); }); // --------------------------------------------------------------------------- diff --git a/packages/ai/test/schema-wire.test.ts b/packages/ai/test/schema-wire.test.ts new file mode 100644 index 000000000..03ca2744c --- /dev/null +++ b/packages/ai/test/schema-wire.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "bun:test"; +import { isZodSchema } from "@oh-my-pi/pi-ai/utils/schema"; +import { z } from "zod/v4"; + +describe("isZodSchema", () => { + it("accepts a live Zod instance", () => { + expect(isZodSchema(z.object({ a: z.string() }))).toBe(true); + expect(isZodSchema(z.string())).toBe(true); + expect(isZodSchema(z.enum({ a: "a", b: "b" }))).toBe(true); + }); + + // Regression: issue #1101. Before tightening, `isZodSchema` returned true + // for `JSON.parse(JSON.stringify(zodSchema))` because the `_zod` property + // (and its object value) survived the round-trip — even though every Zod + // method had been stripped along with the prototype. The relaxed predicate + // fed garbage into `z.toJSONSchema` and (when callers bypassed conversion) + // shipped the raw Zod internals to Anthropic's strict validator. + it("rejects a JSON-roundtripped Zod schema (prototype lost)", () => { + const impostor = JSON.parse(JSON.stringify(z.object({ a: z.string() }))); + expect(isZodSchema(impostor)).toBe(false); + }); + + it("rejects the raw gitnexus_impact.direction payload from issue #1101", () => { + const impostor = { + def: { type: "enum", entries: { upstream: "upstream", downstream: "downstream" } }, + type: "enum", + enum: { upstream: "upstream", downstream: "downstream" }, + options: ["upstream", "downstream"], + }; + expect(isZodSchema(impostor)).toBe(false); + }); + + it("rejects plain JSON Schema objects", () => { + expect(isZodSchema({ type: "object", properties: {} })).toBe(false); + expect(isZodSchema({ type: "string" })).toBe(false); + }); + + it("rejects non-objects", () => { + expect(isZodSchema(null)).toBe(false); + expect(isZodSchema(undefined)).toBe(false); + expect(isZodSchema("string")).toBe(false); + expect(isZodSchema(42)).toBe(false); + }); +}); diff --git a/packages/coding-agent/src/extensibility/typebox.ts b/packages/coding-agent/src/extensibility/typebox.ts index e8182933b..52849961a 100644 --- a/packages/coding-agent/src/extensibility/typebox.ts +++ b/packages/coding-agent/src/extensibility/typebox.ts @@ -21,7 +21,7 @@ * `@sinclair/typebox` directly in their own package. */ -import { areJsonValuesEqual } from "@oh-my-pi/pi-ai/utils/schema"; +import { areJsonValuesEqual, zodToWireSchema } from "@oh-my-pi/pi-ai/utils/schema"; import { type ZodArray, type ZodEnum, @@ -104,19 +104,46 @@ interface ObjectOpts extends Meta { // Helpers // --------------------------------------------------------------------------- -function withMeta(schema: T, opts: Meta | undefined): T { - if (!opts) return schema; - let out: ZodType = schema; - if (typeof opts.description === "string") out = out.describe(opts.description); - if ("default" in opts) out = out.default(opts.default as never) as unknown as ZodType; - - const metadata: Record = {}; - for (const [key, value] of Object.entries(opts)) { - if (key === "description" || key === "default" || key === "additionalProperties") continue; - metadata[key] = value; +/** + * Stamp a non-enumerable `toJSON()` on a schema so `JSON.stringify(schema)` + * yields a clean draft 2020-12 JSON Schema — matching real TypeBox semantics + * where the schema object IS already a JSON Schema. Without this, an extension + * author who serialises the schema across any JSON boundary (worker + * postMessage, MCP transport, config persistence, network hop, structuredClone + * fallback) ships the raw Zod internals (`def`, `_zod`, object-shaped `enum`, + * `"type":"enum"`) — neither valid JSON Schema nor parseable Zod. See + * issue #1101 for the symptoms when this leaks into a tool's `input_schema`. + * + * Idempotent: re-stamping the same instance is a no-op. + */ +function wire(schema: T): T { + if (!Object.hasOwn(schema as object, "toJSON")) { + Object.defineProperty(schema as object, "toJSON", { + value: function toJSON(this: ZodType) { + return zodToWireSchema(this); + }, + enumerable: false, + writable: true, + configurable: true, + }); } - if (Object.keys(metadata).length > 0) out = out.meta(metadata); - return out as T; + return schema; +} + +function withMeta(schema: T, opts: Meta | undefined): T { + let out: ZodType = schema; + if (opts) { + if (typeof opts.description === "string") out = out.describe(opts.description); + if ("default" in opts) out = out.default(opts.default as never) as unknown as ZodType; + + const metadata: Record = {}; + for (const key in opts) { + if (key === "description" || key === "default" || key === "additionalProperties") continue; + metadata[key] = opts[key]; + } + if (Object.keys(metadata).length > 0) out = out.meta(metadata); + } + return wire(out as T); } // --------------------------------------------------------------------------- @@ -313,7 +340,8 @@ function tRecord(key: ZodType, value: V, opts?: Meta): ZodTyp } function tOptional(schema: E, _opts?: Meta): ZodOptional { - return isOptional(schema) ? (schema as unknown as ZodOptional) : (schema.optional() as ZodOptional); + if (isOptional(schema)) return wire(schema as unknown as ZodOptional); + return wire(schema.optional() as ZodOptional); } function tNullable(schema: E, opts?: Meta): ZodType { @@ -322,27 +350,26 @@ function tNullable(schema: E, opts?: Meta): ZodType { function tReadonly(schema: E): E { // TypeBox's `Type.Readonly` is purely a marker; runtime parsing is identical. - return schema; + return wire(schema); } function tPartial

(obj: ZodObject

): ZodObject

{ - return obj.partial() as unknown as ZodObject

; + return wire(obj.partial() as unknown as ZodObject

); } function tRequired

(obj: ZodObject

): ZodObject

{ - return obj.required() as unknown as ZodObject

; + return wire(obj.required() as unknown as ZodObject

); } function tPick

(obj: ZodObject

, keys: readonly K[]): ZodObject> { const mask = Object.fromEntries(keys.map(k => [k as string, true])); - return obj.pick(mask as never) as unknown as ZodObject>; + return wire(obj.pick(mask as never) as unknown as ZodObject>); } function tOmit

(obj: ZodObject

, keys: readonly K[]): ZodObject> { const mask = Object.fromEntries(keys.map(k => [k as string, true])); - return obj.omit(mask as never) as unknown as ZodObject>; + return wire(obj.omit(mask as never) as unknown as ZodObject>); } - function tComposite(objects: readonly ZodObject[], opts?: Meta): ZodObject { // `Type.Composite([...])` flattens every object schema into one object schema // rather than producing an intersection. Mirror that via repeated `extend`. diff --git a/packages/coding-agent/test/extensibility/typebox-shim.test.ts b/packages/coding-agent/test/extensibility/typebox-shim.test.ts index 9514cbac4..4f0fa4006 100644 --- a/packages/coding-agent/test/extensibility/typebox-shim.test.ts +++ b/packages/coding-agent/test/extensibility/typebox-shim.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "bun:test"; -import { toolWireSchema } from "@oh-my-pi/pi-ai/utils/schema"; +import { isValidJsonSchema, toolWireSchema } from "@oh-my-pi/pi-ai/utils/schema"; import { Type } from "../../src/extensibility/typebox"; describe("pi.typebox compatibility shim", () => { @@ -68,4 +68,40 @@ describe("pi.typebox compatibility shim", () => { expect((parsed.data as { extra?: unknown }).extra).toBe(1); } }); + + // Regression: issue #1101. Real TypeBox lets extension authors do + // `JSON.stringify(schema)` and get a clean JSON Schema — that's the + // contract the shim is impersonating. Without a `toJSON` stamp, the shim + // leaks raw Zod internals (`def`, `_zod`, object-shaped `enum`, + // `"type":"enum"`) and breaks any pipeline that crosses a JSON boundary. + describe("JSON.stringify produces valid JSON Schema (TypeBox contract)", () => { + it("emits clean JSON Schema for a complex object", () => { + const schema = Type.Object({ + direction: Type.Enum({ upstream: "upstream", downstream: "downstream" }), + depth: Type.Optional(Type.Integer({ minimum: 1, maximum: 10, default: 3 })), + tags: Type.Array(Type.String()), + }); + const round = JSON.parse(JSON.stringify(schema)) as Record; + expect(isValidJsonSchema(round)).toBe(true); + // No raw Zod internals leak through. + expect(round).not.toHaveProperty("_zod"); + expect(round).not.toHaveProperty("def"); + expect(round.type).toBe("object"); + }); + + it("emits valid JSON Schema for composition operators", () => { + const base = Type.Object({ a: Type.String(), b: Type.Number() }); + for (const schema of [ + Type.Partial(base), + Type.Required(base), + Type.Pick(base, ["a"]), + Type.Omit(base, ["a"]), + Type.Composite([base, Type.Object({ c: Type.Boolean() })]), + ]) { + const round = JSON.parse(JSON.stringify(schema)) as Record; + expect(isValidJsonSchema(round)).toBe(true); + expect(round).not.toHaveProperty("_zod"); + } + }); + }); });