canon 0.3.116 → 0.3.118

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 89df5f303938b98100a1d44399c7eb5fbbf793f0f91c445c4f0533a19234d5ec
4
- data.tar.gz: 1308c9228b0a6efc15e81ebef134c7495aadd100e10978a23ee09b92ad558555
3
+ metadata.gz: 775ec23689366d5e58c5442ad78706027ba7f8e8efba6c06aede93907160f607
4
+ data.tar.gz: 3f73899302308caf78050f7a692a159cb5e2cc53cbf822e0773c9632bd8beeef
5
5
  SHA512:
6
- metadata.gz: 93cfb6bca734697c5cc79173dac20bbe53cb4fb17e72fc72e7aa726af366c187c09ae0c30e5a775d9ea8b9e94f1194b29657f9da3d44c58f6fbc116717e39909
7
- data.tar.gz: 37f713fb17fe7961cb622f42c96605cf0bb19227a6831018d0505afdc33ad2e315e54b1a65145219457e30d8b0c6d06d001869611bbe94f55b443c2cbf0e4252
6
+ metadata.gz: fb497832a82755ec47f0314429f58646c7a93be3e6597c226dc605788492db26b985f98923fd87bdac7b9d365724bb5a4907a73ca10255d99edd13621ce2e6da
7
+ data.tar.gz: 972cf0c38d77cea7dea322430ba681456718c0aed4d153eef40116a438a855c70371289d0d6a74c00a5a3e51e12982d069ede9650e2bc710fd0448e89b8c5f8e
data/CLAUDE.md CHANGED
@@ -128,18 +128,18 @@ The comparison pipeline flows through four independent layers:
128
128
  - `lib/canon/formatters/` — Pretty-printers for canonicalization/formatting (distinct from diff formatters). `XmlFormatter` supports Canonical XML (C14N).
129
129
  - `lib/canon/config.rb` — Global configuration with per-format settings (profiles, preprocessing, diff options). Read at runtime via `Canon::Config.instance`.
130
130
  - `lib/canon/rspec_matchers.rb` — RSpec matchers (`be_xml_equivalent_to`, `be_json_equivalent_to`, etc.). These delegate to `Canon::Comparison.equivalent?` with the global config. Automatically included in RSpec.
131
- - `lib/canon/xml/sax_builder.rb` — SAX-based canon-tree builder (~6x faster than DOM parsing for large documents). Engine-neutral; `lib/canon/xml/sax.rb` selects the driver following the resolved adapter (`MoxmlDriver` → leptris SAX under the leptris default, `NokogiriDriver` when the adapter is nokogiri, `MoxmlDriver` under Opal).
131
+ - `lib/canon/xml/sax_builder.rb` — SAX-based canon-tree builder (~6x faster than DOM parsing for large documents). Engine-neutral; `lib/canon/xml/sax.rb` selects the driver following the resolved adapter (`MoxmlDriver` → leptris SAX under the leptris default, `NokogiriDriver` when the adapter is nokogiri).
132
132
 
133
133
  ### XML Engines
134
134
 
135
135
  Canon is engine-agnostic across three seams (MECE — one concern per module):
136
136
 
137
- - `Canon::XmlBackend` — XML engine selection. The default follows moxml's resolved adapter: **leptris whenever it is installed** (parse ~1.6x, serialize ~3.8x vs Nokogiri), raw Nokogiri otherwise (wrapping Nokogiri in moxml buys nothing — the wrapper adds 2-3x overhead). `:moxml` under Opal. `CANON_XML_BACKEND=nokogiri|moxml` forces either engine.
137
+ - `Canon::XmlBackend` — XML engine selection. The default follows moxml's resolved adapter: **leptris whenever it is installed** (parse ~1.6x, serialize ~3.8x vs Nokogiri), raw Nokogiri otherwise (wrapping Nokogiri in moxml buys nothing — the wrapper adds 2-3x overhead). Opal is retired (moxml 0.6 deleted the Opal surface, lutaml/moxml#516; the forward path is the moxml-ts wasm binding, lutaml/moxml#489). `CANON_XML_BACKEND=nokogiri|moxml` forces either engine.
138
138
  - `Canon::XmlParsing` — the only place that talks to engines for XML parse/serialize (moxml parses pass `readonly: true` — canon never mutates engine documents); node type queries answer for ANY recognized node (Nokogiri or moxml) by type, never by active backend — user-supplied Nokogiri nodes keep working under the moxml engine.
