duck_typer 0.4.0 → 0.6.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: 881833f4db9f3c47049ad45e41c804a74a97db030438f43f933a388dc0f41cba
4
- data.tar.gz: ad0b846adb0389c1db0cd49d9d245cb6c006c136e76ea0e7b3980a8d1fb4d3ce
3
+ metadata.gz: e7c4910b3357c23523997ed6cbb76cc1add4824af460fe8845390f1f69cfe2e6
4
+ data.tar.gz: 4aea61000506666b3ce649e08398247c9ab72c75826cc9e4819e91471547f8dd
5
5
  SHA512:
6
- metadata.gz: be49c0476b7afe2c3df16fcede0480f242cfab68c0d1d0a637bba8ac4aa6070bada1c9bbd0350c556193340d62cc2a5665b9949b1c838840127574c2761ff57a
7
- data.tar.gz: e9f4543f7117f6e7e3709a153cb316ce90974a984fc302bec71dc84c78fff89a706585cffc9581a24b6f9fa679df3dd0f971a0a82b44c1b78644ff1a8948ba1e
6
+ metadata.gz: 48bcd0be970572a490e373064d3894bd174aaffc29b52a0c7b8beea030530b0661bde8221b01a0b681d3511478259cb7edfe858383def4a1e8f22f1afec36577
7
+ data.tar.gz: 923d8722ae7ac8fb7f86aa12292056014fb00f5328c2034d63ed8b3c87984a0cce8890a0983d48fb2148b7d9ff1eb68888079cbaac90d4a7a7ebf33c8b5d6156
data/CHANGELOG.md CHANGED
@@ -1,5 +1,48 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.6.0] - 2026-05-15
4
+
5
+ ### Added
6
+ - `CanonicalInterfaceChecker`: compares every class in a list
7
+ against a single canonical reference rather than in consecutive
8
+ pairs; all failures are aggregated into one result
9
+ - `assert_canonical_interface_match` Minitest assertion
10
+ - `implement_canonical_interface` RSpec matcher
11
+ - `InterfaceSetup`: extracted shared checker initialization
12
+ (argument validation, object resolution, name inference) from
13
+ `BulkInterfaceChecker` and `CanonicalInterfaceChecker`
14
+ - `ResultFormatting`: extracted shared result formatting logic
15
+
16
+ ### Changed
17
+ - Improved test accuracy and coverage for
18
+ `BulkInterfaceChecker` and `InterfaceChecker`
19
+
20
+ ## [0.5.0] - 2026-03-13
21
+
22
+ ### Added
23
+ - `name:` option: includes the interface name in failure messages
24
+ (e.g. `compatible "Linkable" interfaces`)
25
+ - `namespace:` option: resolves a module's constants as the list of
26
+ objects to compare; infers the interface name from the module name
27
+ when `name:` is not given
28
+ - Strict mode now notes itself in the failure message:
29
+ `(strict mode: positional argument names must match)`
30
+ - `BulkInterfaceChecker` and `InterfaceChecker` raise
31
+ `PrivateMethodError` when a private method is specified in
32
+ `methods:`
33
+
34
+ ### Changed
35
+ - `partial_interface_methods:` renamed to `methods:` across
36
+ `BulkInterfaceChecker`, `InterfaceChecker`, `assert_interfaces_match`,
37
+ and `have_matching_interfaces` (breaking change)
38
+ - RSpec matcher now accepts a module as the subject via
39
+ `expect(namespace: MyModule).to have_matching_interfaces`
40
+ - RSpec shared example now accepts `namespace:` as a keyword argument
41
+
42
+ ### Fixed
43
+ - Numbered block parameters (`_1`) replaced with named ones to avoid
44
+ `Style/ItBlockParameter` lint failures on Ruby 3.4
45
+
3
46
  ## [0.4.0] - 2026-03-09
4
47
 
5
48
  ### Added
data/README.md CHANGED
@@ -1,18 +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
5
  <div align="center">
6
- <img alt="DuckTyper mascot" src="assets/swan_mugshot.png" width="300">
6
+ <img alt="Duck Typer mascot" src="assets/swan_mugshot.png" width="300">
7
7
  </div>
8
8
 
9
9
  > If it quacks like a duck, it's a duck... or is it?
10
10
 
11
- DuckTyper enforces duck-typed interfaces in Ruby by comparing the
11
+ Duck Typer enforces duck-typed interfaces in Ruby by comparing the
12
12
  public method signatures of classes, surfacing mismatches through
