duck_typer 0.3.2 → 0.5.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: f51efa5736485f62f43d321e7e3d8d612081c6b5eca59bb66823d78cb38b5442
4
+ data.tar.gz: b75e8517ce41187b4df7b9f5260aa8da119feec892f34bc79e6e2865bd90bce8
5
5
  SHA512:
6
- metadata.gz: d97a298dd8aaa903f2a9e2ae451f530a79aa433d9734ae220f89573f88d3ea9288dd1c9feb61c981eda95b272f2fea4b0763f67374bba972cb678ab52692a03b
7
- data.tar.gz: 75b66d83757541fdd0cc1ead317aa3e15a7fe9878e1fb57a9b348b3a6c63bce5da2e5fe8091296914ea3bf2e637446af0031806baaed208f5ba01497a479c41c
6
+ metadata.gz: 87660bdaeb1b662f9b8c2cdf9399d564c3444990d61fa61223dca973b12d21941575baebd91ff04e0b36e9a5bb2a0b1f56277ea4df43fdaf56d263eb3008b43a
7
+ data.tar.gz: 0ca5ae8d8de916e313655c97555582f8dbd7bff499492bcd90288a4e9d2e2da4c70641cd94de47ef9872be5e1b838894c9f6193f4b739524a0502c60a56fabb1
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,55 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.5.0] - 2026-03-13
4
+
5
+ ### Added
6
+ - `name:` option: includes the interface name in failure messages
7
+ (e.g. `compatible "Linkable" interfaces`)
8
+ - `namespace:` option: resolves a module's constants as the list of
9
+ objects to compare; infers the interface name from the module name
10
+ when `name:` is not given
11
+ - Strict mode now notes itself in the failure message:
12
+ `(strict mode: positional argument names must match)`
13
+ - `BulkInterfaceChecker` and `InterfaceChecker` raise
14
+ `PrivateMethodError` when a private method is specified in
15
+ `methods:`
16
+
17
+ ### Changed
18
+ - `partial_interface_methods:` renamed to `methods:` across
19
+ `BulkInterfaceChecker`, `InterfaceChecker`, `assert_interfaces_match`,
20
+ and `have_matching_interfaces` (breaking change)
21
+ - RSpec matcher now accepts a module as the subject via
22
+ `expect(namespace: MyModule).to have_matching_interfaces`
23
+ - RSpec shared example now accepts `namespace:` as a keyword argument
24
+
25
+ ### Fixed
26
+ - Numbered block parameters (`_1`) replaced with named ones to avoid
27
+ `Style/ItBlockParameter` lint failures on Ruby 3.4
28
+
29
+ ## [0.4.0] - 2026-03-09
30
+
31
+ ### Added
32
+ - Strict mode (`strict: true`) for interface comparison: positional
33
+ argument names must match exactly. Available on
34
+ `assert_interfaces_match`, `have_matching_interfaces`, and the
35
+ RSpec shared example. Keyword argument names always matter
36
+ regardless of this setting.
37
+ - `assert_duck_types_match` as an alias for `assert_interfaces_match`
38
+ - `have_matching_duck_types` as an alias for `have_matching_interfaces`
39
+ - `BulkInterfaceChecker` now raises `ArgumentError` when fewer than
40
+ two classes are given
41
+
42
+ ### Fixed
43
+ - RSpec shared example was broken on Ruby 3.1 due to proc argument
44
+ destructuring differences between Ruby versions
45
+
46
+ ### Changed
47
+ - `ParamsNormalizer` refactored into a factory (`ParamsNormalizer.for(strict:)`)
48
+ with extracted modules: `KeywordNormalizer`, `SequentialNormalizer`,
49
+ `DefaultParamsNormalizer`, `StrictParamsNormalizer`, and
50
+ `NullParamsNormalizer` — all consolidated in a single file
51
+ - CI now runs against Ruby 3.1 (minimum) and 3.4 (latest)
52
+
3
53
  ## [0.3.2] - 2026-03-07
4
54
 
5
55
  ### 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
@@ -1,12 +1,18 @@
1
- # DuckTyper
1
+ # Duck Typer
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
- DuckTyper enforces duck-typed interfaces in Ruby by comparing the
5
+ <div align="center">
6
+ <img alt="Duck Typer 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
+
11
+ Duck Typer enforces duck-typed interfaces in Ruby by comparing the
6
12
  public method signatures of classes, surfacing mismatches through
7
13
  your test suite.
