Skip to content

Commit aa0236f

Browse files
authored
feat: remove virtus dependency (#494)
## Summary Replaces the unmaintained [`virtus`](https://github.com/solnic/virtus#discontinued) gem with a ~90-line native `ValueObject` base class in `axe-core-api`. The `ostruct` workaround in the gemspec — only present to keep `virtus` working when `ostruct` exits stdlib in Ruby 3.5 — is also removed. Both gems are dropped from every downstream lockfile (`axe-core-rspec`, `axe-core-capybara`, `axe-core-cucumber`; `axe-core-selenium` and `axe-core-watir` didn't reference them to begin with). ## What changed - **`packages/axe-core-api/lib/axe/api/value_object.rb`** — replaced `include Virtus.value_object …` with a native PORO base class supporting: - The existing `values do … end` DSL and `attribute :name, Type` syntax (no call-site change in the five subclasses' declaration style). - Virtus-compatible **lenient** type coercion for `::String`, `::Symbol`, `::Integer`, `::Float`, `::TrueClass`/`::FalseClass`, `ValueObject` subclasses, and `[T]` array literals. Non-coercible strings pass through unchanged rather than raising (matches Virtus/Coercible); boolean coercion recognizes Virtus's truthy/falsy string sets (`%w[1 t T true TRUE]` / `%w[0 f F false FALSE]`). - Read-only `attr_reader`s (preserves virtus's `mass_assignment: false` semantics). - String- and symbol-keyed hash construction; nil-tolerance at every level (nil typed-Array → `[]`, nil scalar → `nil`). - Attribute inheritance (`CheckedNode < Node` continues to work). - `==`, `eql?`, `hash`, `inspect`, `[]`, `to_h`, and a `to_hash` that dispatches dynamically to `to_h` (so subclass overrides flow through both names). - **Five result classes** (`results.rb`, `results/rule.rb`, `results/check.rb`, `results/checked_node.rb`) — `::Array[T]` (virtus-only sugar) rewritten as `[T]` literal. Internal-only; no consumer-visible change. - **`axe-core-api.gemspec`** — dropped `spec.add_dependency "virtus"` and `spec.add_dependency "ostruct"` (plus its comment about the Ruby 3.5 stdlib removal). - **Lockfiles** — surgical removal of `virtus`, `ostruct`, and transitive deps (`axiom-types`, `coercible`, `descendants_tracker`, `ice_nine`, `thread_safe`) for `axe-core-api`, `axe-core-rspec`, `axe-core-capybara`, `axe-core-cucumber`. `axe-core-api` version bumped to `4.11.3` to match `version.rb`. No `BUNDLED WITH` change, no platform additions, no unrelated gem version changes. - **`.gitignore`** — added `vendor/` (was missing; only added for tidiness of local bundler installs). ## Ruby support impact **Minimum Ruby version unchanged.** The gemspec still declares `required_ruby_version >= 2.3.0`. The native `ValueObject` uses only features available in Ruby 2.3+: `class << self`, `instance_eval`, `instance_variable_set`, `attr_reader`, `each_with_object`, `public_send`, `alias_method`, `case`/`when` with class matchers, `Hash#key?`, `Array()` coercion, `=~` (not `String#match?`, which is Ruby 2.4+). No `Data.define`, no pattern matching, no endless methods, no kwarg shorthand. **Forward compatibility improved:** - **Ruby 3.5 / `ostruct` removal:** previously `axe-core-api` had to add `ostruct` as a runtime dep because `virtus` pulled it in transitively. With `virtus` gone, `ostruct` is no longer needed at all — one fewer moving part when Ruby 3.5 ships. - **`virtus` is unmaintained** (deprecated by its author in 2016). Pinning to it left the gem and its consumers exposed to compatibility breakage on each new Ruby release. The native replacement is owned by this repo. - **No new runtime deps.** Considered `dry-struct` + `dry-types` (canonical virtus successor) and `anima`; both would swap one external dep for one-or-more external deps. Given the tiny surface area used here (no defaults, no validation, no custom coercers, no finalize hooks), owning ~90 lines is cheaper than carrying an external library's lifecycle. **No consumer-visible behaviour change.** `Axe::API::Results.new(hash)`, attribute readers, and `to_h` outputs are byte-identical to the virtus-backed versions. Downstream gems (`axe-core-rspec`, `axe-core-capybara`, etc.) need no changes. ## Test plan - [x] `cd packages/axe-core-api && bundle exec rspec` — **162 examples, 0 failures, 4 pending** (preexisting "Not yet implemented" markers). Includes 16 new `value_object_spec.rb` examples covering lenient Integer/Float/Boolean coercion and `to_hash`/`inspect` behavior. - [x] `spec/axe/api/results_spec.rb` exercises the hot paths: string-keyed construction, nils at every level, nested `Rule → CheckedNode → Check` graph instantiation, and untyped `target` accepting `String` / `Array[String]` / `Array[Array[String]]`. - [x] `cd packages/axe-core-rspec && bundle exec rspec` — 1/1 pass. - [x] `cd packages/axe-core-cucumber && bundle exec rspec` — 38/38 pass. - [x] `cd packages/axe-core-capybara && bundle exec rspec`, `axe-core-selenium`, `axe-core-watir` — each has one preexisting failure that requires a Chrome driver in the environment; unrelated to this change. - [x] `grep -RIn 'virtus\|ostruct' --include='Gemfile*' --include='*.gemspec' --include='*.rb' .` — returns no matches. - [ ] CI: confirm full matrix passes (the supported Ruby version matrix is what ultimately validates the "min Ruby unchanged" claim and the cleaned lockfiles). ## Backwards-compatibility analysis After shipping the initial replacement, audited Virtus's public API surface against repo usage and addressed the contract issues that surfaced in review. ### Fixed in this PR - **`==` symmetry / `==`/`hash` contract.** The first cut used `other.is_a?(self.class)` which is asymmetric across subclass hierarchies. Tightened to `instance_of?` so equality is strict on class, matching Virtus's default value-object behavior. `hash` keys on `self.class` to match. - **`inspect` regression (+ empty-attribute fix).** Virtus value objects render as `#<Axe::API::Results inapplicable=[…] violations=[…] …>`. Restored an attribute-listing `inspect` after the initial cut; subsequently fixed to suppress the trailing space when a value object has no attributes (`"#<Foo>"` rather than `"#<Foo >"`). - **`obj[name]` Hash-style attribute indexing.** Restored via a lenient `def [](name)` on `ValueObject` (string or symbol keys; `nil` for unknown attrs) — matches Virtus and `Hash` semantics. Caught by an e2e/selenium spec calling `node["target"]`. - **`obj.to_hash` preserved as dynamic-dispatch alias of `to_h`.** Initially shipped as `alias_method :to_hash, :to_h`, which snapshots the base-class body — so subclass `to_h` overrides (which every result class has) did not flow through. Replaced with `def to_hash; to_h; end` so dynamic dispatch routes calls through the subclass. - **Integer/Float/Boolean coercion aligned with Virtus.** Initial branches used `Integer(value)` / `Float(value)` (which raise on non-numeric strings) and `!!value` (which treats `"false"` as truthy). Replaced with lenient helpers that mirror Virtus/Coercible: non-coercible strings pass through unchanged; booleans recognize Virtus's truthy/falsy string sets. ### Disclosed divergences (no observed caller in this repo) These were part of Virtus's value-object API surface and are not provided by the native replacement. Verified via grep across `lib/`, `spec/`, `e2e/`, and `features/` — no caller in this repo. External consumers may want to speak up if they relied on any of these: - **Default `to_h` no longer recurses through nested `ValueObject`s.** Every `axe-core-api` subclass (`Results`, `Rule`, `Check`, `Node`, `CheckedNode`) overrides `to_h` and recurses explicitly, so this is dead code internally. An external consumer who subclasses `Axe::API::ValueObject` (it's a public constant) and doesn't override `to_h` would now get nested objects rather than hashes. - **Removed instance/class API surface:** `obj.attributes` (instance method), `obj.with(attrs)` (functional copy), `Klass.attribute_set` and other Virtus introspection. None used internally — the `.to_hash` calls that exist in the repo target `Axe::API::Options` / `Axe::API::Rules`, which are not `ValueObject` subclasses and are unaffected. - **Instance freezing.** Virtus's `value_object` can freeze instances post-construction. The existing `Results#timestamp=` setter and the `testEngine["name"] = …` mutation in `e2e/selenium/spec/api_spec.rb` only work if instances are not frozen, so Virtus must already not have been freezing here. The native replacement also does not freeze — explicit preservation of behavior, but worth being clear about. ### Re-verified after fixes - `cd packages/axe-core-api && bundle exec rspec` — 162 examples, 0 failures, 4 pending. Closes: #493
1 parent c9ac455 commit aa0236f

12 files changed

Lines changed: 276 additions & 93 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,3 +17,4 @@ rdoc
1717
.ruby-version
1818
bin/
1919
node_modules/
20+
vendor/

packages/axe-core-api/Gemfile.lock

Lines changed: 1 addition & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,15 @@
11
PATH
22
remote: .
33
specs:
4-
axe-core-api (4.8.0)
4+
axe-core-api (4.11.3)
55
dumb_delegator
6-
virtus
76

87
GEM
98
remote: https://rubygems.org/
109
specs:
1110
addressable (2.8.5)
1211
public_suffix (>= 2.0.2, < 6.0)
1312
ast (2.4.2)
14-
axiom-types (0.1.1)
15-
descendants_tracker (~> 0.0.4)
16-
ice_nine (~> 0.11.0)
17-
thread_safe (~> 0.3, >= 0.3.1)
1813
capybara (3.39.2)
1914
addressable
2015
matrix
@@ -24,13 +19,8 @@ GEM
2419
rack-test (>= 0.6.3)
2520
regexp_parser (>= 1.5, < 3.0)
2621
xpath (~> 3.2)
27-
coercible (1.0.0)
28-
descendants_tracker (~> 0.0.1)
29-
descendants_tracker (0.0.4)
30-
thread_safe (~> 0.3, >= 0.3.1)
3122
diff-lcs (1.5.0)
3223
dumb_delegator (1.0.0)
33-
ice_nine (0.11.2)
3424
json (2.7.0)
3525
language_server-protocol (3.17.0.3)
3626
matrix (0.4.2)
@@ -85,12 +75,7 @@ GEM
8575
rexml (~> 3.2, >= 3.2.5)
8676
rubyzip (>= 1.2.2, < 3.0)
8777
websocket (~> 1.0)
88-
thread_safe (0.3.6)
8978
unicode-display_width (2.5.0)
90-
virtus (2.0.0)
91-
axiom-types (~> 0.1)
92-
coercible (~> 1.0)
93-
descendants_tracker (~> 0.0, >= 0.0.3)
9479
watir (7.3.0)
9580
regexp_parser (>= 1.2, < 3)
9681
selenium-webdriver (~> 4.2)

packages/axe-core-api/axe-core-api.gemspec

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,6 @@ Gem::Specification.new do |spec|
2424
]
2525

