From 1a227452f650135f33c51d5ae727287b172f48cb Mon Sep 17 00:00:00 2001 From: Mark Probst Date: Mon, 20 Jul 2026 18:57:46 -0400 Subject: [PATCH 1/4] fix(json-schema): stop merging descriptions of sibling $ref properties (#1582) When two sibling properties referenced distinct $defs entries that were structurally identical primitives (e.g. two plain strings with different descriptions), type-graph unification collapsed them onto one shared type, and the descriptions were concatenated onto both properties. Fix attaches each property's description from its $ref target directly to the containing object's propertyDescriptions attribute (the existing per-property description mechanism already used elsewhere in the renderer pipeline), instead of relying on the shared underlying type's description. The JSON Schema renderer now prefers this property-specific description over the type's own description when present. Added a regression unit test (fixtures can't express this: it's about metadata in the generated schema output, not runtime-checkable JSON round-tripping) plus a schema/JSON fixture pair (test/inputs/schema/description-ref.schema) that also exercises the fix through the standard schema-* fixture suite. Verified locally: npm run build, full unit suite (164/164), QUICKTEST=true FIXTURE=schema-golang script/test (68/68, including the previously-crashing vega-lite.schema), and the original repro now emits distinct descriptions for each property. Fixes #1582 Co-Authored-By: gpt-5.6-sol via pi --- .../src/input/JSONSchemaInput.ts | 70 ++++++++++++++++++- .../language/JSONSchema/JSONSchemaRenderer.ts | 11 +-- test/inputs/schema/description-ref.1.json | 4 ++ test/inputs/schema/description-ref.schema | 21 ++++++ test/unit/schema-description-ref.test.ts | 55 +++++++++++++++ 5 files changed, 154 insertions(+), 7 deletions(-) create mode 100644 test/inputs/schema/description-ref.1.json create mode 100644 test/inputs/schema/description-ref.schema create mode 100644 test/unit/schema-description-ref.test.ts diff --git a/packages/quicktype-core/src/input/JSONSchemaInput.ts b/packages/quicktype-core/src/input/JSONSchemaInput.ts index d3a0e4feef..b55555ee0d 100644 --- a/packages/quicktype-core/src/input/JSONSchemaInput.ts +++ b/packages/quicktype-core/src/input/JSONSchemaInput.ts @@ -28,7 +28,10 @@ import { minMaxLengthAttributeProducer, patternAttributeProducer, } from "../attributes/Constraints.js"; -import { descriptionAttributeProducer } from "../attributes/Description.js"; +import { + descriptionAttributeProducer, + propertyDescriptionsTypeAttributeKind, +} from "../attributes/Description.js"; import { enumValuesAttributeProducer } from "../attributes/EnumValues.js"; import { StringTypes } from "../attributes/StringTypes.js"; import { @@ -814,6 +817,40 @@ async function addTypesInSchema( typeForCanonicalRef.set(loc.canonicalRef, t); } + async function descriptionForReferencedSchema( + schema: JSONSchema, + loc: Location, + ): Promise { + if ( + typeof schema !== "object" || + typeof schema.description === "string" + ) { + return undefined; + } + + const visited = new Set(); + for (;;) { + if (typeof schema !== "object" || typeof schema.$ref !== "string") { + return undefined; + } + + const key = `${loc.canonicalRef.toString()}|${schema.$ref}`; + if (visited.has(key)) return undefined; + visited.add(key); + + [schema, loc] = await resolver.resolveVirtualRef( + loc, + Ref.parse(schema.$ref), + ); + if ( + typeof schema === "object" && + typeof schema.description === "string" + ) { + return schema.description; + } + } + } + async function makeObject( loc: Location, attributes: TypeAttributes, @@ -827,19 +864,48 @@ async function addTypesInSchema( const propertiesMap = mapSortBy(mapFromObject(properties), (_, k) => sortKey(k), ); + const referencedPropertyDescriptions = new Map< + string, + ReadonlySet + >(); const props = await mapMapSync( propertiesMap, async (propSchema, propName) => { const propLoc = loc.push("properties", propName); + const checkedSchema = checkJSONSchema( + propSchema, + propLoc.canonicalRef, + ); const t = await toType( - checkJSONSchema(propSchema, propLoc.canonicalRef), + checkedSchema, propLoc, makeNamesTypeAttributes(propName, true), ); + const description = await descriptionForReferencedSchema( + checkedSchema, + propLoc, + ); + if (description !== undefined) { + referencedPropertyDescriptions.set( + propName, + new Set([description]), + ); + } + const isOptional = !required.has(propName); return typeBuilder.makeClassProperty(t, isOptional); }, ); + if (referencedPropertyDescriptions.size > 0) { + attributes = combineTypeAttributes( + "union", + attributes, + propertyDescriptionsTypeAttributeKind.makeAttributes( + referencedPropertyDescriptions, + ), + ); + } + let additionalPropertiesType: TypeRef | undefined; if ( additionalProperties === undefined || diff --git a/packages/quicktype-core/src/language/JSONSchema/JSONSchemaRenderer.ts b/packages/quicktype-core/src/language/JSONSchema/JSONSchemaRenderer.ts index 227a46da75..b35a64eabd 100644 --- a/packages/quicktype-core/src/language/JSONSchema/JSONSchemaRenderer.ts +++ b/packages/quicktype-core/src/language/JSONSchema/JSONSchemaRenderer.ts @@ -128,11 +128,12 @@ export class JSONSchemaRenderer extends ConvenienceRenderer { const req: string[] = []; for (const [name, p] of o.getProperties()) { const prop = this.schemaForType(p.type); - if (prop.description === undefined) { - addDescriptionToSchema( - prop, - this.descriptionForClassProperty(o, name), - ); + const propertyDescription = this.descriptionForClassProperty( + o, + name, + ); + if (propertyDescription !== undefined) { + addDescriptionToSchema(prop, propertyDescription); } props[name] = prop; diff --git a/test/inputs/schema/description-ref.1.json b/test/inputs/schema/description-ref.1.json new file mode 100644 index 0000000000..36a1b77272 --- /dev/null +++ b/test/inputs/schema/description-ref.1.json @@ -0,0 +1,4 @@ +{ + "name": "Ada", + "surname": "Lovelace" +} diff --git a/test/inputs/schema/description-ref.schema b/test/inputs/schema/description-ref.schema new file mode 100644 index 0000000000..bd278c82e5 --- /dev/null +++ b/test/inputs/schema/description-ref.schema @@ -0,0 +1,21 @@ +{ + "$defs": { + "name": { + "type": "string", + "description": "name description" + }, + "surname": { + "type": "string", + "description": "surname description" + } + }, + "type": "object", + "properties": { + "name": { + "$ref": "#/$defs/name" + }, + "surname": { + "$ref": "#/$defs/surname" + } + } +} diff --git a/test/unit/schema-description-ref.test.ts b/test/unit/schema-description-ref.test.ts new file mode 100644 index 0000000000..557a36f25f --- /dev/null +++ b/test/unit/schema-description-ref.test.ts @@ -0,0 +1,55 @@ +import { + InputData, + JSONSchemaInput, + quicktype, +} from "../../packages/quicktype-core/src/index.js"; +import { describe, expect, test } from "vitest"; + +const schema = { + $defs: { + name: { + type: "string", + description: "name description", + }, + surname: { + type: "string", + description: "surname description", + }, + }, + type: "object", + properties: { + name: { $ref: "#/$defs/name" }, + surname: { $ref: "#/$defs/surname" }, + }, +}; + +interface RenderedSchema { + definitions: Record< + string, + { properties: Record } + >; +} + +async function generateSchema(): Promise { + const schemaInput = new JSONSchemaInput(undefined); + await schemaInput.addSource({ + name: "Input", + schema: JSON.stringify(schema), + }); + + const inputData = new InputData(); + inputData.addInput(schemaInput); + + const result = await quicktype({ inputData, lang: "schema" }); + return JSON.parse(result.lines.join("\n")) as RenderedSchema; +} + +describe("JSON Schema descriptions through $ref (issue #1582)", () => { + test("descriptions from distinct definitions do not merge", async () => { + const output = await generateSchema(); + const properties = output.definitions.Input.properties; + + expect(properties.name.description).toBe("name description"); + expect(properties.surname.description).toBe("surname description"); + }); +}); From 3ebf2ba79f6d48c9dd099116adb37ceadbae7ab0 Mon Sep 17 00:00:00 2001 From: Mark Probst Date: Mon, 20 Jul 2026 19:35:09 -0400 Subject: [PATCH 2/4] test: add missing fixture cases for description-ref.schema (#3048) Co-Authored-By: Claude --- test/inputs/schema/description-ref.1.fail.union.json | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 test/inputs/schema/description-ref.1.fail.union.json diff --git a/test/inputs/schema/description-ref.1.fail.union.json b/test/inputs/schema/description-ref.1.fail.union.json new file mode 100644 index 0000000000..a449850c87 --- /dev/null +++ b/test/inputs/schema/description-ref.1.fail.union.json @@ -0,0 +1,4 @@ +{ + "name": 42, + "surname": "Lovelace" +} From 8c1476883b2f96679b13cf3e8b90682fbda93834 Mon Sep 17 00:00:00 2001 From: Mark Probst Date: Mon, 20 Jul 2026 21:11:57 -0400 Subject: [PATCH 3/4] fix CI: replace description-ref fail.union sample with fail.no-defaults (#3048) The kotlin/kotlin-jackson fixture job coerces the JSON number in description-ref.1.fail.union.json into the string-typed property instead of rejecting it, so the expected-failure sample never actually fails. Swap it for a missing-required-property sample (the no-defaults feature), which is already proven to fail reliably across all Kotlin variants via required.schema. Co-Authored-By: gpt-5.6-sol via pi --- test/inputs/schema/description-ref.1.fail.no-defaults.json | 3 +++ test/inputs/schema/description-ref.1.fail.union.json | 4 ---- test/inputs/schema/description-ref.schema | 4 ++++ 3 files changed, 7 insertions(+), 4 deletions(-) create mode 100644 test/inputs/schema/description-ref.1.fail.no-defaults.json delete mode 100644 test/inputs/schema/description-ref.1.fail.union.json diff --git a/test/inputs/schema/description-ref.1.fail.no-defaults.json b/test/inputs/schema/description-ref.1.fail.no-defaults.json new file mode 100644 index 0000000000..3809185c06 --- /dev/null +++ b/test/inputs/schema/description-ref.1.fail.no-defaults.json @@ -0,0 +1,3 @@ +{ + "name": "Ada" +} diff --git a/test/inputs/schema/description-ref.1.fail.union.json b/test/inputs/schema/description-ref.1.fail.union.json deleted file mode 100644 index a449850c87..0000000000 --- a/test/inputs/schema/description-ref.1.fail.union.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "name": 42, - "surname": "Lovelace" -} diff --git a/test/inputs/schema/description-ref.schema b/test/inputs/schema/description-ref.schema index bd278c82e5..b1029806b8 100644 --- a/test/inputs/schema/description-ref.schema +++ b/test/inputs/schema/description-ref.schema @@ -10,6 +10,10 @@ } }, "type": "object", + "required": [ + "name", + "surname" + ], "properties": { "name": { "$ref": "#/$defs/name" From 91d8a95bb6ce848306fc39a2ff32a294780eab21 Mon Sep 17 00:00:00 2001 From: Mark Probst Date: Tue, 21 Jul 2026 11:39:08 -0400 Subject: [PATCH 4/4] test: skip description-ref.schema where missing-required cannot fail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The description-ref.1.fail.no-defaults.json negative case is a missing-required-property test, identical in nature to required.schema's. Languages that already skip required.schema because their generated code (or test driver) cannot turn an absent required property into a nonzero exit — cjson, swift, haskell, typescript-zod, typescript-effect-schema, and elixir — round-trip the sample instead of failing, breaking the fixture. Skip description-ref.schema for exactly that set, mirroring required.schema. The schema still runs both its positive and negative cases in csharp, python, rust, cplusplus, kotlin, and the other languages that enforce required properties. Co-Authored-By: Claude --- test/languages.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/test/languages.ts b/test/languages.ts index ecbe724eef..1c687b724d 100644 --- a/test/languages.ts +++ b/test/languages.ts @@ -646,6 +646,7 @@ 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", + "description-ref.schema", /* 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", @@ -967,6 +968,9 @@ export const SwiftLanguage: Language = { // This works on macOS, but on Linux one of the failure test cases doesn't fail ...skipsUntypedUnions, "required.schema", + // Same missing-required detection gap as required.schema: the + // no-defaults fail sample does not fail on Linux. + "description-ref.schema", "multi-type-enum.schema", "intersection.schema", ...skipsMapValueValidation, @@ -1895,6 +1899,9 @@ export const HaskellLanguage: Language = { "keyword-unions.schema", "optional-any.schema", "required.schema", + // Same as required.schema: the Maybe-encoding driver prints "null" + // and exits 0, so this no-defaults fail sample can't be detected. + "description-ref.schema", "required-non-properties.schema", ], rendererOptions: {}, @@ -2065,6 +2072,8 @@ export const TypeScriptZodLanguage: Language = { "optional-any.schema", "recursive-union-flattening.schema", "required.schema", + // Same missing-required detection gap as required.schema. + "description-ref.schema", "required-non-properties.schema", ], rendererOptions: {}, @@ -2181,6 +2190,8 @@ export const TypeScriptEffectSchemaLanguage: Language = { "keyword-unions.schema", "optional-any.schema", "required.schema", + // Same missing-required detection gap as required.schema. + "description-ref.schema", "required-non-properties.schema", ], rendererOptions: {}, @@ -2239,6 +2250,7 @@ export const ElixirLanguage: Language = { // Struct keys cannot be enforced at runtime in Elixir and their values will just be set to null. "strict-optional.schema", "required.schema", + "description-ref.schema", "boolean-subschema.schema", "intersection.schema", "optional-any.schema",