13
13
  your test suite.
14
14
 
15
- ## Why DuckTyper?
15
+ ## Why Duck Typer?
16
16
 
17
17
  Ruby is a duck-typed language. When multiple classes play the same
18
18
  role, what matters is not what they _are_, but what they _do_ — the
@@ -25,17 +25,17 @@ from its dynamic nature: abstract base classes that raise
25
25
  signatures, or inheritance hierarchies that couple unrelated
26
26
  classes. These work, but they're not very Ruby.
27
27
 
28
- DuckTyper takes a different approach. It compares public method
28
+ Duck Typer takes a different approach. It compares public method
29
29
  signatures directly and reports mismatches through your test suite —
30
30
  the natural place to enforce design constraints in Ruby. There's
31
31
  nothing to annotate and nothing to inherit from. The classes remain
32
- independent; DuckTyper simply verifies that they're speaking the
32
+ independent; Duck Typer simply verifies that they're speaking the
33
33
  same language. The interface itself needs no declaration — it is
34
34
  the intersection of methods your classes define in common, a living
35
35
  document that evolves naturally.
36
36
 
37
37
  It's also useful during active development. When an interface
38
- evolves, implementations can easily fall out of sync. DuckTyper
38
+ evolves, implementations can easily fall out of sync. Duck Typer
39
39
  catches that immediately and reports clear, precise error messages
40
40
  showing exactly which signatures diverged — keeping your classes
41
41
  aligned as the design changes.
@@ -56,7 +56,7 @@ bundle install
56
56
 
57
57
  ## Usage
58
58
 
59
- When interfaces don't match, DuckTyper reports the differing
59
+ When interfaces don't match, Duck Typer reports the differing
60
60
  signatures:
61
61
 
62
62
  ```
@@ -120,7 +120,7 @@ end
120
120
  > If you prefer duck typing terminology, `assert_duck_types_match`
121
121
  > is available as an alias.
122
122
 
123
- By default, DuckTyper checks instance method interfaces. To check
123
+ By default, Duck Typer checks instance method interfaces. To check
124
124
  class-level interfaces instead, pass `type: :class_methods`:
125
125
 
126
126
  ```ruby
@@ -151,6 +151,95 @@ count and kind (required, optional, rest) are compared. In strict
151
151
  mode, names must match exactly. Keyword argument names always
152
152
  matter regardless of this setting.
153
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
+
171
+ #### Canonical interface
172
+
173
+ Use `assert_canonical_interface_match` when you have a reference
174
+ class and want to verify that every implementation conforms to it.
175
+ The canonical class comes first — following the `assert_equal
176
+ expected, actual` convention — followed by the list of classes to
177
+ check.
178
+
179
+ The recommended approach is to define the interface inline with
180
+ `Class.new`, so it lives next to the assertion and makes the
181
+ expected contract explicit. Duck Typer doesn't encourage
182
+ inheritance by default — your implementations can be completely
183
+ independent:
184
+
185
+ ```ruby
186
+ def test_payment_processors_implement_canonical_interface
187
+ interface = Class.new do
188
+ def charge(amount, currency:) = nil
189
+ def refund(transaction_id) = nil
190
+ end
191
+
192
+ assert_canonical_interface_match interface, [
193
+ StripeProcessor,
194
+ PaypalProcessor,
195
+ BraintreeProcessor
196
+ ]
197
+ end
198
+ ```
199
+
200
+ If you already have an existing class that defines the contract,
201
+ you can pass it directly:
202
+
203
+ ```ruby
204
+ def test_payment_processors_implement_canonical_interface
205
+ assert_canonical_interface_match PaymentGateway, [
206
+ StripeProcessor,
207
+ PaypalProcessor,
208
+ BraintreeProcessor
209
+ ]
210
+ end
211
+ ```
212
+
213
+ You can also pass a `namespace:` instead of a list:
214
+
215
+ ```ruby
216
+ assert_canonical_interface_match PaymentGateway, namespace: Payments
217
+ ```
218
+
219
+ When multiple classes fail, a single assertion reports all of them
220
+ at once:
221
+
222
+ ```
223
+ Expected all objects to implement compatible interfaces defined by
224
+ PaymentGateway, but the following method signatures differ:
225
+
226
+ StripeProcessor: charge(amount)
227
+ PaymentGateway: charge(amount, currency:)
228
+
229
+ PaypalProcessor: refund not defined
230
+ PaymentGateway: refund(transaction_id)
231
+ ```
232
+
233
+ The same `type:`, `methods:`, `strict:`, and `name:` options are
234
+ supported:
235
+
236
+ ```ruby
237
+ assert_canonical_interface_match PaymentGateway, [StripeProcessor, PaypalProcessor],
238
+ methods: %i[charge refund],
239
+ strict: true,
240
+ name: "PaymentProcessor"
241
+ ```
242
+
154
243
  ### RSpec
