duck_typer 0.3.1 → 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: fb7a9d41a358d3238e054a878eca9968910ee1b7917a4379022cbcc7587d0bdd
4
- data.tar.gz: 17724ad54c3caee0ad1ae7dd46a43f431cd064a00f4f1b53995875095b8d52a9
3
+ metadata.gz: 881833f4db9f3c47049ad45e41c804a74a97db030438f43f933a388dc0f41cba
4
+ data.tar.gz: ad0b846adb0389c1db0cd49d9d245cb6c006c136e76ea0e7b3980a8d1fb4d3ce
5
5
  SHA512:
6
- metadata.gz: ef5827a7b0adb92cc6e79c8e152435df3acd66d2905fb075c8535a5af58450a1055121be58f246bcdd8d62fc79124a931bc2904d1c7061a1224b7f19b1c26676
7
- data.tar.gz: bd6f3660f2db388d41de049e90ab0dc3d8715aa83878555dd6faec700079f6fe1acae98887d19800de505c8756962868ba5b431b8ded4e61c6533df8b5a203b5
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,51 @@
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
+
27
+ ## [0.3.2] - 2026-03-07
28
+
29
+ ### Changed
30
+ - Move type validation and `TYPES` constant to `MethodInspector`
31
+ - Refactor `ParamsNormalizer`: extract `SEQUENTIAL_TYPES` constant
32
+ and `sequentialize_params`, reorder methods for natural reading order
33
+ - Introduce `NullParamsNormalizer` as an explicit null object,
34
+ replacing an implicit identity lambda
35
+ - Move `MethodInspector`, `ParamsNormalizer`, and `NullParamsNormalizer`
36
+ to the `DuckTyper` namespace; nest `ClassMethodInspector` and
37
+ `InstanceMethodInspector` inside `MethodInspector`
38
+ - Use `assoc` to look up method params in `join_signature`
39
+ - Inline `inspector` locals throughout `InterfaceChecker`
40
+ - Mark `ParamsNormalizer`, `NullParamsNormalizer`, and `MethodInspector`
41
+ as internal (`:nodoc:`)
42
+
43
+ ### Added
44
+ - `ParamsNormalizer` test suite
45
+ - Test that each differing method appears only once in `failure_message`
46
+ - Test that `assert_interfaces_match` passes for matching pairs even
47
+ when another class in the list mismatches
48
+
3
49
  ## [0.3.1] - 2026-03-07
4
50
 
5
51
  ### Added
data/CLAUDE.md ADDED
@@ -0,0 +1,79 @@
1
+ # CLAUDE.md
2
+
3
+ ## Release
4
+
5
+ - Before writing to `CHANGELOG.md`, pull from `main` and do a
6
+ detailed analysis of all commits since the last tag (e.g.
7
+ `git log <last-tag>..HEAD`).
8
+ - Based on the analysis, determine the next version number: bump
9
+ the patch version for internal changes or bug fixes, the minor
10
+ version for new features, and the major version for breaking
11
+ changes. Confirm the version number with the programmer before
12
+ proceeding.
13
+ - Update `CHANGELOG.md` with the new version and a summary of
14
+ changes.
15
+ - After releasing, delete the generated `.gem` files from the root
16
+ directory — they are not committed and should not linger.
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
+
41
+ ## Commands
42
+
43
+ - `bundle exec rake test` — run Minitest and RSpec suites
44
+ - `bundle exec rake minitest` — run Minitest suite only
45
+ - `bundle exec rake ci` — run tests, linting, and lockfile drift check
46
+ (always run before pushing)
47
+ - `bundle exec standardrb` — lint
48
+
49
+ ## Tests
50
+
51
+ - **Minitest is the default test suite.** Test files live in `test/`.
52
+ - **RSpec is only for testing RSpec-specific functionality.**
53
+ Spec files live in `spec/`.
54
+ - New tests belong in `test/` unless they exercise the RSpec
55
+ integration.
56
+ - When two or more classes are interchangeably used (e.g. a class
57
+ and its null object), write an interface test using the gem's own
58
+ `assert_interfaces_match` (include `DuckTyper::Minitest`).
59
+ - When testing a transformation (e.g. `ParamsNormalizer`), always
60
+ include a mixed test that exercises all cases together, in addition
61
+ to focused tests for each case.
62
+
63
+ ## Code style
64
+
65
+ - Markdown files should be kept at 70 columns.
66
+ - File paths must match the Ruby namespace: `DuckTyper::Foo` lives
67
+ in `lib/duck_typer/foo.rb`, not under a subdirectory of another
68
+ class.
69
+ - Classes are only nested inside another class when they are
70
+ intrinsically part of it (e.g. `InterfaceChecker::Result`).
71
+ Otherwise they belong at the `DuckTyper` root level.
72
+ - Methods are defined in natural reading order: callers before
73
+ callees.
74
+ - Leave a blank line before the return value in multi-line methods.
75
+ - Extract constants for inline literals used in conditionals.
76
+ - Private methods mirror each other when they share a common role
77
+ (e.g. `sort_keyword_params` / `sequentialize_params`).
78
+ - Use `params` instead of `parameters` in names (e.g. method names,
79
+ variable names).
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)
@@ -1,21 +1,16 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "interface_checker/result"
4
- require_relative "interface_checker/method_inspector"
5
- require_relative "interface_checker/params_normalizer"
4
+ require_relative "method_inspector"
5
+ require_relative "params_normalizer"
6
6
 
