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.
This commit is contained in:
Mihaela Mihaljevic 2026-06-10 10:45:17 +02:00
parent 5e7cc292f9
commit d0175d5ca0
13 changed files with 87 additions and 31 deletions

View File

@ -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.

View File

@ -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)

View File

@ -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[..<close])
}

View File

@ -73,9 +73,10 @@ extension DTDScanner {
let opens = replacement.count(where: { $0 == "(" })
let closes = replacement.count(where: { $0 == ")" })
if opens != closes {
doctype.validityFindings.append(
doctype.validityFindings.append(PureXML.Parsing.ValidityFinding(
"the content model of '\(element)' uses parameter entity '%\(name);' with improper group/PE nesting",
)
subject: element,
))
}
}
index = rawModel.index(after: semicolon)

View File

@ -47,7 +47,7 @@ extension DTDScanner {
if entityDeclaredIsWellFormedness {
throw ParseError.undefinedEntity(name: name, refMark)
}
doctype.validityFindings.append("parameter entity '%\(name);' is referenced but not declared")
doctype.validityFindings.append(PureXML.Parsing.ValidityFinding("parameter entity '%\(name);' is referenced but not declared"))
return
}
guard depth < maxDepth else {

View File

@ -298,7 +298,7 @@ extension DTDScanner {
if entityDeclaredIsWellFormedness {
throw ParseError.undefinedEntity(name: name, mark)
}
doctype.validityFindings.append("entity '&\(name);' in an attribute default is referenced but not declared")
doctype.validityFindings.append(PureXML.Parsing.ValidityFinding("entity '&\(name);' in an attribute default is referenced but not declared"))
return
}
try walkDefaultReferences(replacement, visiting: visiting.union([name]), at: mark)

View File

@ -42,8 +42,9 @@ public extension PureXML.Parsing {
/// the DTD: an undeclared entity referenced where the external subset
/// might have declared it (VC: Entity Declared) or a content-model
/// group split across parameter entities (VC: Proper Group/PE
/// Nesting). Reported by the validator at the document root.
public var validityFindings: [String] = []
/// Nesting). The validator turns each into a located error at the
/// finding's subject.
public var validityFindings: [ValidityFinding] = []
public init(
entities: [String: String] = [:],

View File

@ -36,7 +36,7 @@ extension PureXML.Parsing.EventReader {
undeclared: &undeclared,
)
for name in undeclared {
documentType.validityFindings.append("general entity '&\(name);' is referenced but not declared")
documentType.validityFindings.append(PureXML.Parsing.ValidityFinding("general entity '&\(name);' is referenced but not declared"))
}
return decoded
}
@ -79,7 +79,10 @@ extension PureXML.Parsing.EventReader {
let model = documentType.elementModels[current.localName] ?? documentType.elementModels[current.description],
model == "EMPTY"
else { return }
documentType.validityFindings.append("\(what) appears in the EMPTY element <\(current.description)>")
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,
))
}
}

View File

@ -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
}
}
}

View File

@ -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>,
) -> [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>,
) -> [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

View File

@ -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]] = [:]

View File

@ -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<PureXML.Model.Node, DTDSchema> {
.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 },
)
}

View File

@ -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 = "<!DOCTYPE r [<!ELEMENT r EMPTY><!ELEMENT x (#PCDATA)><!ELEMENT x (#PCDATA)><!ATTLIST r a (one|one) #IMPLIED>]>\n<r/>"
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 = "<!DOCTYPE r [<!ELEMENT r EMPTY><!ELEMENT y (#PCDATA|x|x)*>]>\n<r/>"