leptris 1.9.4 → 1.9.5

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: 4bb5caf8a4c09cfd0ad16d3dc414d205e957ef1994875963bf5c4202666925cb
4
- data.tar.gz: 65ff8352d6b7efc9bc7178605af80d6d336e6692b41826dcc6927b1e56d96478
3
+ metadata.gz: 996051e28883434c5ac9cf2041278a37a0ebc3ad628685838e6b743cc87e0b9d
4
+ data.tar.gz: 3909cce576ad3ce14fa65c4058997476ddd814ea9f819e00e0b4bbb969bd09cf
5
5
  SHA512:
6
- metadata.gz: 027f61b41cb7a0b9270ca974ca82b0d227b02598f21f757aeebadc3d1158d8a8d7335a2657512756e3143b446222817520074d83071065520209714889085951
7
- data.tar.gz: 93c71428856e7d460fce88242ab315f63f18382806f76ecd4f7cedcb5f87a55cbe3890ed2f9937dd0dbd0be0a38dbbc16e8900803dcf116112221d3cf517ed36
6
+ metadata.gz: 61f73189528f9c0ffaff803328304baa44af1e545d6618d252ff8e46b5279a229fb204df807815a22a360bffbf107a0c32e1a33f5110cb699ae8135e06d2777e
7
+ data.tar.gz: 4b5b197e569e23820b74b1596b3a8f8bf2feefba09af9858bb830cbcd6339d59c196e71177760ab35eb7daeac30bd5c3edb2b384d1ff43fc56726b838bfe0f0a
data/CHANGELOG.md CHANGED
@@ -5,6 +5,32 @@ All notable changes to Leptris will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [1.9.5] - 2026-08-26
9
+
10
+ ### Fixed
11
+
12
+ - **Borrowed handles validate their lender**: every c_ptr-
13
+ dereferencing read and mutation on Node/Element/Text/Comment/
14
+ CDATA/PI raises `Leptris::XML::UseAfterFreeError` once the owning
15
+ document has been freed (previously `doc.free; node.name` read
16
+ freed memory silently — verified returning `""` — and stale
17
+ mutations risked segfaults; only Document's own methods checked).
18
+ Memoized readonly results stay check-free. `Document#freed?` is
19
+ the single state predicate.
20
+
21
+ ### Added
22
+
23
+ - **`benchmark/read_paths.rb`**: the committed read-path harness
24
+ (readonly loops, SAX, NodeSet unions, css, parse-query-serialize)
25
+ with a README methodology note — review-round benchmarks are now
26
+ reproducible from the repo.
27
+
28
+ ### Changed
29
+
30
+ - The published gem no longer ships dev tooling (`benchmark/`,
31
+ `.rspec`, `.rubocop.yml`); it carries lib, gemspec, Rakefile, and
32
+ documentation only.
33
+
8
34
  ## [1.9.4] - 2026-08-25
9
35
 
10
36
  ### Fixed
data/README.adoc CHANGED
@@ -411,6 +411,17 @@ Notable differences:
411
411
 
412
412
  == Performance
413
413
 
414
+ === Internal read-path harness
415
+
416
+ `benchmark/read_paths.rb` measures the binding's own hot paths
417
+ (readonly read loops, SAX, NodeSet unions, css, parse-query-
418
+ serialize). Machine-relative — compare before/after runs on the same
419
+ machine:
420
+
421
+ bundle exec ruby benchmark/read_paths.rb
422
+
423
+ === Full-field comparison
424
+
414
425
  Measured with metanorma/serialbench (Ruby 3.4.8, leptris 1.6.0,
415
426
  libleptris 1.6.0, macOS arm64) — Leptris vs the full field:
416
427
 
data/leptris.gemspec CHANGED
@@ -28,7 +28,8 @@ Gem::Specification.new do |spec|
28
28
  spec.files = Dir.chdir(__dir__) do
29
29
  `git ls-files -z`.split("\x0").reject do |f|
30
30
  (File.expand_path(f) == __FILE__) ||
31
- f.start_with?(*%w[bin/ test/ spec/ features/ .git .github appveyor Gemfile])
31
+ f.start_with?(*%w[bin/ test/ spec/ features/ benchmark/ .git .github
32
+ appveyor Gemfile .rspec .rubocop.yml])
32
33
  end
33
34
  end
34
35
  spec.bindir = "bin"
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Leptris
4
- VERSION = "1.9.4"
4
+ VERSION = "1.9.5"
5
5
  end
@@ -5,6 +5,7 @@ class Leptris::XML::CDATA < Leptris::XML::Text
5
5
 
6
6
  def content
7
7
  return @content if readonly_cached?(:@content)
8
+ ensure_alive!
8
9
  Leptris::XML::FFI.leptris_cdata_node_get_content(@c_ptr).tap do |text|
9
10
  @content = text if @document&.readonly?
10
11
  end
@@ -5,6 +5,7 @@ class Leptris::XML::Comment < Leptris::XML::Node
5
5
 
6
6
  def content
7
7
  return @content if readonly_cached?(:@content)
8
+ ensure_alive!
8
9
  Leptris::XML::FFI.leptris_comment_node_get_content(@c_ptr).tap do |text|
9
10
  @content = text if @document&.readonly?
10
11
  end
@@ -281,6 +281,12 @@ class Leptris::XML::Document
281
281
  @readonly == true
282
282
  end
283
283
 
284
+ # True once #free has run (or the GC finalizer fired) — borrowed
285
+ # handles check this before dereferencing their c_ptr.
286
+ def freed?
287
+ @freed.state == :freed || @c_ptr.nil?
288
+ end
289
+
284
290
  # The most recent error recorded against this document, or nil.
285
291
  def last_error
286
292
  msg = Leptris::XML::FFI.leptris_document_last_error(@c_ptr)
@@ -2,7 +2,9 @@
2
2
 
3
3
  class Leptris::XML::Element < Leptris::XML::Node
4
4
  def name
5
- @name ||= Leptris::XML::FFI.leptris_element_name(@c_ptr)
5
+ return @name if @name
6
+ ensure_alive!
7
+ @name = Leptris::XML::FFI.leptris_element_name(@c_ptr)
6
8
  end
7
9
  alias_method :node_name, :name
8
10
 
@@ -16,6 +18,7 @@ class Leptris::XML::Element < Leptris::XML::Node
16
18
 
17
19
  def content
18
20
  return @content if readonly_cached?(:@content)
21
+ ensure_alive!
19
22
  Leptris::XML::FFI.leptris_element_text(@c_ptr).tap do |text|
20
23
  @content = text if @document&.readonly?
21
24
  end
@@ -29,6 +32,7 @@ class Leptris::XML::Element < Leptris::XML::Node
29
32
  end
30
33
 
31
34
  def [](key)
35
+ ensure_alive!
32
36
  Leptris::XML::FFI.leptris_element_attribute(@c_ptr, key.to_s)
33
37
  end
34
38
  alias_method :attr, :[]
@@ -43,6 +47,7 @@ class Leptris::XML::Element < Leptris::XML::Node
43
47
  alias_method :set_attribute, :[]=
44
48
 
45
49
  def key?(name)
50
+ ensure_alive!
46
51
  Leptris::XML::FFI.leptris_element_has_attribute(@c_ptr, name.to_s) != 0
47
52
  end
48
53
  alias_method :has_attribute?, :key?
@@ -53,11 +58,13 @@ class Leptris::XML::Element < Leptris::XML::Node
53
58
  # in-scope declarations. uri nil/"" matches no-namespace
54
59
  # attributes only; xmlns declarations are invisible.
55
60
  def attribute_ns(uri, local)
61
+ ensure_alive!
56
62
  Leptris::XML::FFI.leptris_element_attribute_ns(
57
63
  @c_ptr, uri&.to_s, local.to_s)
58
64
  end
59
65
 
60
66
  def has_attribute_ns?(uri, local)
67
+ ensure_alive!
61
68
  Leptris::XML::FFI.leptris_element_has_attribute_ns(
62
69
  @c_ptr, uri&.to_s, local.to_s) != 0
63
70
  end
@@ -77,6 +84,7 @@ class Leptris::XML::Element < Leptris::XML::Node
77
84
  # per-attribute namespace accessors (libleptris 1.8.0).
78
85
  def each_attribute
79
86
  return enum_for(:each_attribute) unless block_given?
87
+ ensure_alive!
80
88
  attr = Leptris::XML::FFI.leptris_element_first_attribute(@c_ptr)
81
89
  until attr.nil? || attr.null?
82
90
  name = Leptris::XML::FFI.leptris_attribute_get_name(attr)
@@ -119,6 +127,7 @@ class Leptris::XML::Element < Leptris::XML::Node
119
127
  # The element's own namespace prefix (e.g. "foo" for <foo:child/>),
120
128
  # or nil when the element has none.
121
129
  def prefix
130
+ ensure_alive!
122
131
  Leptris::XML::FFI.leptris_element_prefix(@c_ptr)
123
132
  end
124
133
 
@@ -231,6 +240,7 @@ class Leptris::XML::Element < Leptris::XML::Node
231
240
 
232
241
  def namespace
233
242
  return @namespace if readonly_cached?(:@namespace)
243
+ ensure_alive!
234
244
  uri = Leptris::XML::FFI.leptris_element_namespace(@c_ptr)
235
245
  result =
236
246
  if uri.nil? || uri.empty?
@@ -249,6 +259,7 @@ class Leptris::XML::Element < Leptris::XML::Node
249
259
 
250
260
  def namespace_definitions
251
261
  return @namespace_definitions if readonly_cached?(:@namespace_definitions)
262
+ ensure_alive!
252
263
  count = Leptris::XML::FFI.leptris_element_namespace_count(@c_ptr)
253
264
  result = count.times.map do |i|
254
265
  prefix = Leptris::XML::FFI.leptris_element_namespace_decl_prefix(@c_ptr, i)
@@ -62,7 +62,9 @@ class Leptris::XML::Node
62
62
  end
63
63
 
64
64
  def type
65
- @node_type ||= Leptris::XML::FFI.leptris_node_get_type(@c_ptr)
65
+ return @node_type if @node_type
66
+ ensure_alive!
67
+ @node_type = Leptris::XML::FFI.leptris_node_get_type(@c_ptr)
66
68
  end
67
69
  alias_method :node_type, :type
68
70
 
@@ -77,13 +79,26 @@ class Leptris::XML::Node
77
79
 
78
80
  def parent
79
81
  return @parent if @parent
82
+ ensure_alive!
80
83
  ptr = Leptris::XML::FFI.leptris_node_parent(@c_ptr)
81
84
  return nil if ptr.null?
82
85
  Leptris::XML::Node.wrap(ptr, @document)
83
86
  end
84
87
 
85
- # Raises ReadOnlyError when the owning document was marked readonly.
88
+ # Borrowed-handle lifetime: every c_ptr dereference is valid only
89
+ # while the owning document lives. Parentless nodes (iterparse
90
+ # yields) cannot validate and are skipped.
91
+ def ensure_alive!
92
+ if @document&.freed?
93
+ raise Leptris::XML::UseAfterFreeError,
94
+ "owning document has been freed — handle used on #{inspect}"
95
+ end
96
+ end
97
+
98
+ # Raises ReadOnlyError when the owning document was marked readonly,
99
+ # UseAfterFreeError when it was freed.
86
100
  def ensure_writable!
101
+ ensure_alive!
87
102
  if @document&.readonly?
88
103
  raise Leptris::XML::ReadOnlyError,
89
104
  "document is readonly — mutation attempted on #{inspect}"
@@ -91,16 +106,19 @@ class Leptris::XML::Node
91
106
  end
92
107
 
93
108
  def line
109
+ ensure_alive!
94
110
  Leptris::XML::FFI.leptris_node_line(@c_ptr)
95
111
  end
96
112
 
97
113
  def <=>(other)
98
114
  return nil unless other.is_a?(Leptris::XML::Node)
99
115
  return nil unless @document == other.document
116
+ ensure_alive!
100
117
  Leptris::XML::FFI.leptris_node_compare(@c_ptr, other.c_ptr)
101
118
  end
102
119
 
103
120
  def child
121
+ ensure_alive!
104
122
  ptr = Leptris::XML::FFI.leptris_node_first_child(@c_ptr)
105
123
  return nil if ptr.null?
106
124
  Leptris::XML::Node.wrap(ptr, @document, parent: as_element_or_self)
@@ -110,6 +128,7 @@ class Leptris::XML::Node
110
128
  # Immutable in readonly mode: the batch fetch plus wrapper
111
129
  # construction is paid once.
112
130
  return @children if readonly_cached?(:@children)
131
+ ensure_alive!
113
132
  parent = as_element_or_self
114
133
  nodes = Leptris::XML::FFI.fetch_children(@c_ptr).map do |ptr|
115
134
  Leptris::XML::Node.wrap(ptr, @document, parent: parent)
@@ -120,6 +139,7 @@ class Leptris::XML::Node
120
139
  end
121
140
 
122
141
  def next_sibling
142
+ ensure_alive!
123
143
  ptr = Leptris::XML::FFI.leptris_node_next_sibling(@c_ptr)
124
144
  return nil if ptr.null?
125
145
  Leptris::XML::Node.wrap(ptr, @document, parent: @parent)
@@ -127,6 +147,7 @@ class Leptris::XML::Node
127
147
  alias_method :next, :next_sibling
128
148
 
129
149
  def previous_sibling
150
+ ensure_alive!
130
151
  ptr = Leptris::XML::FFI.leptris_node_previous_sibling(@c_ptr)
131
152
  return nil if ptr.null?
132
153
  Leptris::XML::Node.wrap(ptr, @document, parent: @parent)
@@ -134,6 +155,7 @@ class Leptris::XML::Node
134
155
  alias_method :previous, :previous_sibling
135
156
 
136
157
  def first_element_child
158
+ ensure_alive!
137
159
  ptr = Leptris::XML::FFI.leptris_node_first_child(@c_ptr)
138
160
  until ptr.nil? || ptr.null?
139
161
  node = Leptris::XML::Node.wrap(ptr, @document, parent: as_element_or_self)
@@ -189,6 +211,7 @@ class Leptris::XML::Node
189
211
  # callback (libleptris #273); the per-node FFI cost is the floor.
190
212
  def traverse
191
213
  return enum_for(:traverse) unless block_given?
214
+ ensure_alive!
192
215
  callback = ::FFI::Function.new(:int, [:pointer, :pointer], blocking: true) do |node_ptr, _|
193
216
  yield Leptris::XML::Node.wrap(node_ptr, @document)
194
217
  0
@@ -199,6 +222,7 @@ class Leptris::XML::Node
199
222
 
200
223
  def path
201
224
  return @path if readonly_cached?(:@path)
225
+ ensure_alive!
202
226
  str_ptr = Leptris::XML::FFI.leptris_node_get_xpath(@c_ptr)
203
227
  result = str_ptr.null? ? nil : Leptris::XML::FFI.read_owned_string(str_ptr)
204
228
  @path = result if @document&.readonly?
@@ -221,6 +245,7 @@ class Leptris::XML::Node
221
245
  end
222
246
 
223
247
  def dup
248
+ ensure_alive!
224
249
  elem_ptr = Leptris::XML::FFI.leptris_node_as_element(@c_ptr)
225
250
  raise Leptris::XML::Error, "dup is only supported for element nodes" if elem_ptr.null?
226
251
  copy_ptr = Leptris::XML::FFI.leptris_element_copy(elem_ptr, @document.c_ptr)
@@ -3,6 +3,7 @@
3
3
  class Leptris::XML::ProcessingInstruction < Leptris::XML::Node
4
4
  def name
5
5
  return @name if readonly_cached?(:@name)
6
+ ensure_alive!
6
7
  Leptris::XML::FFI.leptris_pi_node_get_target(@c_ptr).tap do |target|
7
8
  @name = target if @document&.readonly?
8
9
  end
@@ -11,6 +12,7 @@ class Leptris::XML::ProcessingInstruction < Leptris::XML::Node
11
12
 
12
13
  def content
13
14
  return @content if readonly_cached?(:@content)
15
+ ensure_alive!
14
16
  Leptris::XML::FFI.leptris_pi_node_get_data(@c_ptr).to_s.tap do |data|
15
17
  @content = data if @document&.readonly?
16
18
  end
@@ -5,6 +5,7 @@ class Leptris::XML::Text < Leptris::XML::Node
5
5
 
6
6
  def content
7
7
  return @content if readonly_cached?(:@content)
8
+ ensure_alive!
8
9
  Leptris::XML::FFI.leptris_text_node_get_content(@c_ptr).tap do |text|
9
10
  @content = text if @document&.readonly?
10
11
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: leptris
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.9.4
4
+ version: 1.9.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.
@@ -63,15 +63,11 @@ executables: []
63
63
  extensions: []
64
64
  extra_rdoc_files: []
65
65
  files:
66
- - ".rspec"
67
- - ".rubocop.yml"
68
66
  - CHANGELOG.md
69
67
  - CLAUDE.md
70
68
  - LICENSE.md
71
69
  - README.adoc
72
70
  - Rakefile
73
- - benchmark/README.md
74
- - benchmark/leptris_vs_nokogiri.rb
75
71
  - leptris.gemspec
76
72
  - lib/leptris.rb
77
73
  - lib/leptris/version.rb
data/.rspec DELETED
@@ -1,3 +0,0 @@
1
- --format documentation
2
- --color
3
- --require spec_helper
data/.rubocop.yml DELETED
@@ -1,8 +0,0 @@
1
- AllCops:
2
- TargetRubyVersion: 3.1
3
-
4
- Style/StringLiterals:
5
- EnforcedStyle: double_quotes
6
-
7
- Style/StringLiteralsInInterpolation:
8
- EnforcedStyle: double_quotes
data/benchmark/README.md DELETED
@@ -1,12 +0,0 @@
1
- # leptris vs the field
2
-
3
- The authoritative cross-library numbers live in the README's
4
- Performance section (measured with metanorma/serialbench).
5
-
6
- This local benchmark compares against Nokogiri only:
7
-
8
- LEPTRIS_LIB_PATH=/path/to/libleptris.dylib bundle exec ruby benchmark/leptris_vs_nokogiri.rb
9
-
10
- For the full-field race (Ox, Nokogiri, Oga, REXML, Leptris), use
11
- https://github.com/metanorma/serialbench — leptris's adapter ships
12
- there under `lib/serialbench/serializers/xml/leptris_serializer.rb`.
@@ -1,105 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- # Ruby-level performance comparison: Leptris::XML (FFI → libleptris v0.12.0)
4
- # vs Nokogiri (C extension → libxml2).
5
- #
6
- # Run with: bundle exec ruby -Ilib benchmark/leptris_vs_nokogiri.rb
7
-
8
- require "benchmark"
9
- require "leptris/xml"
10
- require "nokogiri"
11
-
12
- module Fixtures
13
- SMALL = "<catalog>" +
14
- (1..10).map { |i| "<book id='#{i}'><title>Book #{i}</title></book>" }.join +
15
- "</catalog>"
16
-
17
- MEDIUM = ("<catalog version='2.0'>" +
18
- (1..100).map do |i|
19
- "<book id='#{i}' lang='en'>" \
20
- "<title>Book #{i}</title>" \
21
- "<author id='a#{i}'>Author #{i}</author>" \
22
- "<price currency='USD'>#{i}.99</price>" \
23
- "</book>"
24
- end.join +
25
- "</catalog>").freeze
26
- end
27
-
28
- def time_it(label, n, &block)
29
- GC.start
30
- t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
31
- n.times { yield }
32
- elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0
33
- us_per_iter = (elapsed / n) * 1_000_000
34
- printf " %-40s %8.2f µs/iter (%d iters in %.3fs)\n", label, us_per_iter, n, elapsed
35
- us_per_iter
36
- end
37
-
38
- def ratio(label, leptris_us, nokogiri_us)
39
- r = nokogiri_us / leptris_us
40
- who = r > 1 ? "Leptris faster" : "Nokogiri faster"
41
- printf " → %-30s Leptris/Nokogiri = %.2fx (%s)\n\n", label, r, who
42
- end
43
-
44
- N_PARSE_SMALL = 5_000
45
- N_PARSE_MEDIUM = 1_000
46
- N_QUERY = 10_000
47
- N_TRAVERSE = 2_000
48
- N_SERIALIZE = 1_000
49
-
50
- puts "===== Parse — small (#{Fixtures::SMALL.bytesize} B) ====="
51
- t = time_it("leptris parse small", N_PARSE_SMALL) { d = Leptris::XML::Document.parse(Fixtures::SMALL); d.free }
52
- n = time_it("nokogiri parse small", N_PARSE_SMALL) { Nokogiri::XML(Fixtures::SMALL) }
53
- ratio("parse small", t, n)
54
-
55
- puts "===== Parse — medium (#{Fixtures::MEDIUM.bytesize} B) ====="
56
- t = time_it("leptris parse medium", N_PARSE_MEDIUM) { d = Leptris::XML::Document.parse(Fixtures::MEDIUM); d.free }
57
- n = time_it("nokogiri parse medium", N_PARSE_MEDIUM) { Nokogiri::XML(Fixtures::MEDIUM) }
58
- ratio("parse medium", t, n)
59
-
60
- # Pre-parse medium for query benchmarks
61
- doc_t = Leptris::XML::Document.parse(Fixtures::MEDIUM)
62
- doc_n = Nokogiri::XML(Fixtures::MEDIUM)
63
-
64
- puts "===== XPath — count(//book) ====="
65
- t = time_it("leptris xpath count()", N_QUERY) { doc_t.xpath("count(//book)") }
66
- n = time_it("nokogiri xpath count()", N_QUERY) { doc_n.xpath("count(//book)") }
67
- ratio("xpath count()", t, n)
68
-
69
- puts "===== XPath — //book (nodeset of 100) ====="
70
- t = time_it("leptris xpath //book", N_QUERY) { doc_t.xpath("//book") }
71
- n = time_it("nokogiri xpath //book", N_QUERY) { doc_n.xpath("//book") }
72
- ratio("xpath //book", t, n)
73
-
74
- puts "===== XPath — predicate //book[@id='50'] ====="
75
- t = time_it("leptris xpath predicate", N_QUERY) { doc_t.xpath("//book[@id='50']") }
76
- n = time_it("nokogiri xpath predicate", N_QUERY) { doc_n.xpath("//book[@id='50']") }
77
- ratio("xpath predicate", t, n)
78
-
79
- puts "===== XPath — complex //book[price > 50] ====="
80
- t = time_it("leptris xpath complex", N_QUERY) { doc_t.xpath("//book[price > 50]") }
81
- n = time_it("nokogiri xpath complex", N_QUERY) { doc_n.xpath("//book[price > 50]") }
82
- ratio("xpath complex", t, n)
83
-
84
- puts "===== XPath — union //author | //title ====="
85
- t = time_it("leptris xpath union", N_QUERY) { doc_t.xpath("//author | //title") }
86
- n = time_it("nokogiri xpath union", N_QUERY) { doc_n.xpath("//author | //title") }
87
- ratio("xpath union", t, n)
88
-
89
- puts "===== Tree traversal (root.traverse) ====="
90
- t = time_it("leptris traverse", N_TRAVERSE) { doc_t.root.traverse { |n| n.name } }
91
- n = time_it("nokogiri traverse", N_TRAVERSE) { doc_n.root.traverse { |n| n.name } }
92
- ratio("traverse", t, n)
93
-
94
- puts "===== Serialize — Document#to_xml ====="
95
- t = time_it("leptris serialize", N_SERIALIZE) { doc_t.to_xml }
96
- n = time_it("nokogiri serialize", N_SERIALIZE) { doc_n.to_xml }
97
- ratio("serialize", t, n)
98
-
99
- doc_t.free
100
-
101
- puts ""
102
- puts "libleptris v0.12.0 — all upstream issues closed."
103
- puts "All 176 Ruby specs passing, 0 pending."
104
-
105
-