From d0175d5ca041ddb840bc2b3e145878a51e33d77f Mon Sep 17 00:00:00 2001 From: Mihaela Mihaljevic Date: Wed, 10 Jun 2026 10:45:17 +0200 Subject: [PATCH] refactor(validation): located structured validity findings DocumentType.validityFindings carries ValidityFinding values (reason plus subject) instead of strings, DTDSchema.declarationErrors holds ready ValidationErrors, and every declaration-level finding renders at its declaration's coding path (x, r/@a) instead of at the document root, matching the XSD consistency-errors precedent. Texts unchanged. --- CHANGELOG.md | 4 +++ README.md | 2 +- Sources/Parsing/DoctypeScanner.swift | 5 +-- .../Parsing/DoctypeScannerConditional.swift | 5 +-- Sources/Parsing/DoctypeScannerEntities.swift | 2 +- Sources/Parsing/DoctypeScannerStrict.swift | 2 +- Sources/Parsing/DocumentType.swift | 5 +-- Sources/Parsing/EventReaderValidity.swift | 17 +++++++--- Sources/Parsing/ValidityFinding.swift | 16 ++++++++++ .../Validation/DTDDeclarationValidity.swift | 32 ++++++++++++------- Sources/Validation/DTDSchema.swift | 12 +++++-- Sources/Validation/DTDValidations.swift | 7 ++-- Tests/DTDValidityConstraintTests.swift | 9 ++++++ 13 files changed, 87 insertions(+), 31 deletions(-) create mode 100644 Sources/Parsing/ValidityFinding.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e644ca..582624d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- Scanner-side validity findings are now located structured values (validation-framework audit follow-up): `DocumentType.validityFindings` carries `ValidityFinding` values (reason plus the declared name they are about) instead of strings, `DTDSchema.declarationErrors` holds ready `ValidationError`s, and every declaration-level finding renders at its declaration's coding path (`x`, `r/@a`) instead of "at root of document", matching the XSD consistency-errors precedent. Finding texts are unchanged. + ### Added - RELAX NG grammar scoping and include depth, third burn-down (#131): each `grammar` element opens its own define scope (names are scope-mangled in the flat table, so nested grammars do not collide) and `parentRef` reaches the enclosing scope; `include` is transitive (an included grammar's own includes merge first) and a define carried by the include element replaces the included grammar's same-name define (the 4.7 override); `div` wrappers flatten into their grammar while still contributing ns/datatypeLibrary inheritance (4.11); `value` text is kept untrimmed so whitespace-preserving datatypes compare exactly (6.2.9); and `dataExcept` recurses through choice patterns. The spec suite is now clean except one class: valid 286/289 and invalid 287/291, with all six remaining instance cases the QName datatype (its value space needs prefix resolution in both the schema and instance contexts), plus the asserted schema-correctness item. diff --git a/README.md b/README.md index 7a3f25a..b4dd669 100644 --- a/README.md +++ b/README.md @@ -125,7 +125,7 @@ flowchart TB PureXML is a working, dependency-free XML library today: parse, emit, validate, query, and stream documents on macOS, Linux, Windows, and WASM. The test suite -currently runs **932 tests in 160 suites** (`swift test`). +currently runs **933 tests in 160 suites** (`swift test`). ### Shipped (libxml2-aligned surface) diff --git a/Sources/Parsing/DoctypeScanner.swift b/Sources/Parsing/DoctypeScanner.swift index 9ff24f5..d2120da 100644 --- a/Sources/Parsing/DoctypeScanner.swift +++ b/Sources/Parsing/DoctypeScanner.swift @@ -345,9 +345,10 @@ extension DTDScanner { guard !read.closed else { return read.text } let expanded = expandParameterReferences(read.text) guard let close = expanded.firstIndex(of: ">") else { return read.text } - doctype.validityFindings.append( + doctype.validityFindings.append(PureXML.Parsing.ValidityFinding( "the \(kind) declaration for '\(name)' is completed inside a parameter-entity replacement (VC: Proper Declaration/PE Nesting)", - ) + subject: name, + )) return String(expanded[..") + documentType.validityFindings.append(PureXML.Parsing.ValidityFinding( + "\(what) appears in the EMPTY element <\(current.description)>", + subject: current.description, + )) } /// The errata E15 validity findings a text run can carry: any reference @@ -91,7 +94,10 @@ extension PureXML.Parsing.EventReader { let model = documentType.elementModels[current.localName] ?? documentType.elementModels[current.description] else { return } if model == "EMPTY" { - documentType.validityFindings.append("a reference appears in the EMPTY element <\(current.description)>") + documentType.validityFindings.append(PureXML.Parsing.ValidityFinding( + "a reference appears in the EMPTY element <\(current.description)>", + subject: current.description, + )) return } // Errata E15: a direct character reference is character data even @@ -100,7 +106,10 @@ extension PureXML.Parsing.EventReader { let isElementContent = model.hasPrefix("(") && !model.hasPrefix("(#PCDATA") let isReferenceWhitespace = !decoded.isEmpty && decoded.allSatisfy(\.isWhitespace) if isElementContent, isReferenceWhitespace, referencesCharacter(raw) { - documentType.validityFindings.append("character-reference whitespace in the element content of <\(current.description)>") + documentType.validityFindings.append(PureXML.Parsing.ValidityFinding( + "character-reference whitespace in the element content of <\(current.description)>", + subject: current.description, + )) } } diff --git a/Sources/Parsing/ValidityFinding.swift b/Sources/Parsing/ValidityFinding.swift new file mode 100644 index 0000000..42a27ef --- /dev/null +++ b/Sources/Parsing/ValidityFinding.swift @@ -0,0 +1,16 @@ +public extension PureXML.Parsing { + /// One validity (not well-formedness) discovery made while parsing: a + /// reason and, when known, the declared name it is about, which becomes + /// the error's coding path when the validation framework reports it. + struct ValidityFinding: Equatable, Sendable { + public var reason: String + /// The element or declaration name the finding addresses, or nil when + /// it belongs to the document as a whole. + public var subject: String? + + public init(_ reason: String, subject: String? = nil) { + self.reason = reason + self.subject = subject + } + } +} diff --git a/Sources/Validation/DTDDeclarationValidity.swift b/Sources/Validation/DTDDeclarationValidity.swift index 7b4efb8..4a98569 100644 --- a/Sources/Validation/DTDDeclarationValidity.swift +++ b/Sources/Validation/DTDDeclarationValidity.swift @@ -2,25 +2,31 @@ extension PureXML.Validation.DTDSchema { /// The declaration-level validity constraints: violations that exist in the /// DTD itself, independent of any instance content. Computed once when the /// schema is built and reported at the document root. + typealias Finding = PureXML.Validation.ValidationError + typealias Key = PureXML.Validation.PathKey + static func declarationFindings( _ documentType: PureXML.Parsing.DocumentType, attributes: [String: [PureXML.Validation.AttributeDeclaration]], notations: Set, - ) -> [String] { - var findings: [String] = [] + ) -> [Finding] { + var findings: [Finding] = [] // VC: Unique Element Type Declaration. for name in documentType.duplicateElements.sorted() { - findings.append("element type '\(name)' is declared more than once") + findings.append(Finding(reason: "element type '\(name)' is declared more than once", at: [.element(name)])) } // VC: Notation Declared, an unparsed entity's notation must exist. for (entity, unparsed) in documentType.unparsedEntities.sorted(by: { $0.key < $1.key }) { guard !notations.contains(unparsed.notation) else { continue } - findings.append("unparsed entity '\(entity)' names undeclared notation '\(unparsed.notation)'") + findings.append(Finding( + reason: "unparsed entity '\(entity)' names undeclared notation '\(unparsed.notation)'", + at: [.element(entity)], + )) } // VC: No Duplicate Types in mixed content. for (name, model) in documentType.elementModels.sorted(by: { $0.key < $1.key }) { if let duplicate = duplicateMixedName(model) { - findings.append("mixed content of '\(name)' repeats '\(duplicate)'") + findings.append(Finding(reason: "mixed content of '\(name)' repeats '\(duplicate)'", at: [.element(name)])) } } for (element, declarations) in attributes.sorted(by: { $0.key < $1.key }) { @@ -35,24 +41,28 @@ extension PureXML.Validation.DTDSchema { element: String, declarations: [PureXML.Validation.AttributeDeclaration], notations: Set, - ) -> [String] { - var findings: [String] = [] + ) -> [Finding] { + var findings: [Finding] = [] if declarations.count(where: { $0.type == .id }) > 1 { - findings.append("element '\(element)' declares more than one ID attribute") + findings.append(Finding(reason: "element '\(element)' declares more than one ID attribute", at: [.element(element)])) } for declaration in declarations { + let path: [Key] = [.element(element), .attribute(declaration.name)] if case let .notation(allowed) = declaration.type { for name in allowed.sorted() where !notations.contains(name) { - findings.append("NOTATION attribute '\(declaration.name)' on '\(element)' lists undeclared notation '\(name)'") + findings.append(Finding( + reason: "NOTATION attribute '\(declaration.name)' on '\(element)' lists undeclared notation '\(name)'", + at: path, + )) } } // Errata E2: tokens in an enumerated or NOTATION list must be // distinct. if let duplicate = duplicateToken(declaration.type) { - findings.append("attribute '\(declaration.name)' on '\(element)' repeats the token '\(duplicate)'") + findings.append(Finding(reason: "attribute '\(declaration.name)' on '\(element)' repeats the token '\(duplicate)'", at: path)) } if let problem = defaultProblem(declaration) { - findings.append("attribute '\(declaration.name)' on '\(element)' \(problem)") + findings.append(Finding(reason: "attribute '\(declaration.name)' on '\(element)' \(problem)", at: path)) } } return findings diff --git a/Sources/Validation/DTDSchema.swift b/Sources/Validation/DTDSchema.swift index ce0a1a2..be86775 100644 --- a/Sources/Validation/DTDSchema.swift +++ b/Sources/Validation/DTDSchema.swift @@ -17,8 +17,8 @@ public extension PureXML.Validation { /// Validity findings about the declarations themselves (duplicate /// element types, repeated mixed-content names, multiple ID attributes, /// undeclared notations in NOTATION lists, illegal attribute defaults), - /// reported once per validation at the document root. - let declarationErrors: [String] + /// each located at the declaration it is about. + let declarationErrors: [PureXML.Validation.ValidationError] /// Whether the document declared `standalone='yes'`, which forbids /// depending on external declarations (2.9). let standalone: Bool @@ -43,7 +43,13 @@ public extension PureXML.Validation { parsedAttributes[name] = AttributeListParser.parse(body) } attributes = parsedAttributes - declarationErrors = documentType.validityFindings + Self.declarationFindings(documentType, attributes: parsedAttributes, notations: notations) + let scanned = documentType.validityFindings.map { finding in + PureXML.Validation.ValidationError( + reason: finding.reason, + at: finding.subject.map { [PureXML.Validation.PathKey.element($0)] } ?? [], + ) + } + declarationErrors = scanned + Self.declarationFindings(documentType, attributes: parsedAttributes, notations: notations) self.standalone = standalone externalElementModels = Set(documentType.elementModels.keys).subtracting(documentType.internalElementModels) var external: [String: [AttributeDeclaration]] = [:] diff --git a/Sources/Validation/DTDValidations.swift b/Sources/Validation/DTDValidations.swift index 453eaec..58457a4 100644 --- a/Sources/Validation/DTDValidations.swift +++ b/Sources/Validation/DTDValidations.swift @@ -119,13 +119,12 @@ public extension PureXML.Validation { /// The DTD's own declarations are valid (duplicate element types, /// repeated mixed names, multiple IDs, undeclared notations in lists, - /// illegal defaults). Reported once, at the document root. + /// illegal defaults). Emitted once per validation, each finding + /// already located at the declaration it is about. static var declarationValidity: Validation { .init( description: "The DTD declarations satisfy their validity constraints", - check: { context in - context.document.declarationErrors.map { DTDFailure(reason: $0, at: context.codingPath) } - }, + check: { context in context.document.declarationErrors }, when: { $0.codingPath.isEmpty }, ) } diff --git a/Tests/DTDValidityConstraintTests.swift b/Tests/DTDValidityConstraintTests.swift index 3684019..8b836b8 100644 --- a/Tests/DTDValidityConstraintTests.swift +++ b/Tests/DTDValidityConstraintTests.swift @@ -25,6 +25,15 @@ struct DTDValidityConstraintTests { #expect(try errors(xml).contains { $0.contains("declared more than once") }) } + @Test("Declaration findings carry the declaration's coding path") + func test_findingsAreLocated() throws { + let xml = "]>\n" + let rendered = try PureXML.validateAgainstInternalDTD(xml).map { String(describing: $0) } + #expect(rendered.contains { $0.hasSuffix("at path: x") }) + #expect(rendered.contains { $0.hasSuffix("at path: r/@a") }) + #expect(!rendered.contains { $0.contains("at root of document") }) + } + @Test("Mixed content may not repeat a name") func test_mixedDuplicates() throws { let xml = "]>\n"