From 150f441c80da9ebc623c33f6800788287bdaa971 Mon Sep 17 00:00:00 2001 From: Mark Probst Date: Sun, 12 Jul 2026 18:42:36 -0400 Subject: [PATCH 1/3] Restrict default date-time inference to strict RFC 3339 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The default DateTimeRecognizer, vendored from ajv, accepted two forms that RFC 3339 does not: a space as the date/time separator, and times without a timezone offset. As a result, JSON samples containing strings like "2013-06-15 21:10:28" were inferred as date-times, and languages with strict date parsers (Go, Swift, java.time, ...) generated code that could not parse the very samples the types were inferred from. Several languages carried test-skip lists to paper over this. The separator must now be "T" (or "t"), and times must carry an offset ("Z"/"z" or "±hh:mm"), for both isDateTime and standalone isTime (RFC 3339 full-time requires the offset). Non-conforming strings are inferred as plain strings, which every target handles faithfully. Co-Authored-By: Claude Fable 5 --- packages/quicktype-core/src/DateTime.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/packages/quicktype-core/src/DateTime.ts b/packages/quicktype-core/src/DateTime.ts index 7c2f4b049..301a25256 100644 --- a/packages/quicktype-core/src/DateTime.ts +++ b/packages/quicktype-core/src/DateTime.ts @@ -1,5 +1,12 @@ /* eslint-disable */ +// Adapted from ajv: // https://github.com/epoberezkin/ajv/blob/4d76c6fb813b136b6ec4fe74990bc97233d75dea/lib/compile/formats.js +// +// We deviate from ajv in that we recognize only strict RFC 3339 date-times: +// the date/time separator must be "T" (or "t"), not a space, and times must +// carry a timezone offset ("Z"/"z" or "+hh:mm"/"-hh:mm"). ajv accepted the +// lenient forms, which meant we inferred "date-time" for strings like +// "2013-06-15 21:10:28" that most target languages' date parsers reject. /* The MIT License (MIT) @@ -27,7 +34,8 @@ SOFTWARE. const DATE = /^(\d\d\d\d)-(\d\d)-(\d\d)$/; const DAYS = [0, 31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; -const TIME = /^(\d\d):(\d\d):(\d\d)(\.\d+)?(z|[+-]\d\d:\d\d)?$/i; +// RFC 3339 full-time: the timezone offset is mandatory. +const TIME = /^(\d\d):(\d\d):(\d\d)(\.\d+)?(z|[+-]\d\d:\d\d)$/i; export interface DateTimeRecognizer { isDate: (s: string) => boolean; @@ -35,7 +43,8 @@ export interface DateTimeRecognizer { isTime: (s: string) => boolean; } -const DATE_TIME_SEPARATOR = /t|\s/i; +// RFC 3339 date-time: the separator must be "T" or "t", never a space. +const DATE_TIME_SEPARATOR = /t/i; export class DefaultDateTimeRecognizer implements DateTimeRecognizer { isDate(str: string) { From 08fba3e8d96c68bc73b1389084345543947806f8 Mon Sep 17 00:00:00 2001 From: Mark Probst Date: Sun, 12 Jul 2026 18:42:36 -0400 Subject: [PATCH 2/3] Add unit tests for the default date-time recognizer Pins the strict RFC 3339 behavior: rejects space separators and missing timezone offsets, accepts lowercase t/z and fractional seconds, and validates date/time field ranges. Co-Authored-By: Claude Fable 5 --- test/unit/date-time-recognizer.test.ts | 85 ++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 test/unit/date-time-recognizer.test.ts diff --git a/test/unit/date-time-recognizer.test.ts b/test/unit/date-time-recognizer.test.ts new file mode 100644 index 000000000..dee7e5396 --- /dev/null +++ b/test/unit/date-time-recognizer.test.ts @@ -0,0 +1,85 @@ +// The default date/time recognizer decides whether a JSON string sample is +// inferred as "date", "time", or "date-time". It must accept only strict +// RFC 3339 forms: "date-time" requires the "T" (or "t") separator — a space +// is not allowed — and times must carry a timezone offset. Before this was +// tightened, strings like "2013-06-15 21:10:28" were inferred as date-times, +// producing generated code whose strict date parsers (Go, Swift, java.time, +// ...) could not read the very samples the types were inferred from. +import { DefaultDateTimeRecognizer } from "quicktype-core/dist/DateTime.js"; +import { describe, expect, test } from "vitest"; + +const recognizer = new DefaultDateTimeRecognizer(); + +describe("isDateTime", () => { + test("accepts RFC 3339 date-times with offset", () => { + expect(recognizer.isDateTime("2018-08-14T02:45:50Z")).toBe(true); + expect(recognizer.isDateTime("2018-08-14T02:45:50+01:00")).toBe(true); + expect(recognizer.isDateTime("2018-08-14T02:45:50-11:30")).toBe(true); + }); + + test("accepts lowercase t and z", () => { + expect(recognizer.isDateTime("2018-08-14t02:45:50z")).toBe(true); + }); + + test("accepts fractional seconds", () => { + expect(recognizer.isDateTime("2015-04-24T01:46:50.342496Z")).toBe(true); + expect(recognizer.isDateTime("2010-01-12T00:00:00.000Z")).toBe(true); + expect(recognizer.isDateTime("2015-07-15T23:25:21.541+02:00")).toBe( + true, + ); + }); + + test("rejects a space separator", () => { + expect(recognizer.isDateTime("2013-06-15 21:10:28")).toBe(false); + expect(recognizer.isDateTime("1970-01-01 00:00:00")).toBe(false); + expect(recognizer.isDateTime("2015-04-24 01:46:50.342496Z")).toBe( + false, + ); + }); + + test("rejects a missing timezone offset", () => { + expect(recognizer.isDateTime("2018-08-14T02:45:50")).toBe(false); + expect(recognizer.isDateTime("2018-08-14T02:45:50.123")).toBe(false); + }); + + test("rejects invalid dates and times", () => { + expect(recognizer.isDateTime("0000-00-00T00:00:00Z")).toBe(false); + expect(recognizer.isDateTime("2018-13-01T00:00:00Z")).toBe(false); + expect(recognizer.isDateTime("2018-08-14T24:00:00Z")).toBe(false); + expect(recognizer.isDateTime("2018-08-14X02:45:50Z")).toBe(false); + }); +}); + +describe("isDate", () => { + test("accepts RFC 3339 full-date", () => { + expect(recognizer.isDate("2018-08-14")).toBe(true); + expect(recognizer.isDate("1970-01-01")).toBe(true); + }); + + test("rejects invalid dates", () => { + expect(recognizer.isDate("0000-00-00")).toBe(false); + expect(recognizer.isDate("2018-13-01")).toBe(false); + expect(recognizer.isDate("2018-02-30")).toBe(false); + expect(recognizer.isDate("2018-8-14")).toBe(false); + }); +}); + +describe("isTime", () => { + test("accepts RFC 3339 full-time (offset required)", () => { + expect(recognizer.isTime("02:45:50Z")).toBe(true); + expect(recognizer.isTime("02:45:50z")).toBe(true); + expect(recognizer.isTime("02:45:50.123+01:00")).toBe(true); + expect(recognizer.isTime("23:59:59-11:30")).toBe(true); + }); + + test("rejects a missing timezone offset", () => { + expect(recognizer.isTime("02:45:50")).toBe(false); + expect(recognizer.isTime("02:45:50.123")).toBe(false); + }); + + test("rejects invalid times", () => { + expect(recognizer.isTime("24:00:00Z")).toBe(false); + expect(recognizer.isTime("02:60:00Z")).toBe(false); + expect(recognizer.isTime("02:45:60Z")).toBe(false); + }); +}); From f2fda21e28bae521da0ffc57d6217d62a0ce594e Mon Sep 17 00:00:00 2001 From: Mark Probst Date: Sun, 12 Jul 2026 18:42:36 -0400 Subject: [PATCH 3/3] Remove test skips obsoleted by strict date-time inference Now that non-RFC 3339 strings ("2013-06-15 21:10:28") are no longer inferred as date-times, the fixtures that contain them work for languages with strict date parsers: - golang: all 8 skipJSON entries round-trip (verified locally with go) - swift: the 8 "date-time issues" skipDiffViaSchema entries pass -- the divergence between SwiftDateTimeRecognizer (direct path) and the lenient default (schema path) is gone for these files - typescript/javascript/flow/typescript-zod: 7681c.json contains only space-separated date-times, so no field is Date-typed anymore and the "year 0 is out of range" runtime failure cannot occur (verified by round-tripping the typescript, javascript, and zod fixtures locally; flow left to CI) Also removes entries that an audit showed were already stale (passing even before this change): cplusplus and elm space-class skipDiffViaSchema entries and csharp's 437e7.json (both variants). Kept deliberately: python/rust skipDiffViaSchema entries (they still diff, due to schema-path type naming, not date-times), all skips for RFC 3339-valid files with fractional-second normalization issues (0a358, 32d5c, 54d32, 5eae5, 77392, 80aff, 9ac3b, a0496, b4865, d23d5, 337ed), and the zod/effect-schema naming-related lists. Co-Authored-By: Claude Fable 5 --- test/languages.ts | 36 ++---------------------------------- 1 file changed, 2 insertions(+), 34 deletions(-) diff --git a/test/languages.ts b/test/languages.ts index 638c3a91d..7715bb22c 100644 --- a/test/languages.ts +++ b/test/languages.ts @@ -91,7 +91,7 @@ export const CSharpLanguage: Language = { return `dotnet run -p:CheckEolTargetFramework=false -- "${sample}"`; }, diffViaSchema: true, - skipDiffViaSchema: ["34702.json", "437e7.json"], + skipDiffViaSchema: ["34702.json"], allowMissingNull: false, features: [ "enum", @@ -134,7 +134,7 @@ export const CSharpLanguageSystemTextJson: Language = { return `dotnet run -p:CheckEolTargetFramework=false -- "${sample}"`; }, diffViaSchema: true, - skipDiffViaSchema: ["34702.json", "437e7.json"], + skipDiffViaSchema: ["34702.json"], allowMissingNull: false, features: [ "enum", @@ -469,15 +469,6 @@ export const GoLanguage: Language = { // can't differenciate empty array and nothing for optional empty array // (omitempty). "github-events.json", - // files contains datetime filed with contain non rfc3339 format value - "f6a65.json", - "e0ac7.json", - "c3303.json", - "7681c.json", - "437e7.json", - "127a1.json", - "26b49.json", - "0cffa.json", ], skipMiscJSON: false, skipSchema: [ @@ -592,15 +583,9 @@ export const CPlusPlusLanguage: Language = { "bug427.json", "keywords.json", "0a91a.json", - "0cffa.json", - "127a1.json", - "26b49.json", "34702.json", - "7681c.json", "7f568.json", - "c3303.json", "e8b04.json", - "f6a65.json", "fcca3.json", ], allowMissingNull: false, @@ -663,9 +648,7 @@ export const ElmLanguage: Language = { "github-events.json", "nbl-stats.json", "0a91a.json", - "0cffa.json", "0e0c2.json", - "127a1.json", "29f47.json", "2df80.json", "27332.json", @@ -676,13 +659,10 @@ export const ElmLanguage: Language = { "6de06.json", "76ae1.json", "7eb30.json", - "7681c.json", "ae9ca.json", "af2d1.json", "be234.json", - "c3303.json", "e8b04.json", - "f6a65.json", ], allowMissingNull: false, features: ["enum", "union", "no-defaults"], @@ -731,17 +711,12 @@ export const SwiftLanguage: Language = { "keywords.json", "0a358.json", // date-time issues "0a91a.json", - "0cffa.json", // date-time issues - "127a1.json", // date-time issues - "26b49.json", // date-time issues "26c9c.json", // uri/string confusion "32d5c.json", // date-time issues "337ed.json", "34702.json", - "437e7.json", // date-time issues "54d32.json", // date-time issues "5eae5.json", // date-time issues - "7681c.json", // date-time issues "77392.json", // date-time issues "7f568.json", "734ad.json", @@ -750,13 +725,10 @@ export const SwiftLanguage: Language = { "9ac3b.json", // date-time issues "a0496.json", // date-time issues "b4865.json", // date-time issues - "c3303.json", // date-time issues "c8c7e.json", "d23d5.json", // date-time issues - "e0ac7.json", // date-time issues "e53b5.json", "e8b04.json", - "f6a65.json", // date-time issues "fcca3.json", "f82d9.json", "bug863.json", // Unable to resolve reserved keyword use, "description" @@ -872,7 +844,6 @@ export const TypeScriptLanguage: Language = { output: "TopLevel.ts", topLevel: "TopLevel", skipJSON: [ - "7681c.json", // year 0 is out of range ], skipMiscJSON: false, skipSchema: ["keyword-unions.schema"], // can't handle "constructor" property @@ -904,7 +875,6 @@ export const JavaScriptLanguage: Language = { output: "TopLevel.js", topLevel: "TopLevel", skipJSON: [ - "7681c.json", // year 0 is out of range ], skipMiscJSON: false, skipSchema: ["keyword-unions.schema"], // can't handle "constructor" property @@ -962,7 +932,6 @@ export const FlowLanguage: Language = { output: "TopLevel.js", topLevel: "TopLevel", skipJSON: [ - "7681c.json", // year 0 is out of range ], skipMiscJSON: false, skipSchema: [ @@ -1612,7 +1581,6 @@ export const TypeScriptZodLanguage: Language = { "nst-test-suite.json", "keywords.json", "ed095.json", - "7681c.json", "32d5c.json", ], skipMiscJSON: false,