Skip to content

Commit f105c75

Browse files
committed
fix(core-internal): conjunction-preserving oneOf rewrite; skip negated positions
- 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 <noreply@anthropic.com>
1 parent 8270f46 commit f105c75

2 files changed

Lines changed: 50 additions & 2 deletions

File tree

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

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -409,8 +409,16 @@ function rewriteOneOfToAnyOf(node: unknown, seen: Set<unknown> = new Set()): voi
409409
return;
410410
}
411411
const record = node as Record<string, unknown>;
412-
if (Array.isArray(record.oneOf) && record.anyOf === undefined) {
413-
record.anyOf = record.oneOf;
412+
if (Array.isArray(record.oneOf)) {
413+
if (record.anyOf === undefined) {
414+
record.anyOf = record.oneOf;
415+
} else {
416+
// A user `.meta({anyOf})` can coexist with the emitted `oneOf` — preserve
417+
// conjunction semantics without clobbering it: keywords on one node
418+
// combine with AND, so `{anyOf: members}` under `allOf` is equivalent.
419+
const allOf = Array.isArray(record.allOf) ? record.allOf : (record.allOf = []);
420+
allOf.push({ anyOf: record.oneOf });
421+
}
414422
delete record.oneOf;
415423
}
416424
for (const [key, value] of Object.entries(record)) {
@@ -419,6 +427,10 @@ function rewriteOneOfToAnyOf(node: unknown, seen: Set<unknown> = new Set()): voi
419427
// `.meta()` keys — carries user DATA whose literal `oneOf` keys must not be
420428
// renamed.
421429
if (!SCHEMA_CARRYING_JSON_SCHEMA_KEYWORDS.has(key)) continue;
430+
// oneOf→anyOf is a loosening only in POSITIVE polarity: under `not` it
431+
// inverts (a payload matching ≥2 members passed `not {oneOf}` but fails
432+
// `not {anyOf}`), and under `if` it can flip which then/else branch applies.
433+
if (key === 'not' || key === 'if') continue;
422434
// Schema MAPS hold schemas under user-chosen names that may collide with
423435
// annotation keywords (a property literally named `description` still
424436
// carries a schema) — recurse into every value unconditionally.

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

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -622,6 +622,42 @@ describe('zod conversion options (#2464)', () => {
622622
expect(cfg.default).toEqual({ oneOf: [1, 2] });
623623
});
624624

625+
test('the oneOf rewrite preserves a coexisting user anyOf via allOf', () => {
626+
// zod merges .meta({anyOf}) verbatim beside the emitted oneOf — bailing there
627+
// left a reject-everything oneOf after the members were loosened.
628+
const du = z
629+
.discriminatedUnion('t', [
630+
z.object({ t: z.literal('a').catch('a'), x: z.string().optional() }),
631+
z.object({ t: z.literal('b').catch('b'), y: z.string().optional() })
632+
])
633+
.meta({ anyOf: [{ type: 'object' }] });
634+
const result = standardSchemaToJsonSchema(du, 'output');
635+
636+
expect(result.oneOf).toBeUndefined();
637+
expect(result.anyOf).toEqual([{ type: 'object' }]); // the user's anyOf, unclobbered
638+
const allOf = result.allOf as Array<{ anyOf: Array<Record<string, unknown>> }>;
639+
expect(allOf).toHaveLength(1);
640+
expect(allOf[0]!.anyOf.map(member => member.type)).toEqual(['object', 'object']);
641+
expect(new AjvJsonSchemaValidator().getValidator(result)({ t: 'a', x: 'v' }).valid).toBe(true);
642+
});
643+
644+
test('the oneOf rewrite skips negated and conditional positions', () => {
645+
// Under `not`, oneOf→anyOf inverts polarity and TIGHTENS: {v: 4} matches
646+
// both members, so it passed `not {oneOf}` pre-#2464 but would fail the
647+
// rewritten `not {anyOf}`.
648+
const schema = z.object({
649+
v: z.number().meta({ not: { oneOf: [{ type: 'integer' }, { multipleOf: 2 }] } }),
650+
counted: z.number().default(0), // trips the loosened flag
651+
name: z.string()
652+
});
653+
const result = standardSchemaToJsonSchema(schema, 'output');
654+
655+
expect((result.properties as Record<string, Record<string, unknown>>).v!.not).toEqual({
656+
oneOf: [{ type: 'integer' }, { multipleOf: 2 }]
657+
});
658+
expect(new AjvJsonSchemaValidator().getValidator(result)({ v: 4, counted: 1, name: 'n' }).valid).toBe(true);
659+
});
660+
625661
test('custom .meta() keys are annotation-opaque at both carve-out sites', () => {
626662
// zod merges arbitrary .meta() keys verbatim and 2020-12 validators ignore
627663
// unknown keywords — they are annotations by construction, like x-*.

0 commit comments

Comments
 (0)