2626
spec.add_dependency "dumb_delegator"
27-
# used by virtus; including it to make sure we install the gem and do not
28-
# rely on the standard library version, which will be removed in 3.5.0
29-
spec.add_dependency "ostruct"
30-
spec.add_dependency "virtus"
3127

3228
spec.add_development_dependency "bundler", "~> 2.1"
3329
spec.add_development_dependency "capybara"

packages/axe-core-api/lib/axe/api/results.rb

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,16 +6,16 @@ class Results < ValueObject
66
require_relative "./results/rule"
77

88
values do
9-
attribute :inapplicable, ::Array[Rule]
10-
attribute :incomplete, ::Array[Rule]
11-
attribute :passes, ::Array[Rule]
9+
attribute :inapplicable, [Rule]
10+
attribute :incomplete, [Rule]
11+
attribute :passes, [Rule]
1212
attribute :timestamp
1313
attribute :testEngine
1414
attribute :testEnvironment
1515
attribute :testRunner
1616
attribute :toolOptions
1717
attribute :url, ::String
18-
attribute :violations, ::Array[Rule]
18+
attribute :violations, [Rule]
1919
end
2020

2121
def failure_message

packages/axe-core-api/lib/axe/api/results/check.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ class Check < ValueObject
1010
attribute :id, ::Symbol
1111
attribute :impact, ::Symbol
1212
attribute :message, ::String
13-
attribute :relatedNodes, ::Array[Node]
13+
attribute :relatedNodes, [Node]
1414
end
1515

