strict 1.4.0 → 2.0.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.
Files changed (57) hide show
  1. checksums.yaml +4 -4
  2. data/.agents/resume +4 -0
  3. data/.agents/setup +105 -0
  4. data/.rspec +2 -0
  5. data/.rubocop.yml +14 -4
  6. data/.tool-versions +1 -1
  7. data/API.md +335 -0
  8. data/CHANGELOG.md +110 -1
  9. data/Gemfile +13 -0
  10. data/Gemfile.lock +118 -42
  11. data/README.md +278 -4
  12. data/Rakefile +13 -7
  13. data/benchmark/baseline.rb +204 -0
  14. data/lib/strict/assignment_error.rb +5 -2
  15. data/lib/strict/attribute.rb +8 -47
  16. data/lib/strict/attributes/class.rb +0 -6
  17. data/lib/strict/attributes/coercer.rb +4 -3
  18. data/lib/strict/attributes/configuration.rb +15 -0
  19. data/lib/strict/attributes/dsl.rb +18 -10
  20. data/lib/strict/attributes/generated_methods.rb +113 -0
  21. data/lib/strict/attributes/instance.rb +32 -15
  22. data/lib/strict/configuration.rb +44 -0
  23. data/lib/strict/declaration.rb +109 -0
  24. data/lib/strict/detailed_validator.rb +9 -0
  25. data/lib/strict/error.rb +8 -1
  26. data/lib/strict/initialization_error.rb +11 -2
  27. data/lib/strict/interface.rb +39 -21
  28. data/lib/strict/interfaces/conformance.rb +125 -0
  29. data/lib/strict/interfaces/instance.rb +1 -49
  30. data/lib/strict/method.rb +72 -53
  31. data/lib/strict/method_call_error.rb +12 -2
  32. data/lib/strict/method_return_error.rb +2 -2
  33. data/lib/strict/methods/dsl.rb +16 -8
  34. data/lib/strict/methods/module.rb +27 -8
  35. data/lib/strict/methods/verifiable_method.rb +234 -70
  36. data/lib/strict/object.rb +1 -1
  37. data/lib/strict/parameter.rb +3 -52
  38. data/lib/strict/return.rb +14 -10
  39. data/lib/strict/rspec.rb +159 -0
  40. data/lib/strict/union.rb +180 -0
  41. data/lib/strict/unions/coercer.rb +55 -0
  42. data/lib/strict/validation.rb +40 -0
  43. data/lib/strict/validators/all_of.rb +11 -3
  44. data/lib/strict/validators/array_of.rb +18 -3
  45. data/lib/strict/validators/hash_of.rb +23 -3
  46. data/lib/strict/value.rb +18 -4
  47. data/lib/strict/version.rb +1 -1
  48. data/lib/strict/violation.rb +9 -0
  49. data/lib/strict.rb +42 -0
  50. data/sig/strict/rspec.rbs +10 -0
  51. data/sig/strict.rbs +213 -1
  52. data/strict.gemspec +1 -9
  53. metadata +20 -120
  54. data/lib/strict/accessor/attributes.rb +0 -15
  55. data/lib/strict/accessor/module.rb +0 -45
  56. data/lib/strict/reader/attributes.rb +0 -15
  57. data/lib/strict/reader/module.rb +0 -27
data/Gemfile.lock CHANGED
@@ -1,66 +1,142 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- strict (1.4.0)
4
+ strict (2.0.0)
5
5
  zeitwerk (~> 2.6)
6
6
 
7
7
  GEM
8
8
  remote: https://rubygems.org/
9
9
  specs:
