From e14fc8dcc173a6a03bc7440aa386e80b31043239 Mon Sep 17 00:00:00 2001 From: Mark Probst Date: Mon, 20 Jul 2026 16:38:00 -0400 Subject: [PATCH 01/27] fix(haskell): don't rename enum cases for forbidden identifiers when the enum suffix already disambiguates (#2868) Co-Authored-By: gpt-5.6-sol via pi --- .../src/language/Haskell/HaskellRenderer.ts | 38 ++++++++++++++++--- .../schema/haskell-enum-forbidden.1.json | 3 ++ .../schema/haskell-enum-forbidden.schema | 11 ++++++ test/languages.ts | 6 ++- 4 files changed, 51 insertions(+), 7 deletions(-) create mode 100644 test/inputs/schema/haskell-enum-forbidden.1.json create mode 100644 test/inputs/schema/haskell-enum-forbidden.schema diff --git a/packages/quicktype-core/src/language/Haskell/HaskellRenderer.ts b/packages/quicktype-core/src/language/Haskell/HaskellRenderer.ts index df24ab19ef..c27c27f0f2 100644 --- a/packages/quicktype-core/src/language/Haskell/HaskellRenderer.ts +++ b/packages/quicktype-core/src/language/Haskell/HaskellRenderer.ts @@ -4,7 +4,7 @@ import { ConvenienceRenderer, type ForbiddenWordsInfo, } from "../../ConvenienceRenderer.js"; -import type { Name, Namer } from "../../Naming.js"; +import { DependencyName, type Name, type Namer } from "../../Naming.js"; import type { RenderContext } from "../../Renderer.js"; import type { OptionValues } from "../../RendererOptions/index.js"; import { @@ -73,6 +73,26 @@ export class HaskellRenderer extends ConvenienceRenderer { return true; } + protected makeNameForEnumCase( + e: EnumType, + enumName: Name, + caseName: string, + assignedName: string | undefined, + ): Name { + const name = super.makeNameForEnumCase( + e, + enumName, + caseName, + assignedName, + ); + const proposedName = name.firstProposedName(new Map()); + return new DependencyName( + name.namingFunction, + name.order, + (lookup) => `${proposedName}_${lookup(enumName)}`, + ); + } + protected proposeUnionMemberName( u: UnionType, unionName: Name, @@ -226,6 +246,10 @@ export class HaskellRenderer extends ConvenienceRenderer { }); } + private enumCaseName(name: Name, enumName: Name): Sourcelike { + return name instanceof DependencyName ? name : [name, enumName]; + } + private emitEnumDefinition(e: EnumType, enumName: Name): void { this.emitDescription(this.descriptionForType(e)); this.emitLine("data ", enumName); @@ -233,7 +257,11 @@ export class HaskellRenderer extends ConvenienceRenderer { let onFirst = true; this.forEachEnumCase(e, "none", (name) => { const equalsOrPipe = onFirst ? "=" : "|"; - this.emitLine(equalsOrPipe, " ", name, enumName); + this.emitLine( + equalsOrPipe, + " ", + this.enumCaseName(name, enumName), + ); onFirst = false; }); this.emitLine("deriving (Show)"); @@ -355,8 +383,7 @@ export class HaskellRenderer extends ConvenienceRenderer { this.forEachEnumCase(e, "none", (name, jsonName) => { this.emitLine( "toJSON ", - name, - enumName, + this.enumCaseName(name, enumName), ' = "', stringEscape(jsonName), '"', @@ -377,8 +404,7 @@ export class HaskellRenderer extends ConvenienceRenderer { 'parseText "', stringEscape(jsonName), '" = return ', - name, - enumName, + this.enumCaseName(name, enumName), ); }); }); diff --git a/test/inputs/schema/haskell-enum-forbidden.1.json b/test/inputs/schema/haskell-enum-forbidden.1.json new file mode 100644 index 0000000000..1513721d9a --- /dev/null +++ b/test/inputs/schema/haskell-enum-forbidden.1.json @@ -0,0 +1,3 @@ +{ + "health": "error" +} diff --git a/test/inputs/schema/haskell-enum-forbidden.schema b/test/inputs/schema/haskell-enum-forbidden.schema new file mode 100644 index 0000000000..c06f971a56 --- /dev/null +++ b/test/inputs/schema/haskell-enum-forbidden.schema @@ -0,0 +1,11 @@ +{ + "$schema": "http://json-schema.org/draft-06/schema#", + "type": "object", + "properties": { + "health": { + "type": "string", + "enum": ["ok", "error"] + } + }, + "required": ["health"] +} diff --git a/test/languages.ts b/test/languages.ts index 6bcfd43611..c37ad1a3ce 100644 --- a/test/languages.ts +++ b/test/languages.ts @@ -1691,7 +1691,11 @@ export const HaskellLanguage: Language = { setupCommand: "stack install", compileCommand: "true", runCommand(sample: string) { - return `stack run haskell -- "${sample}"`; + // Round-tripping cannot distinguish generated constructor names. + const sourceCheck = sample.endsWith("haskell-enum-forbidden.1.json") + ? "grep -q '^ = ErrorHealth$' QuickType.hs && " + : ""; + return `${sourceCheck}stack run haskell -- "${sample}"`; }, diffViaSchema: true, skipDiffViaSchema: [ From e5a04f372ba74d0de407cd48e2871897c4827369 Mon Sep 17 00:00:00 2001 From: Mark Probst Date: Mon, 20 Jul 2026 17:10:07 -0400 Subject: [PATCH 02/27] fix(haskell): render any-type as Aeson Value instead of Maybe Text (#2005) Co-Authored-By: gpt-5.6-sol via pi --- .../src/language/Haskell/HaskellRenderer.ts | 2 +- test/inputs/schema/any.1.json | 9 ++++++++- test/inputs/schema/any.2.json | 8 +++++++- test/inputs/schema/any.3.json | 7 ++++++- test/inputs/schema/any.schema | 8 ++++++-- test/languages.ts | 1 - 6 files changed, 28 insertions(+), 7 deletions(-) diff --git a/packages/quicktype-core/src/language/Haskell/HaskellRenderer.ts b/packages/quicktype-core/src/language/Haskell/HaskellRenderer.ts index df24ab19ef..7d52f19ca0 100644 --- a/packages/quicktype-core/src/language/Haskell/HaskellRenderer.ts +++ b/packages/quicktype-core/src/language/Haskell/HaskellRenderer.ts @@ -109,7 +109,7 @@ export class HaskellRenderer extends ConvenienceRenderer { private haskellType(t: Type, noOptional = false): MultiWord { return matchType( t, - (_anyType) => multiWord(" ", "Maybe", "Text"), + (_anyType) => singleWord("Value"), (_nullType) => multiWord(" ", "Maybe", "Text"), (_boolType) => singleWord("Bool"), (_integerType) => singleWord("Int"), diff --git a/test/inputs/schema/any.1.json b/test/inputs/schema/any.1.json index 6465e11c40..697eb545b5 100644 --- a/test/inputs/schema/any.1.json +++ b/test/inputs/schema/any.1.json @@ -1 +1,8 @@ -{ "foo": 123 } +{ + "foo": 123, + "values": { + "number": 42, + "object": { "a": 1 }, + "array": [1, 2, 3] + } +} diff --git a/test/inputs/schema/any.2.json b/test/inputs/schema/any.2.json index 2ff04ea90e..5a35b97628 100644 --- a/test/inputs/schema/any.2.json +++ b/test/inputs/schema/any.2.json @@ -1 +1,7 @@ -{ "foo": [1, 2, 3] } +{ + "foo": [1, 2, 3], + "values": { + "boolean": true, + "null": null + } +} diff --git a/test/inputs/schema/any.3.json b/test/inputs/schema/any.3.json index fefd2e6dea..9fb43de4a2 100644 --- a/test/inputs/schema/any.3.json +++ b/test/inputs/schema/any.3.json @@ -1 +1,6 @@ -{ "foo": { "bar": "abc" } } +{ + "foo": { "bar": "abc" }, + "values": { + "string": "text" + } +} diff --git a/test/inputs/schema/any.schema b/test/inputs/schema/any.schema index a7067ed754..166aa4aaf2 100644 --- a/test/inputs/schema/any.schema +++ b/test/inputs/schema/any.schema @@ -1,7 +1,11 @@ { "type": "object", "properties": { - "foo": {} + "foo": {}, + "values": { + "type": "object", + "additionalProperties": true + } }, - "required": ["foo"] + "required": ["foo", "values"] } diff --git a/test/languages.ts b/test/languages.ts index 6bcfd43611..67d5b9bb82 100644 --- a/test/languages.ts +++ b/test/languages.ts @@ -1756,7 +1756,6 @@ export const HaskellLanguage: Language = { ], skipMiscJSON: false, skipSchema: [ - "any.schema", ...skipsUntypedUnions, // The test driver encodes the Maybe result, so a failed decode prints // "null" and exits 0 — expected-failure samples cannot be detected. From 6dd2032b3680b6fe6a8f91cfa4c5ed18f7b702a2 Mon Sep 17 00:00:00 2001 From: Mark Probst Date: Mon, 20 Jul 2026 17:15:40 -0400 Subject: [PATCH 03/27] fix(kotlin): forbid enum case names equal to the enum type name (#1815) Co-Authored-By: gpt-5.6-sol via pi --- .../src/language/Kotlin/KotlinRenderer.ts | 4 ++-- .../kotlin-enum-class-case-collision.json | 22 +++++++++++++++++++ 2 files changed, 24 insertions(+), 2 deletions(-) create mode 100644 test/inputs/json/priority/kotlin-enum-class-case-collision.json diff --git a/packages/quicktype-core/src/language/Kotlin/KotlinRenderer.ts b/packages/quicktype-core/src/language/Kotlin/KotlinRenderer.ts index a39af4d0fc..d4bf50cd2a 100644 --- a/packages/quicktype-core/src/language/Kotlin/KotlinRenderer.ts +++ b/packages/quicktype-core/src/language/Kotlin/KotlinRenderer.ts @@ -57,9 +57,9 @@ export class KotlinRenderer extends ConvenienceRenderer { protected forbiddenForEnumCases( _e: EnumType, - _enumName: Name, + enumName: Name, ): ForbiddenWordsInfo { - return { names: [], includeGlobalForbidden: true }; + return { names: [enumName], includeGlobalForbidden: true }; } protected forbiddenForUnionMembers( diff --git a/test/inputs/json/priority/kotlin-enum-class-case-collision.json b/test/inputs/json/priority/kotlin-enum-class-case-collision.json new file mode 100644 index 0000000000..6fe2377c14 --- /dev/null +++ b/test/inputs/json/priority/kotlin-enum-class-case-collision.json @@ -0,0 +1,22 @@ +[ + { "category": " " }, + { "category": "" }, + { "category": " " }, + { "category": "" }, + { "category": " " }, + { "category": "" }, + { "category": " " }, + { "category": "" }, + { "category": " " }, + { "category": "" }, + { "category": " " }, + { "category": "" }, + { "category": " " }, + { "category": "" }, + { "category": " " }, + { "category": "" }, + { "category": " " }, + { "category": "" }, + { "category": " " }, + { "category": "" } +] From ed60aa350355eb8d405b7b4b92e412f54adef830 Mon Sep 17 00:00:00 2001 From: Mark Probst Date: Mon, 20 Jul 2026 17:18:20 -0400 Subject: [PATCH 04/27] fix(swift): remove deprecated JSONNull.hashValue (#1698) Swift's default JSONNull class emitted both the deprecated `hashValue` protocol requirement and `hash(into:)`, triggering a 'Hashable.hashValue is deprecated as a protocol requirement' compiler warning. Remove the redundant `hashValue` property and have `hash(into:)` call `hasher.combine(0)` as suggested in the issue. Adds/strengthens a unit test asserting the deprecated property is no longer emitted and that hash(into:) uses hasher.combine(0). Fixes #1698 Co-Authored-By: gpt-5.6-sol via pi --- .../quicktype-core/src/language/Swift/SwiftRenderer.ts | 7 +------ test/unit/swift-json-null-hashable.test.ts | 4 +++- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/packages/quicktype-core/src/language/Swift/SwiftRenderer.ts b/packages/quicktype-core/src/language/Swift/SwiftRenderer.ts index a47b189df1..71d3b103b6 100644 --- a/packages/quicktype-core/src/language/Swift/SwiftRenderer.ts +++ b/packages/quicktype-core/src/language/Swift/SwiftRenderer.ts @@ -1183,14 +1183,9 @@ encoder.dateEncodingStrategy = .formatted(formatter)`); }`); if (this._options.objcSupport === false) { - this.ensureBlankLine(); - this.emitMultiline(` public var hashValue: Int { - return 0 - }`); - this.ensureBlankLine(); this.emitMultiline(` public func hash(into hasher: inout Hasher) { - // No-op + hasher.combine(0) }`); } diff --git a/test/unit/swift-json-null-hashable.test.ts b/test/unit/swift-json-null-hashable.test.ts index 78c9cf9288..d8013b9df3 100644 --- a/test/unit/swift-json-null-hashable.test.ts +++ b/test/unit/swift-json-null-hashable.test.ts @@ -13,7 +13,7 @@ const schema = JSON.stringify({ required: ["value"], }); -test("emits hash(into:) for JSONNull by default", async () => { +test("emits modern Hashable implementation for JSONNull by default", async () => { const schemaInput = new JSONSchemaInput(undefined); await schemaInput.addSource({ name: "TopLevel", schema }); @@ -24,4 +24,6 @@ test("emits hash(into:) for JSONNull by default", async () => { const output = result.lines.join("\n"); expect(output).toContain("public func hash(into hasher: inout Hasher)"); + expect(output).toContain("hasher.combine(0)"); + expect(output).not.toContain("public var hashValue: Int"); }); From 0fb568e27fbf81e839f608eeacc2b5a79175f24f Mon Sep 17 00:00:00 2001 From: Mark Probst Date: Mon, 20 Jul 2026 17:24:48 -0400 Subject: [PATCH 05/27] fix(swift): honor --acronym-style for leading acronyms (#1599) The Swift name-styling helper hardcoded allUpperWordStyle/allLowerWordStyle for the first word of a name whenever it was recognized as an acronym, ignoring the acronymsStyle derived from the --acronym-style option. Later words in the same name already used acronymsStyle correctly, so an acronym appearing anywhere but the first position was styled correctly while the leading position always came out upper/lower-cased regardless of the option. Co-Authored-By: gpt-5.6-sol via pi --- .../src/language/Swift/utils.ts | 2 +- test/unit/swift-acronym-names.test.ts | 45 +++++++++++++++++++ 2 files changed, 46 insertions(+), 1 deletion(-) create mode 100644 test/unit/swift-acronym-names.test.ts diff --git a/packages/quicktype-core/src/language/Swift/utils.ts b/packages/quicktype-core/src/language/Swift/utils.ts index 5c068a5a05..228ddeae14 100644 --- a/packages/quicktype-core/src/language/Swift/utils.ts +++ b/packages/quicktype-core/src/language/Swift/utils.ts @@ -69,7 +69,7 @@ export function swiftNameStyle( legalizeName, isUpper ? firstUpperWordStyle : allLowerWordStyle, firstUpperWordStyle, - isUpper ? allUpperWordStyle : allLowerWordStyle, + isUpper ? acronymsStyle : allLowerWordStyle, acronymsStyle, "", isStartCharacter, diff --git a/test/unit/swift-acronym-names.test.ts b/test/unit/swift-acronym-names.test.ts new file mode 100644 index 0000000000..bc9310510b --- /dev/null +++ b/test/unit/swift-acronym-names.test.ts @@ -0,0 +1,45 @@ +// The fixture harness cannot catch acronym casing in Swift type names: the +// generated identifiers are self-consistent, so the code still compiles and +// round-trips JSON successfully. Generate Swift directly and assert on the +// emitted struct declaration instead. + +import { + InputData, + jsonInputForTargetLanguage, + quicktype, +} from "../../packages/quicktype-core/src/index.js"; +import { describe, expect, test } from "vitest"; + +async function swiftStructName(acronymStyle: string): Promise { + const jsonInput = jsonInputForTargetLanguage("swift"); + await jsonInput.addSource({ + name: "FaqCoordinate", + samples: ['{"x":1,"y":2}'], + }); + + const inputData = new InputData(); + inputData.addInput(jsonInput); + + const result = await quicktype({ + inputData, + lang: "swift", + rendererOptions: { "acronym-style": acronymStyle }, + }); + const output = result.lines.join("\n"); + const match = output.match(/^struct (\w+): Codable \{$/m); + + // biome-ignore lint/suspicious/noMisplacedAssertion: helper is only called from within tests + expect(match, `generated Swift output:\n${output}`).not.toBeNull(); + return match?.[1] ?? ""; +} + +describe("Swift leading acronym casing", () => { + test.each([ + ["original", "FaqCoordinate"], + ["pascal", "FAQCoordinate"], + ["camel", "FaqCoordinate"], + ["lowerCase", "faqCoordinate"], + ])("honors acronym-style=%s for struct names", async (acronymStyle, expectedName) => { + await expect(swiftStructName(acronymStyle)).resolves.toBe(expectedName); + }); +}); From 732ef8c5e72f4581b14db356118ef17ffae60b4f Mon Sep 17 00:00:00 2001 From: Mark Probst Date: Mon, 20 Jul 2026 17:26:31 -0400 Subject: [PATCH 06/27] fix(js): export all-objects converters from module.exports (#1655) With --converters all-objects, the JavaScript renderer generates toX/xToJson functions for every object type, but module.exports only listed the top-level converters, making the extra converters unreachable by callers of the module. Co-Authored-By: gpt-5.6-sol via pi --- .../src/language/JavaScript/JavaScriptRenderer.ts | 14 ++++++++++++-- test/fixtures/javascript/main.js | 6 ++++++ test/inputs/json/samples/issue-1655.json | 5 +++++ test/languages.ts | 1 + 4 files changed, 24 insertions(+), 2 deletions(-) create mode 100644 test/inputs/json/samples/issue-1655.json diff --git a/packages/quicktype-core/src/language/JavaScript/JavaScriptRenderer.ts b/packages/quicktype-core/src/language/JavaScript/JavaScriptRenderer.ts index c14a44efdb..3d707b69a8 100644 --- a/packages/quicktype-core/src/language/JavaScript/JavaScriptRenderer.ts +++ b/packages/quicktype-core/src/language/JavaScript/JavaScriptRenderer.ts @@ -554,12 +554,22 @@ function r(name${stringAnnotation}) { this.ensureBlankLine(); this.emitBlock("module.exports = ", ";", () => { - this.forEachTopLevel("none", (_, name) => { + const exporter = (_: Type, name: Name): void => { const serializer = this.serializerFunctionName(name); const deserializer = this.deserializerFunctionName(name); this.emitLine('"', serializer, '": ', serializer, ","); this.emitLine('"', deserializer, '": ', deserializer, ","); - }); + }; + + switch (this._jsOptions.converters) { + case ConvertersOptions.AllObjects: + this.forEachObject("none", exporter); + break; + + default: + this.forEachTopLevel("none", exporter); + break; + } }); } diff --git a/test/fixtures/javascript/main.js b/test/fixtures/javascript/main.js index cb60dd70d8..e52fe6e2a9 100644 --- a/test/fixtures/javascript/main.js +++ b/test/fixtures/javascript/main.js @@ -9,4 +9,10 @@ const json = fs.readFileSync(sample); const value = TopLevel.toTopLevel(json); const backToJson = TopLevel.topLevelToJson(value); +const generatedSource = fs.readFileSync(require.resolve("./TopLevel"), "utf8"); +if (generatedSource.includes("function toData123(")) { + const data123 = TopLevel.toData123(JSON.stringify(value.data123)); + TopLevel.data123ToJson(data123); +} + console.log(backToJson); diff --git a/test/inputs/json/samples/issue-1655.json b/test/inputs/json/samples/issue-1655.json new file mode 100644 index 0000000000..3f32cc9bad --- /dev/null +++ b/test/inputs/json/samples/issue-1655.json @@ -0,0 +1,5 @@ +{ + "data123": { + "name": "quicktype" + } +} diff --git a/test/languages.ts b/test/languages.ts index 6bcfd43611..6f2772924c 100644 --- a/test/languages.ts +++ b/test/languages.ts @@ -1024,6 +1024,7 @@ export const JavaScriptLanguage: Language = { { "runtime-typecheck": "false" }, { "runtime-typecheck-ignore-unknown-properties": "true" }, { converters: "top-level" }, + ["issue-1655.json", { converters: "all-objects" }], ], sourceFiles: ["src/language/JavaScript/index.ts"], }; From 55033dab8ace58a0269b3dc1383db93515803a53 Mon Sep 17 00:00:00 2001 From: Mark Probst Date: Mon, 20 Jul 2026 17:49:29 -0400 Subject: [PATCH 07/27] fix(cpp): initialize class members from JSON Schema default (#1535) Co-Authored-By: gpt-5.6-sol via pi --- .../src/attributes/DefaultValue.ts | 87 +++++++++++++++++++ .../src/input/JSONSchemaInput.ts | 16 +++- .../language/CPlusPlus/CPlusPlusRenderer.ts | 40 ++++++++- test/inputs/schema/default-value.1.json | 3 + test/inputs/schema/default-value.schema | 13 +++ 5 files changed, 156 insertions(+), 3 deletions(-) create mode 100644 packages/quicktype-core/src/attributes/DefaultValue.ts create mode 100644 test/inputs/schema/default-value.1.json create mode 100644 test/inputs/schema/default-value.schema diff --git a/packages/quicktype-core/src/attributes/DefaultValue.ts b/packages/quicktype-core/src/attributes/DefaultValue.ts new file mode 100644 index 0000000000..784e86e0ae --- /dev/null +++ b/packages/quicktype-core/src/attributes/DefaultValue.ts @@ -0,0 +1,87 @@ +import type { + JSONSchemaAttributes, + JSONSchemaType, + Ref, +} from "../input/JSONSchemaInput.js"; +import type { JSONSchema } from "../input/JSONSchemaStore.js"; +import type { Type } from "../Type/Type.js"; + +import { TypeAttributeKind } from "./TypeAttributes.js"; + +export type DefaultValue = boolean | number | string | null; + +class DefaultValueTypeAttributeKind extends TypeAttributeKind { + public constructor() { + super("defaultValue"); + } + + public get inIdentity(): boolean { + return true; + } + + public combine(values: DefaultValue[]): DefaultValue | undefined { + const first = values[0]; + return values.every((value) => value === first) ? first : undefined; + } + + public makeInferred(_: DefaultValue): undefined { + return undefined; + } + + public addToSchema( + schema: { [name: string]: unknown }, + _t: Type, + defaultValue: DefaultValue, + ): void { + schema.default = defaultValue; + } + + public stringify(defaultValue: DefaultValue): string { + return JSON.stringify(defaultValue); + } +} + +export const defaultValueTypeAttributeKind: TypeAttributeKind = + new DefaultValueTypeAttributeKind(); + +export function defaultValueAttributeProducer( + schema: JSONSchema, + _ref: Ref, + types: Set, +): JSONSchemaAttributes | undefined { + if (typeof schema !== "object") return undefined; + + const defaultValue = schema.default; + if ( + defaultValue !== null && + typeof defaultValue !== "boolean" && + typeof defaultValue !== "number" && + typeof defaultValue !== "string" + ) { + return undefined; + } + + const attributes = + defaultValueTypeAttributeKind.makeAttributes(defaultValue); + if (typeof defaultValue === "number") { + if (!types.has("number") && !types.has("integer")) return undefined; + return { forNumber: attributes }; + } + + if (typeof defaultValue === "string") { + if (!types.has("string")) return undefined; + return { forString: attributes }; + } + + if (typeof defaultValue === "boolean") { + if (!types.has("boolean")) return undefined; + return { forBoolean: attributes }; + } + + if (!types.has("null")) return undefined; + return { forNull: attributes }; +} + +export function defaultValueForType(t: Type): DefaultValue | undefined { + return defaultValueTypeAttributeKind.tryGetInAttributes(t.getAttributes()); +} diff --git a/packages/quicktype-core/src/input/JSONSchemaInput.ts b/packages/quicktype-core/src/input/JSONSchemaInput.ts index d3a0e4feef..0b618ee786 100644 --- a/packages/quicktype-core/src/input/JSONSchemaInput.ts +++ b/packages/quicktype-core/src/input/JSONSchemaInput.ts @@ -28,6 +28,7 @@ import { minMaxLengthAttributeProducer, patternAttributeProducer, } from "../attributes/Constraints.js"; +import { defaultValueAttributeProducer } from "../attributes/DefaultValue.js"; import { descriptionAttributeProducer } from "../attributes/Description.js"; import { enumValuesAttributeProducer } from "../attributes/EnumValues.js"; import { StringTypes } from "../attributes/StringTypes.js"; @@ -649,7 +650,9 @@ const schemaTypes = Object.getOwnPropertyNames( ) as JSONSchemaType[]; export interface JSONSchemaAttributes { + forBoolean?: TypeAttributes; forCases?: TypeAttributes[]; + forNull?: TypeAttributes; forNumber?: TypeAttributes; forObject?: TypeAttributes; forString?: TypeAttributes; @@ -1273,6 +1276,12 @@ async function addTypesInSchema( const numberAttributes = combineProducedAttributes( ({ forNumber }) => forNumber, ); + const booleanAttributes = combineProducedAttributes( + ({ forBoolean }) => forBoolean, + ); + const nullAttributes = combineProducedAttributes( + ({ forNull }) => forNull, + ); for (const [name, kind] of [ ["null", "null"], @@ -1284,7 +1293,11 @@ async function addTypesInSchema( const attributes = isNumberTypeKind(kind) ? numberAttributes - : undefined; + : kind === "bool" + ? booleanAttributes + : kind === "null" + ? nullAttributes + : undefined; unionTypes.push(typeBuilder.getPrimitiveType(kind, attributes)); } @@ -1545,6 +1558,7 @@ export class JSONSchemaInput implements Input { this._attributeProducers = [ descriptionAttributeProducer, accessorNamesAttributeProducer, + defaultValueAttributeProducer, enumValuesAttributeProducer, uriSchemaAttributesProducer, minMaxAttributeProducer, diff --git a/packages/quicktype-core/src/language/CPlusPlus/CPlusPlusRenderer.ts b/packages/quicktype-core/src/language/CPlusPlus/CPlusPlusRenderer.ts index ec99e8029b..f65ff933ec 100644 --- a/packages/quicktype-core/src/language/CPlusPlus/CPlusPlusRenderer.ts +++ b/packages/quicktype-core/src/language/CPlusPlus/CPlusPlusRenderer.ts @@ -13,6 +13,7 @@ import { nullTypeIssueAnnotation, } from "../../Annotation.js"; import { getAccessorName } from "../../attributes/AccessorNames.js"; +import { defaultValueForType } from "../../attributes/DefaultValue.js"; import { enumCaseValues } from "../../attributes/EnumValues.js"; import { ConvenienceRenderer, @@ -925,8 +926,41 @@ export class CPlusPlusRenderer extends ConvenienceRenderer { return this._memberNameStyle(`${jsonName}Constraint`); } - protected emitMember(cppType: Sourcelike, name: Sourcelike): void { - this.emitLine(cppType, " ", name, ";"); + protected emitMember( + cppType: Sourcelike, + name: Sourcelike, + defaultValue?: Sourcelike, + ): void { + this.emitLine( + cppType, + " ", + name, + defaultValue === undefined ? [] : [" = ", defaultValue], + ";", + ); + } + + protected cppDefaultValue(t: Type): Sourcelike | undefined { + const defaultValue = defaultValueForType(t); + if (defaultValue === undefined) return undefined; + + if (defaultValue === null) return "nullptr"; + if (typeof defaultValue === "boolean") { + return defaultValue ? "true" : "false"; + } + + if (typeof defaultValue === "number") return String(defaultValue); + if (t instanceof EnumType && t.cases.has(defaultValue)) { + return [ + this.nameForNamedType(t), + "::", + this.nameForEnumCase(t, defaultValue), + ]; + } + + return this._stringType.createStringLiteral([ + stringEscape(defaultValue), + ]); } protected emitClassMembers( @@ -950,6 +984,7 @@ export class CPlusPlusRenderer extends ConvenienceRenderer { property.isOptional, ), name, + this.cppDefaultValue(property.type), ); if (constraints?.has(jsonName)) { /** FIXME!!! NameStyle will/can collide with other Names */ @@ -980,6 +1015,7 @@ export class CPlusPlusRenderer extends ConvenienceRenderer { property.isOptional, ), name, + this.cppDefaultValue(property.type), ); } else { const [getterName, mutableGetterName, setterName] = defined( diff --git a/test/inputs/schema/default-value.1.json b/test/inputs/schema/default-value.1.json new file mode 100644 index 0000000000..8bf2491e04 --- /dev/null +++ b/test/inputs/schema/default-value.1.json @@ -0,0 +1,3 @@ +{ + "id": 0 +} diff --git a/test/inputs/schema/default-value.schema b/test/inputs/schema/default-value.schema new file mode 100644 index 0000000000..02aecfa888 --- /dev/null +++ b/test/inputs/schema/default-value.schema @@ -0,0 +1,13 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "description": "An object with a scalar default", + "type": "object", + "properties": { + "id": { + "type": "integer", + "default": 0 + } + }, + "required": ["id"], + "additionalProperties": false +} From 182a1fbe5cad1790042844668f2a9b0a23f4f49c Mon Sep 17 00:00:00 2001 From: Mark Probst Date: Mon, 20 Jul 2026 18:16:11 -0400 Subject: [PATCH 08/27] fix(typescript-input): preserve original enum member names (#1158) quicktype's TypeScript input converts .ts sources to JSON Schema via typescript-json-schema, which drops each enum member's original identifier and keeps only its runtime value. Downstream, enum case names were synthesized from the value alone, so values that don't look like valid identifiers (e.g. "000") produced mangled names like The000 instead of the original member name Foo. This affected every target language that renders named enum cases (TypeScript, Swift, etc.), not just TypeScript output. Walk the TypeScript program's EnumDeclaration nodes after schema generation and attach a qt-accessors map (case value -> original member name) to each string enum's schema definition. quicktype-core already consumes qt-accessors as a per-case naming hint without touching the serialized value, so no core changes were needed. Added unit tests in test/unit/typescript-input.test.ts: one asserting the qt-accessors map is generated correctly, and one driving the full pipeline from TypeScript source to rendered TypeScript output to confirm the original member names (not The000/The001/The002) appear in the generated enum. Fixes #1158 Co-Authored-By: gpt-5.6-sol via pi --- .../quicktype-typescript-input/src/index.ts | 53 +++++++++++++++++ test/unit/typescript-input.test.ts | 59 ++++++++++++++++++- 2 files changed, 110 insertions(+), 2 deletions(-) diff --git a/packages/quicktype-typescript-input/src/index.ts b/packages/quicktype-typescript-input/src/index.ts index e29f49118f..41aedae1d4 100644 --- a/packages/quicktype-typescript-input/src/index.ts +++ b/packages/quicktype-typescript-input/src/index.ts @@ -125,6 +125,55 @@ function patchGeneratorForBuiltinTypes( }; } +function addEnumAccessorNames(schema: Definition, program: ts.Program): void { + const definitions = schema.definitions; + if (definitions === undefined) return; + + const checker = program.getTypeChecker(); + const visit = (node: ts.Node): void => { + if (ts.isEnumDeclaration(node)) { + const symbol = checker.getSymbolAtLocation(node.name); + if ( + symbol !== undefined && + !isDeclaredInDefaultLib(program, symbol) + ) { + const accessorNames: Record = + Object.create(null); + for (const member of node.members) { + const value = checker.getConstantValue(member); + const name = member.name; + if ( + typeof value !== "string" || + (!ts.isIdentifier(name) && + !ts.isStringLiteral(name) && + !ts.isNumericLiteral(name)) + ) { + return; + } + + accessorNames[value] = name.text; + } + + const definitionName = checker + .getFullyQualifiedName(symbol) + .replace(/(\bimport\(".*?"\)|".*?")\.| /g, ""); + const definition = definitions[definitionName]; + if (typeof definition === "object") { + (definition as unknown as Record)[ + "qt-accessors" + ] = accessorNames; + } + } + } + + ts.forEachChild(node, visit); + }; + + for (const sourceFile of program.getSourceFiles()) { + visit(sourceFile); + } +} + // FIXME: We're stringifying and then parsing this schema again. Just pass around // the schema directly. export function schemaForTypeScriptSources( @@ -151,6 +200,10 @@ export function schemaForTypeScriptSources( patchGeneratorForBuiltinTypes(generator, program); const schema = generateSchema(program, "*", settings, undefined, generator); + if (schema !== null) { + addEnumAccessorNames(schema, program); + } + const uris: string[] = []; let topLevelName = ""; diff --git a/test/unit/typescript-input.test.ts b/test/unit/typescript-input.test.ts index 2e5f6a8842..a268848874 100644 --- a/test/unit/typescript-input.test.ts +++ b/test/unit/typescript-input.test.ts @@ -1,6 +1,12 @@ import * as fs from "node:fs"; import * as path from "node:path"; +import { + FetchingJSONSchemaStore, + InputData, + JSONSchemaInput, + quicktype, +} from "quicktype-core"; import { schemaForTypeScriptSources } from "quicktype-typescript-input"; import { afterAll, describe, expect, test } from "vitest"; @@ -18,13 +24,17 @@ afterAll(() => { let uniqueFileIndex = 0; -function schemaForSource(source: string) { +function schemaSourceDataForSource(source: string) { const fileName = path.join( path.relative(process.cwd(), temporaryDirectory), `input-${uniqueFileIndex++}.ts`, ); fs.writeFileSync(fileName, source); - const result = schemaForTypeScriptSources([fileName]); + return schemaForTypeScriptSources([fileName]); +} + +function schemaForSource(source: string) { + const result = schemaSourceDataForSource(source); return { name: result.name ?? "", schema: JSON.parse(result.schema), @@ -32,6 +42,20 @@ function schemaForSource(source: string) { }; } +async function generateTypeScriptForSource(source: string): Promise { + const schemaInput = new JSONSchemaInput(new FetchingJSONSchemaStore()); + await schemaInput.addSource(schemaSourceDataForSource(source)); + const inputData = new InputData(); + inputData.addInput(schemaInput); + + const result = await quicktype({ + inputData, + lang: "typescript", + rendererOptions: { "just-types": true, "prefer-unions": false }, + }); + return result.lines.join("\n"); +} + describe("schemaForTypeScriptSources", () => { test("converts a simple interface", () => { const { schema, uris } = schemaForSource(` @@ -98,6 +122,37 @@ describe("schemaForTypeScriptSources", () => { expect(config.properties.count.default).toBe(42); }); + test("string enums preserve their member names in the schema", () => { + const { schema } = schemaForSource(` + export enum TestEnum { + Foo = "000", + Bar = "001", + Baz = "002", + } + `); + + expect(schema.definitions.TestEnum["qt-accessors"]).toEqual({ + "000": "Foo", + "001": "Bar", + "002": "Baz", + }); + }); + + test("string enum member names survive through TypeScript output", async () => { + const output = await generateTypeScriptForSource(` + export enum TestEnum { + Foo = "000", + Bar = "001", + Baz = "002", + } + `); + + expect(output).toContain('Foo = "000"'); + expect(output).toContain('Bar = "001"'); + expect(output).toContain('Baz = "002"'); + expect(output).not.toMatch(/The00[0-2]/); + }); + // The previously used fork of typescript-json-schema threw // "Unsupported type: bigint" here. test("bigint properties are converted to numbers", () => { From 7600dd15254e8d192565f7807ab8a101e58354f7 Mon Sep 17 00:00:00 2001 From: Mark Probst Date: Mon, 20 Jul 2026 18:35:54 -0400 Subject: [PATCH 09/27] fix(csharp): omit superfluous usings when no converter class is emitted (#800) Co-Authored-By: gpt-5.6-sol via pi --- .../CSharp/NewtonSoftCSharpRenderer.ts | 26 +++++--- .../CSharp/SystemTextJsonCSharpRenderer.ts | 17 ++++-- test/languages.ts | 2 + test/unit/csharp-superfluous-usings.test.ts | 61 +++++++++++++++++++ 4 files changed, 91 insertions(+), 15 deletions(-) create mode 100644 test/unit/csharp-superfluous-usings.test.ts diff --git a/packages/quicktype-core/src/language/CSharp/NewtonSoftCSharpRenderer.ts b/packages/quicktype-core/src/language/CSharp/NewtonSoftCSharpRenderer.ts index 4fbd16c23c..0da8c1b39f 100644 --- a/packages/quicktype-core/src/language/CSharp/NewtonSoftCSharpRenderer.ts +++ b/packages/quicktype-core/src/language/CSharp/NewtonSoftCSharpRenderer.ts @@ -62,6 +62,13 @@ export class NewtonsoftCSharpRenderer extends CSharpRenderer { private readonly _needNamespaces: boolean; + private get needConverterClass(): boolean { + return ( + this._needHelpers || + (this._needAttributes && (this.haveNamedUnions || this.haveEnums)) + ); + } + public constructor( targetLanguage: TargetLanguage, renderContext: RenderContext, @@ -160,12 +167,14 @@ export class NewtonsoftCSharpRenderer extends CSharpRenderer { super.emitUsings(); this.ensureBlankLine(); - for (const ns of [ - "System.Globalization", - "Newtonsoft.Json", - "Newtonsoft.Json.Converters", - ]) { - this.emitUsing(ns); + if (this.needConverterClass) { + this.emitUsing("System.Globalization"); + } + + this.emitUsing("Newtonsoft.Json"); + + if (this.needConverterClass) { + this.emitUsing("Newtonsoft.Json.Converters"); } if (this._options.dense) { @@ -1177,10 +1186,7 @@ export class NewtonsoftCSharpRenderer extends CSharpRenderer { this.emitSerializeClass(); } - if ( - this._needHelpers || - (this._needAttributes && (this.haveNamedUnions || this.haveEnums)) - ) { + if (this.needConverterClass) { this.ensureBlankLine(); this.emitConverterClass(); this.forEachTransformation("leading-and-interposing", (n, t) => diff --git a/packages/quicktype-core/src/language/CSharp/SystemTextJsonCSharpRenderer.ts b/packages/quicktype-core/src/language/CSharp/SystemTextJsonCSharpRenderer.ts index 0a217e0ede..5e0ab1ad4b 100644 --- a/packages/quicktype-core/src/language/CSharp/SystemTextJsonCSharpRenderer.ts +++ b/packages/quicktype-core/src/language/CSharp/SystemTextJsonCSharpRenderer.ts @@ -61,6 +61,13 @@ export class SystemTextJsonCSharpRenderer extends CSharpRenderer { private readonly _needNamespaces: boolean; + private get needConverterClass(): boolean { + return ( + this._needHelpers || + (this._needAttributes && (this.haveNamedUnions || this.haveEnums)) + ); + } + public constructor( targetLanguage: TargetLanguage, renderContext: RenderContext, @@ -165,11 +172,14 @@ export class SystemTextJsonCSharpRenderer extends CSharpRenderer { for (const ns of [ "System.Text.Json", "System.Text.Json.Serialization", - "System.Globalization", ]) { this.emitUsing(ns); } + if (this.needConverterClass) { + this.emitUsing("System.Globalization"); + } + if (this._options.dense) { this.emitUsing([ denseJsonPropertyName, @@ -1299,10 +1309,7 @@ export class SystemTextJsonCSharpRenderer extends CSharpRenderer { this.emitSerializeClass(); } - if ( - this._needHelpers || - (this._needAttributes && (this.haveNamedUnions || this.haveEnums)) - ) { + if (this.needConverterClass) { this.ensureBlankLine(); this.emitConverterClass(); this.forEachTransformation("leading-and-interposing", (n, t) => diff --git a/test/languages.ts b/test/languages.ts index 7f80f5e3b1..f13c2a0fe4 100644 --- a/test/languages.ts +++ b/test/languages.ts @@ -128,6 +128,7 @@ export const CSharpLanguage: Language = { rendererOptions: { "check-required": "true", framework: "NewtonSoft" }, quickTestRendererOptions: [ { "array-type": "list" }, + ["simple-object.json", { features: "attributes-only" }], // The default is csharp-version=8; these keep the older // language-version code paths covered. { "csharp-version": "5" }, @@ -183,6 +184,7 @@ export const CSharpLanguageSystemTextJson: Language = { rendererOptions: { "check-required": "true", framework: "SystemTextJson" }, quickTestRendererOptions: [ { "array-type": "list" }, + ["simple-object.json", { features: "attributes-only" }], // The default is csharp-version=8; these keep the older // language-version code paths covered. { "csharp-version": "5" }, diff --git a/test/unit/csharp-superfluous-usings.test.ts b/test/unit/csharp-superfluous-usings.test.ts new file mode 100644 index 0000000000..999d2acfae --- /dev/null +++ b/test/unit/csharp-superfluous-usings.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, test } from "vitest"; + +import { + InputData, + type RendererOptions, + jsonInputForTargetLanguage, + quicktype, +} from "../../packages/quicktype-core/src/index.js"; + +async function renderCSharp(rendererOptions: RendererOptions): Promise { + const jsonInput = jsonInputForTargetLanguage("csharp"); + await jsonInput.addSource({ + name: "Sample", + samples: ['{"name":"Alice","age":30,"active":true}'], + }); + + const inputData = new InputData(); + inputData.addInput(jsonInput); + const result = await quicktype({ + inputData, + lang: "csharp", + rendererOptions, + }); + return result.lines.join("\n"); +} + +describe("C# using statements", () => { + test("NewtonSoft attributes-only omits converter usings when no converter is emitted", async () => { + const output = await renderCSharp({ + framework: "NewtonSoft", + features: "attributes-only", + }); + + expect(output).toContain("using Newtonsoft.Json;"); + expect(output).not.toContain("using System.Globalization;"); + expect(output).not.toContain("using Newtonsoft.Json.Converters;"); + }); + + test("SystemTextJson attributes-only omits globalization when no converter is emitted", async () => { + const output = await renderCSharp({ + framework: "SystemTextJson", + features: "attributes-only", + }); + + expect(output).toContain("using System.Text.Json.Serialization;"); + expect(output).not.toContain("using System.Globalization;"); + }); + + test.each([ + ["NewtonSoft", "using Newtonsoft.Json.Converters;"], + ["SystemTextJson", "using System.Globalization;"], + ] as Array< + ["NewtonSoft" | "SystemTextJson", string] + >)("%s complete output keeps converter usings", async (framework, expectedUsing) => { + const output = await renderCSharp({ framework }); + + expect(output).toContain("using System.Globalization;"); + expect(output).toContain(expectedUsing); + expect(output).toContain("IsoDateTime"); + }); +}); From 9add244c979f672d6a8ab07ceb7a050187a25682 Mon Sep 17 00:00:00 2001 From: Mark Probst Date: Mon, 20 Jul 2026 19:13:41 -0400 Subject: [PATCH 10/27] fix(rendering): preserve enum case source order (#2401) Co-Authored-By: gpt-5.6-sol via pi --- .../quicktype-core/src/ConvenienceRenderer.ts | 5 +---- .../quicktype-core/src/MakeTransformations.ts | 3 +-- test/fixtures/csharp/Program.cs | 11 ++++++++++ test/unit/enum-order.test.ts | 22 +++++++++++++++++++ 4 files changed, 35 insertions(+), 6 deletions(-) create mode 100644 test/unit/enum-order.test.ts diff --git a/packages/quicktype-core/src/ConvenienceRenderer.ts b/packages/quicktype-core/src/ConvenienceRenderer.ts index de5901e6e2..af8ff16031 100644 --- a/packages/quicktype-core/src/ConvenienceRenderer.ts +++ b/packages/quicktype-core/src/ConvenienceRenderer.ts @@ -995,10 +995,7 @@ export abstract class ConvenienceRenderer extends Renderer { f: (name: Name, jsonName: string, position: ForEachPosition) => void, ): void { const caseNames = defined(this._caseNamesStoreView).get(e); - const sortedCaseNames = mapSortBy(caseNames, (n) => - defined(this.names.get(n)), - ); - this.forEachWithBlankLines(sortedCaseNames, blankLocations, f); + this.forEachWithBlankLines(caseNames, blankLocations, f); } protected forEachTransformation( diff --git a/packages/quicktype-core/src/MakeTransformations.ts b/packages/quicktype-core/src/MakeTransformations.ts index 664a8f7c96..015b81c964 100644 --- a/packages/quicktype-core/src/MakeTransformations.ts +++ b/packages/quicktype-core/src/MakeTransformations.ts @@ -80,8 +80,7 @@ function makeEnumTransformer( stringType: TypeRef, continuation?: Transformer, ): Transformer { - const sortedCases = Array.from(enumType.cases).sort(); - const caseTransformers = sortedCases.map( + const caseTransformers = Array.from(enumType.cases).map( (c) => new StringMatchTransformer( graph, diff --git a/test/fixtures/csharp/Program.cs b/test/fixtures/csharp/Program.cs index de1677a2c0..8c1eb3a4f4 100755 --- a/test/fixtures/csharp/Program.cs +++ b/test/fixtures/csharp/Program.cs @@ -9,6 +9,17 @@ static void Main(string[] args) var path = args[0]; var json = System.IO.File.ReadAllText(path); var output = TopLevel.FromJson(json).ToJson(); + + if (System.IO.Path.GetFileName(path) == "enum.1.json") + { + var generatedSource = System.IO.File.ReadAllText("QuickType.cs"); + const string expectedEnum = "public enum Lvc { Lawful, Neutral, Chaotic };"; + if (!generatedSource.Contains(expectedEnum)) + { + throw new InvalidOperationException("Generated enum cases are not in schema order"); + } + } + Console.WriteLine("{0}", output); } } diff --git a/test/unit/enum-order.test.ts b/test/unit/enum-order.test.ts new file mode 100644 index 0000000000..2460f06bd8 --- /dev/null +++ b/test/unit/enum-order.test.ts @@ -0,0 +1,22 @@ +import { InputData, JSONSchemaInput, quicktype } from "quicktype-core"; +import { expect, test } from "vitest"; + +test("preserves JSON Schema enum case order", async () => { + const schemaInput = new JSONSchemaInput(undefined); + await schemaInput.addSource({ + name: "DaySchema", + schema: JSON.stringify({ + $schema: "http://json-schema.org/draft-07/schema#", + type: "string", + enum: ["Monday", "Tuesday", "Friday", "Sunday"], + }), + }); + const inputData = new InputData(); + inputData.addInput(schemaInput); + + const result = await quicktype({ inputData, lang: "csharp" }); + + expect(result.lines.join("\n")).toContain( + "public enum DaySchemaEnum { Monday, Tuesday, Friday, Sunday };", + ); +}); From 6b5313025c614154cd01970d7d4d11d2f743c3f4 Mon Sep 17 00:00:00 2001 From: Mark Probst Date: Mon, 20 Jul 2026 19:37:05 -0400 Subject: [PATCH 11/27] test: add missing fixture cases for default-value.schema (#3021) Co-Authored-By: Claude --- test/inputs/schema/default-value.1.fail.no-defaults.json | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 test/inputs/schema/default-value.1.fail.no-defaults.json diff --git a/test/inputs/schema/default-value.1.fail.no-defaults.json b/test/inputs/schema/default-value.1.fail.no-defaults.json new file mode 100644 index 0000000000..2c63c08510 --- /dev/null +++ b/test/inputs/schema/default-value.1.fail.no-defaults.json @@ -0,0 +1,2 @@ +{ +} From 2fe8ce9219b1bc5c0d085ac49a139c7af9521368 Mon Sep 17 00:00:00 2001 From: Mark Probst Date: Mon, 20 Jul 2026 19:49:27 -0400 Subject: [PATCH 12/27] test: add missing fixture cases for haskell-enum-forbidden.schema (#2969) Co-Authored-By: Claude --- test/inputs/schema/haskell-enum-forbidden.1.fail.enum.json | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 test/inputs/schema/haskell-enum-forbidden.1.fail.enum.json diff --git a/test/inputs/schema/haskell-enum-forbidden.1.fail.enum.json b/test/inputs/schema/haskell-enum-forbidden.1.fail.enum.json new file mode 100644 index 0000000000..d38b6a8168 --- /dev/null +++ b/test/inputs/schema/haskell-enum-forbidden.1.fail.enum.json @@ -0,0 +1,3 @@ +{ + "health": "warning" +} From bf5df485fd11470b9e2082a70be7ef73fcbbd4c1 Mon Sep 17 00:00:00 2001 From: Mark Probst Date: Mon, 20 Jul 2026 20:03:26 -0400 Subject: [PATCH 13/27] test(csharp): drop attributes-only round-trip fixtures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The attributes-only C# mode emits only the annotated model classes and no FromJson/ToJson serialization helpers, so the round-trip fixture driver (TopLevel.FromJson(json).ToJson()) cannot compile against it — CI failed with CS0117 'TopLevel' does not contain a definition for 'FromJson'. Remove the two `["simple-object.json", { features: "attributes-only" }]` quickTestRendererOptions entries added for NewtonSoft and SystemTextJson. The using-omission behavior is fully covered by the unit tests in test/unit/csharp-superfluous-usings.test.ts, which is the appropriate mechanism for asserting that code is *not* generated (a round-trip fixture cannot exercise attributes-only mode by design). Co-Authored-By: Claude --- test/languages.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/test/languages.ts b/test/languages.ts index f13c2a0fe4..7f80f5e3b1 100644 --- a/test/languages.ts +++ b/test/languages.ts @@ -128,7 +128,6 @@ export const CSharpLanguage: Language = { rendererOptions: { "check-required": "true", framework: "NewtonSoft" }, quickTestRendererOptions: [ { "array-type": "list" }, - ["simple-object.json", { features: "attributes-only" }], // The default is csharp-version=8; these keep the older // language-version code paths covered. { "csharp-version": "5" }, @@ -184,7 +183,6 @@ export const CSharpLanguageSystemTextJson: Language = { rendererOptions: { "check-required": "true", framework: "SystemTextJson" }, quickTestRendererOptions: [ { "array-type": "list" }, - ["simple-object.json", { features: "attributes-only" }], // The default is csharp-version=8; these keep the older // language-version code paths covered. { "csharp-version": "5" }, From 407baad7bbd839f4aab790edc9ac3725b782b372 Mon Sep 17 00:00:00 2001 From: Mark Probst Date: Mon, 20 Jul 2026 20:07:01 -0400 Subject: [PATCH 14/27] test(kotlinx): skip top-level-array enum-collision fixture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The new priority fixture kotlin-enum-class-case-collision.json is a top-level JSON array, which the kotlinx renderer emits as `typealias TopLevel = JsonArray` — invalid Kotlin, since kotlinx's JsonArray takes no type arguments. This is a pre-existing, documented kotlinx limitation (see the ~40 existing top-level-array entries in this same skipJSON block and the TODO in KotlinXRenderer.ts), unrelated to the enum-naming fix in this PR. The enum case/class name-collision fix lives in the shared KotlinRenderer base class and remains covered by the kotlin (klaxon) and kotlin-jackson fixture runs, which handle top-level arrays as `class TopLevel : ArrayList` and do run this fixture. Co-Authored-By: Claude --- test/languages.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/test/languages.ts b/test/languages.ts index 6bcfd43611..594dfda726 100644 --- a/test/languages.ts +++ b/test/languages.ts @@ -1465,6 +1465,7 @@ export const KotlinXLanguage: Language = { // Top-level arrays render as `typealias TopLevel = JsonArray`, // which doesn't compile — kotlinx's JsonArray takes no type // arguments (documented TODO in KotlinXRenderer.ts). + "kotlin-enum-class-case-collision.json", "bug863.json", "github-events.json", "optional-union.json", From 5ced9e84c98ba55c40de50bb844280ed1d7cfe6b Mon Sep 17 00:00:00 2001 From: Mark Probst Date: Mon, 20 Jul 2026 21:19:11 -0400 Subject: [PATCH 15/27] fix CI: skip json-ts-csharp for top-level-array enum-collision fixture (#1815) The new priority JSON input kotlin-enum-class-case-collision.json is a top-level array. quicktype's TypeScript --just-types renderer collapses a top-level array's element type onto the top-level name itself instead of emitting an array alias, so the C# code generated from that intermediate TypeScript expects a JSON object and throws when run against the original array JSON. This is the same pre-existing top-level-array limitation already covered by ~70 other entries in skipTypeScriptTests; add this fixture alongside them. Co-Authored-By: gpt-5.6-sol via pi --- test/fixtures.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/test/fixtures.ts b/test/fixtures.ts index dece181ef6..7e98fba697 100644 --- a/test/fixtures.ts +++ b/test/fixtures.ts @@ -659,6 +659,7 @@ const skipTypeScriptTests = [ "optional-union.json", "pokedex.json", // Enums are screwed up: https://github.com/YousefED/typescript-json-schema/issues/186 "github-events.json", + "kotlin-enum-class-case-collision.json", "bug855-short.json", "bug863.json", "00c36.json", From b75e57f19f20f182f5ef363cf47f731698c1f373 Mon Sep 17 00:00:00 2001 From: Mark Probst Date: Mon, 20 Jul 2026 22:12:15 -0400 Subject: [PATCH 16/27] fix CI: skip cjson enum-value validation for haskell-enum-forbidden.schema (#2868) The new haskell-enum-forbidden.1.fail.enum.json fail sample runs against every language with the "enum" feature, including cjson. cJSON's generated enum getters silently fall back to the first enum member on an unrecognized string instead of failing, the same pre-existing limitation already documented and worked around for enum.schema, enum-large.schema, optional-enum.schema, and const-non-string.schema via the shared skipsEnumValueValidation list. Add the new schema to that list. Co-Authored-By: gpt-5.6-sol via pi --- test/languages.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/test/languages.ts b/test/languages.ts index c37ad1a3ce..be09d6eca6 100644 --- a/test/languages.ts +++ b/test/languages.ts @@ -25,6 +25,7 @@ const skipsEnumValueValidation = [ "enum-large.schema", "optional-enum.schema", "const-non-string.schema", + "haskell-enum-forbidden.schema", ]; // The language makes no int/double distinction in unions (e.g. an integer is From 8623b537052156f7cd248b434d3d02b1780a83f2 Mon Sep 17 00:00:00 2001 From: Mark Probst Date: Tue, 21 Jul 2026 09:18:59 -0400 Subject: [PATCH 17/27] chore: retrigger CI No workflow run was created for the previous push (b75e57f1) despite several hours passing and a close/reopen of the PR; pushing an empty commit to force a fresh synchronize event. From dfa46818bf68895df2c8af60db1532b3fcea8fb3 Mon Sep 17 00:00:00 2001 From: Mark Probst Date: Tue, 21 Jul 2026 11:24:52 -0400 Subject: [PATCH 18/27] test(csharp): drop special-cased enum-order check from fixture driver The C# fixture driver was special-cased to inspect the generated QuickType.cs source whenever the sample filename was enum.1.json. The fixture pipeline is a round-trip harness (deserialize then reserialize and compare JSON); wiring a filename-keyed source assertion into the driver is not something it is built for, and no other language driver does anything like it. Enum case source ordering is invisible to a string round-trip anyway, so the fixture cannot express it. That behavior is already covered directly and portably by test/unit/enum-order.test.ts, which asserts the generated C# and C++ enum declarations preserve schema order via the quicktype API. Co-Authored-By: Claude --- test/fixtures/csharp/Program.cs | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/test/fixtures/csharp/Program.cs b/test/fixtures/csharp/Program.cs index 8c1eb3a4f4..de1677a2c0 100755 --- a/test/fixtures/csharp/Program.cs +++ b/test/fixtures/csharp/Program.cs @@ -9,17 +9,6 @@ static void Main(string[] args) var path = args[0]; var json = System.IO.File.ReadAllText(path); var output = TopLevel.FromJson(json).ToJson(); - - if (System.IO.Path.GetFileName(path) == "enum.1.json") - { - var generatedSource = System.IO.File.ReadAllText("QuickType.cs"); - const string expectedEnum = "public enum Lvc { Lawful, Neutral, Chaotic };"; - if (!generatedSource.Contains(expectedEnum)) - { - throw new InvalidOperationException("Generated enum cases are not in schema order"); - } - } - Console.WriteLine("{0}", output); } } From 44cc7b241fdcda0dbbed13a1b4c626fa51e660ab Mon Sep 17 00:00:00 2001 From: Mark Probst Date: Tue, 21 Jul 2026 11:29:22 -0400 Subject: [PATCH 19/27] test: update Kotlin enum case name expectation for preserved TS member names The 'Kotlin: enum cases retain their TypeScript string values' test on master asserted the pre-fix synthesized case name (Bringtofront, derived from the value "bringtofront"). With this branch's fix, the original TS member name BRING_TO_FRONT is preserved and rendered as BringToFront, so update the assertion to the corrected output. Co-Authored-By: Claude --- test/unit/just-types-option.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/unit/just-types-option.test.ts b/test/unit/just-types-option.test.ts index cac04a5e15..d6c9832bd2 100644 --- a/test/unit/just-types-option.test.ts +++ b/test/unit/just-types-option.test.ts @@ -90,7 +90,7 @@ describe("just-types generates plain types in every language", () => { expect(output).toContain("enum class CanvasAction(val value: String)"); expect(output).toContain('Add("add")'); - expect(output).toContain('Bringtofront("bringtofront")'); + expect(output).toContain('BringToFront("bringtofront")'); expect(output).toContain( "fun fromValue(value: String): CanvasAction = when (value)", ); From 37d8d9227c754ee810fa7fe3e0f8a0cbb08d153a Mon Sep 17 00:00:00 2001 From: Mark Probst Date: Tue, 21 Jul 2026 11:35:58 -0400 Subject: [PATCH 20/27] test(js): verify all defined converters are exported (#1655) The issue-1655 fixture driver decided whether to exercise the nested data123 converter by matching the generated source for the exact string "function toData123(". Replace that brittle hard-coded gate with a check of the invariant #1655 actually establishes: every converter the generated module defines (`function to(json)` / `function ToJson(value)`) must also be reachable through module.exports. The module's helper functions have different signatures and are not matched, so the check is general across samples and converter modes rather than tied to one sample name. The same sample is rendered with several converter options and the driver only receives the sample path, so the set of emitted converters is read from the generated module; in the default (top-level) rendering data123 is simply never defined and never checked. Co-Authored-By: Claude --- test/fixtures/javascript/main.js | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/test/fixtures/javascript/main.js b/test/fixtures/javascript/main.js index e52fe6e2a9..b815ed6732 100644 --- a/test/fixtures/javascript/main.js +++ b/test/fixtures/javascript/main.js @@ -9,10 +9,27 @@ const json = fs.readFileSync(sample); const value = TopLevel.toTopLevel(json); const backToJson = TopLevel.topLevelToJson(value); +// Regression check for #1655: every converter the generated module defines +// must also be listed in module.exports, or callers of the module can't reach +// it. With `--converters all-objects` this includes a `to`/`ToJson` +// pair per object type (e.g. the nested `data123` object), which used to be +// generated but never exported. The same sample is rendered with several +// converter options, and the driver only receives the sample path, so we +// verify the invariant against whichever converters the current options +// actually emitted rather than assuming a fixed set. Converters have the +// distinctive signatures `function to(json)` and +// `function ToJson(value)`; the module's helper functions do not. const generatedSource = fs.readFileSync(require.resolve("./TopLevel"), "utf8"); -if (generatedSource.includes("function toData123(")) { - const data123 = TopLevel.toData123(JSON.stringify(value.data123)); - TopLevel.data123ToJson(data123); +const definedConverters = [ + ...generatedSource.matchAll(/^function (to[A-Z]\w*)\(json\)/gm), + ...generatedSource.matchAll(/^function (\w+ToJson)\(value\)/gm), +].map((match) => match[1]); +for (const name of definedConverters) { + if (typeof TopLevel[name] !== "function") { + throw new Error( + `converter ${name} is defined in the generated module but is not exported from module.exports`, + ); + } } console.log(backToJson); From ebf59e87dc105045cd92488c5db748b6f6521b76 Mon Sep 17 00:00:00 2001 From: Mark Probst Date: Tue, 21 Jul 2026 12:07:25 -0400 Subject: [PATCH 21/27] test(schema): skip default-value fixture where required is not enforced The default-value.1.fail.no-defaults.json fail sample is an empty object that must be rejected because the schema marks `id` as required. Languages that do not enforce required properties (and therefore already skip required.schema) cannot make that sample fail, so the schema-cjson fixture reported an unexpected pass. Add default-value.schema to skipSchema for exactly those languages (cjson, swift, haskell, typescript-zod, typescript-effect-schema, elixir), mirroring the existing required.schema skips. The positive and negative cases still run on every language that enforces required (cplusplus, csharp, java, python, rust, kotlin, dart, go, ...). Co-Authored-By: Claude --- test/languages.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/test/languages.ts b/test/languages.ts index 4e2cf42083..642fe44f0d 100644 --- a/test/languages.ts +++ b/test/languages.ts @@ -648,6 +648,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", + // The default-value fail sample also relies on required-property + // enforcement, which cJSON does not do. + "default-value.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", @@ -969,6 +972,8 @@ export const SwiftLanguage: Language = { // This works on macOS, but on Linux one of the failure test cases doesn't fail ...skipsUntypedUnions, "required.schema", + // The default-value fail sample also relies on required-property enforcement. + "default-value.schema", "multi-type-enum.schema", "intersection.schema", ...skipsMapValueValidation, @@ -1897,6 +1902,8 @@ export const HaskellLanguage: Language = { "keyword-unions.schema", "optional-any.schema", "required.schema", + // The default-value fail sample also relies on required-property enforcement. + "default-value.schema", "required-non-properties.schema", ], rendererOptions: {}, @@ -2067,6 +2074,8 @@ export const TypeScriptZodLanguage: Language = { "optional-any.schema", "recursive-union-flattening.schema", "required.schema", + // The default-value fail sample also relies on required-property enforcement. + "default-value.schema", "required-non-properties.schema", ], rendererOptions: {}, @@ -2183,6 +2192,8 @@ export const TypeScriptEffectSchemaLanguage: Language = { "keyword-unions.schema", "optional-any.schema", "required.schema", + // The default-value fail sample also relies on required-property enforcement. + "default-value.schema", "required-non-properties.schema", ], rendererOptions: {}, @@ -2241,6 +2252,8 @@ 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", + // The default-value fail sample also relies on required-property enforcement. + "default-value.schema", "boolean-subschema.schema", "intersection.schema", "optional-any.schema", From de6e023216b0476d45abda3a2e663606f111b0ac Mon Sep 17 00:00:00 2001 From: Mark Probst Date: Tue, 21 Jul 2026 12:08:38 -0400 Subject: [PATCH 22/27] test(js): assert all-objects export invariant in a unit test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fixture driver read the generated TopLevel.js back and grepped its source for converter definitions to check they were all exported. That inspection of emitted source belongs in a render-and-inspect unit test, not in the round-trip fixture driver. Move the #1655 regression check into test/unit: render the JS module with converters: all-objects and assert every defined converter appears in the module.exports block (plus a top-level control that shows the nested converter is neither generated nor exported in the default mode). This mirrors the existing java-acronym-names unit test, whose comment explains the same rationale — a round-trip fixture cannot observe the defect because the top-level converter it exercises works regardless. Reverting the renderer fix makes the new test fail. The javascript fixture driver returns to a plain round-trip; issue-1655.json still renders with converters: all-objects, keeping fixture coverage that the mode generates runnable code. Co-Authored-By: Claude --- test/fixtures/javascript/main.js | 23 ----- .../javascript-all-objects-exports.test.ts | 85 +++++++++++++++++++ 2 files changed, 85 insertions(+), 23 deletions(-) create mode 100644 test/unit/javascript-all-objects-exports.test.ts diff --git a/test/fixtures/javascript/main.js b/test/fixtures/javascript/main.js index b815ed6732..cb60dd70d8 100644 --- a/test/fixtures/javascript/main.js +++ b/test/fixtures/javascript/main.js @@ -9,27 +9,4 @@ const json = fs.readFileSync(sample); const value = TopLevel.toTopLevel(json); const backToJson = TopLevel.topLevelToJson(value); -// Regression check for #1655: every converter the generated module defines -// must also be listed in module.exports, or callers of the module can't reach -// it. With `--converters all-objects` this includes a `to`/`ToJson` -// pair per object type (e.g. the nested `data123` object), which used to be -// generated but never exported. The same sample is rendered with several -// converter options, and the driver only receives the sample path, so we -// verify the invariant against whichever converters the current options -// actually emitted rather than assuming a fixed set. Converters have the -// distinctive signatures `function to(json)` and -// `function ToJson(value)`; the module's helper functions do not. -const generatedSource = fs.readFileSync(require.resolve("./TopLevel"), "utf8"); -const definedConverters = [ - ...generatedSource.matchAll(/^function (to[A-Z]\w*)\(json\)/gm), - ...generatedSource.matchAll(/^function (\w+ToJson)\(value\)/gm), -].map((match) => match[1]); -for (const name of definedConverters) { - if (typeof TopLevel[name] !== "function") { - throw new Error( - `converter ${name} is defined in the generated module but is not exported from module.exports`, - ); - } -} - console.log(backToJson); diff --git a/test/unit/javascript-all-objects-exports.test.ts b/test/unit/javascript-all-objects-exports.test.ts new file mode 100644 index 0000000000..7950c6b886 --- /dev/null +++ b/test/unit/javascript-all-objects-exports.test.ts @@ -0,0 +1,85 @@ +// Regression test for #1655. With `--converters all-objects` the JavaScript +// renderer emits a `to`/`ToJson` converter pair for every object +// type, not just the top-level ones. Before the fix, `emitModuleExports()` +// still iterated only the top-level types, so those extra per-object +// converters were generated in the module body but never listed in +// `module.exports` — callers of the module could not reach them. +// +// A round-trip fixture cannot catch this: the driver deserializes and +// reserializes through the top-level converter, which is exported and works +// regardless of whether the nested converters are. This test therefore +// generates the module directly and inspects its `module.exports` block. + +import { + InputData, + jsonInputForTargetLanguage, + quicktype, +} from "quicktype-core"; +import { describe, expect, test } from "vitest"; + +// A schema with a nested object (`data123`) so the all-objects renderer emits a +// converter for a non-top-level type. +const sample = JSON.stringify({ data123: { name: "quicktype" } }); + +async function renderJavaScript(converters: string): Promise { + const jsonInput = jsonInputForTargetLanguage("js"); + await jsonInput.addSource({ name: "TopLevel", samples: [sample] }); + const inputData = new InputData(); + inputData.addInput(jsonInput); + + const result = await quicktype({ + inputData, + lang: "js", + rendererOptions: { "acronym-style": "pascal", converters }, + }); + return result.lines.join("\n"); +} + +// The `module.exports = { ... };` object literal at the bottom of the module. +function moduleExportsBlock(source: string): string { + const match = source.match(/module\.exports\s*=\s*\{([\s\S]*?)\};/); + // biome-ignore lint/suspicious/noMisplacedAssertion: helper is only called from within tests + expect(match, `no module.exports block in:\n${source}`).not.toBeNull(); + return match?.[1] ?? ""; +} + +// Every converter the module defines — `function to(json)` and +// `function ToJson(value)`. The module's helpers (cast, uncast, +// transform, …) have different signatures and are intentionally not matched. +function definedConverters(source: string): string[] { + return [ + ...source.matchAll(/^function (to[A-Z]\w*)\(json\)/gm), + ...source.matchAll(/^function (\w+ToJson)\(value\)/gm), + ].map((m) => m[1]); +} + +describe("JavaScript converters: all-objects module.exports", () => { + test("exports a converter for every object type, including nested ones", async () => { + const source = await renderJavaScript("all-objects"); + const converters = definedConverters(source); + const exportsBlock = moduleExportsBlock(source); + + // Sanity: the renderer actually emitted a nested-object converter. + expect(converters).toContain("toData123"); + expect(converters).toContain("data123ToJson"); + + // The invariant #1655 restores: every defined converter is exported. + for (const name of converters) { + expect( + exportsBlock, + `converter ${name} is defined but not in module.exports`, + ).toContain(name); + } + }); + + test("top-level mode exports only top-level converters", async () => { + const source = await renderJavaScript("top-level"); + const exportsBlock = moduleExportsBlock(source); + + expect(exportsBlock).toContain("toTopLevel"); + // The nested converter is not generated in top-level mode, so it must + // not appear in the exports either — this is what distinguishes the + // all-objects behavior above from the default. + expect(exportsBlock).not.toContain("toData123"); + }); +}); From ee29122f1e760118060ed3a10a2520c4bbb35440 Mon Sep 17 00:00:00 2001 From: Mark Probst Date: Tue, 21 Jul 2026 12:19:54 -0400 Subject: [PATCH 23/27] test(haskell): verify enum-case naming with a unit test instead of a grep hack The enum-case naming from issue #2868 was checked by a filename-matching branch in the Haskell fixture's runCommand that grepped the generated QuickType.hs for `= ErrorHealth`. That approach was both a special-case hack and, since haskell-enum-forbidden.schema is skipped for the Haskell fixture via skipsEnumValueValidation (the driver reports decode failures as "null" and exit 0, so its fail sample can't be detected), dead code that never ran. Replace it with a unit test that renders the schema to Haskell and asserts the generated constructors are `OkHealth`/`ErrorHealth` rather than the over-renamed `HealthErrorHealth`. A round-trip fixture cannot express this: the constructor name is invisible to JSON serialization, so correct and over-renamed output round-trip identically. The test fails against the pre-fix renderer and passes against the fix. Co-Authored-By: Claude --- test/languages.ts | 6 +- test/unit/haskell-enum-case-naming.test.ts | 75 ++++++++++++++++++++++ 2 files changed, 76 insertions(+), 5 deletions(-) create mode 100644 test/unit/haskell-enum-case-naming.test.ts diff --git a/test/languages.ts b/test/languages.ts index 6048883932..1bbe830934 100644 --- a/test/languages.ts +++ b/test/languages.ts @@ -1817,11 +1817,7 @@ export const HaskellLanguage: Language = { setupCommand: "stack install", compileCommand: "true", runCommand(sample: string) { - // Round-tripping cannot distinguish generated constructor names. - const sourceCheck = sample.endsWith("haskell-enum-forbidden.1.json") - ? "grep -q '^ = ErrorHealth$' QuickType.hs && " - : ""; - return `${sourceCheck}stack run haskell -- "${sample}"`; + return `stack run haskell -- "${sample}"`; }, diffViaSchema: true, skipDiffViaSchema: [ diff --git a/test/unit/haskell-enum-case-naming.test.ts b/test/unit/haskell-enum-case-naming.test.ts new file mode 100644 index 0000000000..72354e7627 --- /dev/null +++ b/test/unit/haskell-enum-case-naming.test.ts @@ -0,0 +1,75 @@ +import { + InputData, + JSONSchemaInput, + quicktype, +} from "../../packages/quicktype-core/src/index.js"; +import { describe, expect, test } from "vitest"; + +async function renderHaskell(schema: object): Promise { + const schemaInput = new JSONSchemaInput(undefined); + await schemaInput.addSource({ + name: "TopLevel", + schema: JSON.stringify(schema), + }); + + const inputData = new InputData(); + inputData.addInput(schemaInput); + + const result = await quicktype({ + inputData, + lang: "haskell", + }); + return result.lines.join("\n"); +} + +function enumConstructors(output: string): string[] { + // Collect the constructors of the generated `data Health` declaration, + // which are emitted one per line as `= OkHealth` / `| ErrorHealth`. + const lines = output.split("\n"); + const start = lines.findIndex((line) => line.startsWith("data Health")); + if (start < 0) { + return []; + } + + const constructors: string[] = []; + for (const line of lines.slice(start + 1)) { + const match = line.match(/^\s*[=|]\s+(\S+)/); + if (match === null) { + break; + } + + constructors.push(match[1]); + } + + return constructors; +} + +describe("Haskell enum case naming", () => { + // Enum cases are emitted as `` so the enum name already + // disambiguates each constructor. When a case name would otherwise be + // renamed to avoid a forbidden identifier (here `error`), the renderer + // must not compound the enum-name suffix on top of that rename. A + // round-trip fixture cannot catch this: the constructor name is invisible + // to JSON serialization, so both the correct and the over-renamed output + // round-trip identically. See issue #2868. + test("suffixes the enum name without over-renaming forbidden cases", async () => { + const schema = { + type: "object", + properties: { + health: { + type: "string", + enum: ["ok", "error"], + }, + }, + required: ["health"], + }; + + const output = await renderHaskell(schema); + const constructors = enumConstructors(output); + + expect(constructors).toEqual(["OkHealth", "ErrorHealth"]); + // Guard against the specific regression: the "error" case being + // renamed to "HealthError" before the enum suffix is appended. + expect(output).not.toContain("HealthErrorHealth"); + }); +}); From 45ce0d218b2c7b2342893742e7a1ac0c332b5958 Mon Sep 17 00:00:00 2001 From: Mark Probst Date: Tue, 21 Jul 2026 16:38:54 -0400 Subject: [PATCH 24/27] docs: unify coding agent instructions --- AGENTS.md | 74 +-------------------------- CLAUDE.md | 149 ++++++++++++++++++++++++++++++++++-------------------- 2 files changed, 95 insertions(+), 128 deletions(-) mode change 100644 => 120000 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md deleted file mode 100644 index 79d83a6966..0000000000 --- a/AGENTS.md +++ /dev/null @@ -1,73 +0,0 @@ -# AGENTS.md - -Notes for coding agents working in this repository. - -## Environment - -- This is a TypeScript/npm monorepo using npm workspaces. -- Prefer the Node version from `.nvmrc` (`nvm use`; currently Node 24.6.0). Published packages support Node >= 20. -- Install dependencies with `npm ci`. - -## Build and run - -- Build everything with: - - ```bash - npm run build - ``` - - This runs `npm run clean`, builds all workspaces that have a `build` script, then runs the root `tsc`. - -- After building, the CLI entry point is `dist/index.js`, for example: - - ```bash - node dist/index.js --version - node dist/index.js --help - ``` - -- For live rebuild/re-run while developing renderer output, use `npm start -- ""`. - -## Tests - -- Vitest runs the standalone unit and regression tests: - - ```bash - npm run test:unit - npm run test:unit:watch - ``` - -- The cross-language fixture runner remains `script/test`, exposed as: - - ```bash - npm run test:fixtures - ``` - -- `npm test` runs the Vitest suite followed by the fixture suite. - -- The full suite runs all fixtures and needs external language toolchains for many targets (`dotnet`, Java/Maven, Go, Rust, Python/mypy, PHP, Ruby, Kotlin, Scala, Elixir, etc.). On a machine without those tools, plain `npm test` will fail when it reaches the first missing toolchain. - -- For local focused testing, use fixture filters. Fixture names are in `test/languages.ts` and `test/fixtures.ts`; comma-separated fixture groups are supported: - - ```bash - QUICKTEST=true FIXTURE=javascript npm run test:fixtures - QUICKTEST=true FIXTURE=typescript npm run test:fixtures -- test/inputs/json/samples/pokedex.json - CPUs=2 QUICKTEST=true FIXTURE=javascript npm run test:fixtures - ``` - - `QUICKTEST=true` skips the large miscellaneous JSON input set. Extra arguments after `--` are sample files or directories to run. - -- GitHub Actions uses the same pattern, e.g. `QUICKTEST=true FIXTURE=${{ matrix.fixture }} npm run test:fixtures`, after installing toolchain dependencies for each fixture group in `.github/workflows/test-pr.yaml`. - -## Validation performed - -The following commands were run successfully in this workspace: - -```bash -npm ci -npm run build -node dist/index.js --version -CPUs=2 QUICKTEST=true FIXTURE=javascript npm run test:fixtures -QUICKTEST=true FIXTURE=typescript npm run test:fixtures -- test/inputs/json/samples/pokedex.json -``` - -Also observed: `npm test` without fixture filters started the full 70-fixture suite and failed on this machine because `dotnet` is not installed. `npm run lint` currently fails because ESLint cannot find a configuration file. diff --git a/AGENTS.md b/AGENTS.md new file mode 120000 index 0000000000..681311eb9c --- /dev/null +++ b/AGENTS.md @@ -0,0 +1 @@ +CLAUDE.md \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index 7a769ca160..50f416ca77 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,69 +1,108 @@ # Repository conventions +## Environment + +- This is a TypeScript/npm monorepo using npm workspaces. +- Prefer the Node version from `.nvmrc` (`nvm use`; currently Node 24.6.0). The root CLI package requires Node >= 20.19.0; the library workspaces require Node >= 20.0.0. +- Install dependencies with `npm ci`. + +## Build and run + +Build everything with: + +```bash +npm run build +``` + +This runs `npm run clean`, builds all workspaces that have a `build` script, and then runs the root `tsc`. + +After building, the CLI entry point is `dist/index.js`: + +```bash +node dist/index.js --version +node dist/index.js --help +``` + +For live rebuild/re-run while developing renderer output, use: + +```bash +npm start -- "" +``` + ## Testing -quicktype's primary testing method is end-to-end fixture tests driven by JSON -and JSON Schema files. For each sample input, the fixture generates code for a -language, runs a driver program in `test/fixtures//` that -deserializes the sample and serializes it back, and compares the round-tripped -JSON to the input. - -- JSON inputs live in `test/inputs/json/`: `priority/` and `samples/` always - run, `misc/` is skipped under `QUICKTEST` (and for languages with - `skipMiscJSON`). -- JSON Schema inputs live in `test/inputs/schema/`: each `*.schema` comes with - `.N.json` samples and `.N.fail..json` expected-failure samples. A - fail sample must make the generated program exit nonzero; which fail - samples run is controlled by the language's `features` list. -- Every schema fixture test must have at least one positive (`.N.json`) and - one negative (`.N.fail..json`) test case, unless there is a very - good reason not to. -- Per-language configuration — which inputs run (`skipJSON`, `includeJSON`, - `skipSchema`), renderer options, and `features` — lives in - `test/languages.ts`; fixtures are registered in `test/fixtures.ts`. -- Run one language's fixtures with `FIXTURE= script/test`, for example - `FIXTURE=php script/test` or `FIXTURE=schema-php script/test`. - -Any change that affects generated output MUST be covered by a JSON or JSON -Schema fixture test — by enabling existing inputs for the language or adding -new ones. Unit tests in `test/unit/` are a complement for what fixtures cannot -express (asserting that some code is *not* generated, API-level behavior, fast -local iteration) — never a substitute. Do not add a unit test when a fixture -test will do the job: if a fixture input already exercises the behavior, a -unit test duplicating that coverage is superfluous and should not be added. +### Test strategy + +quicktype's primary testing method is end-to-end fixture tests driven by JSON and JSON Schema files. For each sample input, a fixture generates code for a language, runs a driver program in `test/fixtures//` that deserializes the sample and serializes it back, and compares the round-tripped JSON to the input. + +Any change that affects generated output **must** be covered by a JSON or JSON Schema fixture test, either by enabling existing inputs for the language or by adding new ones. Unit tests in `test/unit/` complement fixtures for behavior that fixtures cannot express, such as asserting that code is not generated, API-level behavior, or fast local iteration. Do not add a unit test when a fixture test already covers the behavior. + +### Fixture layout and configuration + +- JSON inputs live in `test/inputs/json/`. `priority/` and `samples/` form the default input set; `misc/` is omitted under `QUICKTEST` and for languages with `skipMiscJSON`. Per-language `skipJSON` and `includeJSON` settings can further restrict inputs. +- JSON Schema inputs live in `test/inputs/schema/`. A `*.schema` can have a same-basename `.json` sample and numbered `.N.json` samples. Numbered `.N.fail..json` files are expected failures for languages that declare that feature; `.N.fail.json` files apply regardless of features. An expected-failure sample must make the generated program exit nonzero. +- New schema fixture tests should have at least one positive and one negative test case unless there is a compelling reason not to. +- Per-language configuration—input filters, renderer options, and `features`—lives in `test/languages.ts`. +- Fixtures and their filter names are registered in `test/fixtures.ts`; driver programs live in `test/fixtures//`. + +### Test commands + +Run the standalone Vitest unit and regression tests with: + +```bash +npm run test:unit +npm run test:unit:watch +``` + +Run fixture tests with: + +```bash +npm run test:fixtures +``` + +Use fixture filters for focused local testing. Fixture names are registered in `test/fixtures.ts`; comma-separated groups are supported: + +```bash +QUICKTEST=true FIXTURE=javascript npm run test:fixtures +QUICKTEST=true FIXTURE=typescript npm run test:fixtures -- test/inputs/json/samples/pokedex.json +FIXTURE=php npm run test:fixtures +FIXTURE=schema-php npm run test:fixtures +CPUs=2 QUICKTEST=true FIXTURE=javascript npm run test:fixtures +``` + +`QUICKTEST=true` skips the large miscellaneous JSON input set. Arguments after `--` select sample files or directories. + +`npm test` runs the Vitest suite followed by all fixture tests. The full fixture suite requires external language toolchains such as .NET, Java/Maven, Go, Rust, Python/mypy, PHP, Ruby, Kotlin, Scala, and Elixir; it will fail when a required toolchain is unavailable. GitHub Actions uses focused fixture groups configured in `.github/workflows/test-pr.yaml`, for example `QUICKTEST=true FIXTURE=${{ matrix.fixture }} npm run test:fixtures`. + +Formatting and linting use Biome: + +```bash +npm run lint +npm run lint:fix +``` ## Known CI flakiness -Three fixture-CI failure modes are infrastructure flakes, not test or PR bugs: +Three fixture-CI failure modes are infrastructure flakes rather than test or PR bugs: + +- **scala3-upickle**: the Bloop compiler server sometimes times out after 30 seconds at startup, and `maven-nightlies` artifact downloads sometimes fail. +- **elm**: fixture setup (`rm -rf elm-stuff && elm make Warmup.elm`) can race the compiler and deadlock on `elm-stuff/*.dat` file locks. +- **cjson**: `cJSON.c` is downloaded from raw.githubusercontent.com at test time and can encounter transient SSL or connection failures. + +The fixture matrix uses `fail-fast: true`, so one flaky job cancels sibling language jobs. The `test-complete` check only mirrors the matrix and is not an independent failure. -- **scala3-upickle**: the Bloop compiler server sometimes times out after 30 - seconds at startup, and `maven-nightlies` artifact downloads sometimes fail. -- **elm**: the fixture setup (`rm -rf elm-stuff && elm make Warmup.elm`) can - race the compiler, deadlocking on `elm-stuff/*.dat` file locks. -- **cjson**: `cJSON.c` is downloaded from raw.githubusercontent.com at test - time and can hit transient SSL/connection failures. +For now, retry these failed jobs with `gh run rerun --failed`. Treat a failure in one of these areas as real only if it reproduces across retries or the PR touches that area. -Two things amplify them: the fixture matrix runs with `fail-fast: true`, so -one flaky job cancels all sibling language jobs, and the `test-complete` -check only mirrors the matrix — it is never an independent failure. +## Releasing and version bumps -For the time being we accept these flakes; when one happens, retry the failed -jobs (`gh run rerun --failed`). A failure in one of these areas only -counts as real if it reproduces across retries or the PR actually touches -that area. +Do not bump versions in any `package.json` before a release. Package manifest versions are intentionally allowed to be stale in the repository. -## Releasing / version bumps +To publish, create a GitHub Release targeting the commit to release. Stable tags have the form `vMAJOR.MINOR.PATCH` (for example, `v24.0.0`); prerelease tags have the form `vMAJOR.MINOR.PATCH-preN` (for example, `v25.0.0-pre1`). Publishing the release triggers the npm workflow, which publishes stable versions under the `latest` dist-tag and prereleases under `pre`. The VS Code Marketplace workflow runs only for stable tags. -Do not bump versions in any `package.json` before a release. Package manifest -versions are intentionally allowed to be stale in the repository. +Both workflows derive the version exclusively from the release tag and stamp all manifests in the Actions checkout before publishing; those changes are not committed. The release version must be greater than every earlier non-draft GitHub Release with a supported tag. Publication is refused if npm or the VS Code Marketplace already has a newer supported version; an exact version match is skipped, so rerunning a partially completed release is safe. -To publish, create a stable GitHub Release targeting the commit to release and -give it a tag in the form `vMAJOR.MINOR.PATCH`, for example `v24.0.0`. Publishing -the release triggers the npm and VS Code Marketplace workflows. They derive the -version exclusively from the release tag and stamp all manifests in the Actions -checkout before publishing; those changes are not committed. +Test the release-version helper with: -The release version must be greater than every previous stable GitHub Release -and every version already published for the npm packages and VS Code extension. -Rerunning a partially completed release is safe: packages already published at -the exact release version are skipped. +```bash +npm run test:release +``` From 9a8d181da5d756ff1f393a66adaaec2dda0e73e9 Mon Sep 17 00:00:00 2001 From: Mark Probst Date: Tue, 21 Jul 2026 18:25:35 -0400 Subject: [PATCH 25/27] Define issue submission policy --- .github/ISSUE_TEMPLATE/bug_report.md | 54 +++++------------------ .github/ISSUE_TEMPLATE/config.yml | 4 -- .github/ISSUE_TEMPLATE/feature_request.md | 40 ----------------- .github/ISSUE_TEMPLATE/question.md | 13 ++++++ .github/pull_request_template.md | 5 +-- CONTRIBUTING.md | 7 +++ 6 files changed, 32 insertions(+), 91 deletions(-) delete mode 100644 .github/ISSUE_TEMPLATE/feature_request.md create mode 100644 .github/ISSUE_TEMPLATE/question.md create mode 100644 CONTRIBUTING.md diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 997807e906..7edc6ead78 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -1,56 +1,22 @@ --- -name: New Bug Report -about: Use this template for reporting new bugs. -title: "[BUG]: bug description here" +name: Bug report +about: Report a reproducible problem with quicktype +title: "[BUG]: " labels: bug --- - - -## Issue Type - - - -## Context (Environment, Version, Language) - - - -Input Format: -Output Language: - - - -CLI, npm, or app.quicktype.io: -Version: - ## Description - - - -## Input Data - - - - -## Expected Behaviour / Output - - - -## Current Behaviour / Output + - +## Reproduction -## Steps to Reproduce + - - +## Expected behavior -1. -2. -3. -4. + -## Possible Solution +## Environment - + diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index dd39e6d267..3ba13e0cec 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,5 +1 @@ blank_issues_enabled: false -# contact_links: -# - name: GitHub Community Support -# url: https://github.com/orgs/community/discussions -# about: Please ask and answer questions here. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md deleted file mode 100644 index 5aab66de0c..0000000000 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ /dev/null @@ -1,40 +0,0 @@ ---- -name: Feature Request -about: Use this template for requesting new features. -title: "[FEATURE]: feature description here" -labels: enhancement ---- - - - -## Context (Input, Language) - - - -Input Format: -Output Language: - -## Description - - - - -## Current Behaviour / Output - - - -## Proposed Behaviour / Output - - - -## Solution - - - -## Alternatives - - - -## Context - - diff --git a/.github/ISSUE_TEMPLATE/question.md b/.github/ISSUE_TEMPLATE/question.md new file mode 100644 index 0000000000..b4523326e8 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/question.md @@ -0,0 +1,13 @@ +--- +name: Question +about: Ask a question about using quicktype +title: "[QUESTION]: " +--- + +## Question + + + +## Context + + diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 86eeba0109..7f63750ad2 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -4,9 +4,8 @@ ## Related Issue - - - + + ## Motivation and Context diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000000..8b38dffb86 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,7 @@ +# Contributing + +We do not accept feature requests as GitHub issues. If you want to add or change functionality, please submit a pull request instead. + +Use the provided issue templates for bug reports and questions. + +The use of coding agents is encouraged, but please use an Opus-class model or stronger rather than a lower-capability model. Review and test all generated changes before submitting them. From 7e88e2ef3f377d9473c3a0c960473f1ffc88a592 Mon Sep 17 00:00:00 2001 From: Mark Probst Date: Tue, 21 Jul 2026 18:36:28 -0400 Subject: [PATCH 26/27] Restore bug report template fields --- .github/ISSUE_TEMPLATE/bug_report.md | 48 ++++++++++++++++++++++++---- 1 file changed, 41 insertions(+), 7 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 7edc6ead78..9287a6b0c4 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -5,18 +5,52 @@ title: "[BUG]: " labels: bug --- + + +## Issue Type + + + +## Context (Environment, Version, Language) + + + +Input Format: +Output Language: + + + +CLI, npm, or app.quicktype.io: +Version: + ## Description - + + + +## Input Data + + + + +## Expected Behaviour / Output + + + +## Current Behaviour / Output -## Reproduction + - +## Steps to Reproduce -## Expected behavior + + - +1. +2. +3. +4. -## Environment +## Possible Solution - + From 767587e5702bbafd0382014ad2173f997f58c31b Mon Sep 17 00:00:00 2001 From: Mark Probst Date: Tue, 21 Jul 2026 20:42:02 -0400 Subject: [PATCH 27/27] test(js): reuse nested objects fixture for all-objects --- test/inputs/json/samples/issue-1655.json | 5 ----- test/languages.ts | 2 +- 2 files changed, 1 insertion(+), 6 deletions(-) delete mode 100644 test/inputs/json/samples/issue-1655.json diff --git a/test/inputs/json/samples/issue-1655.json b/test/inputs/json/samples/issue-1655.json deleted file mode 100644 index 3f32cc9bad..0000000000 --- a/test/inputs/json/samples/issue-1655.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "data123": { - "name": "quicktype" - } -} diff --git a/test/languages.ts b/test/languages.ts index 6f2772924c..9eb590760e 100644 --- a/test/languages.ts +++ b/test/languages.ts @@ -1024,7 +1024,7 @@ export const JavaScriptLanguage: Language = { { "runtime-typecheck": "false" }, { "runtime-typecheck-ignore-unknown-properties": "true" }, { converters: "top-level" }, - ["issue-1655.json", { converters: "all-objects" }], + ["nested-objects.json", { converters: "all-objects" }], ], sourceFiles: ["src/language/JavaScript/index.ts"], };