PureXML/Sources/Schema/XSDParserUnion.swift
Mihaela Mihaljevic 1fe670581f feat(schema): key the instance derivation/substitution backbone by namespaced identity; re-land blockDefault; invalid-instances-accepted 89 to 81 (#186, #147)
The instance-validity derivation and substitution tables were keyed by bare
local name, so two same-local-name types or elements in different namespaces
collided. In multi-document validation this let one schema's block/derivation
facts shadow another's, and it blocked honouring blockDefault (whose re-land
widened that collision into an over-rejection).

Key the instance subsystem by namespaced identity {ns}local instead. The shared
DerivationTables keeps its bare-keyed fields for the schema-validity machinery
(Particle-Valid-Restriction and the consistency rules, which resolve QNames to
bare names and walk the same backbone) and gains parallel ns* fields built in
the same pass, resolving each container's components through its target
namespace and prefix bindings. Only the instance consumers
(blockedSubstitutionError, notDerivedSubstitutionError, abstractTypeError, the
substitution-group closure, and referenceParticle) read the ns* tables, via a
derivationKey(fromReference:) that reduces a type:{ns}local reference, a bare
name, or an element ref to a {ns}local backbone key. A key miss stays permissive
(no constraint, substitution allowed), so the re-keying cannot over-reject.

With the namespaced backbone in place, blockDefault is re-landed FP-free: a
schema's blockDefault supplies the {disallowed substitutions} of every element
and complex type without an explicit block (own block overrides; block="" clears;
intersected with the methods each component admits), so blockDefault="extension"
bars an extension substitution and "#all" bars all, without one namespace's
defaults shadowing another's.

The pure re-keying preserves single-document behaviour exactly (the corpus is
overwhelmingly single-namespace, where {ns}local and bare coincide): all four
XSTS buckets held at vsr=0 / isa=69 / vir=0 / ivi=89 after re-keying alone.
blockDefault then drove invalid-instances-accepted 89 to 81, no false positive
(valid-instances-rejected and valid-schemas-rejected held at 0), no other bucket
moved. An adversarial review exercised same- and cross-namespace substitutions,
no-targetNamespace keys, simple-type restriction, transitive substitution groups,
and blockDefault override/over-block cases, and the prior multi-document
cross-namespace blockDefault over-rejection is now accepted while a same-namespace
blocked substitution is still rejected (precise, not merely permissive).
2026-06-18 12:41:18 +02:00

75 lines
4.2 KiB
Swift

private typealias XSDNode = PureXML.Schema.XSDNode
extension PureXML.Schema.XSDParser {
/// Compiles several schema documents that jointly form one schema (a
/// multi-document `schemaTest`, or any set assembled outside the
/// `import`/`include` graph) as a single union: their container closures are
/// pooled into one context so cross-document facts (substitution-group
/// membership above all) are global, exactly as if one document imported the
/// rest. Merging separately compiled `Document`s cannot do this, since each
/// document's content models are already frozen with only the substitution
/// members visible in its own closure (#161).
///
/// A document supplied directly and also reachable through another's `import`
/// is pooled once (deduplicated by target namespace and the set of its
/// top-level component names). The document with the largest closure, i.e. the
/// one that imports the others, is the primary, matching the result of
/// compiling that document alone.
static func parse(union sources: [String], loader: (String) -> String? = { _ in nil }) throws -> PureXML.Schema.XSDCompiled {
var parsed: [(schema: XSDTree, tuples: [(location: String?, tree: XSDTree)])] = []
for source in sources {
var visited: Set<String> = []
guard let root = try? PureXML.parseTree(source),
let schema = XSDNode.elementChildren(root).first(where: { XSDNode.localName($0) == "schema" })
else { continue }
parsed.append((schema, XSDNode.collectContainers(schema, loader, &visited)))
}
guard !parsed.isEmpty else { throw PureXML.Schema.SchemaError.notASchema }
// The widest closure is the importing document; compile as if it were the
// single entry point so the primary target namespace matches. Tie-break by
// target namespace so the choice is deterministic for independent roots.
parsed.sort {
$0.tuples.count != $1.tuples.count
? $0.tuples.count > $1.tuples.count
: (XSDNode.attribute($0.schema, "targetNamespace") ?? "") < (XSDNode.attribute($1.schema, "targetNamespace") ?? "")
}
guard let schema = parsed.first?.schema else { throw PureXML.Schema.SchemaError.notASchema }
var seenSignatures: Set<String> = []
var containerTuples: [(location: String?, tree: XSDTree)] = []
for tuple in parsed.flatMap(\.tuples) {
if let signature = unionDedupSignature(tuple.tree) {
guard seenSignatures.insert(signature).inserted else { continue }
}
containerTuples.append(tuple)
}
let containers = containerTuples.map(\.tree)
let derivation = derivationTables(containers, mainTargetNamespace: XSDNode.attribute(schema, "targetNamespace"))
let containerLocations = containerLocationMap(containerTuples, rootLocation: nil)
let compositionLoaded = XSDNode.compositionLoaded(from: containerTuples)
var context = createContext(
schema: schema,
containers: containers,
derivation: derivation,
containerLocations: containerLocations,
compositionLoaded: compositionLoaded,
)
return finishCompile(schema: schema, containers: containers, derivation: derivation, context: &context)
}
/// A dedup key for a schema container in a union: its target namespace plus its
/// sorted top-level component names. The same document parsed twice (as a
/// direct source and through another's `import`) collapses to one. Only
/// namespaced `schema` containers are keyed; chameleon (no-namespace) includes
/// and `redefine` wrappers are parent-specific and always kept.
private static func unionDedupSignature(_ tree: XSDTree) -> String? {
guard XSDNode.localName(tree) == "schema" else { return nil }
guard let namespace = XSDNode.attribute(tree, "targetNamespace"), !namespace.isEmpty else { return nil }
let components = XSDNode.elementChildren(tree)
.map { "\(XSDNode.localName($0)):\(XSDNode.attribute($0, "name") ?? "")" }
.sorted()
return "\(namespace)\u{1}\(components.joined(separator: "\u{1}"))"
}
}