diff --git a/CHANGELOG.md b/CHANGELOG.md index b98b793..ce515e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- An identity-constraint field whose DECLARED target is a complex type with non-simple content is now rejected even when the instance node has no element children. The earlier fix rejected a field whose selected node carries element children (structural); this adds the case where the field's declared type is an empty or element-only/mixed complex type (an attributes-only element resolves to an empty-content complex type, so its instance node is childless yet not simple-typed). The field's target type is resolved through the schema type model (the selector target's type, then the field's same-named child element); a simple or simpleContent target, a type reached only through a named reference, a complex field path, or an unresolvable target is left alone, so no valid field is rejected. Catches idG006. XSTS invalid-instances-accepted 23 → 22, valid-instances-rejected held at 0. - A substitution-group `block` on a head element now bars substituting a member whose INLINE (anonymous) type reaches the head's type by a blocked method. The substitution-group filter already dropped a member when the head's `{disallowed substitutions}` (or the head type's `{prohibited substitutions}`) blocked the member's derivation method, but only for members with a NAMED type recorded in the derivation backbone; an element with an inline complex type had no backbone entry, so a member extending the head's type was wrongly kept even when the head blocked `extension`. Inline-typed elements now record their derivation under a synthetic key (an element key plus a `#` marker no QName can contain, so it cannot collide with a real type and is unreachable as any other derivation base), so the filter sees the inline member's method. A member reaching the head's type by an UNblocked method, a plain inline type with no derivation, and same-local-name elements in different namespaces are all unaffected. Catches disallowedSubst00105m (and a sibling). XSTS invalid-instances-accepted 25 → 23, valid-instances-rejected held at 0. - An identity-constraint (`key`/`unique`/`keyref`) field that identifies a node with complex content is now rejected during instance validation. Per cvc-identity-constraint.3 / c-fields-xpaths a field must evaluate to a node with a simple type (a simple-typed element, an element with simple content, or an attribute); the cardinality clause (at most one node) was enforced, but the simple-content clause was not. The check is structural and false-positive-free: a field whose selected node is an element carrying element children has complex (element-only or mixed) content and can never be simple-typed, so it is flagged, while a simple-typed or simple-content element (text only), an empty or text-only node, and an attribute are left alone. Catches idK012. XSTS invalid-instances-accepted 26 → 25, valid-instances-rejected held at 0. - The attribute-use uniqueness check (`ag-props-correct.2` / `ct-props-correct.4`) now flattens a redefined attribute group's self-reference and excludes prohibited uses. When an attribute group flattens its uses through `` references, a reference to a redefined group whose redefinition self-references its former self previously stopped at the cycle guard, so the original's attributes were never reached and a clash they cause was missed. The self-reference now expands to the group's pre-redefine base definition (already retained for this purpose), so the original's attributes join the set; a genuine non-redefine cycle still terminates without contributing. Separately, a `use="prohibited"` attribute is now excluded from the duplicate-name and single-ID counts, since a prohibited use is a removal marker, not a member of `{attribute uses}` (XSD §3.4.2/§3.6.2); this also keeps a self-reference that re-imports an attribute and then prohibits it from reading as a duplicate. Catches attQ011 and attQ017 (an attribute group referencing both a global attribute and a redefined self-referencing group that supplies the same-named attribute). XSTS invalid-schemas-accepted 22 → 20, valid-schemas-rejected held at 0. diff --git a/Sources/Schema/IdentityFieldType.swift b/Sources/Schema/IdentityFieldType.swift new file mode 100644 index 0000000..10b1ec4 --- /dev/null +++ b/Sources/Schema/IdentityFieldType.swift @@ -0,0 +1,44 @@ +extension PureXML.Schema.IdentityValidator { + /// Whether the field's DECLARED target is a complex type with non-simple content + /// (empty or element-only/mixed), which is not a valid identity field even when its + /// instance node has no element children (an attributes-only element, XSTS idG006). + /// Resolved through the schema type model: the selector target's type is looked up, + /// the field's same-named child element found in its content model, and its declared + /// type classified. A simple/simpleContent type, a type reached only through a + /// reference, a complex field path, or an unresolvable target is left alone, so no + /// valid field is rejected (cvc-identity-constraint.3 / c-fields-xpaths). + func fieldTargetIsComplexNonSimple(_ field: String, at target: PureXML.Model.TreeNode) -> Bool { + guard !field.contains(where: { "@/[(.".contains($0) }), let targetName = target.name, + case let .complex(targetComplex)? = declaredType(of: targetName), + let fieldType = childElementType(named: field, in: targetComplex.content), + case let .complex(fieldComplex) = fieldType + else { return false } + if case .simpleContent = fieldComplex.content { return false } + return true + } + + /// The declared `ElementType` of a global element, by namespaced or local key. + private func declaredType(of name: PureXML.Model.QualifiedName) -> PureXML.Schema.ElementType? { + types[PureXML.Schema.XSDParser.elementDeclarationKey(name)] ?? types[PureXML.Schema.XSDParser.elementKey(name.localName)] + } + + /// The declared type of the first element named `local` directly within a content + /// model (descending through model groups), or nil when none matches. + private func childElementType(named local: String, in content: PureXML.Schema.ContentType) -> PureXML.Schema.ElementType? { + switch content { + case let .elementOnly(particle), let .mixed(particle): childElementType(named: local, in: particle) + default: nil + } + } + + private func childElementType(named local: String, in particle: PureXML.Schema.Particle) -> PureXML.Schema.ElementType? { + switch particle.term { + case let .element(name, type, _, _, _, _): + name.localName == local ? type : nil + case let .group(group): + group.particles.lazy.compactMap { childElementType(named: local, in: $0) }.first + case .wildcard: + nil + } + } +} diff --git a/Sources/Schema/IdentityValidator.swift b/Sources/Schema/IdentityValidator.swift index 78b5bc3..361f1a4 100644 --- a/Sources/Schema/IdentityValidator.swift +++ b/Sources/Schema/IdentityValidator.swift @@ -205,11 +205,14 @@ extension PureXML.Schema { } // cvc-identity-constraint.3 / c-fields-xpaths: a field must identify a node // with a simple type (or simple content). An element with element children - // has complex content and can never be simple-typed, so it is not a valid - // field target. Checked structurally: a simple-typed or simple-content - // element carries only text, never element children, so no valid field is - // rejected; an empty or text-only node, or an attribute, is left alone. - if let node = selected.first, node.kind == .element, node.children.contains(where: { $0.kind == .element }) { + // has complex content (checked structurally), and an element whose DECLARED + // type is a complex type with non-simple content (empty or element-only, so + // an attributes-only element with no children, XSTS idG006) is equally + // invalid (checked through the schema type model). A simple-typed or + // simple-content element, a text-only node, and an attribute are left alone. + let element = selected.first.flatMap { $0.kind == .element ? $0 : nil } + let hasElementChildren = element?.children.contains { $0.kind == .element } ?? false + if element != nil, hasElementChildren || fieldTargetIsComplexNonSimple(field, at: target) { return .init( reason: "\(label(constraint)) '\(constraint.name)': field '\(field)' must identify a node with simple content", at: targetPath + step, diff --git a/Tests/SchemaIdentityFieldTypeTests.swift b/Tests/SchemaIdentityFieldTypeTests.swift new file mode 100644 index 0000000..f8f08b7 --- /dev/null +++ b/Tests/SchemaIdentityFieldTypeTests.swift @@ -0,0 +1,75 @@ +import Testing +@testable import PureXML + +/// cvc-identity-constraint.3 / c-fields-xpaths: an identity-constraint field must +/// identify a node with a simple type (a simple-typed element, an element with simple +/// content, or an attribute). A field whose target is a complex type with non-simple +/// content is invalid, whether the instance node has element children (idK012) or is +/// empty/attributes-only (idG006). A simple or simpleContent target stays valid. +@Suite("XSD identity-constraint field simple-content") +struct SchemaIdentityFieldTypeTests { + private func validate(_ xsd: String, _ xml: String) throws -> [PureXML.Validation.ValidationError] { + try PureXML.Schema.Document(xsd).validate(xml) + } + + private let head = "" + + @Test("a field selecting a complex-content element is rejected (idK012)") + func test_fieldWithElementChildrenRejected() throws { + let complexField = """ + \(head) + + + + + + + + + + """ + #expect(try !validate(complexField, "x").isEmpty) + let simpleField = """ + \(head) + + + + + + + + + + """ + #expect(try validate(simpleField, "ab").isEmpty) + } + + @Test("a field whose declared type is an empty/attributes-only complex type is rejected (idG006)") + func test_emptyComplexFieldRejected() throws { + let emptyComplex = """ + \(head) + + + + + + + + + """ + #expect(try !validate(emptyComplex, "").isEmpty) + let simpleContent = """ + \(head) + + + + + + + + + + """ + #expect(try validate(simpleContent, "a").isEmpty) + } +} diff --git a/Tests/XSDIdentityTests.swift b/Tests/XSDIdentityTests.swift index abc3d91..a685082 100644 --- a/Tests/XSDIdentityTests.swift +++ b/Tests/XSDIdentityTests.swift @@ -9,39 +9,6 @@ struct XSDIdentityTests { private let head = "" - @Test("a field selecting a complex-content element is rejected (cvc-identity-constraint.3)") - func test_fieldMustBeSimpleContent() throws { - // The field `pid` selects an element with element children (`gid`), which has - // complex content and can never be simple-typed: invalid (idK012/idG006). - let complexField = """ - \(head) - - - - - - - - - - """ - #expect(try !validate(complexField, "x").isEmpty) - // Guard: a simple-content field target stays valid (no false positive). - let simpleField = """ - \(head) - - - - - - - - - - """ - #expect(try validate(simpleField, "ab").isEmpty) - } - @Test("xs:unique rejects a duplicated field value") func test_unique() throws { let xsd = """ diff --git a/Tests/XSTSSuiteTests.swift b/Tests/XSTSSuiteTests.swift index 8aaac2e..a14b9ec 100644 --- a/Tests/XSTSSuiteTests.swift +++ b/Tests/XSTSSuiteTests.swift @@ -150,13 +150,13 @@ struct XSTSSuiteTests { private let knownSchemaValidRejected = 0 private let knownSchemaInvalidAccepted = 20 private let knownInstanceValidRejected = 0 - /// Instance bucket (133 -> 23), per-step deltas in CHANGELOG.md: xsi:type must derive from the declared - /// type; anyType cannot stand in for anySimpleType; an untyped substitutionGroup member inherits its - /// head's type; a non-nillable element carries no xsi:nil; block/blockDefault bar substitutions (incl. an - /// inline-typed member reached by a blocked method, disallowedSubst00105m); ur-type anyType wildcards are - /// `lax`; identity fields compare in decimal value space, with default/fixed when absent, and must be - /// simple-content (idK012); an attribute wildcard is the INTERSECTION of its sources; year 0000 is invalid. - private let knownInstanceInvalidAccepted = 23 + /// Instance bucket (133 -> 22), per-step deltas in CHANGELOG.md: xsi:type must derive from the declared + /// type; anyType cannot stand in for anySimpleType; an untyped substitutionGroup member inherits its head's + /// type; a non-nillable element carries no xsi:nil; block/blockDefault bar substitutions (incl. an inline- + /// typed member reached by a blocked method, disallowedSubst00105m); ur-type anyType wildcards are `lax`; + /// identity fields compare in decimal value space (default/fixed when absent) and must be simple-content, + /// element-children (idK012) or empty-complex (idG006); an attr wildcard is the INTERSECTION; year 0000 invalid. + private let knownInstanceInvalidAccepted = 22 @Test("Every XSTS case behaves: compile, reject, validate, invalidate") func test_suite() throws {