From aa7cbb71f8f6b4f3bb4b391b6e5ac2a96d70ac12 Mon Sep 17 00:00:00 2001 From: Mihaela Mihaljevic Date: Sat, 13 Jun 2026 22:10:57 +0200 Subject: [PATCH] feat(schema): validate value-bound facet definitions; accept gMonth --MM-- Continue the facet-validity work. Per XSD Part 2 Datatypes 4.3, each minInclusive/maxInclusive/minExclusive/maxExclusive and enumeration value must be a valid value of the base type; minInclusive excludes minExclusive and maxInclusive excludes maxExclusive; and the lower bound may not exceed the upper bound (nor equal it when either side is exclusive, an empty value space), compared in the base primitive's value space. Bound and enumeration values were previously unchecked, so a malformed or out-of-space value (maxInclusive="", enumeration="" on float) was accepted. The bound-value check (which validates a bound against its base type) exposed a gMonth lexical gap: only --MM was recognized, not the XSD 1.0 form --MM-- used by valid schemas across the corpus. parseMonthOnly now accepts an optional trailing -- (the --MM erratum form still works), with an optional timezone, which removes 26 false schema rejections the bound check would otherwise have caused and also fixes instance-side gMonth validation. XSTS: invalid schemas accepted 2082 to 1789; valid instances rejected 233 to 214 (the gMonth fix); valid schemas rejected held at 72; invalid instances accepted held at 165. --- CHANGELOG.md | 2 + Sources/Schema/DateTimeParser.swift | 3 + Sources/Schema/XSDSimpleParser.swift | 79 +++++++++++++++++++++++--- Tests/SchemaFacetDefinitionTests.swift | 32 +++++++++++ Tests/XSDDatatypeTests.swift | 4 ++ Tests/XSTSSuiteTests.swift | 4 +- docs/schema-validity-burndown.md | 21 ++++--- docs/xsts-deviations.md | 10 ++-- 8 files changed, 132 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a9ff627..04f8a80 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Value-bound facet definitions are now validated at compile time (#147), continuing the facet-validity work. Per XSD Part 2 Datatypes 4.3, each `minInclusive`/`maxInclusive`/`minExclusive`/`maxExclusive` and `enumeration` value must be a valid value of the base type; `minInclusive` excludes `minExclusive` and `maxInclusive` excludes `maxExclusive`; and the lower bound may not exceed the upper (nor equal it when either side is exclusive, an empty value space), compared in the base primitive's value space. XSTS: invalid schemas accepted 2082 to 1789, no other bucket raised. +- `xs:gMonth` now accepts the XSD 1.0 lexical form `--MM--` (the trailing `--` is optional, the later `--MM` erratum form still accepted), with an optional timezone (#147). Only `--MM` was recognized, so valid `gMonth` values and the schemas whose facets used them were wrongly rejected. Surfaced by the value-bound facet check above (which validates a bound value against its base type). XSTS: valid instances rejected 233 to 214; it also removed 26 false schema rejections the bound-facet check would otherwise have caused. - Constraining-facet definitions are now validated at compile time (#147), the first cut at the schema-validity gap (invalid schemas the validator was silently accepting). Per XSD Part 2 Datatypes 4.3, each length-family facet value (`length`, `minLength`, `maxLength`, `totalDigits`, `fractionDigits`) must be a valid `nonNegativeInteger` (`totalDigits` a `positiveInteger`), `length` may not co-occur with `minLength`/`maxLength`, and the integer ranges must be ordered. Malformed values (`length=""`, `"-1"`, `"a"`, `"1e2"`) were dropped by numeric parsing and the schema accepted; the definition is now read raw and rejected at compile time, with well-formedness checked independently of machine-integer range so a huge but valid value is not misreported. The findings travel through a shared diagnostics collector on the parsing context and join the model-level consistency findings. XSTS: invalid schemas accepted 2461 to 2082, with no change to any other bucket. - A built-in datatype named by `xsi:type` (`xsd:int`, `xsd:decimal`, `xsd:string`) is now resolved, gated by substitution validity (#147). The override resolved only against schema-declared types, so a built-in `xsi:type` was reported "unknown" and the element wrongly rejected. The built-in is now resolved when it validly substitutes for the declared type per Schema-Validity Assessment: the ur-type (`anyType`/`anySimpleType`) admits any type, and a bare built-in declared type admits any built-in derived from it through the XSD Part 2 datatype lattice (new `BuiltinType.derives(from:)`). An invalid built-in substitution is a located error, not a silent acceptance, closing the gap the resolution exposed. XSTS: valid instances rejected 248 to 233 (with no regression in invalid instances accepted, which the two fixes below preserve). - A simple-typed element now rejects non-schema-instance attributes (#147). A simple type declares no attribute uses, so an element whose type is simple, including one whose simple type comes from an `xsi:type` override and including an `xsi:nil` element, may carry only the `xsi:*` and namespace attributes; any other attribute is a violation. The check was absent, so resolving a built-in `xsi:type` to a simple type would otherwise have accepted stray attributes on it. diff --git a/Sources/Schema/DateTimeParser.swift b/Sources/Schema/DateTimeParser.swift index d8f625f..31c0506 100644 --- a/Sources/Schema/DateTimeParser.swift +++ b/Sources/Schema/DateTimeParser.swift @@ -75,6 +75,9 @@ extension PureXML.Schema { private mutating func parseMonthOnly() -> DateTimeValue? { guard consume("--"), let month = readFixed(2), (1 ... 12).contains(month) else { return nil } + // XSD 1.0 gMonth is `--MM--`; the later erratum/1.1 form `--MM` is also + // accepted, so the trailing `--` is optional. A timezone may follow either. + _ = consume("--") return DateTimeValue(month: month) } diff --git a/Sources/Schema/XSDSimpleParser.swift b/Sources/Schema/XSDSimpleParser.swift index ba6d787..98abc35 100644 --- a/Sources/Schema/XSDSimpleParser.swift +++ b/Sources/Schema/XSDSimpleParser.swift @@ -27,20 +27,27 @@ extension PureXML.Schema { facets.enumeration = nil } applyFacets(restriction, into: &facets) - for error in facetDefinitionErrors(restriction) { + for error in facetDefinitionErrors(restriction, base: baseType) { context.diagnostics.report(error) } return SimpleType(base: baseType.base, facets: facets, variety: baseType.variety) } /// Schema-validity findings for the constraining facets declared on one - /// `restriction` (XSD Part 2 Datatypes 4.3): each length-family facet value - /// must be a valid `nonNegativeInteger` (`totalDigits` a `positiveInteger`), - /// `length` may not co-occur with `minLength`/`maxLength`, and the integer - /// ranges must be ordered. Reads the raw lexical from the restriction, since - /// numeric facet parsing silently drops a malformed value. Limited to the - /// facets declared on this step, so an inherited facet is never flagged here. - static func facetDefinitionErrors(_ restriction: XSDTree) -> [String] { + /// `restriction` (XSD Part 2 Datatypes 4.3): the length-family rules plus + /// the value-bound rules, both limited to the facets declared on this step + /// so an inherited facet is never flagged here. + static func facetDefinitionErrors(_ restriction: XSDTree, base: SimpleType) -> [String] { + countFacetErrors(restriction) + boundFacetErrors(restriction, base: base) + } + + /// Length-family findings: `length`, `minLength`, `maxLength`, + /// `totalDigits`, and `fractionDigits` must be `nonNegativeInteger` + /// (`totalDigits` a `positiveInteger`), `length` may not co-occur with + /// `minLength`/`maxLength`, and `minLength` <= `maxLength`, + /// `fractionDigits` <= `totalDigits`. Reads the raw lexical, since numeric + /// facet parsing silently drops a malformed value. + private static func countFacetErrors(_ restriction: XSDTree) -> [String] { var errors: [String] = [] var values: [String: String] = [:] // facet name -> canonical digit string let countFacets: Set = ["length", "minLength", "maxLength", "totalDigits", "fractionDigits"] @@ -71,6 +78,62 @@ extension PureXML.Schema { return errors } + /// Value-bound findings: each `minInclusive`/`maxInclusive`/`minExclusive`/ + /// `maxExclusive` and `enumeration` value must be a valid value of the base + /// type; `minInclusive` excludes `minExclusive` and `maxInclusive` excludes + /// `maxExclusive`; and the lower bound may not exceed the upper bound (nor + /// equal it when either side is exclusive, which is an empty value space). + /// Ordering is compared in the base primitive's value space. + private static func boundFacetErrors(_ restriction: XSDTree, base: SimpleType) -> [String] { + var errors: [String] = [] + var bounds: [String: String] = [:] + for facet in XSDNode.elementChildren(restriction) { + guard let name = XSDNode.localName(facet) else { continue } + let raw = XSDNode.attribute(facet, "value") ?? "" + switch name { + case "minInclusive", "maxInclusive", "minExclusive", "maxExclusive": + if base.validate(raw) != nil { + errors.append("facet '\(name)' value '\(raw)' is not a valid value of the base type") + } else { + bounds[name] = raw + } + case "enumeration": + if base.validate(raw) != nil { + errors.append("enumeration value '\(raw)' is not a valid value of the base type") + } + default: + break + } + } + if bounds["minInclusive"] != nil, bounds["minExclusive"] != nil { + errors.append("facets 'minInclusive' and 'minExclusive' may not both be specified") + } + if bounds["maxInclusive"] != nil, bounds["maxExclusive"] != nil { + errors.append("facets 'maxInclusive' and 'maxExclusive' may not both be specified") + } + errors += boundOrderErrors(bounds, base: base) + return errors + } + + /// The lower-versus-upper-bound ordering finding, if any, comparing in the + /// base primitive's value space. Skipped for an unordered primitive (the + /// `ordered` lookup returns nil), so only a defined order is checked. + private static func boundOrderErrors(_ bounds: [String: String], base: SimpleType) -> [String] { + guard let low = bounds["minInclusive"] ?? bounds["minExclusive"], + let high = bounds["maxInclusive"] ?? bounds["maxExclusive"], + let lowValue = base.base.primitive.ordered(low), + let highValue = base.base.primitive.ordered(high) + else { return [] } + if lowValue > highValue { + return ["lower bound '\(low)' exceeds upper bound '\(high)'"] + } + let exclusive = bounds["minExclusive"] != nil || bounds["maxExclusive"] != nil + if lowValue == highValue, exclusive { + return ["bounds '\(low)' and '\(high)' describe an empty value space"] + } + return [] + } + /// The canonical digit string of a lexical `nonNegativeInteger` (optional /// leading `+`, ASCII digits, surrounding whitespace tolerated), with the /// sign and leading zeros stripped (`"0"` for zero), or nil when the lexical diff --git a/Tests/SchemaFacetDefinitionTests.swift b/Tests/SchemaFacetDefinitionTests.swift index 6c346d0..9fbecaa 100644 --- a/Tests/SchemaFacetDefinitionTests.swift +++ b/Tests/SchemaFacetDefinitionTests.swift @@ -71,6 +71,38 @@ struct SchemaFacetDefinitionTests { #expect(rejects(#""#, base: "xs:decimal")) } + @Test("a value-bound facet value must be valid for the base type") + func test_boundValueValidity() throws { + try compile(#""#, base: "xs:decimal") + try compile(#""#, base: "xs:decimal") + #expect(rejects(#""#, base: "xs:decimal")) + #expect(rejects(#""#, base: "xs:decimal")) + #expect(rejects(#""#, base: "xs:decimal")) + } + + @Test("an enumeration value must be valid for the base type") + func test_enumerationValueValidity() throws { + try compile(#""#, base: "xs:decimal") + #expect(rejects(#""#, base: "xs:float")) + #expect(rejects(#""#, base: "xs:decimal")) + } + + @Test("inclusive and exclusive bounds on the same side may not co-occur") + func test_boundMutualExclusion() { + #expect(rejects(#""#, base: "xs:decimal")) + #expect(rejects(#""#, base: "xs:decimal")) + } + + @Test("the lower bound may not exceed (or, if exclusive, equal) the upper bound") + func test_boundOrder() { + #expect(rejects(#""#, base: "xs:decimal")) + #expect(rejects(#""#, base: "xs:decimal")) + // An inclusive single-point range is valid. + do { try compile(#""#, base: "xs:decimal") } catch { + Issue.record("a single-point inclusive range must be valid: \(error)") + } + } + @Test("the rejection message names the offending facet") func test_locatedMessage() { do { diff --git a/Tests/XSDDatatypeTests.swift b/Tests/XSDDatatypeTests.swift index 1c638d9..a3f57f8 100644 --- a/Tests/XSDDatatypeTests.swift +++ b/Tests/XSDDatatypeTests.swift @@ -61,6 +61,10 @@ struct XSDDatatypeTests { func test_gTypes() { #expect(valid("--02-29", .gMonthDay) && !valid("--02-30", .gMonthDay)) #expect(valid("---31", .gDay) && valid("--12", .gMonth) && valid("2026", .gYear)) + // gMonth accepts both the XSD 1.0 `--MM--` form and the `--MM` erratum form, + // with an optional timezone, and still rejects malformed or out-of-range ones. + #expect(valid("--02--", .gMonth) && valid("--02--Z", .gMonth) && valid("--02-05:00", .gMonth)) + #expect(!valid("--13--", .gMonth) && !valid("--02-", .gMonth) && !valid("--02--x", .gMonth)) } // MARK: Other primitives diff --git a/Tests/XSTSSuiteTests.swift b/Tests/XSTSSuiteTests.swift index fb9b96f..5e1c68a 100644 --- a/Tests/XSTSSuiteTests.swift +++ b/Tests/XSTSSuiteTests.swift @@ -29,8 +29,8 @@ struct XSTSSuiteTests { /// XSTS` (debug is far slower and the corpus is large). Per-case deviations /// are written to /tmp/xsts-failures.txt for the burn-down. private let knownSchemaValidRejected = 72 - private let knownSchemaInvalidAccepted = 2082 - private let knownInstanceValidRejected = 233 + private let knownSchemaInvalidAccepted = 1789 + private let knownInstanceValidRejected = 214 private let knownInstanceInvalidAccepted = 165 @Test("Every XSTS case behaves: compile, reject, validate, invalidate") diff --git a/docs/schema-validity-burndown.md b/docs/schema-validity-burndown.md index f11adae..b24ce80 100644 --- a/docs/schema-validity-burndown.md +++ b/docs/schema-validity-burndown.md @@ -10,7 +10,7 @@ irreducible tail. Do not deviate to new features while this is open. | Bucket | Count | Reading | |---|---|---| | valid-schemas-rejected | 72 | we rarely reject a good schema | -| **invalid-schemas-accepted** | **2461 -> 2082** | we rarely catch a bad one (facet-definition validity landed) | +| **invalid-schemas-accepted** | **2461 -> 1789** | we rarely catch a bad one (facet-definition validity landed) | | valid-instances-rejected | 233 | (instance side, separately tracked) | | invalid-instances-accepted | 165 | | @@ -38,17 +38,20 @@ existing pattern: collect ALL findings, report them together. Each iteration targets one self-contained rule family with a clean root cause. -1. **Facet-definition validity** (XSD Part 2 §4.3). _Partly done (2461 -> 2082)._ +1. **Facet-definition validity** (XSD Part 2 §4.3). _Mostly done (2461 -> 1789)._ Landed: length-family facet values must be `nonNegativeInteger` (`totalDigits` a `positiveInteger`); `length` excludes `minLength`/`maxLength`; `minLength` <= `maxLength`; `fractionDigits` <= - `totalDigits`. **Still open in this family:** `minInclusive`/`maxInclusive`/ - `minExclusive`/`maxExclusive` value well-formedness in the base value space - and their mutual exclusions/ordering; facet applicability per base - (`fractionDigits` only on decimal-derived, `length` only on - string/binary/list, etc.); `whiteSpace` may not weaken an inherited value; - facet repetition. Inherited-facet co-occurrence (across derivation steps) is - deliberately not checked yet (current pass reads only the local restriction). + `totalDigits`. Value-bound facets (`min`/`maxInclusive`, + `min`/`maxExclusive`) and `enumeration` values must be valid in the base + value space; `Inclusive` excludes `Exclusive` on each side; lower bound may + not exceed (nor, if exclusive, equal) the upper. (The bound-value check + exposed and fixed a `gMonth` `--MM--` lexical gap.) **Still open in this + family:** facet applicability per base (`fractionDigits` only on + decimal-derived, `length` only on string/binary/list, etc.); `whiteSpace` + may not weaken an inherited value; facet repetition. Inherited-facet + co-occurrence (across derivation steps) is deliberately not checked yet + (current pass reads only the local restriction). 2. **Resolvable references.** Every QName a schema names (`type`, element / attribute / group `ref`, restriction / extension `base`, `itemType`, `memberTypes`, `substitutionGroup`, keyref `refer`) must resolve to a diff --git a/docs/xsts-deviations.md b/docs/xsts-deviations.md index f81c3da..c3d7610 100644 --- a/docs/xsts-deviations.md +++ b/docs/xsts-deviations.md @@ -47,7 +47,7 @@ and to document the remainder as spec-justified, deliberate exclusions. ## Category A: genuine correctness bugs -### Fixed (fourteen root causes, ~1034 deviations cleared) +### Fixed (sixteen root causes, ~1346 deviations cleared) list-datatype length facets (#146) · union pattern/enumeration facets · duration partial-order facets · QName length non-constraining · the full XSD `\p{Is...}` @@ -60,9 +60,11 @@ by substitution validity (the XSD Part 2 derivation lattice), which also closed the two gaps it exposed (a simple-typed element rejecting stray attributes; identity fields comparing in value space when the node carries an `xsi:type`) · compile-time constraining-facet definition validity (length-family facets are -`nonNegativeInteger`, co-occurrence and range order), the first cut at the -schema-validity gap (invalid schemas accepted 2461 to 2082; see -`schema-validity-burndown.md`). +`nonNegativeInteger`, co-occurrence and range order) · value-bound facet +validity (`min`/`maxInclusive`, `min`/`maxExclusive`, `enumeration` values valid +in the base space; inclusive/exclusive exclusions; bound ordering) · `gMonth` +`--MM--` lexical form. The schema-validity work brought invalid schemas accepted +2461 to 1789; see `schema-validity-burndown.md`. Plus the measurement fix (exclude W3C-disputed `status="queried"` entries). ### Remaining, root-caused (the hard tail)