import Foundation import Testing @testable import PureXML /// A generated-instance differential for content-model VALIDATION against libxml2 /// (`xmllint --schema`), the reference. The schema-compile determinism check has its own /// differential; this one exercises the per-instance content matcher (the /// graph-structured-stack `ContentRTNMatcher` now in production) over the open input /// space: for each generated, both-compile schema it validates many generated `` /// instances with PureXML and with libxml2 and flags every disagreement. /// /// Opt-in (needs `/usr/bin/xmllint`, set `INSTANCE_DIFF=1`); never runs in plain /// `swift test` or CI. A PureXML-rejects / libxml2-accepts disagreement is a FALSE /// POSITIVE (the cardinal rule: never reject a valid instance); a PureXML-accepts / /// libxml2-rejects is a false negative to triage. Only schemas BOTH engines compile are /// used, so a disagreement is about instance validation, not schema compilation. @Suite("Instance-validation differential vs libxml2 (opt-in via INSTANCE_DIFF)") struct InstanceValidationDifferentialTests { private var enabled: Bool { ProcessInfo.processInfo.environment["INSTANCE_DIFF"] != nil } private var xmllint: String? { let path = ProcessInfo.processInfo.environment["XMLLINT"].flatMap { $0.isEmpty ? nil : $0 } ?? "/usr/bin/xmllint" return FileManager.default.isExecutableFile(atPath: path) ? path : nil } /// libxml2's verdict for `instance` against `schemaFile`: the schema failed to /// compile (skip), or the instance is valid / invalid. private enum Outcome: Equatable { case schemaInvalid, valid, invalid } private func libxml2(_ schemaFile: String, _ instance: String, xmllint: String) -> Outcome? { #if os(WASI) return nil #else let instanceFile = NSTemporaryDirectory() + "inst-diff-\(abs(instance.hashValue)).xml" guard (try? instance.write(toFile: instanceFile, atomically: true, encoding: .utf8)) != nil else { return nil } defer { try? FileManager.default.removeItem(atPath: instanceFile) } let process = Process() process.executableURL = URL(fileURLWithPath: xmllint) process.arguments = ["--noout", "--schema", schemaFile, instanceFile] let pipe = Pipe() process.standardError = pipe process.standardOutput = pipe guard (try? process.run()) != nil else { return nil } let data = pipe.fileHandleForReading.readDataToEndOfFile() process.waitUntilExit() let output = String(decoding: data, as: UTF8.self) if output.contains("failed to compile") || output.contains("Schemas parser error") { return .schemaInvalid } return process.terminationStatus == 0 ? .valid : .invalid #endif } /// The running comparison totals; the cardinal-rule check is `falsePositives.isEmpty`. private struct Tally { var compared = 0 var agreeValid = 0 var agreeInvalid = 0 var falsePositives: [String] = [] var falseNegatives: [String] = [] /// Instances where libxml2 disagrees with the independent oracle (libxml2's /// own content-validation leniency), tracked but never blamed on PureXML. var libxml2Disagreements = 0 } /// A schema both engines compile: the PureXML document, its independent content /// model (the oracle's ground truth), its source, and the file written for libxml2. private struct CompiledSchema { let document: PureXML.Schema.Document let model: ContentModel let source: String let file: String } /// Validate several generated instances of one both-compile schema. Validity is /// decided by the independent ``ContentModelOracle`` (the generated instances are /// empty `xs:string` elements, so an instance is valid iff its element-name /// sequence is in the model's language); libxml2 is a secondary cross-check whose /// disagreements are counted but not attributed to PureXML. private func compareInstances( _ schema: CompiledSchema, xmllint: String, generator: inout InstanceSchemaGenerator, into tally: inout Tally, ) { for _ in 0 ..< 8 { let instance = generator.instance() let pureValid = ((try? schema.document.validate(instance.xml)) ?? [PureXML.Validation.ValidationError]()).isEmpty guard let reference = libxml2(schema.file, instance.xml, xmllint: xmllint) else { continue } if reference == .schemaInvalid { return } // libxml2 rejects the schema; skip its instances let truth = ContentModelOracle.matches(schema.model, instance.tokens) tally.compared += 1 if (reference == .valid) != truth { tally.libxml2Disagreements += 1 } let detail = "\(schema.source)\n instance: \(instance.xml)\n oracle: \(truth ? "valid" : "invalid"), libxml2: \(reference)" switch (pureValid, truth) { case (false, true): if tally.falsePositives.count < 20 { tally.falsePositives.append(detail) } case (true, false): if tally.falseNegatives.count < 20 { tally.falseNegatives.append(detail) } case (true, true): tally.agreeValid += 1 case (false, false): tally.agreeInvalid += 1 } } } @Test("PureXML's instance-validity verdict agrees with libxml2 over generated documents") func test_instanceDifferential() { guard enabled, let xmllint else { return } let seed = ProcessInfo.processInfo.environment["DIFF_SEED"].flatMap { UInt64($0) } ?? 0x1A57_C0DE_5EED_BEEF var generator = InstanceSchemaGenerator(seed: seed) var tally = Tally() let schemaFile = NSTemporaryDirectory() + "inst-diff-schema.xsd" for _ in 0 ..< 1500 { let generated = generator.schema() guard let document = try? PureXML.Schema.Document(generated.xml) else { continue } // PureXML compiles guard (try? generated.xml.write(toFile: schemaFile, atomically: true, encoding: .utf8)) != nil else { continue } defer { try? FileManager.default.removeItem(atPath: schemaFile) } let compiled = CompiledSchema(document: document, model: generated.model, source: generated.xml, file: schemaFile) compareInstances(compiled, xmllint: xmllint, generator: &generator, into: &tally) } let report = "compared \(tally.compared) instances (agree-valid \(tally.agreeValid), agree-invalid \(tally.agreeInvalid));" + " false positives \(tally.falsePositives.count), false negatives \(tally.falseNegatives.count)," + " libxml2 disagreements with the oracle \(tally.libxml2Disagreements)" FileHandle.standardError.write(Data("instance-diff: \(report)\n".utf8)) try? (tally.falsePositives + ["--- false negatives ---"] + tally.falseNegatives).joined(separator: "\n\n") .write(toFile: NSTemporaryDirectory() + "instance-diff.txt", atomically: true, encoding: .utf8) // The cardinal rule, adjudicated against the independent oracle (not libxml2, // whose content validation is itself lenient): PureXML must never reject an // instance that is genuinely valid. #expect( tally.falsePositives.isEmpty, "\(tally.falsePositives.count) instance-validation false positives (see \(NSTemporaryDirectory())instance-diff.txt): \(tally.falsePositives.first ?? "")", ) } } /// Generates a `doc` element whose content model varies over the matcher's space /// (sequences, choices, `all` groups, group references, occurrences, wildcards) using a /// small element alphabet, and matching `` instances drawn from that alphabet so a /// fair fraction are valid. Kept structurally well-formed so a rejection is a content /// rejection, not a parse error. private struct InstanceSchemaGenerator { /// A generated content model paired with its independent ``ContentModel``, so a /// disagreement can be adjudicated against the oracle rather than libxml2 alone. struct Particle { let xml: String let model: ContentModel } /// A generated occurrence: the attribute text plus its parsed bounds. private struct Occurrence { let xml: String let min: Int let max: Int? } private var rng: InstanceRNG private var groupNames: [String] = [] private var groupModels: [String: ContentModel] = [:] private static let alphabet = ["a", "b", "c"] init(seed: UInt64) { rng = InstanceRNG(seed: seed) } private mutating func pick(_ options: [T]) -> T { options[Int.random(in: 0 ..< options.count, using: &rng)] } private mutating func chance(_ bound: Int) -> Bool { Int.random(in: 0 ..< bound, using: &rng) == 0 } private mutating func occurrence() -> Occurrence { switch Int.random(in: 0 ..< 5, using: &rng) { case 0: Occurrence(xml: "", min: 1, max: 1) case 1: Occurrence(xml: " minOccurs=\"0\"", min: 0, max: 1) case 2: Occurrence(xml: " maxOccurs=\"unbounded\"", min: 1, max: nil) case 3: Occurrence(xml: " minOccurs=\"0\" maxOccurs=\"unbounded\"", min: 0, max: nil) default: Occurrence(xml: " maxOccurs=\"3\"", min: 1, max: 3) } } /// Wraps a base model in an occurrence, leaving an exact-once occurrence bare. private func occurring(_ base: ContentModel, _ occ: Occurrence) -> ContentModel { occ.min == 1 && occ.max == 1 ? base : .occurs(base, occ.min, occ.max) } private mutating func particle(depth: Int) -> Particle { if depth == 0 || chance(2) { let occ = occurrence() if chance(5) { return Particle(xml: "", model: occurring(.any, occ)) } let name = pick(Self.alphabet) return Particle(xml: "", model: occurring(.element(name), occ)) } if !groupNames.isEmpty, chance(3) { let name = pick(groupNames) let occ = occurrence() return Particle(xml: "", model: occurring(groupModels[name] ?? .sequence([]), occ)) } let compositor = pick(["sequence", "choice", "all"]) let count = Int.random(in: 1 ... 3, using: &rng) if compositor == "all" { var xml = "" var members: [ContentModel] = [] for _ in 0 ..< count { let name = pick(Self.alphabet) let optional = chance(2) xml += "" members.append(.occurs(.element(name), optional ? 0 : 1, 1)) } return Particle(xml: "\(xml)", model: .all(members)) } let occ = occurrence() let parts = (0 ..< count).map { _ in particle(depth: depth - 1) } let base: ContentModel = compositor == "sequence" ? .sequence(parts.map(\.model)) : .choice(parts.map(\.model)) return Particle(xml: "\(parts.map(\.xml).joined())", model: occurring(base, occ)) } /// The doc content model is a single compositor; a bare particle is wrapped in a /// sequence in both the XML and the model so the two stay in lockstep. private func wrapModel(_ particle: Particle) -> Particle { if particle.xml.hasPrefix("\(particle.xml)", model: .sequence([particle.model])) } mutating func schema() -> (xml: String, model: ContentModel) { groupNames = [] groupModels = [:] var groups = "" for index in 0 ..< Int.random(in: 0 ... 2, using: &rng) { let name = "g\(index)" let body = wrapModel(particle(depth: 1)) groupNames.append(name) groupModels[name] = body.model groups += "\(body.xml)" } let body = wrapModel(particle(depth: 3)) let xml = "\(groups)" + "\(body.xml)" return (xml, body.model) } mutating func instance() -> (xml: String, tokens: [String]) { let count = Int.random(in: 0 ... 6, using: &rng) let tokens = (0 ..< count).map { _ in pick(Self.alphabet) } return ("\(tokens.map { "<\($0)/>" }.joined())", tokens) } } private struct InstanceRNG: RandomNumberGenerator { private var state: UInt64 init(seed: UInt64) { state = seed == 0 ? 0x9E37_79B9_7F4A_7C15 : seed } mutating func next() -> UInt64 { state ^= state >> 12 state ^= state << 25 state ^= state >> 27 return state &* 0x2545_F491_4F6C_DD1D } }