Skip to content

Commit 96aa9f4

Browse files
thiagoaclaude
andcommitted
Add strict mode for interface comparison
Extract KeywordNormalizer and SequentialNormalizer from ParamsNormalizer. Add DefaultParamsNormalizer (both normalizers) and StrictParamsNormalizer (keywords only), where strict mode makes positional argument names significant. ParamsNormalizer becomes a factory via ParamsNormalizer.for(strict:). Thread strict: false through InterfaceChecker, BulkInterfaceChecker, and both test helpers. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 9eea232 commit 96aa9f4

10 files changed

Lines changed: 226 additions & 92 deletions

CLAUDE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ of these places:
3535
unintentionally included in the commit.
3636
- After staging, double-check the staged changes match what was
3737
asked before committing.
38+
- Always run `bundle exec rake ci` before pushing.
3839
- Always ask for confirmation before pushing.
3940

4041
## Commands

README.md

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,19 @@ which case you can write an assertion for each.
138138
If you prefer duck typing terminology, `assert_duck_types_match`
139139
is available as an alias.
140140

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+
141154
### RSpec
142155

143156
Require the RSpec integration in your `spec_helper.rb`:
@@ -177,6 +190,14 @@ expect([StripeProcessor, PaypalProcessor])
177190
If you prefer duck typing terminology, `have_matching_duck_types`
178191
is available as an alias.
179192

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)
199+
```
200+
180201
#### Shared example
181202

182203
If you prefer shared examples, register one in `spec_helper.rb`
@@ -205,19 +226,22 @@ RSpec.describe "payment processors" do
205226
end
206227
```
207228

208-
The same `type:` and `methods:` options are supported:
229+
The same `type:`, `methods:`, and `strict:` options are supported:
209230

210231
```ruby
211232
it_behaves_like "an interface", [StripeProcessor, PaypalProcessor],
212233
type: :class_methods,
213-
methods: %i[charge refund]
234+
methods: %i[charge refund],
235+
strict: true
214236
```
215237

216238
## Limitations
217239

218-
DuckTyper checks the **structure** of public method signatures
219-
— the number of parameters, their kinds (required, optional,
220-
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
221245
verify the following, which should be covered by your regular
222246
test suite:
223247

lib/duck_typer/bulk_interface_checker.rb

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,11 @@
33
module DuckTyper
44
# Runs interface checks across all consecutive pairs of classes in a list.
55
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)
77
raise ArgumentError, "more than one class is required" if objects.size < 2
88

99
@objects = objects
10-
@checker = InterfaceChecker.new(type:, partial_interface_methods:)
10+
@checker = InterfaceChecker.new(type:, partial_interface_methods:, strict:)
1111
end
1212

1313
def call(&block)

lib/duck_typer/interface_checker.rb

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,14 @@
33
require_relative "interface_checker/result"
44
require_relative "method_inspector"
55
require_relative "params_normalizer"
6-
require_relative "null_params_normalizer"
76

87
module DuckTyper
98
# Compares the public method signatures of two classes and reports mismatches.
109
class InterfaceChecker
11-
def initialize(type: :instance_methods, partial_interface_methods: nil)
10+
def initialize(type: :instance_methods, partial_interface_methods: nil, strict: false)
1211
@type = type
1312
@partial_interface_methods = partial_interface_methods
13+
@strict = strict
1414
@inspectors = Hash.new { |h, k| h[k] = MethodInspector.for(k, @type) }
1515
end
1616

@@ -25,18 +25,19 @@ def call(left, right)
2525
private
2626

2727
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)
3031

3132
(left_params - right_params) + (right_params - left_params)
3233
end
3334

34-
def params_for_comparison(object, params_processor)
35+
def params_for_comparison(object, params_normalizer)
3536
methods = @partial_interface_methods || @inspectors[object].public_methods
3637

3738
methods.map do |method_name|
3839
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|
4041
case type
4142
when :key then "#{name}: :opt"
4243
when :keyreq then "#{name}:"
@@ -61,8 +62,8 @@ def method_params(method_name, object)
6162

6263
def diff_message(left, right, diff)
6364
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)
6667

6768
methods.map do |method_name|
6869
<<~DIFF

lib/duck_typer/minitest.rb

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,9 @@
44

55
module DuckTyper
66
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)
88
checker = BulkInterfaceChecker
9-
.new(objects, type:, partial_interface_methods: methods)
9+
.new(objects, type:, partial_interface_methods: methods, strict:)
1010

1111
checker.call do |result|
1212
assert result.match?, result.failure_message

lib/duck_typer/null_params_normalizer.rb

Lines changed: 0 additions & 12 deletions
This file was deleted.

lib/duck_typer/params_normalizer.rb

Lines changed: 58 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,46 +1,77 @@
11
# frozen_string_literal: true
22

33
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 { |sorted| sequentialize_params(sorted) }
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) }
1617
end
18+
end
1719

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
1928

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) }
2436

2537
sequentials + keywords.sort_by { |_, name| name }
2638
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
2749

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
3054

31-
params.map do |type, name|
32-
if SEQUENTIAL_TYPES.include?(type)
33-
name = next_sequential_param(sequential_name)
55+
[type, name]
3456
end
57+
end
58+
59+
private
3560

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"
3765
end
3866
end
67+
end
3968

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
4475
end
4576
end
4677
end

lib/duck_typer/rspec.rb

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,10 @@
22

33
require_relative "../duck_typer"
44

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|
66
match do |objects|
77
checker = DuckTyper::BulkInterfaceChecker
8-
.new(objects, type:, partial_interface_methods: methods)
8+
.new(objects, type:, partial_interface_methods: methods, strict:)
99

1010
@failures = checker.call.reject(&:match?)
1111
@failures.empty?
@@ -21,15 +21,15 @@
2121
module DuckTyper
2222
module RSpec
2323
def self.define_shared_example(name = "an interface")
24-
::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|
2525
objects = objects.first
2626
# We intentionally avoid reusing the have_matching_interfaces matcher
2727
# here. Since this shared example is defined in gem code, RSpec filters
2828
# it from its backtrace, causing the Failure/Error: line to show an
2929
# internal RSpec constant instead of useful context.
3030
it "has compatible interfaces" do
3131
checker = DuckTyper::BulkInterfaceChecker
32-
.new(objects, type:, partial_interface_methods: methods)
32+
.new(objects, type:, partial_interface_methods: methods, strict:)
3333

3434
failures = checker.call.reject(&:match?)
3535

test/duck_typer/interface_checker_test.rb

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -511,4 +511,27 @@ def foo(bananas, coconuts, *others, format:, locale: nil, **opts, &blk) = nil
511511
"foo(bananas, coconuts, *others, format:, locale: :opt, **opts, &blk)"
512512
)
513513
end
514+
515+
# Strict mode
516+
517+
def test_strict_mode_positional_arg_names_must_match
518+
left = Class.new { def foo(a, b) = nil }
519+
right = Class.new { def foo(x, y) = nil }
520+
521+
refute match?(left, right, strict: true)
522+
end
523+
524+
def test_strict_mode_same_positional_arg_names_match
525+
left = Class.new { def foo(a, b) = nil }
526+
right = Class.new { def foo(a, b) = nil }
527+
528+
assert match?(left, right, strict: true)
529+
end
530+
531+
def test_strict_mode_keyword_order_does_not_matter
532+
left = Class.new { def foo(a:, b:) = nil }
533+
right = Class.new { def foo(b:, a:) = nil }
534+
535+
assert match?(left, right, strict: true)
536+
end
514537
end

0 commit comments

Comments
 (0)