8
14
 
9
- ## Why DuckTyper?
15
+ ## Why Duck Typer?
10
16
 
11
17
  Ruby is a duck-typed language. When multiple classes play the same
12
18
  role, what matters is not what they _are_, but what they _do_ — the
@@ -19,17 +25,17 @@ from its dynamic nature: abstract base classes that raise
19
25
  signatures, or inheritance hierarchies that couple unrelated
20
26
  classes. These work, but they're not very Ruby.
21
27
 
22
- DuckTyper takes a different approach. It compares public method
28
+ Duck Typer takes a different approach. It compares public method
23
29
  signatures directly and reports mismatches through your test suite —
24
30
  the natural place to enforce design constraints in Ruby. There's
25
31
  nothing to annotate and nothing to inherit from. The classes remain
26
- independent; DuckTyper simply verifies that they're speaking the
32
+ independent; Duck Typer simply verifies that they're speaking the
27
33
  same language. The interface itself needs no declaration — it is
28
34
  the intersection of methods your classes define in common, a living
29
35
  document that evolves naturally.
30
36
 
31
37
  It's also useful during active development. When an interface
32
- evolves, implementations can easily fall out of sync. DuckTyper
38
+ evolves, implementations can easily fall out of sync. Duck Typer
33
39
  catches that immediately and reports clear, precise error messages
34
40
  showing exactly which signatures diverged — keeping your classes
35
41
  aligned as the design changes.
@@ -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:
@@ -50,7 +56,7 @@ bundle install
50
56
 
51
57
  ## Usage
52
58
 
53
- When interfaces don't match, DuckTyper reports the differing
59
+ When interfaces don't match, Duck Typer reports the differing
54
60
  signatures:
55
61
 
56
62
  ```
@@ -103,11 +109,18 @@ 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
 
110
- By default, DuckTyper checks instance method interfaces. To check
120
+ > If you prefer duck typing terminology, `assert_duck_types_match`
121
+ > is available as an alias.
122
+
123
+ By default, Duck Typer checks instance method interfaces. To check
111
124
  class-level interfaces instead, pass `type: :class_methods`:
112
125
 
113
126
  ```ruby
@@ -125,6 +138,36 @@ 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
+
154
+ To include the interface name in failure messages, use `name:`:
155
+
156
+ ```ruby
157
+ assert_interfaces_match [StripeProcessor, PaypalProcessor],
158
+ name: "PaymentProcessor"
159
+ ```
160
+
161
+ If your classes are organized under a module, pass it with
162
+ `namespace:` instead of listing them explicitly:
163
+
164
+ ```ruby
165
+ assert_interfaces_match namespace: Payments
166
+ ```
167
+
168
+ Duck Typer will resolve the module's constants and infer the
169
+ interface name from the module name when `name:` is not given.
170
+
128
171
  ### RSpec
129
172
 
130
173
  Require the RSpec integration in your `spec_helper.rb`:
@@ -141,21 +184,48 @@ share compatible interfaces:
141
184
  ```ruby
142
185
  RSpec.describe "payment processors" do
143
186
  it "have compatible interfaces" do
144
- expect([StripeProcessor, PaypalProcessor, BraintreeProcessor]).to have_matching_interfaces
187
+ expect([StripeProcessor, PaypalProcessor, BraintreeProcessor])
188
+ .to have_matching_interfaces
145
189
  end
146
190
  end
147
191
  ```
148
192
 
193
+ > If you prefer duck typing terminology, `have_matching_duck_types`
194
+ > is available as an alias.
195
+
149
196
  For class-level interfaces, pass `type: :class_methods`:
150
197
 
151
198
  ```ruby
152
- expect([StripeProcessor, PaypalProcessor]).to have_matching_interfaces(type: :class_methods)
199
+ expect([StripeProcessor, PaypalProcessor])
200
+ .to have_matching_interfaces(type: :class_methods)
153
201
  ```
154
202
 
155
203
  To check only a subset of methods, use `methods:`:
156
204
 
