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)