10
- ast (2.4.2)
11
- debug (1.6.3)
12
- irb (>= 1.3.6)
13
- reline (>= 0.3.1)
14
- gem-release (2.2.2)
15
- io-console (0.5.11)
16
- irb (1.4.2)
17
- reline (>= 0.3.0)
18
- json (2.6.2)
19
- minitest (5.16.3)
20
- minitest-spec-context (0.0.4)
21
- parallel (1.22.1)
22
- parser (3.1.2.1)
10
+ activesupport (8.1.3.1)
11
+ base64
12
+ bigdecimal
13
+ concurrent-ruby (~> 1.0, >= 1.3.1)
14
+ connection_pool (>= 2.2.5)
15
+ drb
16
+ i18n (>= 1.6, < 2)
17
+ json
18
+ logger (>= 1.4.2)
19
+ minitest (>= 5.1)
20
+ securerandom (>= 0.3)
21
+ tzinfo (~> 2.0, >= 2.0.5)
22
+ uri (>= 0.13.1)
23
+ ast (2.4.3)
24
+ base64 (0.3.0)
25
+ bigdecimal (4.1.2)
26
+ concurrent-ruby (1.3.8)
27
+ connection_pool (3.0.2)
28
+ debug (1.11.1)
29
+ irb (~> 1.10)
30
+ reline (>= 0.3.8)
31
+ diff-lcs (1.6.2)
32
+ drb (2.2.3)
33
+ erb (6.0.7)
34
+ factory_bot (6.6.0)
35
+ activesupport (>= 6.1.0)
36
+ gem-release (2.2.4)
37
+ i18n (1.15.2)
38
+ concurrent-ruby (~> 1.0)
39
+ io-console (0.9.2)
40
+ irb (1.18.0)
41
+ pp (>= 0.6.0)
42
+ prism (>= 1.3.0)
43
+ rdoc (>= 4.0.0)
44
+ reline (>= 0.4.2)
45
+ json (2.21.2)
46
+ language_server-protocol (3.17.0.6)
47
+ lint_roller (1.1.0)
48
+ logger (1.7.0)
49
+ minitest (6.0.6)
50
+ drb (~> 2.0)
51
+ prism (~> 1.5)
52
+ parallel (2.1.0)
53
+ parser (3.3.12.0)
23
54
  ast (~> 2.4.1)
55
+ racc
56
+ pp (0.6.4)
57
+ prettyprint
58
+ prettyprint (0.2.0)
59
+ prism (1.9.0)
60
+ racc (1.8.1)
24
61
  rainbow (3.1.1)
25
- rake (13.0.6)
26
- regexp_parser (2.6.0)
27
- reline (0.3.1)
62
+ rake (13.4.2)
63
+ rbs (4.1.3)
64
+ logger
65
+ prism (>= 1.6.0)
66
+ tsort
67
+ rdoc (8.0.0)
68
+ erb
69
+ prism (>= 1.6.0)
70
+ rbs (>= 4.0.0)
71
+ tsort
72
+ regexp_parser (2.12.0)
73
+ reline (0.7.0)
28
74
  io-console (~> 0.5)
29
- rexml (3.2.5)
30
- rubocop (1.38.0)
75
+ rspec (3.13.2)
76
+ rspec-core (~> 3.13.0)
77
+ rspec-expectations (~> 3.13.0)
78
+ rspec-mocks (~> 3.13.0)
79
+ rspec-core (3.13.6)
80
+ rspec-support (~> 3.13.0)
81
+ rspec-expectations (3.13.5)
82
+ diff-lcs (>= 1.2.0, < 2.0)
83
+ rspec-support (~> 3.13.0)
84
+ rspec-mocks (3.13.8)
85
+ diff-lcs (>= 1.2.0, < 2.0)
86
+ rspec-support (~> 3.13.0)
87
+ rspec-support (3.13.7)
88
+ rubocop (1.89.0)
31
89
  json (~> 2.3)
32
- parallel (~> 1.10)
33
- parser (>= 3.1.2.1)
90
+ language_server-protocol (~> 3.17.0.2)
91
+ lint_roller (~> 1.1.0)
92
+ parallel (>= 1.10)
93
+ parser (>= 3.3.0.2)
34
94
  rainbow (>= 2.2.2, < 4.0)
35
- regexp_parser (>= 1.8, < 3.0)
36
- rexml (>= 3.2.5, < 4.0)
37
- rubocop-ast (>= 1.23.0, < 2.0)
95
+ regexp_parser (>= 2.9.3, < 3.0)
96
+ rubocop-ast (>= 1.49.0, < 2.0)
38
97
  ruby-progressbar (~> 1.7)
