fix(ai): decontaminate leaked Zod schema instances to valid JSON Schema

Rewrites JSON-roundtripped Zod 4 schema objects that leak Zod internals as
JSON Schema keywords (e.g., `type:"enum"`, `enum:{...}`) into valid
JSON Schema 2020-12. This prevents validation failures when such schemas
are used as tool input schemas (e.g., from MCP servers).

Updates `isZodSchema` to reject deserialized Zod impostors that retain
`_zod` but lose their prototype.

Wires `toJSON` methods onto TypeBox shim schemas to ensure `JSON.stringify`
produces clean JSON Schema, preventing future leaks.

Fixes #1101
This commit is contained in:
Can Bölük
2026-05-19 10:16:40 +09:00
parent 12e92262e8
commit 4024fbe7e2
8 changed files with 565 additions and 26 deletions
+1
View File
@@ -10,3 +10,4 @@ export * from "./normalize";
export * from "./spill";
export * from "./types";
export * from "./wire";
export * from "./zod-decontaminate";
+4 -3
View File
@@ -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,
+17 -2
View File
@@ -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"
);
}
@@ -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> = {
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> = {
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> = {
string: "string",
number: "number",
int: "integer",
boolean: "boolean",
null: "null",
bigint: "string",
date: "string",
nan: "number",
};
const ZOD_NOISE_KEYS: Record<string, true> = {
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<string, true> = {
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<object>): 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<object>): 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;
}
@@ -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);
});
});
// ---------------------------------------------------------------------------
+44
View File
@@ -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);
});
});
@@ -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<T extends ZodType>(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<string, unknown> = {};
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<T extends ZodType>(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<T extends ZodType>(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<string, unknown> = {};
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<V extends ZodType>(key: ZodType, value: V, opts?: Meta): ZodTyp
}
function tOptional<E extends ZodType>(schema: E, _opts?: Meta): ZodOptional<E> {
return isOptional(schema) ? (schema as unknown as ZodOptional<E>) : (schema.optional() as ZodOptional<E>);
if (isOptional(schema)) return wire(schema as unknown as ZodOptional<E>);
return wire(schema.optional() as ZodOptional<E>);
}
function tNullable<E extends ZodType>(schema: E, opts?: Meta): ZodType {
@@ -322,27 +350,26 @@ function tNullable<E extends ZodType>(schema: E, opts?: Meta): ZodType {
function tReadonly<E extends ZodType>(schema: E): E {
// TypeBox's `Type.Readonly` is purely a marker; runtime parsing is identical.
return schema;
return wire(schema);
}
function tPartial<P extends ZodRawShape>(obj: ZodObject<P>): ZodObject<P> {
return obj.partial() as unknown as ZodObject<P>;
return wire(obj.partial() as unknown as ZodObject<P>);
}
function tRequired<P extends ZodRawShape>(obj: ZodObject<P>): ZodObject<P> {
return obj.required() as unknown as ZodObject<P>;
return wire(obj.required() as unknown as ZodObject<P>);
}
function tPick<P extends ZodRawShape, K extends keyof P>(obj: ZodObject<P>, keys: readonly K[]): ZodObject<Pick<P, K>> {
const mask = Object.fromEntries(keys.map(k => [k as string, true]));
return obj.pick(mask as never) as unknown as ZodObject<Pick<P, K>>;
return wire(obj.pick(mask as never) as unknown as ZodObject<Pick<P, K>>);
}
function tOmit<P extends ZodRawShape, K extends keyof P>(obj: ZodObject<P>, keys: readonly K[]): ZodObject<Omit<P, K>> {
const mask = Object.fromEntries(keys.map(k => [k as string, true]));
return obj.omit(mask as never) as unknown as ZodObject<Omit<P, K>>;
return wire(obj.omit(mask as never) as unknown as ZodObject<Omit<P, K>>);
}
function tComposite(objects: readonly ZodObject<ZodRawShape>[], opts?: Meta): ZodObject<ZodRawShape> {
// `Type.Composite([...])` flattens every object schema into one object schema
// rather than producing an intersection. Mirror that via repeated `extend`.
@@ -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<string, unknown>;
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<string, unknown>;
expect(isValidJsonSchema(round)).toBe(true);
expect(round).not.toHaveProperty("_zod");
}
});
});
});