From c8cd7be4c77e995d961de225fc6782726bbe6fdb Mon Sep 17 00:00:00 2001 From: Ankur Goyal Date: Sun, 12 Jul 2026 10:14:49 -0700 Subject: [PATCH 1/4] Fix nested intersections in union flattening --- .../src/rewrites/FlattenUnions.ts | 14 +++- test/unit/nested-intersection-union.test.ts | 67 +++++++++++++++++++ 2 files changed, 80 insertions(+), 1 deletion(-) create mode 100644 test/unit/nested-intersection-union.test.ts diff --git a/packages/quicktype-core/src/rewrites/FlattenUnions.ts b/packages/quicktype-core/src/rewrites/FlattenUnions.ts index 4357e7e7d..aab4d8511 100644 --- a/packages/quicktype-core/src/rewrites/FlattenUnions.ts +++ b/packages/quicktype-core/src/rewrites/FlattenUnions.ts @@ -59,6 +59,16 @@ export function flattenUnions( .join(","); } + function containsIntersection(t: Type, seen: Set): boolean { + if (seen.has(t.index)) return false; + seen.add(t.index); + + if (t instanceof IntersectionType) return true; + if (!(t instanceof UnionType)) return false; + + return iterableSome(t.members, (m) => containsIntersection(m, seen)); + } + function replace( types: ReadonlySet, builder: GraphRewriteBuilder, @@ -86,7 +96,9 @@ export function flattenUnions( trefs.map((tref) => derefTypeRef(tref, graph)), ); if ( - iterableSome(typesToUnify, (t) => t instanceof IntersectionType) + iterableSome(typesToUnify, (t) => + containsIntersection(t, new Set()), + ) ) { trefs = trefs.map((tref) => builder.reconstituteType(derefTypeRef(tref, graph)), diff --git a/test/unit/nested-intersection-union.test.ts b/test/unit/nested-intersection-union.test.ts new file mode 100644 index 000000000..d9c3a4a5f --- /dev/null +++ b/test/unit/nested-intersection-union.test.ts @@ -0,0 +1,67 @@ +import { InputData, JSONSchemaInput, quicktype } from "quicktype-core"; +import { describe, expect, test } from "vitest"; + +const schema = JSON.stringify({ + $schema: "http://json-schema.org/draft-07/schema#", + type: "object", + properties: { + input: { + type: "array", + items: { + oneOf: [ + { $ref: "#/definitions/Message" }, + { $ref: "#/definitions/Item" }, + ], + }, + }, + }, + definitions: { + Message: { + type: "object", + properties: { content: { type: "string" } }, + required: ["content"], + }, + Item: { + oneOf: [ + { $ref: "#/definitions/FunctionOutput" }, + { $ref: "#/definitions/ReasoningItem" }, + ], + }, + FunctionOutput: { + allOf: [ + { $ref: "#/definitions/BaseItem" }, + { + type: "object", + properties: { output: { type: "string" } }, + required: ["output"], + }, + ], + }, + BaseItem: { + type: "object", + properties: { id: { type: "string" } }, + required: ["id"], + }, + ReasoningItem: { + type: "object", + properties: { summary: { type: "string" } }, + required: ["summary"], + }, + }, +}); + +describe("nested intersections in unions", () => { + test("flattens an array item union containing an indirect allOf", async () => { + const schemaInput = new JSONSchemaInput(undefined); + await schemaInput.addSource({ name: "TopLevel", schema }); + const inputData = new InputData(); + inputData.addInput(schemaInput); + + const result = await quicktype({ inputData, lang: "rust" }); + const output = result.lines.join("\n"); + + expect(output).toContain("pub struct Item"); + expect(output).toContain("output: Option"); + expect(output).toContain("summary: Option"); + }); +}); From a8f3bae6d3b576c57178c5dd3a62416652f564bf Mon Sep 17 00:00:00 2001 From: Mark Probst Date: Sun, 12 Jul 2026 18:39:58 -0400 Subject: [PATCH 2/4] Convert nested-intersection-union unit test to a schema fixture (#1) Replace the Vitest unit test (which checked Rust output strings for one language) with a standard JSON Schema fixture exercising the same shape: an allOf nested inside a oneOf inside another oneOf, taken from the issue #2913 repro. The fixture runs against every language in CI, round-trips a positive instance, and expects failure on a malformed union member for languages with the "union" feature. Verified end-to-end locally with schema-golang and schema-typescript; all 22 CI target languages generate successfully from the schema. Co-authored-by: Claude Fable 5 --- ...ested-intersection-union.1.fail.union.json | 8 +++ .../schema/nested-intersection-union.1.json | 20 ++++++ .../schema/nested-intersection-union.schema | 58 ++++++++++++++++ test/unit/nested-intersection-union.test.ts | 67 ------------------- 4 files changed, 86 insertions(+), 67 deletions(-) create mode 100644 test/inputs/schema/nested-intersection-union.1.fail.union.json create mode 100644 test/inputs/schema/nested-intersection-union.1.json create mode 100644 test/inputs/schema/nested-intersection-union.schema delete mode 100644 test/unit/nested-intersection-union.test.ts diff --git a/test/inputs/schema/nested-intersection-union.1.fail.union.json b/test/inputs/schema/nested-intersection-union.1.fail.union.json new file mode 100644 index 000000000..cedeb3f00 --- /dev/null +++ b/test/inputs/schema/nested-intersection-union.1.fail.union.json @@ -0,0 +1,8 @@ +{ + "input": [ + { + "content": "valid item" + }, + ["this array is not a valid item"] + ] +} diff --git a/test/inputs/schema/nested-intersection-union.1.json b/test/inputs/schema/nested-intersection-union.1.json new file mode 100644 index 000000000..3018c7a10 --- /dev/null +++ b/test/inputs/schema/nested-intersection-union.1.json @@ -0,0 +1,20 @@ +{ + "input": [ + { + "content": "hello", + "id": "item-1", + "output": "42", + "summary": "all fields present" + }, + { + "content": "just a message" + }, + { + "id": "item-2", + "output": "function result" + }, + { + "summary": "just reasoning" + } + ] +} diff --git a/test/inputs/schema/nested-intersection-union.schema b/test/inputs/schema/nested-intersection-union.schema new file mode 100644 index 000000000..0453188e3 --- /dev/null +++ b/test/inputs/schema/nested-intersection-union.schema @@ -0,0 +1,58 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "additionalProperties": false, + "properties": { + "input": { + "type": "array", + "items": { + "oneOf": [ + { "$ref": "#/definitions/Message" }, + { "$ref": "#/definitions/Item" } + ] + } + } + }, + "required": ["input"], + "definitions": { + "Message": { + "type": "object", + "properties": { + "content": { "type": "string" } + }, + "required": ["content"] + }, + "Item": { + "oneOf": [ + { "$ref": "#/definitions/FunctionOutput" }, + { "$ref": "#/definitions/ReasoningItem" } + ] + }, + "FunctionOutput": { + "allOf": [ + { "$ref": "#/definitions/BaseItem" }, + { + "type": "object", + "properties": { + "output": { "type": "string" } + }, + "required": ["output"] + } + ] + }, + "BaseItem": { + "type": "object", + "properties": { + "id": { "type": "string" } + }, + "required": ["id"] + }, + "ReasoningItem": { + "type": "object", + "properties": { + "summary": { "type": "string" } + }, + "required": ["summary"] + } + } +} diff --git a/test/unit/nested-intersection-union.test.ts b/test/unit/nested-intersection-union.test.ts deleted file mode 100644 index d9c3a4a5f..000000000 --- a/test/unit/nested-intersection-union.test.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { InputData, JSONSchemaInput, quicktype } from "quicktype-core"; -import { describe, expect, test } from "vitest"; - -const schema = JSON.stringify({ - $schema: "http://json-schema.org/draft-07/schema#", - type: "object", - properties: { - input: { - type: "array", - items: { - oneOf: [ - { $ref: "#/definitions/Message" }, - { $ref: "#/definitions/Item" }, - ], - }, - }, - }, - definitions: { - Message: { - type: "object", - properties: { content: { type: "string" } }, - required: ["content"], - }, - Item: { - oneOf: [ - { $ref: "#/definitions/FunctionOutput" }, - { $ref: "#/definitions/ReasoningItem" }, - ], - }, - FunctionOutput: { - allOf: [ - { $ref: "#/definitions/BaseItem" }, - { - type: "object", - properties: { output: { type: "string" } }, - required: ["output"], - }, - ], - }, - BaseItem: { - type: "object", - properties: { id: { type: "string" } }, - required: ["id"], - }, - ReasoningItem: { - type: "object", - properties: { summary: { type: "string" } }, - required: ["summary"], - }, - }, -}); - -describe("nested intersections in unions", () => { - test("flattens an array item union containing an indirect allOf", async () => { - const schemaInput = new JSONSchemaInput(undefined); - await schemaInput.addSource({ name: "TopLevel", schema }); - const inputData = new InputData(); - inputData.addInput(schemaInput); - - const result = await quicktype({ inputData, lang: "rust" }); - const output = result.lines.join("\n"); - - expect(output).toContain("pub struct Item"); - expect(output).toContain("output: Option"); - expect(output).toContain("summary: Option"); - }); -}); From 4c5475213f99cec45fa3b72ce2f7f8e9074b2ca5 Mon Sep 17 00:00:00 2001 From: Mark Probst Date: Mon, 13 Jul 2026 11:07:48 -0400 Subject: [PATCH 3/4] =?UTF-8?q?cjson:=20skip=20nested-intersection-union.s?= =?UTF-8?q?chema=20=E2=80=94=20invalid-type=20inputs=20are=20not=20checked?= =?UTF-8?q?=20(known=20limitation)=20(#2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cjson's generated parser does not validate invalid types in unions, maps, and arrays (documented above this skip list), so it silently coerces the fixture's expected-failure instance (array where an object is required) into an empty object instead of rejecting it. Skip the schema for cjson, exactly like the existing class-with-additional, go-schema-pattern-properties, and multi-type-enum skips. Co-authored-by: Claude Fable 5 --- test/languages.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/test/languages.ts b/test/languages.ts index 638c3a91d..2effea392 100644 --- a/test/languages.ts +++ b/test/languages.ts @@ -554,6 +554,7 @@ export const CJSONLanguage: Language = { "class-with-additional.schema", "go-schema-pattern-properties.schema", "multi-type-enum.schema", + "nested-intersection-union.schema", /* Constraints (min/max and regex) are not supported (for the current implementation, can be added later, should abord parsing and return NULL) */ "minmaxlength.schema", "optional-const-ref.schema", From e1f86a15344a0001d238f6dc3f07d8ef30d58e4e Mon Sep 17 00:00:00 2001 From: Mark Probst Date: Mon, 13 Jul 2026 16:17:10 -0400 Subject: [PATCH 4/4] cplusplus: skip nested intersection union fixture (#3) --- test/languages.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/test/languages.ts b/test/languages.ts index 2effea392..a0d1ff7d7 100644 --- a/test/languages.ts +++ b/test/languages.ts @@ -631,6 +631,8 @@ export const CPlusPlusLanguage: Language = { skipSchema: [ // uses too much memory "keyword-unions.schema", + // The generated deserializer accepts non-object values when all class properties are optional. + "nested-intersection-union.schema", // Recursive top-level unions produce aliases that can refer to later aliases. "recursive-union-flattening.schema", ],