39
- unicode-display_width (>= 1.4.0, < 3.0)
40
- rubocop-ast (1.23.0)
41
- parser (>= 3.1.1.0)
42
- rubocop-minitest (0.23.0)
43
- rubocop (>= 0.90, < 2.0)
44
- rubocop-rake (0.6.0)
45
- rubocop (~> 1.0)
46
- ruby-progressbar (1.11.0)
47
- unicode-display_width (2.3.0)
48
- zeitwerk (2.6.4)
98
+ unicode-display_width (>= 2.4.0, < 4.0)
99
+ rubocop-ast (1.50.0)
100
+ parser (>= 3.3.7.2)
101
+ prism (~> 1.7)
102
+ rubocop-factory_bot (2.28.0)
103
+ lint_roller (~> 1.1)
104
+ rubocop (~> 1.72, >= 1.72.1)
105
+ rubocop-rake (0.7.1)
106
+ lint_roller (~> 1.1)
107
+ rubocop (>= 1.72.1)
108
+ rubocop-rspec (3.10.2)
109
+ lint_roller (~> 1.1)
110
+ regexp_parser (>= 2.0)
111
+ rubocop (~> 1.86, >= 1.86.2)
112
+ ruby-progressbar (1.13.0)
113
+ securerandom (0.4.1)
114
+ tsort (0.2.0)
115
+ tzinfo (2.0.6)
116
+ concurrent-ruby (~> 1.0)
117
+ unicode-display_width (3.2.0)
118
+ unicode-emoji (~> 4.1)
119
+ unicode-emoji (4.2.0)
120
+ uri (1.1.1)
121
+ zeitwerk (2.8.3)
49
122
 
50
123
  PLATFORMS
51
124
  arm64-darwin-21
125
+ arm64-darwin-23
52
126
  x86_64-linux
53
127
 
54
128
  DEPENDENCIES
55
- debug (>= 1.0.0)
129
+ debug (~> 1.11)
130
+ factory_bot (~> 6.5)
56
131
  gem-release (~> 2.2)
57
- minitest (~> 5.0)
58
- minitest-spec-context (~> 0.0.4)
59
- rake (~> 13.0)
60
- rubocop (~> 1.21)
61
- rubocop-minitest (~> 0.22)
62
- rubocop-rake (~> 0.6)
132
+ rake (~> 13.4)
133
+ rbs (~> 4.1)
134
+ rspec (~> 3.13)
135
+ rubocop (~> 1.89)
136
+ rubocop-factory_bot (~> 2.28)
137
+ rubocop-rake (~> 0.7)
138
+ rubocop-rspec (~> 3.10)
63
139
  strict!
64
140
 
65
141
  BUNDLED WITH
66
- 2.3.23
142
+ 4.0.19
data/README.md CHANGED
@@ -4,6 +4,8 @@ Strict provides a means to strictly validate instantiation of values, instantiat
4
4
 
5
5
  ## Installation
6
6
 
7
+ Strict requires Ruby 3.3 or newer.
8
+
7
9
  Install the gem and add to the application's Gemfile by executing:
8
10
 
9
11
  ```sh
@@ -18,6 +20,8 @@ $ gem install strict
18
20
 
19
21
  ## Usage
20
22
 
23
+ See [Supported API](API.md) for the 2.0 compatibility boundary.
24
+
21
25
  ### `Strict::Value`
22
26
 
23
27
  ```rb
@@ -49,6 +53,83 @@ Money.new(amount_in_cents: 100_00) == Money.new(amount_in_cents: 100_00)
49
53
  # => true