7
7
  module DuckTyper
8
8
  # Compares the public method signatures of two classes and reports mismatches.
9
9
  class InterfaceChecker
10
- TYPES = %i[instance_methods class_methods].freeze
11
-
12
- def initialize(type: :instance_methods, partial_interface_methods: nil)
13
- unless TYPES.include?(type)
14
- raise ArgumentError, "Invalid type #{type.inspect}, must be one of #{TYPES}"
15
- end
16
-
10
+ def initialize(type: :instance_methods, partial_interface_methods: nil, strict: false)
17
11
  @type = type
18
12
  @partial_interface_methods = partial_interface_methods
13
+ @strict = strict
19
14
  @inspectors = Hash.new { |h, k| h[k] = MethodInspector.for(k, @type) }
20
15
  end
21
16
 
@@ -30,25 +25,19 @@ module DuckTyper
30
25
  private
31
26
 
32
27
  def calculate_diff(left, right)
33
- left_params = params_for_comparison(left, ParamsNormalizer)
34
- 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)
35
31
 
36
32
  (left_params - right_params) + (right_params - left_params)
37
33
  end
38
34
 
39
- def method_params(inspector, method_name, object)
40
- inspector.parameters_for(method_name)
41
- rescue NameError
42
- raise MethodNotFoundError, "undefined method `#{method_name}' for #{object}"
43
- end
44
-
45
- def params_for_comparison(object, params_processor = -> { _1 })
46
- inspector = @inspectors[object]
47
- methods = @partial_interface_methods || inspector.public_methods
35
+ def params_for_comparison(object, params_normalizer)
36
+ methods = @partial_interface_methods || @inspectors[object].public_methods
48
37
 
49
38
  methods.map do |method_name|
50
- params = method_params(inspector, method_name, object)
51
- args = params_processor.call(params).map do |type, name|
39
+ params = method_params(method_name, object)
40
+ args = params_normalizer.call(params).map do |type, name|
52
41
  case type
53
42
  when :key then "#{name}: :opt"
54
43
  when :keyreq then "#{name}:"
@@ -65,10 +54,16 @@ module DuckTyper
65
54
  end
66
55
  end
67
56
 
57
+ def method_params(method_name, object)
58
+ @inspectors[object].parameters_for(method_name)
59
+ rescue NameError
60
+ raise MethodNotFoundError, "undefined method `#{method_name}' for #{object}"
61
+ end
62
+
68
63
  def diff_message(left, right, diff)
69
64
  methods = diff.map(&:first).uniq
70
- left_params = params_for_comparison(left).to_h.slice(*methods)
71
- right_params = params_for_comparison(right).to_h.slice(*methods)
65
+ left_params = params_for_comparison(left, ParamsNormalizer::NullParamsNormalizer)
66
+ right_params = params_for_comparison(right, ParamsNormalizer::NullParamsNormalizer)
72
67
 
73
68
  methods.map do |method_name|
74
69
  <<~DIFF
@@ -78,12 +73,12 @@ module DuckTyper
78
73
  end.join("\n")
79
74
  end
80
75
 
81
- def join_signature(object, method_name, params)
82
- inspector = @inspectors[object]
83
- display_name = inspector.display_name_for(method_name)
76
+ def join_signature(object, method_name, all_params)
77
+ display_name = @inspectors[object].display_name_for(method_name)
78
+ method_params = all_params.assoc(method_name)&.last
84
79
 
85
- signature = if params[method_name]
86
- "#{display_name}(#{params[method_name].join(", ")})"
80
+ signature = if method_params
81
+ "#{display_name}(#{method_params.join(", ")})"
87
82
  else
