canon 0.3.115 → 0.3.117

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: f0837e14a667e91da2ce1818ecab8ca194f7ab1353ac8db3703ba6bee4ea6857
4
- data.tar.gz: 4a9345754f64e4cae207ee7285ab0bc88b8aa5747090897aa479f9b30cf07f45
3
+ metadata.gz: 223b5ab0201bbebfe8bfee1de2f23229260f574bd6d42fed426256b199f03d6c
4
+ data.tar.gz: 803eaba7a9fd850a38669c9ea2bbd567503fbc936fd39147a473d2526b3539ce
5
5
  SHA512:
6
- metadata.gz: 6524509bb0f1c3aa27b419c45959fee9eb84c38c44859373f0d926cb729bb0796cf59cc4fe12567cd2480090bbc6dad640ca759e166cbd6e629c38e254d20832
7
- data.tar.gz: 01fb13b4b41701f57127324a0f667c721a7a55121a08e64eae8ac291595a0596544916daf274e62da30de07a4f2b1153f2f73afa76d63c1f5fca31751763a1a7
6
+ metadata.gz: 24ae7e292b598242df4c98bad7d98d98815d7d28ddebd6d1ed79df4451e40017cd3840f2a3556bf4b78681ee7b6653ee91a67623d806d2aec1d8e170093a19d8
7
+ data.tar.gz: 35a5642e0e6b5b0268935d6888470614058888a3e4cc33d587ea079737a3817b9543aa63fbae986da95240a2c4c72174419eae1fba9336e871859ae6aae6ee3d
@@ -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.
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Canon
4
+ module Comparison
5
+ # W5.1 validate-then-compare: runs both documents through the
6
+ # engine's XSD assessment before the diff. Returns a hash of
7
+ # side-index (0 or 1) to Canon::ValidationError — empty when both
8
+ # documents pass and the comparison may proceed.
9
+ module SchemaGate
10
+ module_function
11
+
12
+ # @param obj1 [String] first XML document
13
+ # @param obj2 [String] second XML document
14
+ # @param schema [String, Leptris::XML::XSD::Schema] schema text
15
+ # (compiled once here) or a compiled schema
16
+ # @return [Hash{Integer=>Canon::ValidationError}]
17
+ def gate(obj1, obj2, schema)
18
+ schema = Canon::Validators::XsdValidator.compile(schema) if schema.is_a?(String)
19
+ failures = {}
20
+ [obj1, obj2].each_with_index do |obj, side|
21
+ Canon::Validators::XsdValidator.validate!(obj, schema: schema)
22
+ rescue Canon::ValidationError => e
23
+ failures[side] = e
24
+ end
25
+ failures
26
+ end
27
+ end
28
+ end
29
+ end
@@ -95,6 +95,7 @@ module Canon
95
95
  autoload :DiffNodeBuilder, "canon/comparison/diff_node_builder"
96
96
  autoload :Dimensions, "canon/comparison/dimensions"
97
97
  autoload :FormatDetector, "canon/comparison/format_detector"
98
+ autoload :SchemaGate, "canon/comparison/schema_gate"
98
99
  autoload :HtmlComparator, "canon/comparison/html_comparator"
99
100
  autoload :HtmlCompareProfile, "canon/comparison/html_compare_profile"
100
101
  autoload :HtmlParser, "canon/comparison/html_parser"
@@ -246,6 +247,33 @@ module Canon
246
247
  :semantic_tree))
247
248
  end
248
249
 
250
+ # W5.1 schema gate: when a schema rides the options, both XML
251
+ # documents must pass the engine's XSD assessment BEFORE the
252
+ # diff runs — a validation failure is normative regardless of
253
+ # what the tree comparison would say.
254
+ if (xsd = opts[:xsd])
255
+ gate = SchemaGate.gate(obj1, obj2, xsd)
256
+ unless gate.empty?
257
+ diffs = gate.map do |side, error|
258
+ node = Diff::DiffNode.new(node1: side.zero? ? obj1 : obj2,
259
+ node2: side.zero? ? obj2 : obj1,
260
+ dimension: :schema_validation,
261
+ reason: error.message)
262
+ node.normative = true # a schema failure is normative by definition
263
+ node
264
+ end
265
+ return false unless opts[:verbose]
266
+
267
+ return ComparisonResult.new(
268
+ differences: diffs,
269
+ preprocessed_strings: [obj1.to_s, obj2.to_s],
270
+ format: opts[:format] || :xml,
271
+ algorithm: :dom,
272
+ original_strings: [obj1.to_s, obj2.to_s],
273
+ )
274
+ end
275
+ end
276
+
249
277
  if %i[semantic semantic_tree].include?(opts[:diff_algorithm])