155
244
 
156
245
  Require the RSpec integration in your `spec_helper.rb`:
@@ -198,6 +287,73 @@ expect([StripeProcessor, PaypalProcessor])
198
287
  .to have_matching_interfaces(strict: true)
199
288
  ```
200
289
 
290
+ To include the interface name in failure messages, use `name:`:
291
+
292
+ ```ruby
293
+ expect([StripeProcessor, PaypalProcessor])
294
+ .to have_matching_interfaces(name: "PaymentProcessor")
295
+ ```
296
+
297
+ To check all classes in a module, pass it as a named subject:
298
+
299
+ ```ruby
300
+ expect(namespace: Payments).to have_matching_interfaces
301
+ ```
302
+
303
+ #### Canonical interface
304
+
305
+ Use `implement_canonical_interface` when you have a reference class
306
+ and want to verify that a list of classes conforms to it. The
307
+ recommended approach is to define the interface inline with
308
+ `Class.new` — Duck Typer doesn't encourage inheritance by default,
309
+ and your implementations can be completely independent:
310
+
311
+ ```ruby
312
+ RSpec.describe "payment processors" do
313
+ it "implement the canonical interface" do
314
+ interface = Class.new do
315
+ def charge(amount, currency:) = nil
316
+ def refund(transaction_id) = nil
317
+ end
318
+
319
+ expect([StripeProcessor, PaypalProcessor, BraintreeProcessor])
320
+ .to implement_canonical_interface(interface)
321
+ end
322
+ end
323
+ ```
324
+
325
+ If you already have an existing class that defines the contract,
326
+ you can pass it directly:
327
+
328
+ ```ruby
329
+ RSpec.describe "payment processors" do
330
+ it "implement the canonical interface" do
331
+ expect([StripeProcessor, PaypalProcessor, BraintreeProcessor])
332
+ .to implement_canonical_interface(PaymentGateway)
333
+ end
334
+ end
335
+ ```
336
+
337
+ You can also pass a namespace:
338
+
339
+ ```ruby
340
+ expect(namespace: Payments).to implement_canonical_interface(PaymentGateway)
341
+ ```
342
+
343
+ The same `type:`, `methods:`, `strict:`, and `name:` options are
344
+ supported:
345
+
346
+ ```ruby
347
+ expect([StripeProcessor, PaypalProcessor])
348
+ .to implement_canonical_interface(PaymentGateway,
349
+ methods: %i[charge refund],
350
+ strict: true,
351
+ name: "PaymentProcessor")
352
+ ```
353
+
354
+ When multiple classes fail, the failure message reports all of them
355
+ at once.
356
+
201
357
  #### Shared example
202
358
 
203
359
  If you prefer shared examples, register one in `spec_helper.rb`
@@ -226,7 +382,8 @@ RSpec.describe "payment processors" do
226
382
  end
227
383
  ```
228
384
 
229
- The same `type:`, `methods:`, and `strict:` options are supported:
385
+ The same `type:`, `methods:`, `strict:`, and `name:` options are
386
+ supported:
230
387
 
231
388
  ```ruby
232
389
  it_behaves_like "an interface", [StripeProcessor, PaypalProcessor],
@@ -235,9 +392,15 @@ it_behaves_like "an interface", [StripeProcessor, PaypalProcessor],
235
392
  strict: true
236
393
  ```
237
394
 
395
+ To check all classes in a module, pass it with `namespace:`:
396
+
397
+ ```ruby
398
+ it_behaves_like "an interface", namespace: Payments
399
+ ```
400
+
238
401
  ## Limitations
239
402
 
240
- By default, DuckTyper checks the **structure** of public method
403
+ By default, Duck Typer checks the **structure** of public method
241
404
  signatures — the number of parameters, their kinds (required,
242
405
  optional, keyword, rest, block), and keyword argument names. In
243
406
  strict mode, positional argument names are also compared. It does
@@ -245,7 +408,7 @@ not
245
408
  verify the following, which should be covered by your regular
