duck_typer 0.3.2 → 0.4.0

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: 24207ce1f63f8b0871e06127e2644497f4c60314bbb5fab55f0e8a9f2b30349b
4
- data.tar.gz: 8dbebe87508eb66d745af1ce8a0fd6d30865b5929b2de47e092cba9122bcab29
3
+ metadata.gz: 881833f4db9f3c47049ad45e41c804a74a97db030438f43f933a388dc0f41cba
4
+ data.tar.gz: ad0b846adb0389c1db0cd49d9d245cb6c006c136e76ea0e7b3980a8d1fb4d3ce
5
5
  SHA512:
6
- metadata.gz: d97a298dd8aaa903f2a9e2ae451f530a79aa433d9734ae220f89573f88d3ea9288dd1c9feb61c981eda95b272f2fea4b0763f67374bba972cb678ab52692a03b
7
- data.tar.gz: 75b66d83757541fdd0cc1ead317aa3e15a7fe9878e1fb57a9b348b3a6c63bce5da2e5fe8091296914ea3bf2e637446af0031806baaed208f5ba01497a479c41c
6
+ metadata.gz: be49c0476b7afe2c3df16fcede0480f242cfab68c0d1d0a637bba8ac4aa6070bada1c9bbd0350c556193340d62cc2a5665b9949b1c838840127574c2761ff57a
7
+ data.tar.gz: e9f4543f7117f6e7e3709a153cb316ce90974a984fc302bec71dc84c78fff89a706585cffc9581a24b6f9fa679df3dd0f971a0a82b44c1b78644ff1a8948ba1e
data/.ruby-version ADDED
@@ -0,0 +1 @@
1
+ 3.1.0
data/.tool-versions ADDED
@@ -0,0 +1 @@
1
+ ruby 3.1.0
data/CHANGELOG.md CHANGED
@@ -1,5 +1,29 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.4.0] - 2026-03-09
4
+
5
+ ### Added
6
+ - Strict mode (`strict: true`) for interface comparison: positional
7
+ argument names must match exactly. Available on
8
+ `assert_interfaces_match`, `have_matching_interfaces`, and the
9
+ RSpec shared example. Keyword argument names always matter
10
+ regardless of this setting.
11
+ - `assert_duck_types_match` as an alias for `assert_interfaces_match`
12
+ - `have_matching_duck_types` as an alias for `have_matching_interfaces`
13
+ - `BulkInterfaceChecker` now raises `ArgumentError` when fewer than
14
+ two classes are given
15
+
16
+ ### Fixed
17
+ - RSpec shared example was broken on Ruby 3.1 due to proc argument
18
+ destructuring differences between Ruby versions
19
+
20
+ ### Changed
21
+ - `ParamsNormalizer` refactored into a factory (`ParamsNormalizer.for(strict:)`)
22
+ with extracted modules: `KeywordNormalizer`, `SequentialNormalizer`,
23
+ `DefaultParamsNormalizer`, `StrictParamsNormalizer`, and
24
+ `NullParamsNormalizer` — all consolidated in a single file
25
+ - CI now runs against Ruby 3.1 (minimum) and 3.4 (latest)
26
+
3
27
  ## [0.3.2] - 2026-03-07
4
28
 
5
29
  ### Changed
data/CLAUDE.md CHANGED
@@ -15,6 +15,29 @@
15
15
  - After releasing, delete the generated `.gem` files from the root
16
16
  directory — they are not committed and should not linger.
17
17
 
18
+ ## Ruby version
19
+
20
+ For now, use the minimum required Ruby version for development —
21
+ this ensures the gem works for everyone who meets that requirement.
22
+
23
+ When updating the minimum required Ruby version, update it in all
24
+ of these places:
25
+
26
+ - `.ruby-version`
27
+ - `.tool-versions`
28
+ - `duck_typer.gemspec` (`spec.required_ruby_version`)
29
+ - `.github/workflows/ci.yml` (`ruby-version`)
30
+
31
+ ## Git
32
+
33
+ - Before staging specific files, always run `git status` to check
34
+ whether the staging area already has changes that would be
35
+ unintentionally included in the commit.
36
+ - After staging, double-check the staged changes match what was
37
+ asked before committing.
38
+ - Always run `bundle exec rake ci` before pushing.
39
+ - Always ask for confirmation before pushing.
40
+
18
41
  ## Commands