250
278
  return semantic_diff(obj1, obj2, opts)
251
279
  end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openssl"
4
+
5
+ module Canon
6
+ # W5.3 — canonical digests: the hash of a document's canonical form
7
+ # as a first-class API for deduplication and change detection.
8
+ #
9
+ # The digest is taken over the format's CANONICAL form — the form
10
+ # under which the comparison pipeline deems documents equivalent:
11
+ # XML rides Canonical XML 1.1 (attribute order sorted,
12
+ # namespace-normalized); JSON/YAML ride the key-sorted forms; HTML
13
+ # rides the whitespace-law form with sorted attributes. Two
14
+ # documents that compare equivalent under the canonical form
15
+ # produce identical digests. The digests are stable across releases
16
+ # for as long as the canonical form is (the digest-stability suite
17
+ # pins the bytes per release pair).
18
+ module Digest
19
+ DEFAULT_ALGORITHM = "sha256"
20
+
21
+ class << self
22
+ # @param content [String] the document
23
+ # @param format [Symbol] the format (see SUPPORTED_FORMATS)
24
+ # @param algorithm [String, Symbol] any OpenSSL digest name
25
+ # @return [String] the hex digest of the canonical form
26
+ def digest(content, format, algorithm: DEFAULT_ALGORITHM)
27
+ OpenSSL::Digest.new(algorithm.to_s)
28
+ .hexdigest(canonical_form(content, format))
29
+ end
30
+
31
+ # The canonical form the digest rides: XML's is C14N (the
32
+ # pretty-printed form preserves attribute order and is NOT
33
+ # canonical); every other format's formatter output IS the
34
+ # canonical form.
35
+ def canonical_form(content, format)
36
+ if format.to_sym == :xml
37
+ Formatters::XmlFormatter.format(content, pretty: false)
38
+ else
39
+ Canon.format(content, format)
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "leptris" # the XSD lane rides the engine's tier-1 validator
4
+ # (libleptris >= 1.9.321, leptris#1075)
5
+
6
+ module Canon
7
+ module Validators
8
+ # XSD validation (W5.1) through the engine's tier-1 validator:
9
+ # schema compilation, whole-instance assessment, enumerated
10
+ # failures. The schema is a compiled `Leptris::XML::XSD::Schema`
11
+ # — callers validating many documents against one schema compile
12
+ # once and reuse (compilation is the expensive step).
13
+ #
14
+ # schema = Canon::Validators::XsdValidator.compile(schema_xml)
15
+ # Canon::Validators::XsdValidator.validate!(xml, schema: schema)
16
+ class XsdValidator < BaseValidator
17
+ # Compile a schema from XSD text. Raises Canon::ValidationError
18
+ # when the text is not well-formed XML, and when the engine
19
+ # reports schema-level problems.
20
+ #
21
+ # @param schema_xml [String] the XSD document
22
+ # @return [Leptris::XML::XSD::Schema] the compiled schema
23
+ def self.compile(schema_xml)
24
+ schema = Leptris::XML::XSD.compile(schema_xml)
25
+ if (detail = schema.error)
26
+ raise Canon::ValidationError.new(
27
+ "schema invalid: #{detail}",
28
+ format: :xml,
29
+ )
30
+ end
31
+ schema
32
+ end
33
+
34
+ # Validate an XML instance against a compiled schema
35
+ #
36
+ # @param input [String] the XML instance
37
+ # @param schema [Leptris::XML::XSD::Schema] a compiled schema
38
+ # @raise [Canon::ValidationError] if the instance fails
39
+ # assessment; the message carries the first failure, details
40
+ # carry the full enumerated list
41
+ # @return [void]
42
+ def self.validate!(input, schema:)
43
+ return if input.nil? || input.strip.empty?
44
+
45
+ doc = Leptris::XML.parse(input, readonly: true)
46
+ errors = schema.validate_errors(doc)
47
+ return if errors.empty?
48
+
49
+ raise Canon::ValidationError.new(
50
+ errors.first.to_s,
51
+ format: :xml,
52
+ details: errors.join("\n"),
53
+ )
54
+ end
55
+ end
56
+ end
57
+ end
@@ -7,6 +7,7 @@ module Canon
7
7
  autoload :HtmlValidator, "canon/validators/html_validator"
