fix(schema): a non-nillable element must carry no xsi:nil at all (cvc-elt.3.1)

cvc-elt.3.1: when an element's declaration is not nillable, the xsi:nil attribute
is forbidden entirely, whatever its value. PureXML only rejected xsi:nil="true" on
a non-nillable element, so xsi:nil="false" slipped through. ComplexValidator.nilErrors
now flags the presence of any xsi:nil on a non-nillable element via the new
nilAttributeValue helper, while a nillable element still accepts xsi:nil="false"
(explicitly not nilled, valid empty-or-content per its type).

XSTS invalid-instances-accepted 28 -> 26 (both xsi:nil-on-non-nillable cases), no
other bucket moved (valid-schemas-rejected and valid-instances-rejected held at 0
across all 14383 groups). 1452 unit tests pass.
This commit is contained in:
Mihaela Mihaljevic 2026-06-20 03:00:38 +02:00
parent 45f2e7dbf5
commit 2a9a5afaa9
6 changed files with 40 additions and 11 deletions

View File

@ -28,6 +28,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
- A non-nillable element may now carry no `xsi:nil` attribute at all, whatever its
value (cvc-elt.3.1). Previously only `xsi:nil="true"` on a non-nillable element
was rejected; `xsi:nil="false"` slipped through. `ComplexValidator.nilErrors` now
flags the presence of any `xsi:nil` on a non-nillable element, while a nillable
element still accepts `xsi:nil="false"` (explicitly not nilled). XSTS
invalid-instances-accepted 28 -> 26, no other bucket moved (valid-rejected held
at 0 across all 14383 groups).
- An element with neither a `type` attribute nor an inline type, but a
`substitutionGroup`, now takes the type definition of its head (XSD 1.0 3.3.2)
instead of defaulting to `anyType`. `XSDParser.elementType` resolves the head

View File

@ -86,11 +86,14 @@ public extension PureXML.Schema {
/// element is not nillable, or when it carries content. Returns nil when
/// the element is not nilled.
func nilErrors(_ element: PureXML.Model.Element, at path: XSDPath) -> [XSDFailure]? {
guard Self.isNil(element) else { return nil }
let name = element.name.localName
if !nillableElements.contains(name) {
// cvc-elt.3.1: a non-nillable element must carry no `xsi:nil` attribute at
// all, whatever its value (so `xsi:nil="false"` is equally forbidden, not
// only `xsi:nil="true"`).
if Self.nilAttributeValue(element) != nil, !nillableElements.contains(name) {
return [XSDFailure(reason: "element '\(name)' is not nillable but has xsi:nil", at: path)]
}
guard Self.isNil(element) else { return nil }
if Self.hasContent(element) {
return [XSDFailure(reason: "nil element '\(name)' must be empty", at: path)]
}

View File

@ -185,11 +185,17 @@ extension PureXML.Schema.ComplexValidator {
extension PureXML.Schema.ComplexValidator {
/// Whether the element carries `xsi:nil="true"`.
static func isNil(_ element: PureXML.Model.Element) -> Bool {
element.attributes.contains { attribute in
nilAttributeValue(element) == "true"
}
/// The value of the `xsi:nil` attribute if present (any value), or nil if the
/// element carries no `xsi:nil`. Used for the cvc-elt.3.1 rule that a
/// non-nillable element must carry no `xsi:nil` attribute at all.
static func nilAttributeValue(_ element: PureXML.Model.Element) -> String? {
element.attributes.first { attribute in
attribute.name.localName == "nil"
&& (attribute.name.namespaceURI == "http://www.w3.org/2001/XMLSchema-instance" || attribute.name.prefix == "xsi")
&& attribute.value == "true"
}
}?.value
}
/// Whether the element has any child element or non-whitespace text.

View File

@ -90,6 +90,18 @@ struct XSDCategoryTests {
#expect(validate(element("a", [xsi]), type, validator: nillable).isEmpty)
}
@Test("Nillable: xsi:nil='false' is also forbidden on a non-nillable element (cvc-elt.3.1)")
func test_notNillableNilFalse() {
let nilFalse = ("xsi:nil", "false")
let type = Schema.ComplexType(content: .empty)
// cvc-elt.3.1: a non-nillable element must carry no xsi:nil attribute at all,
// so even xsi:nil="false" is rejected (not only "true").
#expect(validate(element("a", [nilFalse]), type).contains { $0.reason.contains("not nillable") })
// A nillable element may carry xsi:nil="false" (explicitly not nilled): accepted.
let nillable = Schema.ComplexValidator(nillableElements: ["a"])
#expect(validate(element("a", [nilFalse]), type, validator: nillable).isEmpty)
}
@Test("Fixed: an element fixed value must match")
func test_fixedElement() {
let validator = Schema.ComplexValidator(elementConstraints: ["a": .fixed("yes")])

View File

@ -150,13 +150,13 @@ struct XSTSSuiteTests {
private let knownSchemaValidRejected = 0
private let knownSchemaInvalidAccepted = 43
private let knownInstanceValidRejected = 0
/// Instance bucket (133 -> 28), per-step deltas in CHANGELOG.md: xsi:type must derive from the
/// Instance bucket (133 -> 26), per-step deltas in CHANGELOG.md: xsi:type must derive from the
/// declared type (complex/atomic/list/union via the element-ref chain); anyType cannot stand in
/// for an anySimpleType element; an untyped substitutionGroup member inherits its head's type;
/// block/blockDefault bar substitutions on the namespace-keyed backbone; ur-type anyType wildcards
/// are `lax`; identity fields compare in decimal value space, taking a declared default/fixed when
/// for anySimpleType; an untyped substitutionGroup member inherits its head's type; a non-nillable
/// element carries no xsi:nil (cvc-elt.3.1); block/blockDefault bar substitutions; ur-type anyType
/// wildcards are `lax`; identity fields compare in decimal value space, taking a default/fixed when
/// absent/empty; an attribute wildcard is the INTERSECTION of its sources; year 0000 is invalid.
private let knownInstanceInvalidAccepted = 28
private let knownInstanceInvalidAccepted = 26
@Test("Every XSTS case behaves: compile, reject, validate, invalidate")
func test_suite() throws {

View File

@ -91,7 +91,7 @@ classification is wrong.
|---|---|---|
| Target namespace / form matching | `targetns00101m/targetNS00101m1_n` | `ComplexValidator*`, reference resolution |
| Disallowed substitution | `disallowedsubst00105m/disallowedSubst00105m1_n`, `disallowedsubst00106m2/Negative` | `XSDSubstitutionMembers`, `ComplexValidatorXSIType` |
| Nillability | `nillable00201m/nillable00201m2_n` | `XSDParserHelpers`, instance validator |
| Nillability | ~~`nillable00201m/nillable00201m2_n`~~ closed: a non-nillable element carries no `xsi:nil` at all, even `"false"` (cvc-elt.3.1) | `ComplexValidator.nilErrors` |
| Type derivation | ~~`typedef00204m/typeDef00204m1_n`~~ closed: an untyped `substitutionGroup` member inherits its head's type (XSD 1.0 3.3.2) | `XSDParser.elementType` |
| Additional-suite instance triage | `addB065/addB065.i` | to classify from fixture |
| 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` |