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 +4 -4
- data/CLAUDE.md +4 -4
- data/Rakefile +0 -40
- data/docs/HANDOFF.adoc +178 -0
- data/docs/_references/canonical-digests.adoc +50 -0
- data/docs/_references/jcs-rfc8785.adoc +52 -0
- data/lib/canon/version.rb +1 -1
- data/lib/canon/xml/exclusive_c14n.rb +24 -6
- data/lib/tasks/performance_comparator.rb +19 -2
- metadata +11 -8
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 775ec23689366d5e58c5442ad78706027ba7f8e8efba6c06aede93907160f607
|
|
4
|
+
data.tar.gz: 3f73899302308caf78050f7a692a159cb5e2cc53cbf822e0773c9632bd8beeef
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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).
|
|
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
|
|
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
|
|
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
|
@@ -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
|
-
#
|
|
59
|
-
# expression (with prefix declarations and inline comments)
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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 =
|
|
36
|
-
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.
|
|
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-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|