157
205
  ```ruby
158
- expect([StripeProcessor, PaypalProcessor]).to have_matching_interfaces(methods: %i[charge refund])
206
+ expect([StripeProcessor, PaypalProcessor])
207
+ .to have_matching_interfaces(methods: %i[charge refund])
208
+ ```
209
+
210
+ To enforce that positional argument names also match, pass
211
+ `strict: true`:
212
+
213
+ ```ruby
214
+ expect([StripeProcessor, PaypalProcessor])
215
+ .to have_matching_interfaces(strict: true)
216
+ ```
217
+
218
+ To include the interface name in failure messages, use `name:`:
219
+
220
+ ```ruby
221
+ expect([StripeProcessor, PaypalProcessor])
222
+ .to have_matching_interfaces(name: "PaymentProcessor")
223
+ ```
224
+
225
+ To check all classes in a module, pass it as a named subject:
226
+
227
+ ```ruby
228
+ expect(namespace: Payments).to have_matching_interfaces
159
229
  ```
160
230
 
161
231
  #### Shared example
@@ -178,27 +248,41 @@ Then use it in your specs:
178
248
 
179
249
  ```ruby
180
250
  RSpec.describe "payment processors" do
181
- it_behaves_like "an interface", [StripeProcessor, PaypalProcessor, BraintreeProcessor]
251
+ it_behaves_like "an interface", [
252
+ StripeProcessor,
253
+ PaypalProcessor,
254
+ BraintreeProcessor
255
+ ]
182
256
  end
183
257
  ```
184
258
 
185
- The same `type:` and `methods:` options are supported:
259
+ The same `type:`, `methods:`, `strict:`, and `name:` options are
260
+ supported:
186
261
 
187
262
  ```ruby
188
263
  it_behaves_like "an interface", [StripeProcessor, PaypalProcessor],
189
264
  type: :class_methods,
190
- methods: %i[charge refund]
265
+ methods: %i[charge refund],
266
+ strict: true
267
+ ```
268
+
269
+ To check all classes in a module, pass it with `namespace:`:
270
+
271
+ ```ruby
272
+ it_behaves_like "an interface", namespace: Payments
191
273
  ```
192
274
 
193
275
  ## Limitations
194
276
 
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
277
+ By default, Duck Typer checks the **structure** of public method
278
+ signatures — the number of parameters, their kinds (required,
279
+ optional, keyword, rest, block), and keyword argument names. In
280
+ strict mode, positional argument names are also compared. It does
281
+ not
198
282
  verify the following, which should be covered by your regular
199
283
  test suite:
200
284
 
201
- - **Parameter types.** DuckTyper only checks that both methods
285
+ - **Parameter types.** Duck Typer only checks that both methods
202
286
  declare an `amount` parameter — not what type of value it
203
287
  expects. Two methods with identical signatures may still be
204
288
  incompatible if they expect different types.
@@ -206,7 +290,7 @@ test suite:
206
290
  but return completely different things.
207
291
  - **Behavior.** Matching signatures are a necessary but not
208
292
  sufficient condition for duck typing to work correctly at
209
- runtime. DuckTyper catches structural drift, not semantic
293
+ runtime. Duck Typer catches structural drift, not semantic
210
294
  divergence.
211
295
 
212
296
  Some things are intentionally out of scope:
@@ -217,6 +301,14 @@ Some things are intentionally out of scope:
217
301
  `initialize`: how an object is constructed is not an interface
218
302
  concern.
219
303
 
304
+ ## Stability
305
+
306
+ Duck Typer is intentionally minimal. It reflects Ruby's own method
307
+ introspection API, which rarely changes — so the gem rarely needs
308
+ to either. When it does change, it will most likely be for additive reasons:
309
+ new API options, better error messages, or broader test framework
310
+ support. It is safe to depend on without worrying about churn.
311
+
220
312
  ## Development
221
313
 
222
314
  After checking out the repo, run `bin/setup` to install
@@ -240,7 +332,7 @@ Thank you, [contributors]!
240
332
 
241
333
  ## License
242
334
 
243
- DuckTyper is Copyright (c) thoughtbot, inc.
335
+ Duck Typer is Copyright (c) thoughtbot, inc.
244
336
  It is free software, and may be redistributed
245
337
  under the terms specified in the [LICENSE] file.
246
338
 
@@ -3,17 +3,37 @@
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)
7
- @objects = objects
8
- @checker = InterfaceChecker.new(type:, partial_interface_methods:)
6
+ def initialize(objects = nil, namespace: nil, type: :instance_methods, methods: nil, strict: false, name: nil)
7
+ raise ArgumentError, "cannot specify both objects and namespace" if objects && namespace
8
+ raise ArgumentError, "objects or namespace is required" if objects.nil? && namespace.nil?
9
+
10
+ @objects = resolve_objects(objects, namespace)
11
+ raise ArgumentError, "more than one object is required" if @objects.size < 2
12
+
13
+ name ||= namespace&.name
14
+ @checker = InterfaceChecker.new(type:, methods:, strict:, name:)
9
15
  end