139
139
  - `Canon::Html::NokogiriSupport` — HTML engine seam. **HTML is engine-served end-to-end** since 0.3.86-0.3.101: `HtmlParser.parse` routes through moxml `Context#parse_html_fragment` (WHATWG fragment parsing, moxml#353 riding leptris) and builds a Canon tree via the materialize-records fast path — whitespace text nodes survive the parse unconditionally (HTML whitespace significance is the comparison profile's decision, #112/#137), and `parse_node` walks Canon trees directly. The semantic lane's `DataModel.from_html`, the `:format` string normalizer, the canonical HTML formatters, `format_html`'s default/XHTML display modes, and `validate_xhtml!` are all engine-served (0.3.99/0.3.100/0.3.101) — **HTML comparison and formatting run nokogiri-less** (pinned by the optionality contract spec). The Nokogiri HTML fragment path remains only for runtimes without an engine HTML adapter and for user-supplied Nokogiri nodes. Nokogiri's residual roles: the `fixture_ready` display mode (libxml FORMAT|AS_XHTML — moxml#390 filed for an engine XHTML mode), the HTML5 validation lint layer (gumbo error-level classification — leptris#1771 filed), and the fragment-records lane once delivered nil URIs for prefixed attributes (moxml#367, fixed in moxml 0.5.130 — the bulk read now falls back to the reserved-prefix table); the TreeBuilder keeps the one legal `xml`-prefix binding as standing defense.
140
- - `Canon::Xml::Sax` — SAX driver selection follows the resolved adapter: `MoxmlDriver` (leptris SAX — the comparison hot path, CI-gated in the split flip) under the leptris default, `NokogiriDriver` when the resolved adapter is nokogiri, `MoxmlDriver` under Opal. The builder (`SaxBuilder`) is engine-neutral.
140
+ - `Canon::Xml::Sax` — SAX driver selection follows the resolved adapter: `MoxmlDriver` (leptris SAX — the comparison hot path, CI-gated in the split flip) under the leptris default, `NokogiriDriver` when the resolved adapter is nokogiri. The builder (`SaxBuilder`) is engine-neutral.
141
141
 
142
- Engine parity is complete through libleptris 1.9.8 / leptris-ruby 1.9.33 / moxml 0.5.12: attribute-value normalization, prolog/epilog PIs and comments, DTD ATTLIST defaults off by default (opt-in via `ParseOptions::DTDATTR`), PI-data whitespace matching libxml2, materialize with namespace declarations and a root-subtree-only record contract (moxml#140), readonly parse, deterministic `Document#free` (wired into `from_moxml_xml` — moxml#134). Canon's moxml conversion is record-based (`build_from_moxml` consumes `materialize` records — no per-node wrapper allocation; ~0.55x the Nokogiri conversion). `spec/canon/xml/engine_parity_spec.rb` is fully green. Pretty-printing runs the leptris serializer through `PrettyPrinter::Xml#moxml_format` (byte parity via moxml#153/#155/#156; the #167 escaping race fixed in 0.5.28), with Nokogiri serving the fallback engine and Opal — pretty-printed bytes are canon's product, gated by the fixture-integrity suite and the CI performance gate.
142
+ Engine parity is complete through libleptris 1.9.8 / leptris-ruby 1.9.33 / moxml 0.5.12: attribute-value normalization, prolog/epilog PIs and comments, DTD ATTLIST defaults off by default (opt-in via `ParseOptions::DTDATTR`), PI-data whitespace matching libxml2, materialize with namespace declarations and a root-subtree-only record contract (moxml#140), readonly parse, deterministic `Document#free` (wired into `from_moxml_xml` — moxml#134). Canon's moxml conversion is record-based (`build_from_moxml` consumes `materialize` records — no per-node wrapper allocation; ~0.55x the Nokogiri conversion). `spec/canon/xml/engine_parity_spec.rb` is fully green. Pretty-printing runs the leptris serializer through `PrettyPrinter::Xml#moxml_format` (byte parity via moxml#153/#155/#156; the #167 escaping race fixed in 0.5.28), with Nokogiri serving the fallback engine — pretty-printed bytes are canon's product, gated by the fixture-integrity suite and the CI performance gate.
143
143
 
144
144
  Engine A/B testing: `CANON_XML_BACKEND=nokogiri bundle exec rspec` (or `=moxml` to force leptris when it isn't the resolved default). The default suite must stay green under BOTH values; the only expected pendings are the upstream-tracked ones. The benchmark header (`rake performance:quick`) reports the active engine.
145
145
 
data/Rakefile CHANGED
@@ -5,50 +5,10 @@ require "rspec/core/rake_task"
5
5
 
6
6
  RSpec::Core::RakeTask.new(:spec)
7
7
 
8
- begin
9
- require "opal/rspec/rake_task"
10
-
11
- # Configure Opal load paths at load time (same pattern as moxml)
12
- if defined?(Opal)
13
- Opal.append_path File.expand_path("lib", __dir__)
14
-
15
- # moxml gem
16
- begin
17
- moxml_spec = Gem::Specification.find_by_name("moxml")
18
- moxml_lib = moxml_spec.full_require_paths.first
19
- Opal.append_path moxml_lib
20
- moxml_compat = File.expand_path("compat/opal", moxml_lib)
21
- Opal.append_path moxml_compat if File.directory?(moxml_compat)
22
- rescue Gem::MissingSpecError
23
- # moxml not installed
24
- end
25
-
26
- # REXML: bundled gem since Ruby 3.4
27
- rexml_lib = $LOAD_PATH.find do |p|
28
- File.exist?(File.join(p, "rexml", "document.rb"))
29
- end
30
- Opal.append_path rexml_lib if rexml_lib
31
- end
32
- rescue LoadError
33
- # Opal not available or incompatible with current Ruby version
34
- end
35
-
36
8
  require "rubocop/rake_task"
37
9
 
38
10
  RuboCop::RakeTask.new
39
11
 
40
12
  Dir.glob("lib/tasks/**/*.rake").each { |r| load r }
41
13
 
42
- namespace :spec do
43
- if defined?(Opal::RSpec::RakeTask)
44
- desc "Run Opal (JavaScript) tests"
45
- Opal::RSpec::RakeTask.new(:opal) do |_server, runner|
46
- runner.default_path = "spec"
47
- runner.requires = %w[rexml_compat rexml/document rexml/xpath
48
- moxml moxml/adapter/rexml spec_helper]
49
- runner.pattern = "spec/canon/opal_xml_smoke_spec.rb"
50
- end
51
- end
52
- end
53
-
54
14
  task default: %i[spec rubocop]
data/docs/HANDOFF.adoc ADDED
@@ -0,0 +1,178 @@
1
+ ---
2
+ published: false
3
+ ---
4
+ = Cross-team handoff: the engine issue board
5
+
6
+ A snapshot of the open arcs across the org repos — what is known, where
7
+ to dig, and what has already been proven — so any team can pick an issue
8
+ up cold. Statuses as of 2026-10-28.
9
+
10
+ == lutaml/moxml
11
+
12
+ [cols="1,1,2"]
13
+ |===
14
+ |Issue |Status |Known + first moves
15
+
16
+ |moxml#474 — parent-of-attribute (`./@id/..`) returns empty through the
17
+ Ruby engine on the leptris adapter
18
+ |Open (filed 2026-10-27 with probes)
19
+ |The adapter routes parent-axis expressions to the Ruby engine
20
+ (`REVERSE_AXES` in `adapter/leptris.rb`); that lane loses attribute
21
+ inputs. The ENGINE answers correctly since leptris v1.9.385 (both eval
22
+ paths). Easiest: widen the native gate for parent/ancestor-after-
23
+ attribute paths; or fix `on_axis_parent` in `xpath/compiler.rb` for
24
+ `Wrappers::Attribute` inputs (the wrappers carry `@parent_node`).
25
+ Unblocks metanorma-standoc's `svg_uniqueids1`.
26
+
27
+ |moxml#472 — `.//*[@class]` predicate segfaults the compiled evaluator
28
+ |Open
29
+ |Crash in `leptris_xpath_compiled_eval` (.ips on file); the
30
+ `attribute::class` spelling does not crash. FIRST retest against
31
+ leptris v1.9.385 — a corrupt-attribute-table class (the cross-doc
32
+ adopt, leptris#1869) shipped fixed there and could be the fuel. If it
33
+ still crashes: the VM's predicate lane (`src/leptris/xpath/vm.c`,
34
+ attribute existence inside `[...]`). Production hit: standoc's
35
+ `svg_classupdate1`.
36
+
37
+ |moxml#473 — XSD compile of large occurrence products grows unbounded
38
+ (jetsam SIGKILL, low RSS)
39
+ |Open
40
+ |W3C `particlesZ034_b.xsd` repro in the issue. The compiled automaton's
41
+ product construction needs cardinality folding (`unbounded` inner terms
42
+ to epsilon-loops) instead of enumeration.
43
+ |===
44
+
45
+ == leptris/leptris
46
+
47
+ [cols="1,1,2"]
48
+ |===
49
+ |Issue |Status |Known + first moves
50
+
51
+ |leptris#1866 — cross-doc replace + `@id/..` + serialize
52
+ |CLOSED (v1.9.385, #1869)
53
+ |Shipped: the adopt copy's attribute-tail resync
54
+ (`copy_subtree_detached`, `src/leptris/dom/element_modify.c`) and the
55
+ evaluator-path parent-of-attribute (`apply_axis`,
56
+ `src/leptris/xpath/evaluator_axes.c`). Pin:
57
+ `AdoptKeepsMixedPlainAndNamespacedAttrs`. Re-verify downstream crash
58
+ reports (moxml#472) against 1.9.385.
59
+
60
+ |leptris#1842 — C-face XML parse/gen/xpath ~3-4x behind pugixml
61
+ |Open
62
+ |Decomposed into #1846/#1854/#1855. Use the interleaved A/B harness.
63
+
64
+ |leptris#1846 — parse gap is allocator-shaped (1.0x mingw -> 4.3x old
65
+ Intel mac, same code)
66
+ |Open, slice 3 pending
67
+ |Allocation profile done (the issue's wave comment). ARENA
68
+ RIGHT-SIZING with the interleaved harness — cross-wave comparison is
69
+ INVALID (pugixml swung 1.7x between waves; only within-run interleaved
70
+ A/B counts). A counting-malloc interposer via `DYLD_INTERPOSE` beats
71
+ xctrace for parse loops. Prior art: `leptris-xsitype` worktree,
72
+ branch `perf/1846-adopt-conversion-buffer`.
73
+
74
+ |leptris#1854 — `to_xml` native emission: `FFI::Pointer#+` is 37% of
75
+ the loop
76
+ |Open
77
+ |An address-Integer emission API (`uintptr_t` in/out, the #408 law);
78
+ batch per subtree, not per node.
79
+
80
+ |leptris#1855 — plan-path parse ~35% FFI materialization
81
+ |Open
82
+ |Batched child/string asks; mirror the `children_read` bulk face
83
+ (leptris-ruby#530).
84
+ |===
85
+
86
+ == leptris/yeptris
87
+
88
+ [cols="1,1,2"]
89
+ |===
90
+ |Issue |Status |Known + first moves
91
+
92
+ |yeptris#739 — JSON parse 2x behind yyjson
93
+ |Open
94
+ |The 12x WRITE gap is closed (serialize 16->221 MB/s: the one-pass
95
+ route, the run-batched `emit_dq_json` SWAR scan, the `emit_json_value`
96
+ walker). Parse remains; profile with the counting-malloc interposer.
97
+
98
+ |yeptris#742 — YAML emit ~1.5x behind rapidyaml; CBOR decode 1.3-2.2x
99
+ behind tinycbor
100
+ |Open
101
+ |13-leg matrix. The emit side shares machinery with the closed #739
102
+ write work — reuse the walker patterns.
103
+
104
+ |yeptris#474 — `parse_json_tape` 0.61x vs simdjson on arm64 (ubuntu
105
+ 1.07x, same binary)
106
+ |Open
107
+ |Platform-specific: diff the arm64 SIMD kernels vs the scalar fallback
108
+ (`src/yeptris/common/simd_text*`), starting from the kernel-selection
109
+ law.
110
+
111
+ |yeptris#352 — yaml generation 1.4-1.7x behind rapidyaml (10/12
112
+ platforms)
113
+ |Open, partially improved
114
+ |Remaining: scalar-route derivation cost. `benchmarks/PERF-LEDGER.md`
115
+ has the trusted-into entries.
116
+ |===
117
+
118
+ == leptris/yeptris-ruby
119
+
120
+ [cols="1,1,2"]
121
+ |===
122
+ |Issue |Status |Known + first moves
123
+
124
+ |yeptris-ruby#322 — CBOR dump is marshaling-bound
125
+ |Open
126
+ |The JSON half shipped (the marshal face: 4.4x end-to-end). CBOR: a
127
+ `yeptris_cbor_from_marshal`-style face, reusing the marshal reader's
128
+ REGISTRY LAW and its pin (`test/unit/test_marshal_json.cpp`) — the
129
+ registry law (who occupies an `@` slot) is the hard-won part; copy both.
130
+ |===
131
+
132
+ == Mechanics every team hits
133
+
134
+ [cols="1,3"]
135
+ |===
136
+ |Topic |The law
137
+
138
+ |Differential corpora
139
+ |Random-tree corpora never produce repeated keys or shared references —
140
+ shift/registration bugs survive 300/300 "green". Include linked shapes
141
+ (users-style rows). Lock byte-level fixtures to FILES (Ruby's fstring
142
+ interning varies per process).
143
+
144
+ |Performance claims
145
+ |Cross-wave serialbench comparison is INVALID (runner noise; pugixml
146
+ swung 1.7x between waves). Only within-run interleaved A/B on fresh
147
+ binaries counts. The perf gate is best-of-N per side.
148
+
149
+ |Release cuts (yeptris)
150
+ |`next_version` must be a LITERAL X.Y.Z. The release PR self-closes ~2
151
+ min after cut if checks never start: push a PARENTED trigger commit
152
+ (`git commit-tree -p "$sha" -m ... "$sha^{tree}"` — without `-p` the
153
+ commit orphans and the PR creation fails) within the window, then
154
+ approve runs gated at `conclusion == "action_required"` (the `status`
155
+ field reads `completed` while gated). The changelog gate requires an
156
+ `## [Unreleased]` section before EVERY cut, version-only ones included.
157
+
158
+ |Release cuts (yeptris-ruby)
159
+ |RELEASE_TOKEN unset -> the bump job's 60-min wait step always fails;
160
+ the PR opens anyway. Hand-merge on green, then dispatch the republish
161
+ with EMPTY next_version (reuses main's VERSION). A new gem version needs
162
+ its C `v<c_semver>` tag to EXIST first (platform legs vendor from it).
163
+
164
+ |Release cuts (leptris)
165
+ |The same cut/trigger/approve cycle; the Unreleased section exists on
166
+ main — keep it fed.
167
+
168
+ |Formatting
169
+ |The repos target clang-format 14+ (CI runs LLVM 18); local 11.1/22.1
170
+ both reject the config — the CI format job is the gate.
171
+
172
+ |CI flakes
173
+ |`pull_request` events intermittently vanish for fresh PRs (yeptris,
174
+ leptris). Fallback: `gh workflow run test.yml format.yml asan.yml --ref
175
+ <branch>` — sha-keyed check runs satisfy required-context protection.
176
+ A "failed" check may be CANCELLED by a concurrent push (concurrency
177
+ groups) — read the conclusion, `gh run rerun <id> --failed`.
178
+ |===
@@ -0,0 +1,50 @@
1
+ = The canonical digests (W5.3)
2
+
3
+ This document specifies canon's canonical digest API as shipped by
4
+ `Canon::Digest`. Reviewed against the implementation by
5
+ `spec/canon/docs/canonical_digests_spec.rb` (the W6.3 review-gate
6
+ pattern), with the cross-release byte pins carried by
7
+ `spec/canon/digest_stability_spec.rb`.
8
+
9
+ == 1. The law
10
+
11
+ The digest of a document is the OpenSSL hash of its CANONICAL
12
+ FORM — the form under which the format's comparison pipeline deems
13
+ documents equivalent. Two documents that compare equivalent produce
14
+ identical digests; two that compare distinct produce distinct
15
+ digests. The digest is an API over the comparison semantics, not a
16
+ new canonicalization.
17
+
18
+ == 2. Per-format canonical forms
19
+
20
+ * *XML* rides Canonical XML 1.1
21
+ (`Canon::Xml::C14n.canonicalize`, reached as
22
+ `Formatters::XmlFormatter.format(pretty: false)`). The
23
+ pretty-printed XML form preserves attribute order and is NOT
24
+ canonical; the digest lane never touches it.
25
+ * *JSON and YAML* ride the key-sorted formatter forms (the JCS
26
+ profile for JSON's canonical lane; the `[class, to_s]` TOTAL
27
+ key order for YAML).
28
+ * *HTML* rides the whitespace-law form with sorted attributes
29
+ (the W1.3 specification).
30
+
31
+ Equivalence consequences the digest inherits: attribute order is
32
+ insignificant in XML and HTML; mapping key order is insignificant
33
+ in JSON and YAML; element/sequence order is significant everywhere.
34
+
35
+ == 3. Entry points
36
+
37
+ `Canon::Digest.digest(content, format, algorithm: "sha256")`
38
+ accepts any OpenSSL digest name; `canonical_form(content, format)`
39
+ exposes the hashed bytes. Per-format shorthands
40
+ (`Canon.digest_xml`, `digest_yaml`, `digest_json`, ...) mirror the
41
+ format lane's shorthands.
42
+
43
+ == 4. Stability contract
44
+
45
+ The digests are stable across releases for as long as the
46
+ underlying canonical form is. The stability suite pins the digest
47
+ hex of a representative document per format; a canonical-form
48
+ change moves a digest and MUST fail that suite, forcing a
49
+ conscious pin update (and a release note) rather than silent drift
50
+ for downstream dedup stores.
@@ -0,0 +1,52 @@
1
+ = The JCS canonical profile (W6.1, RFC 8785)
2
+
3
+ This document specifies canon's JSON JCS canonical form as shipped
4
+ by `Canon::Json::JCS`. Reviewed against the implementation by
5
+ `spec/canon/docs/jcs_spec_review.rb` (the W6.3 review-gate
6
+ pattern).
7
+
8
+ == 1. Scope
9
+
10
+ RFC 8785 — the I-JSON Canonicalization Scheme. Input must be I-JSON
11
+ (RFC 7493): valid UTF-8 without lone surrogates, no duplicate
12
+ object keys, numbers expressible as IEEE 754 doubles. Violations
13
+ raise `Canon::Json::JCS::Error`.
14
+
15
+ == 2. The canonical laws
16
+
17
+ * *Strings* (`escape_string`): RFC 8785 §3.2.2.2 — the
18
+ JSON.stringify escaping set: short forms for `\b \t \n \f \r`
19
+ (the SHORT_ESCAPES table), two-hex lowercase `\u00xx` for the
20
+ remaining C0 controls, `\"` and `\\`; every other code point
21
+ emits verbatim as UTF-8 (DEL U+007F is NOT escaped).
22
+
23
+ * *Numbers* (`es6_number`): ECMAScript `Number::toString`
24
+ serialization — the shortest round-tripping decimal, fixed
25
+ notation inside [1e-6, 1e21), exponential outside; integers
26
+ via `ijson_integer` (the I-JSON 2^53 range).
27
+
28
+ * *Object keys* (`write_object`): sorted by their
29
+ UTF-16 code units (`utf16_code_units` — the RFC's sort is over
30
+ UTF-16 code units, not code points, so surrogate pairs and BMP
31
+ characters interleave per the RFC's examples; e.g. the key
32
+ U+1D160 (a surrogate pair, code units 0xD834 0xDD60) sorts
33
+ BEFORE U+20AC (a single BMP code unit 0x20AC), which sorts
34
+ BEFORE "a" (0x0061)).
35
+
36
+ * *Containers*: arrays comma-separated in order; objects
37
+ comma-separated in sorted key order; no whitespace anywhere.
38
+
39
+ == 3. Entry points
40
+
41
+ `canonicalize(json)` accepts a JSON string (parsed through
42
+ `JSON.parse`) or an already-parsed Ruby graph; `serialize(value)`
43
+ skips the parse. The canonical form is byte-stable across
44
+ platforms and suitable for hashing and signing.
45
+
46
+ == 4. Fixed point and parity
47
+
48
+ JCS output re-parses to the same graph, and the form is a fixed
49
+ point. The phase 02 property suite (JSON/YAML equivalence legs)
50
+ and the diff-display corpus pin the behavior; the JCS spec
51
+ vectors (RFC 8785 §3.2.3 examples) pin the number and sorting
52
+ laws.
data/lib/canon/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Canon
4
- VERSION = "0.3.116"
4
+ VERSION = "0.3.118"
5
5
  end
@@ -55,12 +55,30 @@ module Canon
55
55
  # — every node in the subtrees rooted at pfx:name. Returns
56
56
  # [prefix, local_name], or nil for anything else.
57
57
  def self.c14n_selection_idiom(xpath)
58
- # the suite's .xpath files are XML documents wrapping the
59
- # expression (with prefix declarations and inline comments)
60
- text = xpath.sub(/\A<\?xml[^?]*\?>/, "")
61
- .gsub(/<!--[\s\S]*?-->/, "")
62
- .gsub(/<[^>]*>/, " ")
63
- .squeeze(" ")
58
+ # The suite's .xpath files are XML documents wrapping the
59
+ # expression (with prefix declarations and inline comments).
60
+ # Extraction is index-based string surgery — the CodeQL
61
+ # polynomial-redos query flags the gsub-chain form.
62
+ close_tag = xpath.rindex("</XPath>")
63
+ text = close_tag ? xpath[0...close_tag] : xpath.dup
64
+ # drop the XML declaration and the <XPath> open tag: each
65
+ # ends at the first '>' at/after its '<'
66
+ if (decl = text.index("<"))
67
+ gt = text.index(">", decl)
68
+ if (xopen = text.index("<XPath", decl)) && (xopen < gt)
69
+ gt = text.index(">", xopen)
70
+ end
71
+ text = text[(gt + 1)..] if gt
72
+ end
73
+ # strip <!-- --> comments by index (no regex backtracking)
74
+ loop do
75
+ open_c = text.index("<!--")
76
+ break unless open_c
77
+
78
+ close_c = text.index("-->", open_c + 4)
79
+ text = close_c ? text[0...open_c] + text[(close_c + 3)..] : text[0...open_c]
80
+ end
81
+ text = text.squeeze(" ")
64
82
  m = text.match(%r{\(//\.\s*\|\s*//@\*\s*\|\s*//namespace::\*\)\s*\[\s*ancestor-or-self::([A-Za-z_][\w.-]*)(?::([\w.-]+))?\s*\]\s*\z})
65
83
  return nil unless m
66
84
 
@@ -30,16 +30,33 @@ class PerformanceComparator
30
30
  DIM = "\e[2m"
31
31
  CLEAR = "\e[0m"
32
32
 
33
+ BEST_OF = Integer(ENV.fetch("CANON_PERF_BEST_OF", "3"))
34
+
33
35
  def run
34
36
  clone_base
35
- current = run_report(REPO_ROOT, "current")
36
- base = run_report(base_clone_dir, "base (#{DEFAULT_BASE})")
37
+ current = best_of(REPO_ROOT, "current")
38
+ base = best_of(base_clone_dir, "base (#{DEFAULT_BASE})")
37
39
  print_report(current, base)
38
40
  exit(1) if regressions?(current, base)
39
41
  ensure
40
42
  cleanup
41
43
  end
42
44
 
45
+ # The html_parsing lanes drift up to ~27% BETWEEN SAME-CONTENT runs
46
+ # on shared runners (main failed against itself: run 40047205814) —
47
+ # single-run comparisons measure the runner, not the code. Each side
48
+ # runs BEST_OF times; per benchmark, the run with the highest upper
49
+ # bound stands (the capability estimate a fair gate compares).
50
+ def best_of(working_dir, label)
51
+ runs = Array.new(BEST_OF) { |i| run_report(working_dir, "#{label} ##{i + 1}") }
52
+ runs.first.fetch("benchmarks").keys.each_with_object({}) do |bench, merged|
53
+ best = runs.max_by { |r| r.fetch("benchmarks").fetch(bench).fetch("upper").to_f }
54
+ merged[bench] = best.fetch("benchmarks").fetch(bench)
55
+ end.then do |benchmarks|
56
+ runs.first.merge("benchmarks" => benchmarks)
57
+ end
58
+ end
59
+
43
60
  private
44
61
 
45
62
  def clone_base
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: canon
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.116
4
+ version: 0.3.118
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-10-24 00:00:00.000000000 Z
11
+ date: 2026-10-31 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: diff-lcs
@@ -44,28 +44,28 @@ dependencies:
44
44
  requirements:
45
45
  - - "~>"
46
46
  - !ruby/object:Gem::Version
47
- version: 1.9.376
47
+ version: 1.9.398
48
48
  type: :runtime
49
49
  prerelease: false
50
50
  version_requirements: !ruby/object:Gem::Requirement
51
51
  requirements:
52
52
  - - "~>"
53
53
  - !ruby/object:Gem::Version
54
- version: 1.9.376
54
+ version: 1.9.398
55
55
  - !ruby/object:Gem::Dependency
56
56
  name: moxml
57
57
  requirement: !ruby/object:Gem::Requirement
58
58
  requirements:
59
59
  - - "~>"
60
60
  - !ruby/object:Gem::Version
61
- version: 0.5.149
61
+ version: 0.6.0
62
62
  type: :runtime
63
63
  prerelease: false
64
64
  version_requirements: !ruby/object:Gem::Requirement
65
65
  requirements:
66
66
  - - "~>"
67
67
  - !ruby/object:Gem::Version
68
- version: 0.5.149
68
+ version: 0.6.0
69
69
  - !ruby/object:Gem::Dependency
70
70
  name: paint
71
71
  requirement: !ruby/object:Gem::Requirement
@@ -142,14 +142,14 @@ dependencies:
142
142
  requirements:
143
143
  - - "~>"
144
144
  - !ruby/object:Gem::Version
145
- version: 0.6.32
145
+ version: 0.6.52
146
146
  type: :runtime
147
147
  prerelease: false
148
148
  version_requirements: !ruby/object:Gem::Requirement
149
149
  requirements:
150
150
  - - "~>"
151
151
  - !ruby/object:Gem::Version
152
- version: 0.6.32
152
+ version: 0.6.52
153
153
  description: |-
154
154
  Canon provides canonicalization and pretty-printing for various serialization
155
155
  formats (XML, HTML, JSON, YAML), producing standardized forms suitable for
@@ -171,10 +171,13 @@ files:
171
171
  - Rakefile
172
172
  - docs/.lycheeignore
173
173
  - docs/Gemfile
174
+ - docs/HANDOFF.adoc
174
175
  - docs/INDEX.adoc
175
176
  - docs/_config.yml
177
+ - docs/_references/canonical-digests.adoc
176
178
  - docs/_references/comparison-theory.adoc
177
179
  - docs/_references/html-canonical-form.adoc
180
+ - docs/_references/jcs-rfc8785.adoc
178
181
  - docs/_references/yaml-canonical-form.adoc
179
182
  - docs/advanced/diff-classification.adoc
180
183
  - docs/advanced/diff-pipeline.adoc