50
54
  ```
51
55
 
56
+ Subclasses can add attributes while retaining their inherited attributes:
57
+
58
+ ```rb
59
+ class Person
60
+ include Strict::Value
61
+
62
+ attributes do
63
+ name String
64
+ end
65
+ end
66
+
67
+ class Employee < Person
68
+ attributes do
69
+ employee_id String
70
+ end
71
+ end
72
+ ```
73
+
74
+ A subclass can also redeclare an inherited attribute by its exact name. The subclass declaration replaces the inherited validator, coercer, and default without changing the parent or the attribute order.
75
+
76
+ ### `Strict::Union`
77
+
78
+ ```rb
79
+ class PaymentResult
80
+ include Strict::Union
81
+
82
+ discriminator :status
83
+
84
+ attributes do
85
+ request_id String
86
+ end
87
+
88
+ variant :authorized, tag: "payment.authorized" do
89
+ attributes do
90
+ authorization_id String
91
+ amount_in_cents Integer
92
+ end
93
+
94
+ def successful? = true
95
+ end
96
+
97
+ variant :declined do
98
+ attributes do
99
+ reason String
100
+ end
101
+
102
+ def successful? = false
103
+ end
104
+ end
105
+
106
+ authorized = PaymentResult::Authorized.new(
107
+ request_id: "request_123",
108
+ authorization_id: "auth_123",
109
+ amount_in_cents: 1_000
110
+ )
111
+ authorized.to_h
112
+ # => { status: "payment.authorized", request_id: "request_123", authorization_id: "auth_123", amount_in_cents: 1_000 }
113
+
114
+ authorized.successful?
115
+ # => true
116
+
117
+ result = PaymentResult.coercer.call(
118
+ "status" => "declined",
119
+ "request_id" => "request_456",
120
+ "reason" => "insufficient_funds"
121
+ )
122
+ # => #<PaymentResult::Declined status=:declined request_id="request_456" reason="insufficient_funds">
123
+
124
+ case result
125
+ in PaymentResult::Authorized(authorization_id:)
126
+ authorization_id
127
+ in PaymentResult::Declined(reason:)
128
+ reason
129
+ end
130
+ # => "insufficient_funds"
131
+ ```
132
+
52
133
  ### `Strict::Object`
53
134
 
54
135
  ```rb
@@ -78,11 +159,59 @@ Stateful.new(some_state: "123") == Stateful.new(some_state: "123")
78
159
  # => false
79
160
  ```
80
161
 
162
+ Validation errors provide structured violations with paths into nested values:
163
+
164
+ ```rb
165
+ class Batch
166
+ include Strict::Value
167
+
168
+ attributes do
169
+ labels ArrayOf(String)
170
+ end
171
+ end
172
+
173
+ begin
174
+ Batch.new(labels: ["ready", 404], extra: true)
175
+ rescue Strict::InitializationError => error
176
+ error.violations.map do |violation|
177
+ [violation.path, violation.code, violation.value, violation.validator]
178
+ end
179
+ end
180
+ # => [
181
+ # [[:labels, 1], :invalid, 404, String],
182
+ # [[:extra], :unexpected, true, nil]
183
+ # ]
184
+ ```
185
+
186
+ The codes are `:invalid`, `:missing`, and `:unexpected`. Custom validators only need to implement `===`; Strict reports a rejected value against that validator at the current path. A custom validator can include `Strict::DetailedValidator` and implement `violations(value)` when it needs to report relative nested paths:
187
+
188
+ ```rb
189
+ class Emails
190
+ include Strict::DetailedValidator
191
+
192
+ def violations(value)
193
+ unless Array === value
194
+ return [Strict::Violation.new(path: [], code: :invalid, value: value, validator: Array)]
195
+ end
196
+
197
+ value.each_with_index.filter_map do |email, index|
198
+ next if String === email
199
+
200
+ Strict::Violation.new(path: [index], code: :invalid, value: email, validator: String)
201
+ end
202
+ end
203
+ end
204
+ ```
205
+
206
+ The module provides `===` from `violations`, and Strict prefixes each relative path with its enclosing attribute, parameter, or collection path.
207
+
208
+ When an attribute or parameter validator provides a `.coercer`, Strict uses it automatically before validation. An explicit `coerce:` value overrides it, and `coerce: false` disables it. This lets nested Strict values, unions, and interfaces accept the input handled by their class coercers without repeating `coerce:` in each declaration. `ArrayOf` and `HashOf` propagate coercers from their element, key, and value validators, so compositions such as `ArrayOf(ValueClass)` also coerce automatically.
209
+
81
210
  ### `Strict::Method`
82
211
 
83
212
  ```rb
