diff --git a/CHANGELOG.md b/CHANGELOG.md index 052ddf7..3b2d556 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/Sources/Schema/ComplexValidator.swift b/Sources/Schema/ComplexValidator.swift index 0923936..7288a36 100644 --- a/Sources/Schema/ComplexValidator.swift +++ b/Sources/Schema/ComplexValidator.swift @@ -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)] } diff --git a/Sources/Schema/ComplexValidatorHelpers.swift b/Sources/Schema/ComplexValidatorHelpers.swift index 3c98ea3..5aad85a 100644 --- a/Sources/Schema/ComplexValidatorHelpers.swift +++ b/Sources/Schema/ComplexValidatorHelpers.swift @@ -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. diff --git a/Tests/XSDCategoryTests.swift b/Tests/XSDCategoryTests.swift index 0387b12..cba2278 100644 --- a/Tests/XSDCategoryTests.swift +++ b/Tests/XSDCategoryTests.swift @@ -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")]) diff --git a/Tests/XSTSSuiteTests.swift b/Tests/XSTSSuiteTests.swift index a03fd5c..f7f0873 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 -> 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 { diff --git a/docs/plans/2026-06-18-false-negative-tail.md b/docs/plans/2026-06-18-false-negative-tail.md index 8e57474..70b6d39 100644 --- a/docs/plans/2026-06-18-false-negative-tail.md +++ b/docs/plans/2026-06-18-false-negative-tail.md @@ -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` |