88
83
  "#{display_name} not defined"
89
84
  end
@@ -1,15 +1,19 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module DuckTyper
4
- class InterfaceChecker
5
- class MethodInspector
6
- def self.for(object, type)
7
- if type == :class_methods
8
- ClassMethodInspector
9
- else
10
- InstanceMethodInspector
11
- end.new(object)
4
+ module MethodInspector # :nodoc:
5
+ TYPES = %i[instance_methods class_methods].freeze
6
+
7
+ def self.for(object, type)
8
+ unless TYPES.include?(type)
9
+ raise ArgumentError, "Invalid type #{type.inspect}, must be one of #{TYPES}"
12
10
  end
11
+
12
+ if type == :class_methods
13
+ ClassMethodInspector
14
+ else
15
+ InstanceMethodInspector
16
+ end.new(object)
13
17
  end
14
18
 
15
19
  class ClassMethodInspector
@@ -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
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DuckTyper
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) }
17
+ end
18
+ end
19
+
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
28
+
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) }
36
+
37
+ sequentials + keywords.sort_by { |_, name| name }
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
49
+
50
+ params.map do |type, name|
51
+ if SEQUENTIAL_TYPES.include?(type)
52
+ name = next_sequential_param(sequential_name)
53
+ end
54
+
55
+ [type, name]
56
+ end
57
+ end
58
+
59
+ private
60
+
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"
65
+ end
66
+ end
67
+ end
68
+
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
75
+ end
76
+ end
77
+ end
78
+ 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.1"
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.1
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,8 +17,11 @@ 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
24
+ - CLAUDE.md
22
25
  - CONTRIBUTING.md
23
26
  - LICENSE
24
27
  - README.md
@@ -26,10 +29,10 @@ files:
26
29
  - lib/duck_typer.rb
27
30
  - lib/duck_typer/bulk_interface_checker.rb
28
31
  - lib/duck_typer/interface_checker.rb
29
- - lib/duck_typer/interface_checker/method_inspector.rb
30
- - lib/duck_typer/interface_checker/params_normalizer.rb
31
32
  - lib/duck_typer/interface_checker/result.rb
33
+ - lib/duck_typer/method_inspector.rb
32
34
  - lib/duck_typer/minitest.rb
35
+ - lib/duck_typer/params_normalizer.rb
33
36
  - lib/duck_typer/rspec.rb
34
37
  - lib/duck_typer/version.rb
35
38
  homepage: https://github.com/thoughtbot/duck_typer
@@ -55,7 +58,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
55
58
  - !ruby/object:Gem::Version
56
59
  version: '0'
57
60
  requirements: []
58
- rubygems_version: 3.5.22
61
+ rubygems_version: 3.3.3
59
62
  signing_key:
60
63
  specification_version: 4
61
64
  summary: Enforce duck-typed interfaces in Ruby through your test suite.
@@ -1,48 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module DuckTyper
4
- class InterfaceChecker
5
- # Normalizes method parameters to enable interface comparison. For
6
- # example, two methods may use different names for positional
7
- # arguments, but if the parameter types and order match, they should
8
- # be considered equivalent. This class replaces argument names with
9
- # sequential placeholders when appropriate, focusing the comparison on
10
- # parameter structure rather than naming.
11
- class ParamsNormalizer
12
- KEYWORD_TYPES = %i[key keyreq].freeze
13
-
14
- class << self
15
- def call(params)
16
- sequential_name = ("a".."z").to_enum
17
-
18
- sort_keyword_params(params).map do |type, name|
19
- if %i[req opt rest keyrest block].include?(type)
20
- name = next_sequential_param(sequential_name)
21
- end
22
-
23
- [type, name]
24
- end
25
- end
26
-
27
- private
28
-
29
- # Keyword argument order is irrelevant to a method's interface —
30
- # m(a:, b:) and m(b:, a:) are equivalent. Sort keyword params
31
- # alphabetically so comparison is order-independent.
32
- def sort_keyword_params(params)
33
- keywords, non_keywords = params.partition do |type, _|
34
- KEYWORD_TYPES.include?(type)
35
- end
36
-
37
- non_keywords + keywords.sort_by { |_, name| name }
38
- end
39
-
40
- def next_sequential_param(enumerator)
41
- enumerator.next
42
- rescue StopIteration
43
- raise TooManyParametersError, "too many positional parameters, maximum supported is 26"
44
- end
45
- end
46
- end
47
- end
48
- end