8
8
  autoload :JsonValidator, "canon/validators/json_validator"
9
9
  autoload :XmlValidator, "canon/validators/xml_validator"
10
+ autoload :XsdValidator, "canon/validators/xsd_validator"
10
11
  autoload :YamlValidator, "canon/validators/yaml_validator"
11
12
  end
12
13
  end
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.115"
4
+ VERSION = "0.3.117"
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
 
data/lib/canon.rb CHANGED
@@ -32,6 +32,7 @@ module Canon
32
32
  autoload :Cli, "canon/cli"
33
33
  autoload :ColorDetector, "canon/color_detector"
34
34
  autoload :DiffFormatter, "canon/diff_formatter"
35
+ autoload :Digest, "canon/digest"
35
36
  autoload :Json, "canon/json"
36
37
 
37
38
  SUPPORTED_FORMATS = %i[xml yaml json html html4 html5 string].freeze
@@ -84,5 +85,15 @@ module Canon
84
85
  define_singleton_method("format_#{format}") do |content|
85
86
  format(content, format)
86
87
  end
88
+
89
+ define_singleton_method("digest_#{format}") do |content, algorithm: Canon::Digest::DEFAULT_ALGORITHM|
90
+ Canon::Digest.digest(content, format, algorithm: algorithm)
91
+ end
92
+ end
93
+
94
+ # The canonical digest of a document (W5.3): the hash of its
95
+ # canonical form. See Canon::Digest for the stability contract.
96
+ def self.digest(content, format = :xml, algorithm: Canon::Digest::DEFAULT_ALGORITHM)
97
+ Canon::Digest.digest(content, format, algorithm: algorithm)
87
98
  end
88
99
  end
@@ -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.115
4
+ version: 0.3.117
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-23 00:00:00.000000000 Z
11
+ date: 2026-10-24 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: diff-lcs
@@ -173,8 +173,10 @@ files:
173
173
  - docs/Gemfile
174
174
  - docs/INDEX.adoc
175
175
  - docs/_config.yml
176
+ - docs/_references/canonical-digests.adoc
176
177
  - docs/_references/comparison-theory.adoc
177
178
  - docs/_references/html-canonical-form.adoc
179
+ - docs/_references/jcs-rfc8785.adoc
178
180
  - docs/_references/yaml-canonical-form.adoc
179
181
  - docs/advanced/diff-classification.adoc
180
182
  - docs/advanced/diff-pipeline.adoc
@@ -267,6 +269,7 @@ files:
267
269
  - lib/canon/comparison/pipeline.rb
268
270
  - lib/canon/comparison/profile_definition.rb
269
271
  - lib/canon/comparison/ruby_object_comparator.rb
272
+ - lib/canon/comparison/schema_gate.rb
270
273
  - lib/canon/comparison/strategies.rb
271
274
  - lib/canon/comparison/strategies/base_match_strategy.rb
272
275
  - lib/canon/comparison/strategies/match_strategy_factory.rb
@@ -338,6 +341,7 @@ files:
338
341
  - lib/canon/diff_formatter/legend.rb
339
342
  - lib/canon/diff_formatter/pretty_diff_formatter.rb
340
343
  - lib/canon/diff_formatter/theme.rb
344
+ - lib/canon/digest.rb
341
345
  - lib/canon/errors.rb
342
346
  - lib/canon/formatters.rb
343
347
  - lib/canon/formatters/html4_formatter.rb
@@ -404,6 +408,7 @@ files:
404
408
  - lib/canon/validators/html_validator.rb
405
409
  - lib/canon/validators/json_validator.rb
406
410
  - lib/canon/validators/xml_validator.rb
411
+ - lib/canon/validators/xsd_validator.rb
407
412
  - lib/canon/validators/yaml_validator.rb
408
413
  - lib/canon/version.rb
409
414
  - lib/canon/xml.rb