84
213
  class UpdateEmail
85
- extend Strict::Method
214
+ include Strict::Method
86
215
 
87
216
  sig do
88
217
  user_id String, coerce: ->(value) { value.to_s }
@@ -108,11 +237,13 @@ UpdateEmail.new.call(user_id: "123", email: "456")
108
237
  # => Strict::MethodReturnError
109
238
  ```
110
239
 
240
+ `returns` validates the exact value produced by the method. It never coerces or replaces that value, and a successful call returns the same object to the caller.
241
+
111
242
  ### `Strict::Interface`
112
243
 
113
244
  ```rb
114
245
  class Storage
115
- extend Strict::Interface
246
+ include Strict::Interface
116
247
 
117
248
  expose(:write) do
118
249
  key String
@@ -147,7 +278,15 @@ module Storages
147
278
  end
148
279
  end
149
280
 
150
- storage = Storage.new(Storages::Memory.new)
281
+ adapter = Storages::Memory.new
282
+
283
+ Storage.implemented_by?(adapter)
284
+ # => true
285
+
286
+ Storage.verify_implementation!(adapter)
287
+ # => nil
288
+
289
+ storage = Storage.new(adapter)
151
290
  # => #<Storage implementation=#<Storages::Memory>>
152
291
 
153
292
  storage.write(key: "some/path/to/file.rb", contents: "Hello")
@@ -173,9 +312,144 @@ storage = Storage.new(Storages::Wat.new)
173
312
  # => Strict::ImplementationDoesNotConformError
