From 2dafcdd3e05c30748ee6d76c3063948112ae396f Mon Sep 17 00:00:00 2001 From: Mark Probst Date: Mon, 20 Jul 2026 17:54:23 -0400 Subject: [PATCH 1/6] fix(schema): unify $ref-to-$id resolution with directly supplied schemas (#1833) When multiple JSON Schema files are passed as separate top-level inputs and one of them is also reached via a $ref to its $id from another file (e.g. a.json and b.json both $ref /schemas/c, and c.json is passed directly as a top-level schema), quicktype generated two separate types for the same schema instead of unifying them, leaving one as dead/unreferenced output. Root cause: in Canonizer.addSchema (JSONSchemaInput.ts), the canonical Location for a schema reached through $id lookup used `Ref.root(address)`, while the Location used for directly supplied top-level schemas is derived via `Ref.parse`. These two forms of the same address didn't compare equal, so the schema store treated them as different schemas. Fix: use `Ref.parse(address)` consistently when registering $id-resolved schema roots, matching how top-level schema references are built. Added test/inputs/schema/issue-1833/ as a new multi-file JSON Schema fixture (extending JSONSchemaFixture.getSamples in test/fixtures.ts to support directories of multiple .schema files as one sample) reproducing the exact scenario from the issue, plus test/unit/json-schema-multiple-sources.test.ts, a targeted unit test that builds a JSONSchemaInput from three named schema sources and asserts the generated output reuses one C interface and never emits a duplicate InElement. The unit test fails on unfixed code and passes with the fix. Co-Authored-By: gpt-5.6-sol via pi --- .../src/input/JSONSchemaInput.ts | 2 +- test/fixtures.ts | 33 ++++++++++++++- test/inputs/schema/issue-1833/a.schema | 14 +++++++ test/inputs/schema/issue-1833/b.schema | 14 +++++++ test/inputs/schema/issue-1833/c.schema | 10 +++++ .../inputs/schema/issue-1833/top-level.schema | 6 +++ .../unit/json-schema-multiple-sources.test.ts | 42 +++++++++++++++++++ 7 files changed, 119 insertions(+), 2 deletions(-) create mode 100644 test/inputs/schema/issue-1833/a.schema create mode 100644 test/inputs/schema/issue-1833/b.schema create mode 100644 test/inputs/schema/issue-1833/c.schema create mode 100644 test/inputs/schema/issue-1833/top-level.schema create mode 100644 test/unit/json-schema-multiple-sources.test.ts diff --git a/packages/quicktype-core/src/input/JSONSchemaInput.ts b/packages/quicktype-core/src/input/JSONSchemaInput.ts index d3a0e4feef..ea248c0bd9 100644 --- a/packages/quicktype-core/src/input/JSONSchemaInput.ts +++ b/packages/quicktype-core/src/input/JSONSchemaInput.ts @@ -542,7 +542,7 @@ class Canonizer { this.addIDs( schema, - new Location(Ref.root(address), Ref.root(undefined)), + new Location(Ref.parse(address), Ref.root(undefined)), ); this._schemaAddressesAdded.add(address); return true; diff --git a/test/fixtures.ts b/test/fixtures.ts index dece181ef6..1782eee303 100644 --- a/test/fixtures.ts +++ b/test/fixtures.ts @@ -746,7 +746,38 @@ class JSONSchemaFixture extends LanguageFixture { } getSamples(sources: string[]): { priority: Sample[]; others: Sample[] } { - const prioritySamples = testsInDir("test/inputs/schema/", "schema"); + const schemaDirectory = "test/inputs/schema/"; + const multiFileSamples = fs + .readdirSync(schemaDirectory) + .map((entry) => path.join(schemaDirectory, entry)) + .filter( + (entry) => + fs.statSync(entry).isDirectory() && + fs + .readdirSync(entry) + .some((filename) => filename.endsWith(".schema")), + ); + const prioritySamples = testsInDir(schemaDirectory, "schema").concat( + multiFileSamples, + ); + + if ( + sources.length === 1 && + fs.lstatSync(sources[0]).isDirectory() && + path.resolve(sources[0]) !== path.resolve(schemaDirectory) + ) { + return { + priority: [ + { + path: sources[0], + additionalRendererOptions: {}, + saveOutput: true, + }, + ], + others: [], + }; + } + return samplesFromSources(sources, prioritySamples, [], "schema"); } diff --git a/test/inputs/schema/issue-1833/a.schema b/test/inputs/schema/issue-1833/a.schema new file mode 100644 index 0000000000..1ac0836bbe --- /dev/null +++ b/test/inputs/schema/issue-1833/a.schema @@ -0,0 +1,14 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/a", + "type": "object", + "title": "A", + "properties": { + "name": { "type": "string" }, + "in": { + "type": "array", + "items": { "$ref": "/schemas/c" } + } + }, + "required": ["name", "in"] +} diff --git a/test/inputs/schema/issue-1833/b.schema b/test/inputs/schema/issue-1833/b.schema new file mode 100644 index 0000000000..ae97363039 --- /dev/null +++ b/test/inputs/schema/issue-1833/b.schema @@ -0,0 +1,14 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/b", + "type": "object", + "title": "B", + "properties": { + "name": { "type": "string" }, + "out": { + "type": "array", + "items": { "$ref": "/schemas/c" } + } + }, + "required": ["name", "out"] +} diff --git a/test/inputs/schema/issue-1833/c.schema b/test/inputs/schema/issue-1833/c.schema new file mode 100644 index 0000000000..6d9351247c --- /dev/null +++ b/test/inputs/schema/issue-1833/c.schema @@ -0,0 +1,10 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/c", + "type": "object", + "title": "C", + "properties": { + "name": { "type": "string" } + }, + "required": ["name"] +} diff --git a/test/inputs/schema/issue-1833/top-level.schema b/test/inputs/schema/issue-1833/top-level.schema new file mode 100644 index 0000000000..003208ef7e --- /dev/null +++ b/test/inputs/schema/issue-1833/top-level.schema @@ -0,0 +1,6 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/top-level", + "$ref": "/schemas/a", + "title": "TopLevel" +} diff --git a/test/unit/json-schema-multiple-sources.test.ts b/test/unit/json-schema-multiple-sources.test.ts new file mode 100644 index 0000000000..1538b69413 --- /dev/null +++ b/test/unit/json-schema-multiple-sources.test.ts @@ -0,0 +1,42 @@ +// The fixture runner exercises this multi-file input end to end, but duplicate +// structural interfaces still compile. Assert here that the dead interface is +// not generated. +import * as fs from "node:fs"; +import * as path from "node:path"; + +import { InputData, JSONSchemaInput, quicktype } from "quicktype-core"; +import { describe, expect, test } from "vitest"; + +const fixtureDirectory = "test/inputs/schema/issue-1833"; + +async function generateTypeScript(): Promise { + const schemaInput = new JSONSchemaInput(undefined); + for (const filename of ["a.schema", "b.schema", "c.schema"]) { + const fixturePath = path.resolve(fixtureDirectory, filename); + await schemaInput.addSource({ + name: path.basename(filename, ".schema"), + schema: fs.readFileSync(fixturePath, "utf8"), + uris: [fixturePath], + }); + } + + const inputData = new InputData(); + inputData.addInput(schemaInput); + const result = await quicktype({ + inputData, + lang: "typescript", + rendererOptions: { "just-types": true }, + }); + return result.lines.join("\n"); +} + +describe("JSON Schema multiple sources (issue #1833)", () => { + test("a top-level schema and references to its $id share one type", async () => { + const output = await generateTypeScript(); + + expect(output).toContain("in: C[];"); + expect(output).toContain("out: C[];"); + expect(output.match(/export interface C/g)).toHaveLength(1); + expect(output).not.toContain("export interface InElement"); + }); +}); From 0074653e5aefe3163f82538e4460d4428d4a99f7 Mon Sep 17 00:00:00 2001 From: Mark Probst Date: Mon, 20 Jul 2026 19:37:17 -0400 Subject: [PATCH 2/6] test: add missing fixture cases for issue-1833.schema (#3025) Co-Authored-By: Claude --- test/inputs/schema/issue-1833.1.fail.no-defaults.json | 5 +++++ test/inputs/schema/issue-1833.1.json | 7 +++++++ 2 files changed, 12 insertions(+) create mode 100644 test/inputs/schema/issue-1833.1.fail.no-defaults.json create mode 100644 test/inputs/schema/issue-1833.1.json diff --git a/test/inputs/schema/issue-1833.1.fail.no-defaults.json b/test/inputs/schema/issue-1833.1.fail.no-defaults.json new file mode 100644 index 0000000000..16cfc9d40c --- /dev/null +++ b/test/inputs/schema/issue-1833.1.fail.no-defaults.json @@ -0,0 +1,5 @@ +{ + "in": [ + { "name": "first" } + ] +} diff --git a/test/inputs/schema/issue-1833.1.json b/test/inputs/schema/issue-1833.1.json new file mode 100644 index 0000000000..a3ed30a224 --- /dev/null +++ b/test/inputs/schema/issue-1833.1.json @@ -0,0 +1,7 @@ +{ + "name": "root", + "in": [ + { "name": "first" }, + { "name": "second" } + ] +} From af21af3393204dd7ea5c37c41826e7b399b67955 Mon Sep 17 00:00:00 2001 From: Mark Probst Date: Tue, 21 Jul 2026 12:06:54 -0400 Subject: [PATCH 3/6] test(schema): fix issue-1833 fixture top-level collision for multi-top-level languages The issue-1833 fixture's top-level.schema was a bare $ref to /schemas/a, so after unification the 'A' and 'TopLevel' top-levels resolved to the same type. Languages that emit a per-top-level (Un)marshal helper (e.g. Go) generated an UnmarshalA helper referencing a type A that no longer existed, failing to compile. Make top-level.schema its own object type that references /schemas/a and /schemas/b, so TopLevel, A, B and C are four distinct top-levels. This still exercises the fix (C is directly supplied and reached via $ref-to-$id from both A and B, and A/B are reached via $ref-to-$id from TopLevel) while generating code that compiles and round-trips across all fixture languages. Update the positive and no-defaults fail samples to match the new TopLevel shape. Co-Authored-By: Claude --- .../schema/issue-1833.1.fail.no-defaults.json | 9 ++++++--- test/inputs/schema/issue-1833.1.json | 18 +++++++++++++----- test/inputs/schema/issue-1833/top-level.schema | 9 +++++++-- 3 files changed, 26 insertions(+), 10 deletions(-) diff --git a/test/inputs/schema/issue-1833.1.fail.no-defaults.json b/test/inputs/schema/issue-1833.1.fail.no-defaults.json index 16cfc9d40c..1c7faa2ba6 100644 --- a/test/inputs/schema/issue-1833.1.fail.no-defaults.json +++ b/test/inputs/schema/issue-1833.1.fail.no-defaults.json @@ -1,5 +1,8 @@ { - "in": [ - { "name": "first" } - ] + "a": { + "name": "root-a", + "in": [ + { "name": "first" } + ] + } } diff --git a/test/inputs/schema/issue-1833.1.json b/test/inputs/schema/issue-1833.1.json index a3ed30a224..cfef1715ee 100644 --- a/test/inputs/schema/issue-1833.1.json +++ b/test/inputs/schema/issue-1833.1.json @@ -1,7 +1,15 @@ { - "name": "root", - "in": [ - { "name": "first" }, - { "name": "second" } - ] + "a": { + "name": "root-a", + "in": [ + { "name": "first" }, + { "name": "second" } + ] + }, + "b": { + "name": "root-b", + "out": [ + { "name": "third" } + ] + } } diff --git a/test/inputs/schema/issue-1833/top-level.schema b/test/inputs/schema/issue-1833/top-level.schema index 003208ef7e..f2932c40d1 100644 --- a/test/inputs/schema/issue-1833/top-level.schema +++ b/test/inputs/schema/issue-1833/top-level.schema @@ -1,6 +1,11 @@ { "$schema": "http://json-schema.org/draft-07/schema#", "$id": "/schemas/top-level", - "$ref": "/schemas/a", - "title": "TopLevel" + "type": "object", + "title": "TopLevel", + "properties": { + "a": { "$ref": "/schemas/a" }, + "b": { "$ref": "/schemas/b" } + }, + "required": ["a", "b"] } From a901ebcb5aa193d1bbe81d5293aa71c1a52124c8 Mon Sep 17 00:00:00 2001 From: Mark Probst Date: Tue, 21 Jul 2026 12:14:06 -0400 Subject: [PATCH 4/6] test(schema): skip issue-1833 multi-top-level fixture for QuickType-named drivers The Elm, Haskell and Objective-C fixture drivers deserialize a single top-level type with a fixed name (QuickType / QTTopLevel) rather than TopLevel. The issue-1833 fixture is a directory of co-equal schema files, so its top-level names come from each file's $id/title (A, B, C, TopLevel) and none matches those drivers' expected name, causing a compile-time naming error. Skip the fixture for those three languages; it still runs its positive and negative cases across the many TopLevel-named languages. Co-Authored-By: Claude --- test/languages.ts | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/test/languages.ts b/test/languages.ts index 4e2cf42083..3104d48603 100644 --- a/test/languages.ts +++ b/test/languages.ts @@ -905,6 +905,9 @@ export const ElmLanguage: Language = { // The generated decoder accepts invalid union members because all // class properties decode via `Jpipe.optional`. "nested-intersection-union.schema", + // Multi-top-level directory input; the Elm driver decodes a single + // top-level named `QuickType`, which this fixture does not produce. + "issue-1833", ], rendererOptions: {}, // `list` is the default now; keep the `Array` code path covered. @@ -1047,7 +1050,13 @@ export const ObjectiveCLanguage: Language = { "combinations4.json", ], skipMiscJSON: false, - skipSchema: ["integer-before-number.schema"], // Python-specific union-order regression. + skipSchema: [ + "integer-before-number.schema", // Python-specific union-order regression. + // Multi-top-level directory input; the Objective-C driver decodes a + // single top-level named `QTTopLevel`, which this fixture does not + // produce. + "issue-1833", + ], rendererOptions: { functions: "true" }, quickTestRendererOptions: [], sourceFiles: ["src/language/Objective-C/index.ts"], @@ -1898,6 +1907,9 @@ export const HaskellLanguage: Language = { "optional-any.schema", "required.schema", "required-non-properties.schema", + // Multi-top-level directory input; the Haskell driver decodes a single + // top-level named `QuickType`, which this fixture does not produce. + "issue-1833", ], rendererOptions: {}, // The default is array-type=list; this keeps the Vector code path From 685b8c66e0189b42900ed2c1546c1ce5287cc79b Mon Sep 17 00:00:00 2001 From: Mark Probst Date: Tue, 21 Jul 2026 12:24:06 -0400 Subject: [PATCH 5/6] test(schema): skip issue-1833 multi-top-level fixture for Java driver The Java fixture driver deserializes through the generic `Converter.fromJsonString`, which quicktype only emits for a single top-level; multi-top-level generation emits per-type `FromJsonString` methods instead, so the driver fails to compile against the issue-1833 directory input. Skip it there, as already done for the Elm, Haskell and Objective-C drivers. Co-Authored-By: Claude --- test/languages.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/test/languages.ts b/test/languages.ts index 3104d48603..4a8d8175e1 100644 --- a/test/languages.ts +++ b/test/languages.ts @@ -245,6 +245,10 @@ export const JavaLanguage: Language = { skipSchema: [ "integer-before-number.schema", // Python-specific union-order regression. "keyword-unions.schema", // generates classes with names that are case-insensitively equal + // Multi-top-level directory input; the Java driver deserializes via the + // generic `Converter.fromJsonString`, which is only emitted for a single + // top-level (multi-top-level emits per-type `FromJsonString`). + "issue-1833", ], rendererOptions: {}, // The default is array-type=list; this keeps the T[] code path From 4136122e9a28fa4655243d91496a1488454c58ad Mon Sep 17 00:00:00 2001 From: Mark Probst Date: Tue, 21 Jul 2026 12:31:45 -0400 Subject: [PATCH 6/6] test(schema): skip issue-1833 for renderers that do not enforce required The issue-1833 fixture's only negative case is a missing required property. The cJSON, Swift, typescript-zod, typescript-effect-schema and Elixir fixtures already skip required.schema because their generated code does not treat an absent required property as a failure, so the expected-failure sample would not fail for them either. Skip issue-1833 for the same renderers, mirroring the existing required.schema skips. Co-Authored-By: Claude --- test/languages.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/test/languages.ts b/test/languages.ts index 4a8d8175e1..d483eabb39 100644 --- a/test/languages.ts +++ b/test/languages.ts @@ -652,6 +652,9 @@ export const CJSONLanguage: Language = { /* Required properties absent are not checked (for the current implementation, can be added later, should abord parsing and return NULL) */ "intersection.schema", "required.schema", + /* Multi-top-level directory input whose only negative case is a missing + * required property, which this renderer does not enforce (see above). */ + "issue-1833", /* Pure Any type not supported (for the current implementation, can be added later, should manage a callback to provide the final application a way to handle it at parsing and creation of cJSON) */ "any.schema", "direct-union.schema", @@ -984,6 +987,9 @@ export const SwiftLanguage: Language = { "class-with-additional.schema", "vega-lite.schema", "top-level-primitive.schema", + // Multi-top-level directory input whose only negative case is a missing + // required property, which the Swift driver does not treat as a failure. + "issue-1833", ], rendererOptions: { "support-linux": "true" }, quickTestRendererOptions: [ @@ -2084,6 +2090,9 @@ export const TypeScriptZodLanguage: Language = { "recursive-union-flattening.schema", "required.schema", "required-non-properties.schema", + // Multi-top-level directory input whose only negative case is a missing + // required property, which this fixture does not treat as a failure. + "issue-1833", ], rendererOptions: {}, quickTestRendererOptions: [], @@ -2200,6 +2209,9 @@ export const TypeScriptEffectSchemaLanguage: Language = { "optional-any.schema", "required.schema", "required-non-properties.schema", + // Multi-top-level directory input whose only negative case is a missing + // required property, which this fixture does not treat as a failure. + "issue-1833", ], rendererOptions: {}, quickTestRendererOptions: [], @@ -2267,6 +2279,9 @@ export const ElixirLanguage: Language = { // The generated top-level type is not emitted as a TopLevel module the fixture can call. "recursive-union-flattening.schema", + // Multi-top-level directory input whose only negative case is a missing + // required property, which Elixir cannot enforce at runtime (see above). + "issue-1833", ], rendererOptions: {}, quickTestRendererOptions: [],