10
16
 
11
17
  def call(&block)
12
18
  @objects.each_cons(2).map do |left, right|
13
19
  result = @checker.call(left, right)
14
20
  block&.call(result)
21
+
15
22
  result
16
23
  end
17
24
  end
25
+
26
+ private
27
+
28
+ def resolve_objects(objects, namespace)
29
+ namespace ? resolve_namespace(namespace) : Array(objects)
30
+ end
31
+
32
+ def resolve_namespace(namespace)
33
+ namespace
34
+ .constants
35
+ .map { |const| namespace.const_get(const) }
36
+ .select { |const| const.is_a?(Module) }
37
+ end
18
38
  end
19
39
  end
@@ -5,11 +5,13 @@ module DuckTyper
5
5
  class Result
6
6
  attr_reader :left, :right
7
7
 
8
- def initialize(left:, right:, match:, diff_message:)
8
+ def initialize(left:, right:, match:, diff_message:, name:, strict:)
9
9
  @left = left
10
10
  @right = right
11
11
  @match = match
12
12
  @diff_message = diff_message
13
+ @name = name
14
+ @strict = strict
13
15
  end
14
16
 
15
17
  def match?
@@ -21,11 +23,21 @@ module DuckTyper
21
23
 
22
24
  <<~MSG
23
25
  Expected #{@left} and #{@right} to implement compatible \
24
- interfaces, but the following method signatures differ:
26
+ #{interface_label}, but the following method signatures differ:#{strict_note}
25
27
 
26
28
  #{@diff_message.call}
27
29
  MSG
28
30
  end
31
+
32
+ private
33
+
34
+ def interface_label
35
+ @name ? %("#{@name}" interfaces) : "interfaces"
36
+ end
37
+
38
+ def strict_note
39
+ @strict ? " (strict mode: positional argument names must match)" : ""
40
+ end
29
41
  end
30
42
  end
31
43
  end
@@ -3,14 +3,15 @@
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, methods: nil, strict: false, name: nil)
12
11
  @type = type
13
- @partial_interface_methods = partial_interface_methods
12
+ @methods = methods
13
+ @strict = strict
14
+ @name = name
14
15
  @inspectors = Hash.new { |h, k| h[k] = MethodInspector.for(k, @type) }
15
16
  end
16
17
 
@@ -19,24 +20,25 @@ module DuckTyper
19
20
  match = -> { diff.empty? }
20
21
  diff_message = -> { diff_message(left, right, diff) }
21
22
 
22
- Result.new(left:, right:, match:, diff_message:)
23
+ Result.new(left:, right:, match:, diff_message:, name: @name, strict: @strict)
23
24
  end
24
25
 
25
26
  private
26
27
 
27
28
  def calculate_diff(left, right)
28
- left_params = params_for_comparison(left, ParamsNormalizer)
29
- right_params = params_for_comparison(right, ParamsNormalizer)
29
+ normalizer = ParamsNormalizer.for(strict: @strict)
30
+ left_params = params_for_comparison(left, normalizer)
31
+ right_params = params_for_comparison(right, normalizer)
30
32
 
31
33
  (left_params - right_params) + (right_params - left_params)
32
34
  end
33
35
 
34
- def params_for_comparison(object, params_processor)
35
- methods = @partial_interface_methods || @inspectors[object].public_methods
36
+ def params_for_comparison(object, params_normalizer)
37
+ method_names = @methods || @inspectors[object].public_methods
36
38
 
37
- methods.map do |method_name|
39
+ method_names.map do |method_name|
38
40
  params = method_params(method_name, object)
39
- args = params_processor.call(params).map do |type, name|
41
+ args = params_normalizer.call(params).map do |type, name|
40
42
  case type
41
43
  when :key then "#{name}: :opt"
42
44
  when :keyreq then "#{name}:"
@@ -54,6 +56,10 @@ module DuckTyper
54
56
  end
55
57
 
56
58
  def method_params(method_name, object)
59
+ if @inspectors[object].private_method?(method_name)
60
+ raise PrivateMethodError, "private method `#{method_name}' for #{object}"
61
+ end
62
+
57
63
  @inspectors[object].parameters_for(method_name)
58
64
  rescue NameError