174
313
  ```
175
314
 
315
+ ### RSpec extensions
316
+
317
+ Strict provides supported, opt-in integration with RSpec 3.13. RSpec remains an optional dependency and is not loaded by
318
+ `require "strict"`. Add RSpec to the test bundle:
319
+
320
+ ```rb
321
+ group :test do
322
+ gem "rspec", "~> 3.13"
323
+ end
324
+ ```
325
+
326
+ Then require the adapter from the spec helper:
327
+
328
+ ```rb
329
+ require "strict/rspec"
330
+ ```
331
+
332
+ The adapter provides matchers for validators and interfaces:
333
+
334
+ ```rb
335
+ expect(String).to validate("value")
336
+ expect(String).not_to validate(1)
337
+
338
+ expect(Storages::Memory.new).to conform_to(Storage)
339
+ expect(Object.new).not_to conform_to(Storage)
340
+ ```
341
+
342
+ When validation fails, `validate` uses `Strict::Violation` records to report root and nested
343
+ `Strict::DetailedValidator` failure paths.
344
+
345
+ `strict_double` builds an RSpec verifying double. For an interface, it stubs every exposed method to `nil` unless a
346
+ different result is provided, so the double conforms without extra setup:
347
+
348
+ ```rb
349
+ storage = strict_double(Storage, write: true, read: "contents")
350
+
351
+ expect(storage).to conform_to(Storage)
352
+ Storage.new(storage).read(key: "some/path")
353
+ # => "contents"
354
+ ```
355
+
356
+ RSpec instance doubles also satisfy Strict class validators for attributes, signed parameters, and return values. Plain
357
+ doubles remain invalid:
358
+
359
+ ```rb
360
+ class Item
361
+ include Strict::Value
362
+
363
+ attributes do
364
+ sku String
365
+ end
366
+ end
367
+
368
+ class Shipment
369
+ include Strict::Value
370
+
371
+ attributes do
372
+ item Item
373
+ end
374
+ end
375
+
376
+ item = instance_double(Item, sku: "item_123")
377
+ Shipment.new(item: item)
378
+ # => #<Shipment item=#<InstanceDouble(Item)>>
379
+
380
+ Shipment.new(item: double("item"))
381
+ # => Strict::InitializationError
382
+ ```
383
+
384
+ Matcher objects can also stand in for validated fields or elements of built-in collection validators when constructing
385
+ expected Strict values. RSpec recursively applies the nested matchers in argument expectations:
386
+
387
+ ```rb
388
+ expect(dispatcher).to have_received(:ship).with(
389
+ shipment: Shipment.new(
390
+ item: have_attributes(sku: "item_123")
391
+ )
392
+ )
393
+ ```
394
+
395
+ This composition does not change normal Strict value equality or hashing.
396
+
397
+ ### Configuration
398
+
399
+ Strict exposes some configuration options which can be configured globally via `Strict.configure { ... }` or overridden
400
+ within a block via `Strict.with_overrides(...) { ... }`.
401
+
402
+ #### Example
403
+
404
+ ```ruby
405
+ # Globally
406
+
407
+ Strict.configure do |c|
408
+ c.sample_rate = 0.75 # run validation ~75% of the time
409
+ end
410
+
411
+ Strict.configure do |c|
412
+ c.sample_rate = 0 # disable validation (Strict becomes Lenient)
413
+ end
414
+
415
+ Strict.configure do |c|
416
+ c.sample_rate = 1 # always run validation
417
+ end
418
+
419
+ # Locally within the block (only applies to the current execution context)
420
+
421
+ Strict.with_overrides(sample_rate: 0) do
422
+ # Use Strict as you normally would
423
+
424
+ Strict.with_overrides(sample_rate: 0.5) do
425
+ # Overrides can be nested
426
+ end
427
+ end
428
+ ```
429
+
430
+ Overrides are local to the current execution context (fiber). They can be nested and are restored when a block returns
431
+ or raises. Neither a newly created fiber nor a new thread inherits an active override.
432
+
433
+ #### `Strict.configuration.random`
434
+
435
+ The instance of a `Random::Formatter` that Strict uses in tandom with the `sample_rate` to determine when validation
436
+ should be checked.
437
+
438
+ **Default**: `Random.new`
439
+
440
+ #### `Strict.configuration.sample_rate`
441
+
442
+ The rate of samples Strict will consider when validating attributes, parameters, and return values. A rate of 0.25 will
443
+ validate roughly 25% of the time, a rate of 0 will disable validation entirely, and a rate of 1 will always
444
+ run validations. The `sample_rate` is used in tandem with `random` to determine whether validation should be run.
445
+
446
+ **Default**: 1
447
+
176
448
  ## Development
177
449
 
178
- After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
450
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the specs. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
451
+
452
+ Run `bundle exec rake benchmark` to measure baseline timing and allocations for value initialization, mutable assignment, verified method calls, interface construction, interface calls, `to_h`, equality, and hashing. Set `ITERATIONS`, `WARMUP_ITERATIONS`, or `SAMPLES` to change the workload, and set `FORMAT=markdown` to produce a Markdown table. Pull requests publish this table in a non-blocking job summary and upload it as an artifact. These benchmarks report measurements only and do not enforce thresholds.
179
453
 
180
454
  To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
181
455
 
data/Rakefile CHANGED
@@ -1,16 +1,22 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "bundler/gem_tasks"
4
- require "rake/testtask"
4
+ require "rspec/core/rake_task"
5
5
 
6
- Rake::TestTask.new(:test) do |t|
7
- t.libs << "test"
8
- t.libs << "lib"
9
- t.test_files = FileList["test/**/*_test.rb"]
10
- end
6
+ RSpec::Core::RakeTask.new(:spec)
11
7
 
12
8
  require "rubocop/rake_task"
13
9
 
14
10
  RuboCop::RakeTask.new
15
11
 
16
- task default: %i[test rubocop]
12
+ desc "Validate RBS signatures"
13
+ task :rbs do
14
+ sh "bundle exec rbs -I sig validate"
15
+ end
16
+
17
+ desc "Run timing and allocation baselines"
18
+ task :benchmark do
19
+ sh "bundle exec ruby benchmark/baseline.rb", verbose: false
20
+ end
21
+
22
+ task default: %i[spec rubocop rbs]