246
409
  test suite:
247
410
 
248
- - **Parameter types.** DuckTyper only checks that both methods
411
+ - **Parameter types.** Duck Typer only checks that both methods
249
412
  declare an `amount` parameter — not what type of value it
250
413
  expects. Two methods with identical signatures may still be
251
414
  incompatible if they expect different types.
@@ -253,7 +416,7 @@ test suite:
253
416
  but return completely different things.
254
417
  - **Behavior.** Matching signatures are a necessary but not
255
418
  sufficient condition for duck typing to work correctly at
256
- runtime. DuckTyper catches structural drift, not semantic
419
+ runtime. Duck Typer catches structural drift, not semantic
257
420
  divergence.
258
421
 
259
422
  Some things are intentionally out of scope:
@@ -266,7 +429,7 @@ Some things are intentionally out of scope:
266
429
 
267
430
  ## Stability
268
431
 
269
- DuckTyper is intentionally minimal. It reflects Ruby's own method
432
+ Duck Typer is intentionally minimal. It reflects Ruby's own method
270
433
  introspection API, which rarely changes — so the gem rarely needs
271
434
  to either. When it does change, it will most likely be for additive reasons:
272
435
  new API options, better error messages, or broader test framework
@@ -295,7 +458,7 @@ Thank you, [contributors]!
295
458
 
296
459
  ## License
297
460
 
298
- DuckTyper is Copyright (c) thoughtbot, inc.
461
+ Duck Typer is Copyright (c) thoughtbot, inc.
299
462
  It is free software, and may be redistributed
300
463
  under the terms specified in the [LICENSE] file.
301
464
 
@@ -1,19 +1,19 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "interface_setup"
4
+
3
5
  module DuckTyper
4
6
  # Runs interface checks across all consecutive pairs of classes in a list.
5
7
  class BulkInterfaceChecker
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
-
9
- @objects = objects
10
- @checker = InterfaceChecker.new(type:, partial_interface_methods:, strict:)
8
+ def initialize(objects = nil, namespace: nil, type: :instance_methods, methods: nil, strict: false, name: nil)
9
+ @setup = InterfaceSetup.new(objects, namespace:, type:, methods:, strict:, name:, minimum: 2)
11
10
  end
12
11
 
13
12
  def call(&block)
14
- @objects.each_cons(2).map do |left, right|
15
- result = @checker.call(left, right)
13
+ @setup.objects.each_cons(2).map do |left, right|
14
+ result = @setup.checker.call(left, right)
16
15
  block&.call(result)
16
+
17
17
  result
18
18
  end
19
19
  end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../result_formatting"
4
+
5
+ module DuckTyper
6
+ class CanonicalInterfaceChecker
7
+ class Result
8
+ include ResultFormatting
9
+
10
+ def initialize(canonical:, results:, name:, strict:)
11
+ @canonical = canonical
12
+ @results = results
13
+ @name = name
14
+ @strict = strict
15
+ end
16
+
17
+ def match?
18
+ @results.all?(&:match?)
19
+ end
20
+
21
+ def failure_message
22
+ return if match?
23
+
24
+ failing = @results.reject(&:match?)
25
+
26
+ <<~MSG
27
+ Expected all objects to implement compatible \
28
+ #{interface_label} defined by #{@canonical}, \
29
+ but the following method signatures differ:#{strict_note}
30
+
31
+ #{failing.map(&:diff_message).join("\n")}
32
+ MSG
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "canonical_interface_checker/result"
4
+ require_relative "interface_setup"
5
+
6
+ module DuckTyper
7
+ # Compares each class in a list against a single canonical reference class.
8
+ class CanonicalInterfaceChecker
9
+ def initialize(objects = nil, canonical:, namespace: nil, type: :instance_methods, methods: nil, strict: false, name: nil)
10
+ @setup = InterfaceSetup.new(objects, namespace:, type:, methods:, strict:, name:, minimum: 1)
11
+ @canonical = canonical
12
+ end
13
+
14
+ def call
15
+ results = @setup.objects.map { |obj| @setup.checker.call(obj, @canonical) }
16
+
17
+ Result.new(canonical: @canonical, results:, name: @setup.name, strict: @setup.strict)
18
+ end
19
+ end
20
+ end
@@ -1,29 +1,39 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../result_formatting"
4
+
3
5
  module DuckTyper
4
6
  class InterfaceChecker
5
7
  class Result