59
65
  raise MethodNotFoundError, "undefined method `#{method_name}' for #{object}"
@@ -61,8 +67,8 @@ module DuckTyper
61
67
 
62
68
  def diff_message(left, right, diff)
63
69
  methods = diff.map(&:first).uniq
64
- left_params = params_for_comparison(left, NullParamsNormalizer)
65
- right_params = params_for_comparison(right, NullParamsNormalizer)
70
+ left_params = params_for_comparison(left, ParamsNormalizer::NullParamsNormalizer)
71
+ right_params = params_for_comparison(right, ParamsNormalizer::NullParamsNormalizer)
66
72
 
67
73
  methods.map do |method_name|
68
74
  <<~DIFF
@@ -25,6 +25,10 @@ module DuckTyper
25
25
  @object.public_methods - Object.methods
26
26
  end
27
27
 
28
+ def private_method?(method_name)
29
+ @object.singleton_class.private_method_defined?(method_name)
30
+ end
31
+
28
32
  def parameters_for(method_name)
29
33
  @object.method(method_name).parameters
30
34
  end
@@ -43,6 +47,10 @@ module DuckTyper
43
47
  @object.public_instance_methods - Object.methods
44
48
  end
45
49
 
50
+ def private_method?(method_name)
51
+ @object.private_method_defined?(method_name)
52
+ end
53
+
46
54
  def parameters_for(method_name)
47
55
  @object.instance_method(method_name).parameters
48
56
  end
@@ -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 = nil, namespace: nil, type: :instance_methods, methods: nil, strict: false, name: nil)
8
8
  checker = BulkInterfaceChecker
9
- .new(objects, type:, partial_interface_methods: methods)
9
+ .new(objects, namespace:, type:, methods:, strict:, name:)
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,13 @@
2
2
 
3
3
  require_relative "../duck_typer"
4
4
 
5
- RSpec::Matchers.define :have_matching_interfaces do |type: :instance_methods, methods: nil|
6
- match do |objects|
5
+ RSpec::Matchers.define :have_matching_interfaces do |name: nil, type: :instance_methods, methods: nil, strict: false|
6
+ match do |actual|
7
+ namespace = actual.is_a?(Hash) ? actual[:namespace] : nil
8
+ objects = namespace ? nil : actual
9
+
7
10
  checker = DuckTyper::BulkInterfaceChecker
8
- .new(objects, type:, partial_interface_methods: methods)
11
+ .new(objects, namespace:, type:, methods:, strict:, name:)
9
12
 
10
13
  @failures = checker.call.reject(&:match?)
11
14
  @failures.empty?
@@ -16,17 +19,21 @@ RSpec::Matchers.define :have_matching_interfaces do |type: :instance_methods, me
16
19
  end
17
20
  end
18
21
 
22
+ RSpec::Matchers.alias_matcher :have_matching_duck_types, :have_matching_interfaces
23
+
19
24
  module DuckTyper
20
25
  module RSpec
21
26
  def self.define_shared_example(name = "an interface")
22
- ::RSpec.shared_examples name do |objects, type: :instance_methods, methods: nil|
27
+ ::RSpec.shared_examples name do |*args, namespace: nil, name: nil, type: :instance_methods, methods: nil, strict: false|
28
+ objects = namespace ? nil : args.first
29
+
23
30
  # We intentionally avoid reusing the have_matching_interfaces matcher
24
31
  # here. Since this shared example is defined in gem code, RSpec filters
25
32
  # it from its backtrace, causing the Failure/Error: line to show an
26
33
  # internal RSpec constant instead of useful context.
27
34
  it "has compatible interfaces" do
28
35
  checker = DuckTyper::BulkInterfaceChecker
29
- .new(objects, type:, partial_interface_methods: methods)
36
+ .new(objects, namespace:, type:, methods:, strict:, name:)
30
37
 
31
38
  failures = checker.call.reject(&:match?)
32
39
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module DuckTyper
4
- VERSION = "0.3.2"
4
+ VERSION = "0.5.0"
5
5
  end
data/lib/duck_typer.rb CHANGED
@@ -8,5 +8,6 @@ require_relative "duck_typer/bulk_interface_checker"
8
8
  # method signatures of classes, surfacing mismatches through your test suite.
9
9
  module DuckTyper
10
10
  class MethodNotFoundError < StandardError; end
11
+ class PrivateMethodError < StandardError; end
11
12
  class TooManyParametersError < StandardError; end
12
13
  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.5.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-13 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