canon 0.3.26 → 0.3.27
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/README.adoc +27 -2
- data/lib/canon/comparison/xml_comparator/attribute_filter.rb +34 -4
- data/lib/canon/diff_formatter/by_line/html_formatter.rb +4 -296
- data/lib/canon/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d8de5d7008a348037ef44fdd8bd5ee722e8077e058ab1e53cd2995b0dd5f0e2c
|
|
4
|
+
data.tar.gz: 930b0efd7c7302968a6ff2f4fd9af8a6261a1a57fc71707d9206333a380f47a8
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 570bd7072c65d9c87316becd43334255bb176ee4260c1d40738cd51ee3d030c4ea3df9d93e923a2f6062c493271fb122bb252e1f41c6c0e40836d7fbe675fd5b
|
|
7
|
+
data.tar.gz: a7d60c235f74cdf466a35b4ef6204c9943d306a1167dead26123314326936f90f7d96a5d2ee3bd2ace308556655c2e6492d2beb2ebab25933d4ce796dfd84e9f
|
data/README.adoc
CHANGED
|
@@ -12,9 +12,19 @@ Key features:
|
|
|
12
12
|
|
|
13
13
|
* **Format support**: XML, HTML, JSON, YAML
|
|
14
14
|
* **Canonicalization**: W3C XML C14N 1.1, sorted JSON/YAML keys
|
|
15
|
-
* **Semantic comparison**: Compare meaning, not formatting
|
|
15
|
+
* **Semantic comparison**: Compare meaning, not formatting — attributes by
|
|
16
|
+
expanded name (namespace URI + local name), prefixes never affect results
|
|
16
17
|
* **Multiple interfaces**: Ruby API, CLI, RSpec matchers
|
|
17
18
|
* **Smart diff output**: By-line or by-object modes with syntax highlighting
|
|
19
|
+
* **Fast native engines, zero mandatory C deps**:
|
|
20
|
+
** XML on https://github.com/leptris/leptris[libleptris] (via moxml) whenever
|
|
21
|
+
installed — Nokogiri otherwise and for HTML; both byte-compatible, engine
|
|
22
|
+
parity is spec-gated
|
|
23
|
+
** JSON on https://github.com/leptris/yeptris[libyeptris]' strict JSON surface
|
|
24
|
+
whenever the native materializer is installed (`gem install yeptris` —
|
|
25
|
+
platform gems, zero compilation), the stdlib JSON extension otherwise
|
|
26
|
+
** YAML on yeptris opt-in (`CANON_YAML_BACKEND=yeptris`, ~3x Psych loads);
|
|
27
|
+
Psych otherwise
|
|
18
28
|
|
|
19
29
|
== When to use formatting vs comparison
|
|
20
30
|
|
|
@@ -957,10 +967,25 @@ bundle exec rake performance:yaml
|
|
|
957
967
|
* **XML/HTML Comparison**: Identical, similar, different documents
|
|
958
968
|
* **Format Canonicalization**: XML C14N, JSON, YAML
|
|
959
969
|
|
|
970
|
+
=== Engines
|
|
971
|
+
|
|
972
|
+
Canon is engine-agnostic across its formats:
|
|
973
|
+
|
|
974
|
+
* **XML**: libleptris via moxml whenever installed (parse, comparison, C14N,
|
|
975
|
+
pretty-printing); raw Nokogiri otherwise, and always Nokogiri for HTML.
|
|
976
|
+
`CANON_XML_BACKEND=nokogiri|moxml` forces either engine; parity between the
|
|
977
|
+
two is spec-gated (`spec/canon/xml/engine_parity_spec.rb`).
|
|
978
|
+
* **JSON**: yeptris' strict JSON surface when the native materializer is
|
|
979
|
+
installed (platform gems — zero compilation), the stdlib JSON extension
|
|
980
|
+
otherwise. `CANON_YAML_BACKEND=psych` forces stdlib.
|
|
981
|
+
* **YAML**: Psych by default; yeptris (≈3x loads) behind
|
|
982
|
+
`CANON_YAML_BACKEND=yeptris`. Parity is spec-gated
|
|
983
|
+
(`spec/canon/yaml_engine_parity_spec.rb`).
|
|
984
|
+
|
|
960
985
|
=== SAX Parser
|
|
961
986
|
|
|
962
987
|
Canon includes a SAX-based XML parser (`Canon::Xml::SaxBuilder`) that provides
|
|
963
|
-
significantly faster XML parsing by avoiding intermediate
|
|
988
|
+
significantly faster XML parsing by avoiding intermediate engine DOM trees.
|
|
964
989
|
|
|
965
990
|
[source,ruby]
|
|
966
991
|
----
|
|
@@ -34,14 +34,24 @@ module Canon
|
|
|
34
34
|
# @param filtered [Hash] Output hash to populate
|
|
35
35
|
def self.filter_array_attributes(attributes, opts, match_opts, filtered)
|
|
36
36
|
attributes.each do |attr|
|
|
37
|
-
name
|
|
37
|
+
# Expanded name (XML Namespaces 1.0 §5.2/§5.3): an
|
|
38
|
+
# unprefixed attribute is in NO namespace — {no-ns}srsName
|
|
39
|
+
# and {uri}srsName are different attributes, and two
|
|
40
|
+
# prefixes bound to the same URI are the same attribute.
|
|
41
|
+
# Local-name keys conflated qualified with unqualified
|
|
42
|
+
# attributes whenever the prefix matched the element's
|
|
43
|
+
# (issue #155).
|
|
44
|
+
name = expanded_attribute_name(attr)
|
|
38
45
|
value = attr.value
|
|
39
46
|
|
|
40
47
|
# Skip namespace declarations - they're handled separately
|
|
41
|
-
next if namespace_declaration?(name)
|
|
48
|
+
next if namespace_declaration?(attr.name)
|
|
42
49
|
|
|
43
|
-
# Skip if attribute name should be ignored
|
|
44
|
-
|
|
50
|
+
# Skip if attribute name should be ignored — by local name
|
|
51
|
+
# (user-facing list) or expanded key, so ignore lists keep
|
|
52
|
+
# working against both forms.
|
|
53
|
+
next if ignore_by_name?(attr.name, opts) ||
|
|
54
|
+
ignore_by_name?(name, opts)
|
|
45
55
|
|
|
46
56
|
# Skip if attribute content should be ignored
|
|
47
57
|
next if ignore_by_content?(value, opts)
|
|
@@ -54,6 +64,26 @@ module Canon
|
|
|
54
64
|
end
|
|
55
65
|
end
|
|
56
66
|
|
|
67
|
+
# Expanded attribute key: "{namespace-uri}local-name", or the
|
|
68
|
+
# bare local name when the attribute is in no namespace.
|
|
69
|
+
# Prefixed-but-unresolvable attributes (namespace-invalid
|
|
70
|
+
# documents — an undeclared prefix has no expanded name) fall
|
|
71
|
+
# back to the local name: recovery comparison cannot do better.
|
|
72
|
+
def self.expanded_attribute_name(attr)
|
|
73
|
+
case attr
|
|
74
|
+
when Canon::Xml::Nodes::AttributeNode
|
|
75
|
+
uri = attr.namespace_uri
|
|
76
|
+
attr.prefix
|
|
77
|
+
when defined?(Nokogiri) && Nokogiri::XML::Attr
|
|
78
|
+
uri = attr.namespace&.href
|
|
79
|
+
attr.namespace&.prefix
|
|
80
|
+
else
|
|
81
|
+
uri = attr.namespace_uri
|
|
82
|
+
nil
|
|
83
|
+
end
|
|
84
|
+
uri && !uri.empty? ? "{#{uri}}#{attr.name}" : attr.name
|
|
85
|
+
end
|
|
86
|
+
|
|
57
87
|
# Filter hash-format attributes (Nokogiri/Moxml)
|
|
58
88
|
#
|
|
59
89
|
# @param attributes [Hash] Hash-like attributes
|
|
@@ -33,82 +33,11 @@ module Canon
|
|
|
33
33
|
# @return [String] Formatted diff
|
|
34
34
|
def format(doc1, doc2)
|
|
35
35
|
compute_line_num_width(doc1, doc2)
|
|
36
|
-
|
|
37
|
-
if @differences&.any?(Canon::Diff::DiffNode)
|
|
38
|
-
# Check if we should skip based on show_diffs setting
|
|
39
|
-
if should_skip_diff_display?
|
|
40
|
-
return ""
|
|
41
|
-
end
|
|
42
|
-
|
|
43
|
-
# Use new pipeline when DiffNodes available
|
|
44
|
-
return format_with_pipeline(doc1, doc2)
|
|
45
|
-
end
|
|
46
|
-
|
|
47
|
-
# LEGACY: Fall back to old DOM-based behavior
|
|
48
|
-
# Check if we should show any diffs based on differences array
|
|
49
|
-
if should_skip_diff_display?
|
|
50
|
-
return ""
|
|
51
|
-
end
|
|
52
|
-
|
|
53
|
-
output = []
|
|
54
|
-
|
|
55
|
-
begin
|
|
56
|
-
# Parse to DOM using HTML parser
|
|
57
|
-
root1 = Canon::Html::DataModel.from_html(doc1,
|
|
58
|
-
version: @html_version)
|
|
59
|
-
root2 = Canon::Html::DataModel.from_html(doc2,
|
|
60
|
-
version: @html_version)
|
|
61
|
-
|
|
62
|
-
# Match elements semantically
|
|
63
|
-
matcher = Canon::Xml::ElementMatcher.new
|
|
64
|
-
matches = matcher.match_trees(root1, root2)
|
|
65
|
-
|
|
66
|
-
# Pretty-print HTML for line mapping
|
|
67
|
-
pretty_printer = Canon::PrettyPrinter::Html.new(indent: 2)
|
|
68
|
-
pretty1 = pretty_printer.format(doc1)
|
|
69
|
-
pretty2 = pretty_printer.format(doc2)
|
|
70
|
-
|
|
71
|
-
# Build line range maps using pretty-printed documents
|
|
72
|
-
mapper1 = Canon::Xml::LineRangeMapper.new(indent: 2)
|
|
73
|
-
mapper2 = Canon::Xml::LineRangeMapper.new(indent: 2)
|
|
74
|
-
map1 = mapper1.build_map(root1, pretty1)
|
|
75
|
-
map2 = mapper2.build_map(root2, pretty2)
|
|
76
|
-
|
|
77
|
-
# Use pretty-printed document lines for display
|
|
78
|
-
lines1 = pretty1.split("\n")
|
|
79
|
-
lines2 = pretty2.split("\n")
|
|
80
|
-
|
|
81
|
-
# Display diffs based on element matches
|
|
82
|
-
result = format_element_matches(matches, map1, map2, lines1, lines2)
|
|
83
|
-
output << result
|
|
84
|
-
rescue StandardError => e
|
|
85
|
-
# Fall back to simple diff on error
|
|
86
|
-
output << colorize("Warning: DOM parsing failed, using simple diff",
|
|
87
|
-
:yellow)
|
|
88
|
-
output << colorize("Error: #{e.class}: #{e.message}", :red)
|
|
36
|
+
return "" if should_skip_diff_display?
|
|
89
37
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
end.take(3)
|
|
94
|
-
unless relevant_trace.empty?
|
|
95
|
-
output << colorize("Backtrace:", :yellow)
|
|
96
|
-
relevant_trace.each do |line|
|
|
97
|
-
output << colorize(" #{line}", :yellow)
|
|
98
|
-
end
|
|
99
|
-
end
|
|
100
|
-
|
|
101
|
-
output << ""
|
|
102
|
-
simple = SimpleFormatter.new(
|
|
103
|
-
use_color: @use_color,
|
|
104
|
-
context_lines: @context_lines,
|
|
105
|
-
diff_grouping_lines: @diff_grouping_lines,
|
|
106
|
-
visualization_map: @visualization_map,
|
|
107
|
-
)
|
|
108
|
-
output << simple.format(doc1, doc2)
|
|
109
|
-
end
|
|
110
|
-
|
|
111
|
-
output.join("\n")
|
|
38
|
+
# The DiffNode pipeline is the only renderer: without
|
|
39
|
+
# DiffNodes there is nothing to report (#84).
|
|
40
|
+
format_with_pipeline(doc1, doc2)
|
|
112
41
|
end
|
|
113
42
|
|
|
114
43
|
# Format using new DiffReportBuilder pipeline
|
|
@@ -260,227 +189,6 @@ module Canon
|
|
|
260
189
|
|
|
261
190
|
output.join("\n")
|
|
262
191
|
end
|
|
263
|
-
|
|
264
|
-
private
|
|
265
|
-
|
|
266
|
-
# Format element matches for display
|
|
267
|
-
def format_element_matches(matches, map1, map2, lines1, lines2)
|
|
268
|
-
output = []
|
|
269
|
-
|
|
270
|
-
# Detect non-ASCII characters in the diff
|
|
271
|
-
all_text = (lines1 + lines2).join
|
|
272
|
-
non_ascii = Legend.detect_non_ascii(all_text, @visualization_map)
|
|
273
|
-
|
|
274
|
-
# Add Unicode legend if any non-ASCII characters detected
|
|
275
|
-
unless non_ascii.empty?
|
|
276
|
-
output << Legend.build_legend(non_ascii, use_color: @use_color)
|
|
277
|
-
output << ""
|
|
278
|
-
end
|
|
279
|
-
|
|
280
|
-
# Build a set of elements to skip (children of parents showing diffs)
|
|
281
|
-
elements_to_skip = build_skip_set(matches, map1, map2, lines1,
|
|
282
|
-
lines2)
|
|
283
|
-
|
|
284
|
-
# Build a set of children of matched parents
|
|
285
|
-
children_of_matched_parents = build_children_set(matches)
|
|
286
|
-
|
|
287
|
-
# Collect diff sections with metadata
|
|
288
|
-
diff_sections = collect_diff_sections(matches, map1, map2, lines1,
|
|
289
|
-
lines2, elements_to_skip,
|
|
290
|
-
children_of_matched_parents)
|
|
291
|
-
|
|
292
|
-
# Sort by line number
|
|
293
|
-
diff_sections.sort_by! do |section|
|
|
294
|
-
section[:start_line1] || section[:start_line2] || 0
|
|
295
|
-
end
|
|
296
|
-
|
|
297
|
-
# Group diffs by proximity if diff_grouping_lines is set
|
|
298
|
-
formatted_diffs = if @diff_grouping_lines
|
|
299
|
-
groups = group_diff_sections(diff_sections,
|
|
300
|
-
@diff_grouping_lines)
|
|
301
|
-
format_diff_groups(groups)
|
|
302
|
-
else
|
|
303
|
-
diff_sections.filter_map do |s|
|
|
304
|
-
s[:formatted]
|
|
305
|
-
end.join("\n\n")
|
|
306
|
-
end
|
|
307
|
-
|
|
308
|
-
output << formatted_diffs
|
|
309
|
-
output.join("\n")
|
|
310
|
-
end
|
|
311
|
-
|
|
312
|
-
# Build set of elements to skip (children with parents showing diffs)
|
|
313
|
-
def build_skip_set(matches, map1, map2, lines1, lines2)
|
|
314
|
-
elements_to_skip = Set.new
|
|
315
|
-
elements_with_diffs = Set.new
|
|
316
|
-
|
|
317
|
-
# Build set of element pairs that have semantic diffs
|
|
318
|
-
build_elements_with_semantic_diffs_set
|
|
319
|
-
|
|
320
|
-
# First pass: identify elements with line differences
|
|
321
|
-
# (semantic filtering happens in collect_diff_sections)
|
|
322
|
-
matches.each do |match|
|
|
323
|
-
next unless match.status == :matched
|
|
324
|
-
|
|
325
|
-
range1 = map1[match.elem1]
|
|
326
|
-
range2 = map2[match.elem2]
|
|
327
|
-
next unless range1 && range2
|
|
328
|
-
|
|
329
|
-
elem_lines1 = lines1[range1.start_line..range1.end_line]
|
|
330
|
-
elem_lines2 = lines2[range2.start_line..range2.end_line]
|
|
331
|
-
|
|
332
|
-
# Add if there are line diffs
|
|
333
|
-
# Semantic filtering is done in collect_diff_sections
|
|
334
|
-
if elem_lines1 != elem_lines2
|
|
335
|
-
elements_with_diffs.add(match.elem1)
|
|
336
|
-
end
|
|
337
|
-
end
|
|
338
|
-
|
|
339
|
-
# Second pass: skip children of elements with diffs
|
|
340
|
-
elements_with_diffs.each do |elem|
|
|
341
|
-
current = Canon::Comparison::NodeInspector.parent(elem)
|
|
342
|
-
while current
|
|
343
|
-
if Canon::Comparison::NodeInspector.element_node?(current) &&
|
|
344
|
-
elements_with_diffs.include?(current)
|
|
345
|
-
elements_to_skip.add(elem)
|
|
346
|
-
break
|
|
347
|
-
end
|
|
348
|
-
current = Canon::Comparison::NodeInspector.parent(current)
|
|
349
|
-
end
|
|
350
|
-
end
|
|
351
|
-
|
|
352
|
-
elements_to_skip
|
|
353
|
-
end
|
|
354
|
-
|
|
355
|
-
# Collect diff sections with metadata
|
|
356
|
-
def collect_diff_sections(matches, map1, map2, lines1, lines2,
|
|
357
|
-
elements_to_skip, _children_of_matched_parents)
|
|
358
|
-
diff_sections = []
|
|
359
|
-
no_range_count = 0
|
|
360
|
-
no_diff_count = 0
|
|
361
|
-
|
|
362
|
-
# If there are NO semantic diffs, don't show any matched elements
|
|
363
|
-
# (all text diffs were normalized away)
|
|
364
|
-
elements_with_semantic_diffs = build_elements_with_semantic_diffs_set
|
|
365
|
-
|
|
366
|
-
matches.each do |match|
|
|
367
|
-
case match.status
|
|
368
|
-
when :matched
|
|
369
|
-
next if elements_to_skip.include?(match.elem1)
|
|
370
|
-
|
|
371
|
-
# Only apply semantic filtering if we have DiffNode objects
|
|
372
|
-
# (when called standalone or without DiffNodes, show all diffs)
|
|
373
|
-
if !@differences.nil? && !@differences.empty? && @differences.any?(Canon::Diff::DiffNode)
|
|
374
|
-
# Skip if no semantic diffs exist (all diffs were normalized)
|
|
375
|
-
next if elements_with_semantic_diffs.empty?
|
|
376
|
-
|
|
377
|
-
# Skip if this element has no semantic diffs in its subtree
|
|
378
|
-
next unless has_semantic_diff_in_subtree?(match.elem1,
|
|
379
|
-
elements_with_semantic_diffs)
|
|
380
|
-
end
|
|
381
|
-
|
|
382
|
-
range1 = map1[match.elem1]
|
|
383
|
-
range2 = map2[match.elem2]
|
|
384
|
-
if !range1 || !range2
|
|
385
|
-
no_range_count += 1
|
|
386
|
-
end
|
|
387
|
-
|
|
388
|
-
section = format_matched_element_with_metadata(match, map1,
|
|
389
|
-
map2, lines1,
|
|
390
|
-
lines2)
|
|
391
|
-
if range1 && range2 && !section
|
|
392
|
-
no_diff_count += 1
|
|
393
|
-
end
|
|
394
|
-
diff_sections << section if section
|
|
395
|
-
when :deleted
|
|
396
|
-
# Don't skip deleted elements - they should always be shown
|
|
397
|
-
section = format_deleted_element_with_metadata(match, map1,
|
|
398
|
-
lines1)
|
|
399
|
-
diff_sections << section if section
|
|
400
|
-
when :inserted
|
|
401
|
-
# Don't skip inserted elements - they should always be shown
|
|
402
|
-
section = format_inserted_element_with_metadata(match, map2,
|
|
403
|
-
lines2)
|
|
404
|
-
diff_sections << section if section
|
|
405
|
-
end
|
|
406
|
-
end
|
|
407
|
-
|
|
408
|
-
diff_sections
|
|
409
|
-
end
|
|
410
|
-
|
|
411
|
-
# Format a matched element showing differences
|
|
412
|
-
def format_matched_element(match, map1, map2, lines1, lines2)
|
|
413
|
-
range1 = map1[match.elem1]
|
|
414
|
-
range2 = map2[match.elem2]
|
|
415
|
-
return nil unless range1 && range2
|
|
416
|
-
|
|
417
|
-
# Extract line ranges
|
|
418
|
-
elem_lines1 = lines1[range1.start_line..range1.end_line]
|
|
419
|
-
elem_lines2 = lines2[range2.start_line..range2.end_line]
|
|
420
|
-
|
|
421
|
-
# Skip if identical
|
|
422
|
-
return nil if elem_lines1 == elem_lines2
|
|
423
|
-
|
|
424
|
-
# Run line diff
|
|
425
|
-
diffs = ::Diff::LCS.sdiff(elem_lines1, elem_lines2)
|
|
426
|
-
|
|
427
|
-
# Identify diff blocks
|
|
428
|
-
diff_blocks = identify_diff_blocks(diffs)
|
|
429
|
-
return nil if diff_blocks.empty?
|
|
430
|
-
|
|
431
|
-
# Group into contexts
|
|
432
|
-
contexts = group_diff_blocks_into_contexts(diff_blocks,
|
|
433
|
-
@diff_grouping_lines || 0)
|
|
434
|
-
|
|
435
|
-
# Expand with context lines
|
|
436
|
-
expanded_contexts = expand_contexts_with_context_lines(contexts,
|
|
437
|
-
@context_lines,
|
|
438
|
-
diffs.length)
|
|
439
|
-
|
|
440
|
-
# Format contexts
|
|
441
|
-
output = []
|
|
442
|
-
expanded_contexts.each_with_index do |context, idx|
|
|
443
|
-
output << "" if idx.positive?
|
|
444
|
-
output << format_context(context, diffs, range1.start_line,
|
|
445
|
-
range2.start_line)
|
|
446
|
-
end
|
|
447
|
-
|
|
448
|
-
output.join("\n")
|
|
449
|
-
end
|
|
450
|
-
|
|
451
|
-
# Format a deleted element
|
|
452
|
-
def format_deleted_element(match, map1, lines1)
|
|
453
|
-
range1 = map1[match.elem1]
|
|
454
|
-
return nil unless range1
|
|
455
|
-
|
|
456
|
-
output = []
|
|
457
|
-
path_str = match.path.join("/")
|
|
458
|
-
output << colorize("Element: #{path_str} [DELETED]", :red, :bold)
|
|
459
|
-
|
|
460
|
-
# Show all lines as deleted
|
|
461
|
-
(range1.start_line..range1.end_line).each do |i|
|
|
462
|
-
output << format_unified_line(i + 1, nil, "-", lines1[i], :red)
|
|
463
|
-
end
|
|
464
|
-
|
|
465
|
-
output.join("\n")
|
|
466
|
-
end
|
|
467
|
-
|
|
468
|
-
# Format an inserted element
|
|
469
|
-
def format_inserted_element(match, map2, lines2)
|
|
470
|
-
range2 = map2[match.elem2]
|
|
471
|
-
return nil unless range2
|
|
472
|
-
|
|
473
|
-
output = []
|
|
474
|
-
path_str = match.path.join("/")
|
|
475
|
-
output << colorize("Element: #{path_str} [INSERTED]", :green, :bold)
|
|
476
|
-
|
|
477
|
-
# Show all lines as inserted
|
|
478
|
-
(range2.start_line..range2.end_line).each do |i|
|
|
479
|
-
output << format_unified_line(nil, i + 1, "+", lines2[i], :green)
|
|
480
|
-
end
|
|
481
|
-
|
|
482
|
-
output.join("\n")
|
|
483
|
-
end
|
|
484
192
|
end
|
|
485
193
|
end
|
|
486
194
|
end
|
data/lib/canon/version.rb
CHANGED
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.27
|
|
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-09-
|
|
11
|
+
date: 2026-09-08 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: diff-lcs
|