8
+ include ResultFormatting
9
+
6
10
  attr_reader :left, :right
7
11
 
8
- def initialize(left:, right:, match:, diff_message:)
12
+ def initialize(left:, right:, match:, diff_message:, name:, strict:)
9
13
  @left = left
10
14
  @right = right
11
15
  @match = match
12
16
  @diff_message = diff_message
17
+ @name = name
18
+ @strict = strict
13
19
  end
14
20
 
15
21
  def match?
16
22
  @match.call
17
23
  end
18
24
 
25
+ def diff_message
26
+ @diff_message.call
27
+ end
28
+
19
29
  def failure_message
20
30
  return if match?
21
31
 
22
32
  <<~MSG
23
33
  Expected #{@left} and #{@right} to implement compatible \
24
- interfaces, but the following method signatures differ:
34
+ #{interface_label}, but the following method signatures differ:#{strict_note}
25
35
 
26
- #{@diff_message.call}
36
+ #{diff_message}
27
37
  MSG
28
38
  end
29
39
  end
@@ -7,10 +7,11 @@ require_relative "params_normalizer"
7
7
  module DuckTyper
8
8
  # Compares the public method signatures of two classes and reports mismatches.
9
9
  class InterfaceChecker
10
- def initialize(type: :instance_methods, partial_interface_methods: nil, strict: false)
10
+ def initialize(type: :instance_methods, methods: nil, strict: false, name: nil)
11
11
  @type = type
12
- @partial_interface_methods = partial_interface_methods
12
+ @methods = methods
13
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,7 +20,7 @@ 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
@@ -33,9 +34,9 @@ module DuckTyper
33
34
  end
34
35
 
35
36
  def params_for_comparison(object, params_normalizer)
36
- methods = @partial_interface_methods || @inspectors[object].public_methods
37
+ method_names = @methods || @inspectors[object].public_methods
37
38
 
38
- methods.map do |method_name|
39
+ method_names.map do |method_name|
39
40
  params = method_params(method_name, object)
40
41
  args = params_normalizer.call(params).map do |type, name|
41
42
  case type
@@ -55,6 +56,10 @@ module DuckTyper
55
56
  end
56
57
 
57
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
+
58
63
  @inspectors[object].parameters_for(method_name)
59
64
  rescue NameError
60
65
  raise MethodNotFoundError, "undefined method `#{method_name}' for #{object}"
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "interface_checker"
4
+
5
+ module DuckTyper
6
+ class InterfaceSetup
7
+ attr_reader :objects, :checker, :name, :strict
8
+
9
+ def initialize(objects, namespace:, type:, methods:, strict:, name:, minimum: 0)
10
+ raise ArgumentError, "cannot specify both objects and namespace" if objects && namespace
11
+ raise ArgumentError, "objects or namespace is required" if objects.nil? && namespace.nil?
12
+
13
+ @objects = resolve_objects(objects, namespace)
14
+ raise ArgumentError, "at least #{minimum} object(s) required" if @objects.size < minimum
15
+
16
+ name ||= namespace&.name
17
+ @name = name
18
+ @strict = strict
19
+ @checker = InterfaceChecker.new(type:, methods:, strict:, name:)
20
+ end
21
+
22
+ private
23
+
24
+ def resolve_objects(objects, namespace)
25
+ namespace ? resolve_namespace(namespace) : Array(objects)
26
+ end
27
+
28
+ def resolve_namespace(namespace)
29
+ namespace
30
+ .constants
31
+ .map { |const| namespace.const_get(const) }
32
+ .select { |const| const.is_a?(Module) }
33
+ end
34
+ end
35
+ end
@@ -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,9 +4,9 @@ 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, strict: false)
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, strict:)
9
+ .new(objects, namespace:, type:, methods:, strict:, name:)
10
10
 
11
11
  checker.call do |result|
12
12
  assert result.match?, result.failure_message
@@ -14,5 +14,13 @@ module DuckTyper
14
14
  end
15
15
 
16
16
  alias_method :assert_duck_types_match, :assert_interfaces_match
17
+
18
+ def assert_canonical_interface_match(canonical, objects = nil, namespace: nil, type: :instance_methods, methods: nil, strict: false, name: nil)
19
+ checker = CanonicalInterfaceChecker
20
+ .new(objects, canonical:, namespace:, type:, methods:, strict:, name:)
21
+
22
+ result = checker.call
23
+ assert result.match?, result.failure_message
24
+ end
17
25
  end
