test(conformance): xalan-test XSLT 1.0 runner with exact file baseline

The Apache xalan-test conf corpus (1690 gold-bearing cases, 36
categories) runs opt-in via XALAN_TS_ROOT with normalized comparison:
canonical XML when both sides parse, collapsed whitespace otherwise.
First measurement passes 1086; the 604 known failures live in
Tests/Fixtures/xalan-baseline.txt, asserted exactly in both directions.
Top classes: namespace handling, xsl:number, axes corners, output
options, attribute sets, id()/key().
This commit is contained in:
Mihaela Mihaljevic 2026-06-10 11:46:18 +02:00
parent 3415ff3d9a
commit 1634af137b
4 changed files with 722 additions and 2 deletions

View File

@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- XSLT 1.0 conformance runner over the Apache xalan-test corpus (#122, #130): the de facto OASIS-era suite (1690 gold-bearing cases across 36 categories) runs opt-in via XALAN_TS_ROOT, never vendored. Comparison is deliberately normalized: outputs that parse as XML compare by canonical form (Xalan's indentation is not normative), others with whitespace runs collapsed. First measurement: 1086 pass; the 604 known failures are carried exactly in Tests/Fixtures/xalan-baseline.txt, asserted in both directions (a fixed case must leave the file, a regression shows as a new failure). The top burn-down classes by count: namespace handling (123, led by namespaced xsl:attribute creation), xsl:number depth (85), axes corners (68), output/serialization options (55), attribute sets (52), id()/key() (41).
- RELAX NG schema-correctness validation (#131): a schema document is now validated before any pattern interpretation, in three layers. The grammar check (section 3) rejects unknown or misplaced elements, illegal unqualified and RELAX NG-namespace attributes, wrong child arity, stray text, malformed or undeclared-prefix QNames, non-URI datatypeLibrary values (absolute, fragment-free, valid percent escapes), fragment-carrying hrefs, the 4.16 except restrictions (anyName/nsName inside excepts, propagated through choice), and the xmlns name prohibitions including the ns-attribute and except-mention forms. The compiler checks reject combine conflicts and double combine-less defines (4.17), unresolved refs and parentRef without a parent, startless grammars, include/externalRef self-reference, include overrides with nothing to override, non-grammar include targets (loaded documents are grammar-checked too), unknown datatypes, and params against the built-in library. The restrictions checker (4.19, 7.1-7.4) runs over the compiled algebra after 4.20/4.21 normalization (notAllowed/empty operands simplify away first; recursion detection deliberately precedes it): prohibited paths under attribute, oneOrMore-group, list, and in start; computable content types; duplicate attributes by representative-name overlap; infinite-name-class attributes outside oneOrMore; and the interleave text/element constraints. The spec suite's incorrect class drops from 213 compiling to 7, all pre-Fifth-Edition name-class cases (the xmltest-141 class, count asserted), with zero correct-schema or instance regressions. Two leniency tests updated to the spec behavior (double combine-less defines and unknown datatypes are schema errors).
- RELAX NG QName values, instance level clean (#131): `<value type="QName">` compiles to its (namespace, local-name) value pair via the schema's xmlns/ns scope, and the derivative engine threads the instance element's in-scope namespace bindings down to value comparison (in both the tree walk and the streaming state, whose frames now carry their bindings), so an instance QName resolves its prefix, or the default namespace when unprefixed, against the instance document itself; an unbound prefix matches nothing. The streaming path also gains the tree walk's empty-element rule. With this the spec suite's instance level is fully clean: valid 289/289 accepted and invalid 291/291 rejected; only the asserted schema-correctness class remains.
- 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

@ -100,7 +100,7 @@ flowchart TB
BaseURI["#138 per-entity base URIs: complete"]:::done
Epic122["#122 official spec suites"]:::epic
XSTS["#129 W3C XSD test suite (XSTS)"]:::todo
XSLTTS["#130 OASIS XSLT 1.0 suite (+XPath corpus)"]:::todo
XSLTTS["#130 XSLT 1.0 suite (xalan-test): runner + baselines in, burning down"]:::review
RNGTS["#131 RELAX NG spec suite: complete, one documented class"]:::done
C14NTS["#132 C14N spec vectors: complete"]:::done
Epic105 --> Epic121
@ -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 **940 tests in 162 suites** (`swift test`).
currently runs **941 tests in 163 suites** (`swift test`).
### Shipped (libxml2-aligned surface)

View File

@ -0,0 +1,607 @@
# The xalan-test conf cases this implementation does not yet pass,
# one category/name per line, exact in both directions: a fixed case
# must leave this list and a regression shows up as a new failure.
attribset/attribset04
attribset/attribset09
attribset/attribset10
attribset/attribset13
attribset/attribset14
attribset/attribset16
attribset/attribset17
attribset/attribset18
attribset/attribset19
attribset/attribset21
attribset/attribset24
attribset/attribset26
attribset/attribset27
attribset/attribset28
attribset/attribset29
attribset/attribset31
attribset/attribset32
attribset/attribset35
attribset/attribset36
attribset/attribset40
attribset/attribset41
attribset/attribset43
attribset/attribset44
attribset/attribset45
attribset/attribset46
attribset/attribset48
attribvaltemplate/attribvaltemplate08
axes/axes03
axes/axes103
axes/axes104
axes/axes105
axes/axes106
axes/axes107
axes/axes108
axes/axes109
axes/axes110
axes/axes111
axes/axes112
axes/axes113
axes/axes116
axes/axes120
axes/axes123
axes/axes124
axes/axes126
axes/axes128
axes/axes129
axes/axes130
axes/axes18
axes/axes19
axes/axes44
axes/axes45
axes/axes46
axes/axes58
axes/axes59
axes/axes60
axes/axes62
axes/axes68
axes/axes88
axes/axes89
axes/axes93
axes/axes98
axes/axes99
boolean/boolean43
boolean/boolean87
conflictres/conflictres04
conflictres/conflictres18
conflictres/conflictres19
conflictres/conflictres20
conflictres/conflictres22
conflictres/conflictres23
conflictres/conflictres25
conflictres/conflictres26
conflictres/conflictres29
conflictres/conflictres30
conflictres/conflictres32
conflictres/conflictres34
conflictres/conflictres37
copy/copy01
copy/copy03
copy/copy07
copy/copy09
copy/copy16
copy/copy17
copy/copy19
copy/copy20
copy/copy21
copy/copy22
copy/copy25
copy/copy27
copy/copy31
copy/copy33
copy/copy34
copy/copy35
copy/copy36
copy/copy37
copy/copy38
copy/copy39
copy/copy44
copy/copy46
copy/copy47
copy/copy48
copy/copy51
copy/copy52
copy/copy54
copy/copy56
copy/copy58
copy/copy60
copy/copy62
dflt/dflt01
embed/embed03
embed/embed04
expression/expression02
extend/extend01
extend/extend02
extend/extend04
extend/extend05
idkey/idkey01
idkey/idkey03
idkey/idkey04
idkey/idkey09
idkey/idkey12
idkey/idkey18
idkey/idkey19
idkey/idkey21
idkey/idkey22
idkey/idkey23
idkey/idkey24
idkey/idkey26
idkey/idkey30
idkey/idkey31
idkey/idkey32
idkey/idkey33
idkey/idkey35
idkey/idkey37
idkey/idkey38
idkey/idkey39
idkey/idkey40
idkey/idkey41
idkey/idkey42
idkey/idkey43
idkey/idkey44
idkey/idkey45
idkey/idkey46
idkey/idkey47
idkey/idkey48
idkey/idkey50
idkey/idkey51
idkey/idkey53
idkey/idkey55
idkey/idkey56
idkey/idkey57
idkey/idkey58
idkey/idkey59
idkey/idkey60
idkey/idkey61
idkey/idkey62
idkey/idkey63
impincl/impincl07
impincl/impincl08
impincl/impincl09
impincl/impincl11
impincl/impincl17
impincl/impincl19
impincl/impincl21
impincl/impincl23
impincl/impincl25
impincl/impincl26
impincl/impincl27
lre/lre01
lre/lre02
lre/lre03
lre/lre04
lre/lre05
lre/lre10
lre/lre11
lre/lre12
lre/lre15
lre/lre17
lre/lre18
lre/lre19
lre/lre20
lre/lre21
lre/lre22
lre/lre23
match/match11
match/match14
match/match34
mdocs/mdocs01
mdocs/mdocs05
mdocs/mdocs06
mdocs/mdocs07
mdocs/mdocs08
mdocs/mdocs09
mdocs/mdocs11
mdocs/mdocs12
mdocs/mdocs13
mdocs/mdocs14
mdocs/mdocs15
mdocs/mdocs16
mdocs/mdocs17
mdocs/mdocs18
mdocs/mdocs19
modes/modes06
modes/modes07
modes/modes11
modes/modes12
modes/modes14
modes/modes16
namedtemplate/namedtemplate04
namedtemplate/namedtemplate05
namedtemplate/namedtemplate12
namedtemplate/namedtemplate16
namedtemplate/namedtemplate17
namedtemplate/namedtemplate18
namedtemplate/namedtemplate19
namespace/namespace01
namespace/namespace02
namespace/namespace03
namespace/namespace04
namespace/namespace06
namespace/namespace07
namespace/namespace09
namespace/namespace10
namespace/namespace100
namespace/namespace101
namespace/namespace102
namespace/namespace103
namespace/namespace104
namespace/namespace105
namespace/namespace106
namespace/namespace107
namespace/namespace108
namespace/namespace109
namespace/namespace11
namespace/namespace110
namespace/namespace111
namespace/namespace112
namespace/namespace113
namespace/namespace115
namespace/namespace116
namespace/namespace117
namespace/namespace12
namespace/namespace120
namespace/namespace121
namespace/namespace122
namespace/namespace123
namespace/namespace124
namespace/namespace125
namespace/namespace126
namespace/namespace127
namespace/namespace128
namespace/namespace13
namespace/namespace130
namespace/namespace131
namespace/namespace132
namespace/namespace134
namespace/namespace137
namespace/namespace138
namespace/namespace139
namespace/namespace140
namespace/namespace141
namespace/namespace142
namespace/namespace15
namespace/namespace17
namespace/namespace18
namespace/namespace19
namespace/namespace20
namespace/namespace21
namespace/namespace22
namespace/namespace23
namespace/namespace24
namespace/namespace25
namespace/namespace26
namespace/namespace27
namespace/namespace32
namespace/namespace33
namespace/namespace34
namespace/namespace35
namespace/namespace36
namespace/namespace37
namespace/namespace38
namespace/namespace39
namespace/namespace40
namespace/namespace42
namespace/namespace43
namespace/namespace44
namespace/namespace45
namespace/namespace46
namespace/namespace47
namespace/namespace48
namespace/namespace49
namespace/namespace50
namespace/namespace51
namespace/namespace52
namespace/namespace53
namespace/namespace54
namespace/namespace55
namespace/namespace56
namespace/namespace57
namespace/namespace58
namespace/namespace59
namespace/namespace60
namespace/namespace61
namespace/namespace62
namespace/namespace63
namespace/namespace64
namespace/namespace65
namespace/namespace66
namespace/namespace67
namespace/namespace68
namespace/namespace69
namespace/namespace72
namespace/namespace73
namespace/namespace74
namespace/namespace75
namespace/namespace77
namespace/namespace78
namespace/namespace79
namespace/namespace80
namespace/namespace81
namespace/namespace82
namespace/namespace83
namespace/namespace84
namespace/namespace86
namespace/namespace87
namespace/namespace88
namespace/namespace89
namespace/namespace90
namespace/namespace91
namespace/namespace92
namespace/namespace93
namespace/namespace94
namespace/namespace95
namespace/namespace96
namespace/namespace97
namespace/namespace98
namespace/namespace99
node/node03
node/node04
node/node05
node/node06
node/node09
node/node10
node/node11
node/node12
node/node13
node/node15
node/node16
node/node17
node/node19
numberformat/numberformat01
numberformat/numberformat02
numberformat/numberformat03
numberformat/numberformat04
numberformat/numberformat06
numberformat/numberformat07
numberformat/numberformat08
numberformat/numberformat09
numberformat/numberformat11
numberformat/numberformat12
numberformat/numberformat16
numberformat/numberformat17
numberformat/numberformat19
numberformat/numberformat20
numberformat/numberformat21
numberformat/numberformat22
numberformat/numberformat23
numberformat/numberformat24
numberformat/numberformat25
numberformat/numberformat27
numberformat/numberformat28
numberformat/numberformat29
numberformat/numberformat30
numberformat/numberformat31
numberformat/numberformat34
numberformat/numberformat35
numberformat/numberformat36
numberformat/numberformat41
numberformat/numberformat43
numberformat/numberformat44
numbering/numbering03
numbering/numbering04
numbering/numbering05
numbering/numbering06
numbering/numbering07
numbering/numbering08
numbering/numbering09
numbering/numbering10
numbering/numbering11
numbering/numbering12
numbering/numbering13
numbering/numbering14
numbering/numbering17
numbering/numbering18
numbering/numbering19
numbering/numbering20
numbering/numbering21
numbering/numbering22
numbering/numbering23
numbering/numbering24
numbering/numbering25
numbering/numbering26
numbering/numbering27
numbering/numbering28
numbering/numbering29
numbering/numbering30
numbering/numbering31
numbering/numbering32
numbering/numbering33
numbering/numbering34
numbering/numbering35
numbering/numbering36
numbering/numbering37
numbering/numbering38
numbering/numbering39
numbering/numbering40
numbering/numbering41
numbering/numbering42
numbering/numbering43
numbering/numbering44
numbering/numbering45
numbering/numbering46
numbering/numbering47
numbering/numbering48
numbering/numbering49
numbering/numbering50
numbering/numbering51
numbering/numbering52
numbering/numbering56
numbering/numbering57
numbering/numbering58
numbering/numbering59
numbering/numbering60
numbering/numbering61
numbering/numbering62
numbering/numbering63
numbering/numbering65
numbering/numbering66
numbering/numbering67
numbering/numbering68
numbering/numbering69
numbering/numbering70
numbering/numbering71
numbering/numbering72
numbering/numbering73
numbering/numbering74
numbering/numbering77
numbering/numbering78
numbering/numbering79
numbering/numbering80
numbering/numbering81
numbering/numbering82
numbering/numbering83
numbering/numbering84
numbering/numbering85
numbering/numbering86
numbering/numbering87
numbering/numbering89
numbering/numbering90
numbering/numbering91
numbering/numbering92
numbering/numbering93
numbering/numbering94
numbering/numbering95
output/output01
output/output02
output/output03
output/output04
output/output05
output/output06
output/output08
output/output09
output/output10
output/output101
output/output102
output/output103
output/output13
output/output15
output/output17
output/output22
output/output26
output/output27
output/output30
output/output32
output/output33
output/output34
output/output35
output/output36
output/output37
output/output38
output/output39
output/output40
output/output41
output/output48
output/output49
output/output50
output/output51
output/output52
output/output58
output/output59
output/output60
output/output61
output/output62
output/output63
output/output64
output/output65
output/output70
output/output71
output/output72
output/output73
output/output74
output/output75
output/output80
output/output82
output/output86
output/output89
output/output90
output/output98
output/output99
position/position09
position/position10
position/position101
position/position106
position/position111
position/position70
position/position75
position/position91
position/position98
predicate/predicate38
processorinfo/processorinfo03
reluri/reluri01
reluri/reluri02
reluri/reluri03
reluri/reluri04
reluri/reluri05
reluri/reluri06
reluri/reluri07
reluri/reluri08
reluri/reluri09
reluri/reluri10
reluri/reluri11
select/select04
select/select13
select/select16
select/select27
select/select28
select/select59
select/select67
select/select68
select/select69
select/select73
select/select74
select/select75
select/select83
select/select84
sort/sort-alphabet-english
sort/sort-alphabet-polish
sort/sort-alphabet-russian
sort/sort01
sort/sort02
sort/sort08
sort/sort09
sort/sort10
sort/sort13
sort/sort14
sort/sort21
sort/sort27
sort/sort28
sort/sort32
sort/sort33
sort/sort34
sort/sort40
sort/sort41
string/string130
string/string132
string/string143
string/string30
string/string31
string/string32
string/string33
string/string34
string/string35
string/string36
variable/variable29
variable/variable31
variable/variable32
variable/variable33
variable/variable34
variable/variable35
variable/variable41
variable/variable48
variable/variable54
variable/variable55
variable/variable56
variable/variable63
variable/variable67
variable/variable70
whitespace/whitespace06
whitespace/whitespace07
whitespace/whitespace13
whitespace/whitespace17
whitespace/whitespace20
whitespace/whitespace21
whitespace/whitespace35

View File

@ -0,0 +1,112 @@
import Foundation
@testable import PureXML
import Testing
/// Opt-in runner for the Apache xalan-test XSLT 1.0 conformance corpus (the
/// de facto OASIS-era suite: per-category stylesheet/source pairs with gold
/// outputs, Apache License 2.0). Point `XALAN_TS_ROOT` at the checkout's
/// `tests` directory to run; the corpus is never vendored. Comparison
/// normalizes deliberately: a gold and an actual that both parse as XML are
/// compared by canonical form (Xalan's indentation is not normative); other
/// outputs compare with whitespace runs collapsed. Foundation is used for
/// file access only.
@Suite("Xalan XSLT conformance (opt-in via XALAN_TS_ROOT)")
struct XalanConfSuiteTests {
private var root: String? {
ProcessInfo.processInfo.environment["XALAN_TS_ROOT"]
}
/// Cases this implementation knowingly fails, kept exact in
/// Tests/Fixtures/xalan-baseline.txt (one category/name per line): a
/// fixed case must leave the file and a regression shows as a new
/// failure.
private func knownFailures() -> Set<String> {
guard let url = Bundle.module.url(forResource: "xalan-baseline", withExtension: "txt", subdirectory: "Fixtures"),
let text = try? String(contentsOf: url, encoding: .utf8)
else { return [] }
return Set(text.split(separator: "\n").map(String.init).filter { !$0.hasPrefix("#") && !$0.isEmpty })
}
/// Categories that are out of scope: extension mechanisms beyond the
/// XSLT 1.0 core that this package does not ship.
private let outOfScopeCategories: Set<String> = []
@Test("Every conf case transforms to its gold output")
func test_confSuite() throws {
guard let root else {
return // Opt-in: suite not present.
}
let confDirectory = root + "/conf"
let goldDirectory = root + "/conf-gold"
let manager = FileManager.default
let baseline = knownFailures()
var failures: [String: String] = [:]
var passed = 0
var skippedNoGold = 0
let categories = try manager.contentsOfDirectory(atPath: confDirectory)
.filter { !outOfScopeCategories.contains($0) }
.sorted()
for category in categories {
let categoryPath = confDirectory + "/" + category
var isDirectory = ObjCBool(false)
guard manager.fileExists(atPath: categoryPath, isDirectory: &isDirectory), isDirectory.boolValue else { continue }
for sheet in try manager.contentsOfDirectory(atPath: categoryPath).filter({ $0.hasSuffix(".xsl") }).sorted() {
let name = String(sheet.dropLast(4))
let key = category + "/" + name
let goldPath = goldDirectory + "/" + category + "/" + name + ".out"
guard manager.fileExists(atPath: goldPath) else {
skippedNoGold += 1
continue
}
if let problem = runCase(category: category, name: name, root: root, goldPath: goldPath) {
failures[key] = problem
} else {
passed += 1
}
}
}
try? failures.keys.sorted().joined(separator: "\n").write(toFile: "/tmp/xalan-failures.txt", atomically: true, encoding: .utf8)
let newFailures = Set(failures.keys).subtracting(baseline).sorted()
let fixed = baseline.subtracting(failures.keys).sorted()
let sample = newFailures.prefix(3).map { ($0, failures[$0] ?? "") }
let report = "passed \(passed), no-gold \(skippedNoGold), \(newFailures.count) NEW: \(newFailures.prefix(40)) :: \(sample)"
#expect(newFailures.isEmpty, Comment(rawValue: report))
#expect(fixed.isEmpty, "\(fixed.count) baselined cases now pass (remove from xalan-baseline.txt): \(fixed.prefix(40))")
}
private func runCase(category: String, name: String, root: String, goldPath: String) -> String? {
let directory = root + "/conf/" + category
guard let stylesheet = try? String(contentsOfFile: directory + "/" + name + ".xsl", encoding: .utf8),
let source = try? String(contentsOfFile: directory + "/" + name + ".xml", encoding: .utf8)
else {
return "unreadable inputs"
}
guard let goldData = FileManager.default.contents(atPath: goldPath),
let gold = (try? PureXML.Parsing.ByteDecoder.decode([UInt8](goldData))) ?? String(data: goldData, encoding: .utf8)
else {
return "unreadable gold"
}
let loader: (String) -> String? = { uri in
(try? String(contentsOfFile: directory + "/" + uri, encoding: .utf8))
}
let actual: String
do {
actual = try PureXML.XSLT.transform(stylesheet: stylesheet, source: source, documentLoader: loader)
} catch {
return "transform threw: \(error)"
}
return Self.equivalent(actual: actual, gold: gold) ? nil : "output differs from gold"
}
/// Gold comparison: canonical XML when both sides parse (indentation is
/// not normative), collapsed whitespace otherwise.
static func equivalent(actual: String, gold: String) -> Bool {
if let actualNode = try? PureXML.parse(actual), let goldNode = try? PureXML.parse(gold) {
return PureXML.Canonical.canonicalize(actualNode) == PureXML.Canonical.canonicalize(goldNode)
}
let collapse = { (text: String) in
text.split(whereSeparator: \.isWhitespace).joined(separator: " ")
}
return collapse(actual) == collapse(gold)
}
}