PureXML/Sources/Schema/ContentModelGroupDependency.swift
Mihaela Mihaljevic 1f8d99adfc
perf(schema): make the UPA determinism check depth-safe on group-reference chains (#371)
A flat chain of <xs:group> references is not bounded by the parser's maxDepth (the definitions are siblings, not nested), so the compositional UPA determinism check's lazy per-reference recursion overflowed the native 8 MB stack while compiling a legal schema. Group summaries form an acyclic graph, so they are now precomputed bottom-up in dependency order (GroupDependencyOrder): the walk's group lookup is a cache hit and recursion is bounded by a single group's own model nesting. The precomputed reachable set is identical to the lazy walk's, so the verdict is unchanged. Verified verdict-equivalent over the full W3C XSTS corpus, the libxml2 determinism differential (0 FP, same 3 FN), the full suite, and an adversarial critic. Residual, tracked: the particle compiler that inlines a group reference still overflows on a far longer chain; closing it needs a content-model nesting-depth limit with a located error.
2026-06-28 23:15:32 +02:00

86 lines
4.5 KiB
Swift

/// Orders the model groups reachable from a set of content models so each group
/// comes after every group it references. The compositional determinism check
/// summarizes groups in this order, so its recursive walk is always a cache hit at a
/// `<group ref>` and never recurses through a reference chain. A chain is FLAT in the
/// schema document (group definitions are siblings, not nested), so it is not bounded
/// by the parser's `maxDepth`; ordering and scanning here are iterative so neither
/// step recurses on the chain length either.
enum GroupDependencyOrder {
/// Reachable group names in dependency order (dependencies first), by an iterative
/// post-order DFS from the groups the `models` reference. A name already on the
/// current path is not re-entered, so a circular reference (illegal, rejected
/// elsewhere) terminates.
static func reachable(from models: [PureXML.Model.TreeNode], context: PureXML.Schema.XSDContext) -> [String] {
var roots: [String] = []
var seen: Set<String> = []
for model in models {
for ref in referencedGroupNames(in: model) where seen.insert(ref).inserted {
roots.append(ref)
}
}
var order: [String] = []
var visited: Set<String> = []
var onPath: Set<String> = []
var work: [(name: String, exit: Bool)] = roots.reversed().map { ($0, false) }
while let (name, exit) = work.popLast() {
if exit {
onPath.remove(name)
order.append(name)
continue
}
if visited.contains(name) { continue }
visited.insert(name)
onPath.insert(name)
work.append((name, true))
for ref in dependencies(of: name, context: context).reversed() where !visited.contains(ref) && !onPath.contains(ref) {
work.append((ref, false))
}
}
return order
}
/// The group names referenced within `node`, by an iterative tree walk (so scanning
/// a deep model does not itself recurse on depth). It descends compositors but stops
/// at an `<element>`, exactly as the determinism walk's `summarize` treats an element
/// as terminal: a group inside an element's inline complex type belongs to THAT type's
/// own content model (checked separately), not this one, and must not be drawn in.
private static func referencedGroupNames(in node: PureXML.Model.TreeNode) -> [String] {
var names: [String] = []
var stack = [node]
while let current = stack.popLast() {
let local = PureXML.Schema.XSDNode.localName(current)
if local == "group", let ref = PureXML.Schema.XSDNode.attribute(current, "ref") {
names.append(PureXML.Schema.XSDNode.stripPrefix(ref))
}
if local != "element" {
stack.append(contentsOf: PureXML.Schema.XSDNode.elementChildren(current))
}
}
return names
}
/// The groups `name` references. A redefined group's base-model references are added
/// ONLY when the redefinition actually self-references (`<group ref=name>` inside the
/// redefinition of `name`): that reference expands to the base model
/// (`CompWalk.redefineSelfReference`), making the base's groups part of the effective
/// content model, so they must be ordered first. When the redefinition does NOT
/// self-reference, the base is fully replaced and dead, and pulling its refs in would
/// summarise unreachable groups: surfacing a conflict in a dead group is a false
/// positive, so it is deliberately excluded. This keeps the reachable set identical
/// to the lazy walk's.
private static func dependencies(of name: String, context: PureXML.Schema.XSDContext) -> [String] {
guard let definition = context.groups[name], let model = modelChild(definition) else { return [] }
var refs = referencedGroupNames(in: model)
if refs.contains(name), context.redefinedGroups.contains(name), let base = context.baseGroups[name], let baseModel = modelChild(base) {
refs += referencedGroupNames(in: baseModel)
}
return refs
}
/// The `sequence`/`choice`/`all` model child of a group definition, if any.
private static func modelChild(_ definition: PureXML.Model.TreeNode) -> PureXML.Model.TreeNode? {
PureXML.Schema.XSDNode.elementChildren(definition)
.first { ["sequence", "choice", "all"].contains(PureXML.Schema.XSDNode.localName($0) ?? "") }
}
}