18
26
  end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DuckTyper
4
+ module ResultFormatting
5
+ private
6
+
7
+ def interface_label
8
+ @name ? %("#{@name}" interfaces) : "interfaces"
9
+ end
10
+
11
+ def strict_note
12
+ @strict ? " (strict mode: positional argument names must match)" : ""
13
+ end
14
+ end
15
+ 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, strict: false|
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, strict:)
11
+ .new(objects, namespace:, type:, methods:, strict:, name:)
9
12
 
10
13
  @failures = checker.call.reject(&:match?)
11
14
  @failures.empty?
@@ -18,18 +21,36 @@ end
18
21
 
19
22
  RSpec::Matchers.alias_matcher :have_matching_duck_types, :have_matching_interfaces
20
23
 
24
+ RSpec::Matchers.define :implement_canonical_interface do |canonical, name: nil, type: :instance_methods, methods: nil, strict: false|
25
+ match do |actual|
26
+ namespace = actual.is_a?(Hash) ? actual[:namespace] : nil
27
+ objects = namespace ? nil : actual
28
+
29
+ checker = DuckTyper::CanonicalInterfaceChecker
30
+ .new(objects, canonical:, namespace:, type:, methods:, strict:, name:)
31
+
32
+ @result = checker.call
33
+ @result.match?
34
+ end
35
+
36
+ failure_message do
37
+ @result.failure_message
38
+ end
39
+ end
40
+
21
41
  module DuckTyper
22
42
  module RSpec
23
43
  def self.define_shared_example(name = "an interface")
24
- ::RSpec.shared_examples name do |*objects, type: :instance_methods, methods: nil, strict: false|
25
- objects = objects.first
44
+ ::RSpec.shared_examples name do |*args, namespace: nil, name: nil, type: :instance_methods, methods: nil, strict: false|
45
+ objects = namespace ? nil : args.first
46
+
26
47
  # We intentionally avoid reusing the have_matching_interfaces matcher
27
48
  # here. Since this shared example is defined in gem code, RSpec filters
28
49
  # it from its backtrace, causing the Failure/Error: line to show an
29
50
  # internal RSpec constant instead of useful context.
30
51
  it "has compatible interfaces" do
31
52
  checker = DuckTyper::BulkInterfaceChecker
32
- .new(objects, type:, partial_interface_methods: methods, strict:)
53
+ .new(objects, namespace:, type:, methods:, strict:, name:)
33
54
 
34
55
  failures = checker.call.reject(&:match?)
35
56
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module DuckTyper
4
- VERSION = "0.4.0"
4
+ VERSION = "0.6.0"
5
5
  end
data/lib/duck_typer.rb CHANGED
@@ -3,10 +3,12 @@
3
3
  require_relative "duck_typer/version"
4
4
  require_relative "duck_typer/interface_checker"
5
5
  require_relative "duck_typer/bulk_interface_checker"
6
+ require_relative "duck_typer/canonical_interface_checker"
6
7
 
7
8
  # DuckTyper enforces duck-typed interfaces in Ruby by comparing the public
8
9
  # method signatures of classes, surfacing mismatches through your test suite.
9
10
  module DuckTyper
10
11
  class MethodNotFoundError < StandardError; end
12
+ class PrivateMethodError < StandardError; end
11
13
  class TooManyParametersError < StandardError; end
12
14
  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.4.0
4
+ version: 0.6.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-09 00:00:00.000000000 Z
11
+ date: 2026-05-15 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description:
14
14
  email:
@@ -28,11 +28,15 @@ files:
28
28
  - Rakefile
29
29
  - lib/duck_typer.rb
30
30
  - lib/duck_typer/bulk_interface_checker.rb
31
+ - lib/duck_typer/canonical_interface_checker.rb
32
+ - lib/duck_typer/canonical_interface_checker/result.rb
31
33
  - lib/duck_typer/interface_checker.rb
32
34
  - lib/duck_typer/interface_checker/result.rb
35
+ - lib/duck_typer/interface_setup.rb
33
36
  - lib/duck_typer/method_inspector.rb
34
37
  - lib/duck_typer/minitest.rb
35
38
  - lib/duck_typer/params_normalizer.rb
39
+ - lib/duck_typer/result_formatting.rb
36
40
  - lib/duck_typer/rspec.rb
37
41
  - lib/duck_typer/version.rb
38
42
  homepage: https://github.com/thoughtbot/duck_typer