From 01650a454e6fba4040dc3d168ebd5c5e7448b575 Mon Sep 17 00:00:00 2001 From: Mihaela Mihaljevic Date: Sat, 20 Jun 2026 01:09:43 +0200 Subject: [PATCH] fix(schema): reject year 0000 in dateTime, date, gYearMonth, gYear XSD 1.0 has no year zero (the year before 0001 is -0001), so 0000-01-01T00:00:00 and the other 0000 forms are not valid values. DateTimeParser.parseYearOnly now rejects a zero magnitude for both signs, so negative years like -0001 still parse. Closes the XSTS dateTime011 false negative: invalid-instances-accepted 31 -> 30, no other bucket moved (valid-schemas-rejected and valid-instances-rejected held at 0). 1449 unit tests pass; the full XSTS suite settles at 0/43/0/30. --- CHANGELOG.md | 7 +++++++ Sources/Schema/DateTimeParser.swift | 5 ++++- Tests/XSDDatatypeTests.swift | 12 ++++++++++++ Tests/XSTSSuiteTests.swift | 6 +++--- docs/plans/2026-06-18-false-negative-tail.md | 2 +- 5 files changed, 27 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 541215a..7110ee0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- `dateTime`, `date`, `gYearMonth`, and `gYear` now reject the year `0000`. XSD + 1.0 has no year zero (the year before `0001` is `-0001`), so `0000-01-01T00:00:00` + and the other `0000` forms are not valid values. `DateTimeParser.parseYearOnly` + rejects a zero magnitude for both signs, so `-0001` and other negative years + still parse. XSTS invalid-instances-accepted 31 -> 30 (`dateTime011`), no other + bucket moved (valid-rejected held at 0). + - Fixed a dead `rootLocation` fallback in `XSDParser.containerLocationMap`. The map value type is itself `String?`, so `loc ?? rootLocation` bound the coalescing at the outer optional and never used `rootLocation` (the compiler diff --git a/Sources/Schema/DateTimeParser.swift b/Sources/Schema/DateTimeParser.swift index 31c0506..3f415a2 100644 --- a/Sources/Schema/DateTimeParser.swift +++ b/Sources/Schema/DateTimeParser.swift @@ -54,7 +54,10 @@ extension PureXML.Schema { private mutating func parseYearOnly() -> DateTimeValue? { var value = DateTimeValue() let negative = consume("-") - guard let digits = readDigits(minimum: 4) else { return nil } + // XSD 1.0 has no year zero: the lexical year 0000 (and -0000) is not a + // valid value for dateTime, date, gYearMonth, or gYear. `digits` is the + // magnitude, so rejecting 0 covers both signs. + guard let digits = readDigits(minimum: 4), digits != 0 else { return nil } value.year = (negative ? -1 : 1) * digits return value } diff --git a/Tests/XSDDatatypeTests.swift b/Tests/XSDDatatypeTests.swift index 0e87ded..c976597 100644 --- a/Tests/XSDDatatypeTests.swift +++ b/Tests/XSDDatatypeTests.swift @@ -57,6 +57,18 @@ struct XSDDatatypeTests { #expect(!valid("2026-06-08T12:30:00+15:00", .dateTime)) } + @Test("XSD 1.0 has no year zero: 0000 is rejected across the year-bearing types") + func test_yearZero() { + // Rejected: the prohibited year 0000 (and -0000) wherever a year appears. + #expect(!valid("0000-01-01T00:00:00", .dateTime)) + #expect(!valid("0000-01-01", .date)) + #expect(!valid("0000-01", .gYearMonth)) + #expect(!valid("0000", .gYear) && !valid("-0000", .gYear)) + // Accepted: the smallest magnitudes either side of zero, and a normal year. + #expect(valid("0001-01-01T00:00:00", .dateTime) && valid("-0001-12-31", .date)) + #expect(valid("0001", .gYear) && valid("2026", .gYear) && valid("-0044-03", .gYearMonth)) + } + @Test("the g* date types parse their reduced forms") func test_gTypes() { #expect(valid("--02-29", .gMonthDay) && !valid("--02-30", .gMonthDay)) diff --git a/Tests/XSTSSuiteTests.swift b/Tests/XSTSSuiteTests.swift index 68a48a7..de87d9e 100644 --- a/Tests/XSTSSuiteTests.swift +++ b/Tests/XSTSSuiteTests.swift @@ -150,13 +150,13 @@ struct XSTSSuiteTests { private let knownSchemaValidRejected = 0 private let knownSchemaInvalidAccepted = 43 private let knownInstanceValidRejected = 0 - /// Instance bucket (133 -> 31), per-step deltas in CHANGELOG.md: xsi:type must derive from the + /// Instance bucket (133 -> 30), per-step deltas in CHANGELOG.md: xsi:type must derive from the /// declared type (complex, then atomic and list/union, resolved through the element-ref chain); /// block/blockDefault (incl. "substitution" and an untyped head) bar substitutions after the /// derivation backbone was re-keyed by namespace; ur-type `xsd:anyType` wildcards are `lax`; /// identity fields compare in the decimal value space and take a declared default/fixed when - /// absent or empty; a complex type's attribute wildcard is the INTERSECTION of its sources. - private let knownInstanceInvalidAccepted = 31 + /// absent/empty; an attribute wildcard is the INTERSECTION of its sources; year 0000 is invalid. + private let knownInstanceInvalidAccepted = 30 @Test("Every XSTS case behaves: compile, reject, validate, invalidate") func test_suite() throws { diff --git a/docs/plans/2026-06-18-false-negative-tail.md b/docs/plans/2026-06-18-false-negative-tail.md index 8b9e237..6b46581 100644 --- a/docs/plans/2026-06-18-false-negative-tail.md +++ b/docs/plans/2026-06-18-false-negative-tail.md @@ -94,7 +94,7 @@ classification is wrong. | Nillability | `nillable00201m/nillable00201m2_n` | `XSDParserHelpers`, instance validator | | Type derivation | `typedef00204m/typeDef00204m1_n` | `ComplexValidatorXSIType`, derivation tables | | Additional-suite instance triage | `addB065/addB065.i` | to classify from fixture | -| Datatype value-space edges | `anyURI_a004_1339/anyURI_a004_1339.i`, `dateTime011_2008/dateTime011_2008.i` | `SimpleType` | +| Datatype value-space edges | `anyURI_a004_1339/anyURI_a004_1339.i` (open); ~~`dateTime011_2008/dateTime011_2008.i`~~ closed: XSD 1.0 has no year zero, `0000` now rejected | `SimpleType`, `DateTimeParser` | | Element constraints | `elemO011/elemO011.i`, `elemT074/elemT074.i` | instance validator, element declarations | | Identity constraints | `idG006/idG006.i`, `idK012/idK012.i`, `idZ010/idZ010.i`, `idZ012/idZ012.i` | `IdentityValidator*` | | Model group instance matching | `mgO013/mgO013.i`, `mgZ001/mgZ001.i` | `ContentMatcher`, `ParticleRestriction` fallout |