1616
def failure_message

packages/axe-core-api/lib/axe/api/results/checked_node.rb

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,9 @@ class Results
77
class CheckedNode < Node
88
values do
99
attribute :impact, ::Symbol
10-
attribute :any, ::Array[Check]
11-
attribute :all, ::Array[Check]
12-
attribute :none, ::Array[Check]
10+
attribute :any, [Check]
11+
attribute :all, [Check]
12+
attribute :none, [Check]
1313
attribute :failureSummary, ::Symbol
1414
attribute :html, ::String
1515
attribute :target

packages/axe-core-api/lib/axe/api/results/rule.rb

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,8 @@ class Rule < ValueObject
1111
attribute :help, ::String
1212
attribute :helpUrl, ::String
1313
attribute :impact, ::Symbol
14-
attribute :tags, ::Array[::Symbol]
15-
attribute :nodes, ::Array[CheckedNode]
14+
attribute :tags, [::Symbol]
15+
attribute :nodes, [CheckedNode]
1616
end
1717

1818
def failure_messages(index)
Lines changed: 123 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,129 @@
1-
require 'virtus'
2-
31
module Axe
42
module API
53
class ValueObject
6-
include ::Virtus.value_object mass_assignment: false, nullify_blank: false
4+
class << self
5+
def attributes
6+
@attributes ||= superclass.respond_to?(:attributes) ? superclass.attributes.dup : {}
7+
end
8+
9+
def values(&block)
10+
instance_eval(&block)
11+
end
12+
13+
def attribute(name, type = nil)
14+
attributes[name] = type
15+
attr_reader(name) unless method_defined?(name)
16+
end
17+
end
18+
19+
def initialize(attrs = {})
20+
attrs ||= {}
21+
self.class.attributes.each do |name, type|
22+
raw = if attrs.respond_to?(:key?)
23+
if attrs.key?(name)
24+
attrs[name]
25+
elsif attrs.key?(name.to_s)
26+
attrs[name.to_s]
27+
end
28+
elsif attrs.respond_to?(name)
29+
attrs.public_send(name)
30+
end
31+
instance_variable_set("@#{name}", coerce(raw, type))
32+
end
33+
end
34+
35+
def attributes
36+
self.class.attributes.each_key.each_with_object({}) do |name, hash|
37+
hash[name] = public_send(name)
38+
end
39+
end
40+
41+
def to_h
42+
attributes
43+
end
44+
45+
def to_hash
46+
to_h
47+
end
48+
49+
def ==(other)
50+
other.instance_of?(self.class) && attributes == other.attributes
51+
end
52+
alias_method :eql?, :==
53+
54+
def hash
55+
[self.class, attributes].hash
56+
end
57+
58+
def inspect
59+
pairs = attributes.map { |name, value| "#{name}=#{value.inspect}" }
60+
pairs.empty? ? "#<#{self.class.name}>" : "#<#{self.class.name} #{pairs.join(" ")}>"
61+
end
62+
63+
def [](name)
64+
key = name.to_sym
65+
self.class.attributes.key?(key) ? public_send(key) : nil
66+
end
67+
68+
BOOLEAN_TRUE_STRINGS = %w[1 t T true TRUE].freeze
69+
BOOLEAN_FALSE_STRINGS = %w[0 f F false FALSE].freeze
70+
71+
private
72+
73+
def coerce(value, type)
74+
return value if type.nil?
75+
76+
case type
77+
when Array
78+
Array(value).map { |element| coerce(element, type.first) }
79+
when Class
80+
coerce_class(value, type)
81+
else
82+
value
83+
end
84+
end
85+
86+
# Virtus-compatible lenient coercion: returns the input unchanged when
87+
# it can't be cleanly converted, rather than raising.
88+
def coerce_class(value, type)
89+
return nil if value.nil?
90+
91+
if type == ::Symbol
92+
value.to_sym
93+
elsif type == ::String
94+
value.to_s
95+
elsif type == ::Integer
96+
coerce_integer(value)
97+
elsif type == ::Float
98+
coerce_float(value)
99+
elsif type == ::TrueClass || type == ::FalseClass
100+
coerce_boolean(value)
101+
elsif type <= ValueObject
102+
value.is_a?(type) ? value : type.new(value)
103+
else
104+
value
105+
end
106+
end
107+
108+
def coerce_integer(value)
109+
return value.to_i if value.is_a?(::Numeric)
110+
return value.to_i if value.is_a?(::String) && value =~ /\A-?\d+\z/
111+
value
112+
end
113+
114+
def coerce_float(value)
115+
return value.to_f if value.is_a?(::Numeric)
116+
return value.to_f if value.is_a?(::String) && value =~ /\A-?(?:\d+\.?\d*|\.\d+)\z/
117+
value
118+
end
119+
120+
def coerce_boolean(value)
121+
return value if value == true || value == false
122+
str = value.to_s
123+
return true if BOOLEAN_TRUE_STRINGS.include?(str)
124+
return false if BOOLEAN_FALSE_STRINGS.include?(str)
125+
value
126+
end
7127
end
8128
end
9129
end
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
require "spec_helper"
2+
require_relative "../../../lib/axe/api/value_object"
3+
4+
module Axe::API
5+
describe ValueObject do
6+
describe "Virtus-compatible lenient coercion" do
7+
describe "Integer attribute" do
8+
let(:klass) do
9+
Class.new(ValueObject) do
10+
values { attribute :count, Integer }
11+
end
12+
end
13+
14+
it "coerces numeric strings to Integer" do
15+
expect(klass.new(count: "42").count).to eq(42)
16+
expect(klass.new(count: "-7").count).to eq(-7)
17+
end
18+
19+
it "passes through non-numeric strings unchanged (does not raise)" do
20+
expect(klass.new(count: "abc").count).to eq("abc")
21+
expect(klass.new(count: "42abc").count).to eq("42abc")
22+
end
23+
24+
it "coerces numeric types via to_i" do
25+
expect(klass.new(count: 3.7).count).to eq(3)
26+
expect(klass.new(count: 5).count).to eq(5)
27+
end
28+
29+
it "preserves nil" do
30+
expect(klass.new(count: nil).count).to be_nil
31+
end
32+
end
33+
34+
describe "Float attribute" do
35+
let(:klass) do
36+
Class.new(ValueObject) do
37+
values { attribute :ratio, Float }
38+
end
39+
end
40+
41+
it "coerces numeric strings to Float" do
42+
expect(klass.new(ratio: "3.14").ratio).to eq(3.14)
43+
expect(klass.new(ratio: ".5").ratio).to eq(0.5)
44+
expect(klass.new(ratio: "-2.5").ratio).to eq(-2.5)
45+
expect(klass.new(ratio: "42").ratio).to eq(42.0)
46+
end
47+
48+
it "passes through non-numeric strings unchanged (does not raise)" do
49+
expect(klass.new(ratio: "abc").ratio).to eq("abc")
50+
end
51+
52+
it "coerces numeric types via to_f" do
53+
expect(klass.new(ratio: 42).ratio).to eq(42.0)
54+
end
55+
56+
it "preserves nil" do
57+
expect(klass.new(ratio: nil).ratio).to be_nil
58+
end
59+
end
60+
61+
describe "Boolean attribute" do
62+
let(:klass) do
63+
Class.new(ValueObject) do
64+
values { attribute :flag, TrueClass }
65+
end
66+
end
67+
68+
it "maps Virtus-style truthy strings to true" do
69+
%w[1 t T true TRUE].each do |s|
70+
expect(klass.new(flag: s).flag).to be(true), "expected #{s.inspect} -> true"
71+
end
72+
end
73+
74+
it "maps Virtus-style falsy strings to false" do
75+
%w[0 f F false FALSE].each do |s|
76+
expect(klass.new(flag: s).flag).to be(false), "expected #{s.inspect} -> false"
77+
end
78+
end
79+
80+
it "passes through booleans unchanged" do
81+
expect(klass.new(flag: true).flag).to be(true)
82+
expect(klass.new(flag: false).flag).to be(false)
83+
end
84+
85+
it "passes through unrecognized strings unchanged" do
86+
expect(klass.new(flag: "maybe").flag).to eq("maybe")
87+
end
88+
89+
it "preserves nil" do
90+
expect(klass.new(flag: nil).flag).to be_nil
91+
end
92+
end
93+
end
94+
95+
describe "#to_hash" do
96+
it "dispatches dynamically to subclass #to_h overrides" do
97+
klass = Class.new(ValueObject) do
98+
values { attribute :x }
99+
def to_h
100+
{ x: x, extra: :from_override }
101+
end
102+
end
103+
104+
instance = klass.new(x: 1)
105+
expect(instance.to_hash).to eq(instance.to_h)
106+
expect(instance.to_hash).to eq(x: 1, extra: :from_override)
107+
end
108+
end
109+
110+
describe "#inspect" do
111+
it "renders attributes inline" do
112+
klass = Class.new(ValueObject) do
113+
def self.name; "Sample"; end
114+
values do
115+
attribute :a
116+
attribute :b
117+
end
118+
end
119+
120+
expect(klass.new(a: 1, b: "x").inspect).to eq('#<Sample a=1 b="x">')
121+
end
122+
123+
it "has no trailing space when there are no attributes" do
124+
klass = Class.new(ValueObject) do
125+
def self.name; "Empty"; end
126+
end
127+
128+
expect(klass.new.inspect).to eq("#<Empty>")
129+
end
130+
end
131+
end
132+
end

0 commit comments

Comments
 (0)