19
42
 
20
43
  - `bundle exec rake test` — run Minitest and RSpec suites
data/CONTRIBUTING.md CHANGED
@@ -34,11 +34,11 @@ Run the setup script.
34
34
  Make sure everything passes:
35
35
 
36
36
  ```
37
- bundle exec rake test
38
- bundle exec standardrb
37
+ bundle exec rake ci
39
38
  ```
40
39
 
41
- Make your change, with new passing tests.
40
+ Make your change, with new passing tests. Before pushing, run
41
+ `bundle exec rake ci` again to make sure nothing is broken.
42
42
 
43
43
  Push to your fork. Write a [good commit message][commit]. Submit a
44
44
  pull request.
data/README.md CHANGED
@@ -2,6 +2,12 @@
2
2
 
3
3
  [![CI](https://github.com/thoughtbot/duck_typer/actions/workflows/ci.yml/badge.svg)](https://github.com/thoughtbot/duck_typer/actions/workflows/ci.yml)
4
4
 
5
+ <div align="center">
6
+ <img alt="DuckTyper mascot" src="assets/swan_mugshot.png" width="300">
7
+ </div>
8
+
9
+ > If it quacks like a duck, it's a duck... or is it?
10
+
5
11
  DuckTyper enforces duck-typed interfaces in Ruby by comparing the
6
12
  public method signatures of classes, surfacing mismatches through
7
13
  your test suite.
@@ -39,7 +45,7 @@ aligned as the design changes.
39
45
  Add to your Gemfile:
40
46
 
41
47
  ```ruby
42
- gem "duck_typer"
48
+ gem "duck_typer", group: :test
43
49
  ```
44
50
 
45
51
  Then run:
@@ -103,10 +109,17 @@ classes share compatible interfaces:
103
109
 
104
110
  ```ruby
105
111
  def test_payment_processors_have_compatible_interfaces
106
- assert_interfaces_match [StripeProcessor, PaypalProcessor, BraintreeProcessor]
112
+ assert_interfaces_match [
113
+ StripeProcessor,
114
+ PaypalProcessor,
115
+ BraintreeProcessor
116
+ ]
107
117
  end
108
118
  ```
109
119
 
120
+ > If you prefer duck typing terminology, `assert_duck_types_match`
121
+ > is available as an alias.
122
+
110
123
  By default, DuckTyper checks instance method interfaces. To check
111
124
  class-level interfaces instead, pass `type: :class_methods`:
112
125
 
@@ -125,6 +138,19 @@ assert_interfaces_match [StripeProcessor, PaypalProcessor],
125
138
  This is useful if your class implements multiple interfaces, in
126
139
  which case you can write an assertion for each.
127
140
 
141
+ To enforce that positional argument names also match (strict
142
+ mode), pass `strict: true`:
143
+
144
+ ```ruby
145
+ assert_interfaces_match [StripeProcessor, PaypalProcessor],
146
+ strict: true
147
+ ```
148
+
149
+ By default, positional argument names are ignored — only their
150
+ count and kind (required, optional, rest) are compared. In strict
151
+ mode, names must match exactly. Keyword argument names always
152
+ matter regardless of this setting.
153
+
128
154
  ### RSpec
129
155
 
130
156
  Require the RSpec integration in your `spec_helper.rb`:
@@ -141,21 +167,35 @@ share compatible interfaces:
141
167
  ```ruby
142
168
  RSpec.describe "payment processors" do
143
169
  it "have compatible interfaces" do
144
- expect([StripeProcessor, PaypalProcessor, BraintreeProcessor]).to have_matching_interfaces
170
+ expect([StripeProcessor, PaypalProcessor, BraintreeProcessor])
171
+ .to have_matching_interfaces
145
172
  end
146
173
  end
147
174
  ```
148
175
 
176
+ > If you prefer duck typing terminology, `have_matching_duck_types`
177
+ > is available as an alias.
178
+
149
179
  For class-level interfaces, pass `type: :class_methods`:
150
180
 
151
181
  ```ruby
152
- expect([StripeProcessor, PaypalProcessor]).to have_matching_interfaces(type: :class_methods)
182
+ expect([StripeProcessor, PaypalProcessor])
183
+ .to have_matching_interfaces(type: :class_methods)
153
184
  ```
154
185
 
155
186
  To check only a subset of methods, use `methods:`:
156
187
 
157
188
  ```ruby
158
- expect([StripeProcessor, PaypalProcessor]).to have_matching_interfaces(methods: %i[charge refund])
189
+ expect([StripeProcessor, PaypalProcessor])
190
+ .to have_matching_interfaces(methods: %i[charge refund])
191
+ ```
192
+
193
+ To enforce that positional argument names also match, pass
194
+ `strict: true`:
195
+
196
+ ```ruby
197
+ expect([StripeProcessor, PaypalProcessor])
198
+ .to have_matching_interfaces(strict: true)
159
199
  ```
160
200
 
161
201
  #### Shared example
@@ -178,23 +218,30 @@ Then use it in your specs:
178
218
 
179
219
  ```ruby
180
220
  RSpec.describe "payment processors" do
181
- it_behaves_like "an interface", [StripeProcessor, PaypalProcessor, BraintreeProcessor]
221
+ it_behaves_like "an interface", [
222
+ StripeProcessor,
223
+ PaypalProcessor,
224
+ BraintreeProcessor
225
+ ]
182
226
  end
183
227
  ```
184
228
 
185
- The same `type:` and `methods:` options are supported:
229
+ The same `type:`, `methods:`, and `strict:` options are supported:
186
230
 
187
231
  ```ruby
188
232
  it_behaves_like "an interface", [StripeProcessor, PaypalProcessor],
189
233
  type: :class_methods,
190
- methods: %i[charge refund]
234
+ methods: %i[charge refund],
235
+ strict: true
191
236
  ```
192
237
 
193
238
  ## Limitations
194
239
 
195
- DuckTyper checks the **structure** of public method signatures
196
- — the number of parameters, their kinds (required, optional,
197
- keyword, rest, block), and keyword argument names. It does not
240
+ By default, DuckTyper checks the **structure** of public method
241
+ signatures — the number of parameters, their kinds (required,
242
+ optional, keyword, rest, block), and keyword argument names. In
243
+ strict mode, positional argument names are also compared. It does
244
+ not
198
245
  verify the following, which should be covered by your regular
199
246
  test suite:
200
247
 
@@ -217,6 +264,14 @@ Some things are intentionally out of scope:
217
264
  `initialize`: how an object is constructed is not an interface
218
265
  concern.
219
266
 
267
+ ## Stability
268
+
269
+ DuckTyper is intentionally minimal. It reflects Ruby's own method
270
+ introspection API, which rarely changes — so the gem rarely needs
271
+ to either. When it does change, it will most likely be for additive reasons:
272
+ new API options, better error messages, or broader test framework
273
+ support. It is safe to depend on without worrying about churn.
274
+
220
275
  ## Development
221
276
 
222
277
  After checking out the repo, run `bin/setup` to install
@@ -3,9 +3,11 @@
3
3
  module DuckTyper
4
4
  # Runs interface checks across all consecutive pairs of classes in a list.
5
5
  class BulkInterfaceChecker
6
- def initialize(objects, type: :instance_methods, partial_interface_methods: nil)
6
+ def initialize(objects, type: :instance_methods, partial_interface_methods: nil, strict: false)
7
+ raise ArgumentError, "more than one class is required" if objects.size < 2
8
+
7
9
  @objects = objects
8
- @checker = InterfaceChecker.new(type:, partial_interface_methods:)
10
+ @checker = InterfaceChecker.new(type:, partial_interface_methods:, strict:)
9
11
  end
10
12
 
11
13
  def call(&block)
@@ -3,14 +3,14 @@
3
3
  require_relative "interface_checker/result"
4
4
  require_relative "method_inspector"
5
5
  require_relative "params_normalizer"
6
- require_relative "null_params_normalizer"
7
6
 
8
7
  module DuckTyper
9
8
  # Compares the public method signatures of two classes and reports mismatches.
10
9
  class InterfaceChecker
11
- def initialize(type: :instance_methods, partial_interface_methods: nil)
10
+ def initialize(type: :instance_methods, partial_interface_methods: nil, strict: false)
12
11
  @type = type
13
12
  @partial_interface_methods = partial_interface_methods
13
+ @strict = strict
14
14
  @inspectors = Hash.new { |h, k| h[k] = MethodInspector.for(k, @type) }
15
15
  end
16
16
 
@@ -25,18 +25,19 @@ module DuckTyper
25
25
  private
26
26
 
27
27
  def calculate_diff(left, right)
28
- left_params = params_for_comparison(left, ParamsNormalizer)
29
- right_params = params_for_comparison(right, ParamsNormalizer)
28
+ normalizer = ParamsNormalizer.for(strict: @strict)
29
+ left_params = params_for_comparison(left, normalizer)
30
+ right_params = params_for_comparison(right, normalizer)
30
31
 
31
32
  (left_params - right_params) + (right_params - left_params)
32
33
  end
33
34
 
34
- def params_for_comparison(object, params_processor)
35
+ def params_for_comparison(object, params_normalizer)
35
36
  methods = @partial_interface_methods || @inspectors[object].public_methods
36
37
 
37
38
  methods.map do |method_name|
38
39
  params = method_params(method_name, object)
39
- args = params_processor.call(params).map do |type, name|
40
+ args = params_normalizer.call(params).map do |type, name|
40
41
  case type
41
42
  when :key then "#{name}: :opt"
42
43
  when :keyreq then "#{name}:"
@@ -61,8 +62,8 @@ module DuckTyper
61
62
 
62
63
  def diff_message(left, right, diff)
63
64
  methods = diff.map(&:first).uniq
64
- left_params = params_for_comparison(left, NullParamsNormalizer)
65
- right_params = params_for_comparison(right, NullParamsNormalizer)
65
+ left_params = params_for_comparison(left, ParamsNormalizer::NullParamsNormalizer)
66
+ right_params = params_for_comparison(right, ParamsNormalizer::NullParamsNormalizer)
66
67
 
67
68
  methods.map do |method_name|
68
69
  <<~DIFF
@@ -4,13 +4,15 @@ require_relative "../duck_typer"
4
4
 
5
5
  module DuckTyper
6
6
  module Minitest
7
- def assert_interfaces_match(objects, type: :instance_methods, methods: nil)
7
+ def assert_interfaces_match(objects, type: :instance_methods, methods: nil, strict: false)
8
8
  checker = BulkInterfaceChecker
9
- .new(objects, type:, partial_interface_methods: methods)
9
+ .new(objects, type:, partial_interface_methods: methods, strict:)
10
10
 
11
11
  checker.call do |result|
12
12
  assert result.match?, result.failure_message
13
13
  end
14
14
  end
15
+
16
+ alias_method :assert_duck_types_match, :assert_interfaces_match
15
17
  end
16
18
  end
@@ -1,46 +1,77 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module DuckTyper
4
- # Normalizes method parameters to enable interface comparison.
5
- # Keyword argument order is irrelevant m(a:, b:) and m(b:, a:)
6
- # are equivalent — so keywords are sorted alphabetically. Positional
7
- # argument names are also replaced with sequential placeholders,
8
- # focusing the comparison on parameter structure rather than naming.
9
- class ParamsNormalizer # :nodoc:
10
- KEYWORD_TYPES = %i[key keyreq].freeze
11
- SEQUENTIAL_TYPES = %i[req opt rest keyrest block].freeze
12
-
13
- class << self
14
- def call(params)
15
- sort_keyword_params(params).then { sequentialize_params(_1) }
4
+ # Factory for parameter normalization. Use ParamsNormalizer.for(strict:)
5
+ # to get the right normalizer for the given comparison mode.
6
+ module ParamsNormalizer # :nodoc:
7
+ def self.for(strict:)
8
+ strict ? StrictParamsNormalizer : DefaultParamsNormalizer
9
+ end
10
+
11
+ # Normalizes method parameters for default interface comparison.
12
+ # Sorts keywords alphabetically and replaces positional argument
13
+ # names with sequential placeholders.
14
+ module DefaultParamsNormalizer # :nodoc:
15
+ def self.call(params)
16
+ KeywordNormalizer.call(params).then { |p| SequentialNormalizer.call(p) }
16
17
  end
18
+ end
17
19
 
18
- private
20
+ # Normalizes method parameters for strict interface comparison,
21
+ # where positional argument names are significant. Sorts keywords
22
+ # alphabetically but preserves positional argument names.
23
+ module StrictParamsNormalizer # :nodoc:
24
+ def self.call(params)
25
+ KeywordNormalizer.call(params)
26
+ end
27
+ end
19
28
 
20
- def sort_keyword_params(params)
21
- keywords, sequentials = params.partition do |type, _|
22
- KEYWORD_TYPES.include?(type)
23
- end
29
+ # Sorts keyword argument parameters alphabetically, making keyword
30
+ # order irrelevant for interface comparison.
31
+ module KeywordNormalizer # :nodoc:
32
+ KEYWORD_TYPES = %i[key keyreq].freeze
33
+
34
+ def self.call(params)
35
+ keywords, sequentials = params.partition { |type, _| KEYWORD_TYPES.include?(type) }
24
36
 
25
37
  sequentials + keywords.sort_by { |_, name| name }
26
38
  end
39
+ end
40
+
41
+ # Replaces positional parameter names with sequential placeholders
42
+ # (a, b, c, ...), focusing comparison on structure rather than naming.
43
+ module SequentialNormalizer # :nodoc:
44
+ SEQUENTIAL_TYPES = %i[req opt rest keyrest block].freeze
45
+
46
+ class << self
47
+ def call(params)
48
+ sequential_name = ("a".."z").to_enum
27
49
 
28
- def sequentialize_params(params)
29
- sequential_name = ("a".."z").to_enum
50
+ params.map do |type, name|
51
+ if SEQUENTIAL_TYPES.include?(type)
52
+ name = next_sequential_param(sequential_name)
53
+ end
30
54
 
31
- params.map do |type, name|
32
- if SEQUENTIAL_TYPES.include?(type)
33
- name = next_sequential_param(sequential_name)
55
+ [type, name]
34
56
  end
57
+ end
58
+
59
+ private
35
60
 
36
- [type, name]
61
+ def next_sequential_param(enumerator)
62
+ enumerator.next.to_sym
63
+ rescue StopIteration
64
+ raise TooManyParametersError, "too many positional parameters, maximum supported is 26"
37
65
  end
38
66
  end
67
+ end
39
68
 
40
- def next_sequential_param(enumerator)
41
- enumerator.next.to_sym
42
- rescue StopIteration
43
- raise TooManyParametersError, "too many positional parameters, maximum supported is 26"
69
+ # A no-op params processor that returns params unchanged. Used when
70
+ # interface comparison should preserve original parameter names rather
71
+ # than normalizing them.
72
+ module NullParamsNormalizer # :nodoc:
73
+ def self.call(params)
74
+ params
44
75
  end
45
76
  end
46
77
  end
@@ -2,10 +2,10 @@
2
2
 
3
3
  require_relative "../duck_typer"
4
4
 
5
- RSpec::Matchers.define :have_matching_interfaces do |type: :instance_methods, methods: nil|
5
+ RSpec::Matchers.define :have_matching_interfaces do |type: :instance_methods, methods: nil, strict: false|
6
6
  match do |objects|
7
7
  checker = DuckTyper::BulkInterfaceChecker
8
- .new(objects, type:, partial_interface_methods: methods)
8
+ .new(objects, type:, partial_interface_methods: methods, strict:)
9
9
 
10
10
  @failures = checker.call.reject(&:match?)
11
11
  @failures.empty?
@@ -16,17 +16,20 @@ RSpec::Matchers.define :have_matching_interfaces do |type: :instance_methods, me
16
16
  end
17
17
  end
18
18
 
19
+ RSpec::Matchers.alias_matcher :have_matching_duck_types, :have_matching_interfaces
20
+
19
21
  module DuckTyper
20
22
  module RSpec
21
23
  def self.define_shared_example(name = "an interface")
22
- ::RSpec.shared_examples name do |objects, type: :instance_methods, methods: nil|
24
+ ::RSpec.shared_examples name do |*objects, type: :instance_methods, methods: nil, strict: false|
25
+ objects = objects.first
23
26
  # We intentionally avoid reusing the have_matching_interfaces matcher
24
27
  # here. Since this shared example is defined in gem code, RSpec filters
25
28
  # it from its backtrace, causing the Failure/Error: line to show an
26
29
  # internal RSpec constant instead of useful context.
27
30
  it "has compatible interfaces" do
28
31
  checker = DuckTyper::BulkInterfaceChecker
29
- .new(objects, type:, partial_interface_methods: methods)
32
+ .new(objects, type:, partial_interface_methods: methods, strict:)
30
33
 
31
34
  failures = checker.call.reject(&:match?)
32
35
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module DuckTyper
4
- VERSION = "0.3.2"
4
+ VERSION = "0.4.0"
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: duck_typer
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.2
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Thiago A. Silva
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-03-07 00:00:00.000000000 Z
11
+ date: 2026-03-09 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description:
14
14
  email:
@@ -17,7 +17,9 @@ executables: []
17
17
  extensions: []
18
18
  extra_rdoc_files: []
19
19
  files:
20
+ - ".ruby-version"
20
21
  - ".standard.yml"
22
+ - ".tool-versions"
21
23
  - CHANGELOG.md
22
24
  - CLAUDE.md
23
25
  - CONTRIBUTING.md
@@ -30,7 +32,6 @@ files:
30
32
  - lib/duck_typer/interface_checker/result.rb
31
33
  - lib/duck_typer/method_inspector.rb
32
34
  - lib/duck_typer/minitest.rb
33
- - lib/duck_typer/null_params_normalizer.rb
34
35
  - lib/duck_typer/params_normalizer.rb
35
36
  - lib/duck_typer/rspec.rb
36
37
  - lib/duck_typer/version.rb
@@ -57,7 +58,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
57
58
  - !ruby/object:Gem::Version
58
59
  version: '0'
59
60
  requirements: []
60
- rubygems_version: 3.5.22
61
+ rubygems_version: 3.3.3
61
62
  signing_key:
62
63
  specification_version: 4
63
64
  summary: Enforce duck-typed interfaces in Ruby through your test suite.
@@ -1,12 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module DuckTyper
4
- # A no-op params processor that returns params unchanged. Used when
5
- # interface comparison should preserve original parameter names rather
6
- # than normalizing them.
7
- class NullParamsNormalizer # :nodoc:
8
- def self.call(params)
9
- params
10
- end
11
- end
12
- end