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 +4 -4
- data/.ruby-version +1 -0
- data/.tool-versions +1 -0
- data/CHANGELOG.md +50 -0
- data/CLAUDE.md +23 -0
- data/CONTRIBUTING.md +3 -3
- data/README.md +114 -22
- data/lib/duck_typer/bulk_interface_checker.rb +23 -3
- data/lib/duck_typer/interface_checker/result.rb +14 -2
- data/lib/duck_typer/interface_checker.rb +18 -12
- data/lib/duck_typer/method_inspector.rb +8 -0
- data/lib/duck_typer/minitest.rb +4 -2
- data/lib/duck_typer/params_normalizer.rb +58 -27
- data/lib/duck_typer/rspec.rb +12 -5
- data/lib/duck_typer/version.rb +1 -1
- data/lib/duck_typer.rb +1 -0
- metadata +5 -4
- data/lib/duck_typer/null_params_normalizer.rb +0 -12
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f51efa5736485f62f43d321e7e3d8d612081c6b5eca59bb66823d78cb38b5442
|
|
4
|
+
data.tar.gz: b75e8517ce41187b4df7b9f5260aa8da119feec892f34bc79e6e2865bd90bce8
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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
|
-
#
|
|
1
|
+
# Duck Typer
|
|
2
2
|
|
|
3
3
|
[](https://github.com/thoughtbot/duck_typer/actions/workflows/ci.yml)
|
|
4
4
|
|
|
5
|
-
|
|
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
|
|
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
|
-
|
|
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;
|
|
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.
|
|
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,
|
|
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 [
|
|
112
|
+
assert_interfaces_match [
|
|
113
|
+
StripeProcessor,
|
|
114
|
+
PaypalProcessor,
|
|
115
|
+
BraintreeProcessor
|
|
116
|
+
]
|
|
107
117
|
end
|
|
108
118
|
```
|
|
109
119
|
|
|
110
|
-
|
|
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])
|
|
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])
|
|
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])
|
|
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", [
|
|
251
|
+
it_behaves_like "an interface", [
|
|
252
|
+
StripeProcessor,
|
|
253
|
+
PaypalProcessor,
|
|
254
|
+
BraintreeProcessor
|
|
255
|
+
]
|
|
182
256
|
end
|
|
183
257
|
```
|
|
184
258
|
|
|
185
|
-
The same `type
|
|
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
|
-
|
|
196
|
-
— the number of parameters, their kinds (required,
|
|
197
|
-
keyword, rest, block), and keyword argument names.
|
|
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.**
|
|
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.
|
|
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
|
-
|
|
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,
|
|
7
|
-
|
|
8
|
-
|
|
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
|
-
|
|
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,
|
|
10
|
+
def initialize(type: :instance_methods, methods: nil, strict: false, name: nil)
|
|
12
11
|
@type = type
|
|
13
|
-
@
|
|
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
|
-
|
|
29
|
-
|
|
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,
|
|
35
|
-
|
|
36
|
+
def params_for_comparison(object, params_normalizer)
|
|
37
|
+
method_names = @methods || @inspectors[object].public_methods
|
|
36
38
|
|
|
37
|
-
|
|
39
|
+
method_names.map do |method_name|
|
|
38
40
|
params = method_params(method_name, object)
|
|
39
|
-
args =
|
|
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
|
data/lib/duck_typer/minitest.rb
CHANGED
|
@@ -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:,
|
|
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
|
-
#
|
|
5
|
-
#
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
|
|
29
|
-
|
|
50
|
+
params.map do |type, name|
|
|
51
|
+
if SEQUENTIAL_TYPES.include?(type)
|
|
52
|
+
name = next_sequential_param(sequential_name)
|
|
53
|
+
end
|
|
30
54
|
|
|
31
|
-
|
|
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
|
-
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
data/lib/duck_typer/rspec.rb
CHANGED
|
@@ -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 |
|
|
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:,
|
|
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
|
|
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:,
|
|
36
|
+
.new(objects, namespace:, type:, methods:, strict:, name:)
|
|
30
37
|
|
|
31
38
|
failures = checker.call.reject(&:match?)
|
|
32
39
|
|
data/lib/duck_typer/version.rb
CHANGED
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.
|
|
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-
|
|
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.
|
|
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
|