strict 1.5.0 → 2.1.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 (56) 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 +337 -0
  8. data/CHANGELOG.md +117 -1
  9. data/Gemfile +13 -0
  10. data/Gemfile.lock +117 -41
  11. data/README.md +238 -6
  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 -49
  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/declaration.rb +109 -0
  23. data/lib/strict/detailed_validator.rb +9 -0
  24. data/lib/strict/error.rb +8 -1
  25. data/lib/strict/initialization_error.rb +11 -2
  26. data/lib/strict/interface.rb +39 -21
  27. data/lib/strict/interfaces/conformance.rb +125 -0
  28. data/lib/strict/interfaces/instance.rb +1 -49
  29. data/lib/strict/method.rb +72 -53
  30. data/lib/strict/method_call_error.rb +12 -2
  31. data/lib/strict/method_return_error.rb +2 -2
  32. data/lib/strict/methods/dsl.rb +16 -8
  33. data/lib/strict/methods/module.rb +27 -8
  34. data/lib/strict/methods/verifiable_method.rb +234 -70
  35. data/lib/strict/object.rb +1 -1
  36. data/lib/strict/parameter.rb +3 -54
  37. data/lib/strict/return.rb +14 -12
  38. data/lib/strict/rspec.rb +159 -0
  39. data/lib/strict/union.rb +214 -0
  40. data/lib/strict/unions/coercer.rb +55 -0
  41. data/lib/strict/validation.rb +40 -0
  42. data/lib/strict/validators/all_of.rb +11 -3
  43. data/lib/strict/validators/array_of.rb +18 -3
  44. data/lib/strict/validators/hash_of.rb +23 -3
  45. data/lib/strict/value.rb +18 -4
  46. data/lib/strict/version.rb +1 -1
  47. data/lib/strict/violation.rb +9 -0
  48. data/lib/strict.rb +10 -9
  49. data/sig/strict/rspec.rbs +10 -0
  50. data/sig/strict.rbs +213 -1
  51. data/strict.gemspec +1 -9
  52. metadata +19 -120
  53. data/lib/strict/accessor/attributes.rb +0 -15
  54. data/lib/strict/accessor/module.rb +0 -45
  55. data/lib/strict/reader/attributes.rb +0 -15
  56. 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.5.0)
4
+ strict (2.1.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.7.2)
12
- irb (>= 1.5.0)
13
- reline (>= 0.3.1)
14
- gem-release (2.2.2)
15
- io-console (0.6.0)
16
- irb (1.6.4)
17
- reline (>= 0.3.0)
18
- json (2.6.3)
19
- minitest (5.18.0)
20
- minitest-spec-context (0.0.4)
21
- parallel (1.22.1)
22
- parser (3.2.2.0)
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.7.0)
27
- reline (0.3.3)
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.49.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.2.0.0)
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.28.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 (>= 2.4.0, < 3.0)
40
- rubocop-ast (1.28.0)
41
- parser (>= 3.2.1.0)
42
- rubocop-minitest (0.30.0)
43
- rubocop (>= 1.39, < 2.0)
44
- rubocop-rake (0.6.0)
45
- rubocop (~> 1.0)
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)
46
112
  ruby-progressbar (1.13.0)
47
- unicode-display_width (2.4.2)
48
- zeitwerk (2.6.7)
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,89 @@ 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(
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.authorized?
115
+ # => true
116
+
117
+ authorized.declined?
118
+ # => false
119
+
120
+ authorized.successful?
121
+ # => true
122
+
123
+ result = PaymentResult.coercer.call(
124
+ "status" => "declined",
125
+ "request_id" => "request_456",
126
+ "reason" => "insufficient_funds"
127
+ )
128
+ # => #<PaymentResult::Declined status=:declined request_id="request_456" reason="insufficient_funds">
129
+
130
+ case result
131
+ in PaymentResult::Authorized(authorization_id:)
132
+ authorization_id
133
+ in PaymentResult::Declined(reason:)
134
+ reason
135
+ end
136
+ # => "insufficient_funds"
137
+ ```
138
+
52
139
  ### `Strict::Object`
53
140
 
54
141
  ```rb
@@ -78,11 +165,59 @@ Stateful.new(some_state: "123") == Stateful.new(some_state: "123")
78
165
  # => false
79
166
  ```
80
167
 
168
+ Validation errors provide structured violations with paths into nested values:
169
+
170
+ ```rb
171
+ class Batch
172
+ include Strict::Value
173
+
174
+ attributes do
175
+ labels ArrayOf(String)
176
+ end
177
+ end
178
+
179
+ begin
180
+ Batch.new(labels: ["ready", 404], extra: true)
181
+ rescue Strict::InitializationError => error
182
+ error.violations.map do |violation|
183
+ [violation.path, violation.code, violation.value, violation.validator]
184
+ end
185
+ end
186
+ # => [
187
+ # [[:labels, 1], :invalid, 404, String],
188
+ # [[:extra], :unexpected, true, nil]
189
+ # ]
190
+ ```
191
+
192
+ 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:
193
+
194
+ ```rb
195
+ class Emails
196
+ include Strict::DetailedValidator
197
+
198
+ def violations(value)
199
+ unless Array === value
200
+ return [Strict::Violation.new(path: [], code: :invalid, value: value, validator: Array)]
201
+ end
202
+
203
+ value.each_with_index.filter_map do |email, index|
204
+ next if String === email
205
+
206
+ Strict::Violation.new(path: [index], code: :invalid, value: email, validator: String)
207
+ end
208
+ end
209
+ end
210
+ ```
211
+
212
+ The module provides `===` from `violations`, and Strict prefixes each relative path with its enclosing attribute, parameter, or collection path.
213
+
214
+ 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.
215
+
81
216
  ### `Strict::Method`
82
217
 
83
218
  ```rb
84
219
  class UpdateEmail
85
- extend Strict::Method
220
+ include Strict::Method
86
221
 
87
222
  sig do
88
223
  user_id String, coerce: ->(value) { value.to_s }
@@ -108,11 +243,13 @@ UpdateEmail.new.call(user_id: "123", email: "456")
108
243
  # => Strict::MethodReturnError
109
244
  ```
110
245
 
246
+ `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.
247
+
111
248
  ### `Strict::Interface`
112
249
 
113
250
  ```rb
114
251
  class Storage
115
- extend Strict::Interface
252
+ include Strict::Interface
116
253
 
117
254
  expose(:write) do
118
255
  key String
@@ -147,7 +284,15 @@ module Storages
147
284
  end
148
285
  end
149
286
 
150
- storage = Storage.new(Storages::Memory.new)
287
+ adapter = Storages::Memory.new
288
+
289
+ Storage.implemented_by?(adapter)
290
+ # => true
291
+
292
+ Storage.verify_implementation!(adapter)
293
+ # => nil
294
+
295
+ storage = Storage.new(adapter)
151
296
  # => #<Storage implementation=#<Storages::Memory>>
152
297
 
153
298
  storage.write(key: "some/path/to/file.rb", contents: "Hello")
@@ -173,6 +318,88 @@ storage = Storage.new(Storages::Wat.new)
173
318
  # => Strict::ImplementationDoesNotConformError
174
319
  ```
175
320
 
321
+ ### RSpec extensions
322
+
323
+ Strict provides supported, opt-in integration with RSpec 3.13. RSpec remains an optional dependency and is not loaded by
324
+ `require "strict"`. Add RSpec to the test bundle:
325
+
326
+ ```rb
327
+ group :test do
328
+ gem "rspec", "~> 3.13"
329
+ end
330
+ ```
331
+
332
+ Then require the adapter from the spec helper:
333
+
334
+ ```rb
335
+ require "strict/rspec"
336
+ ```
337
+
338
+ The adapter provides matchers for validators and interfaces:
339
+
340
+ ```rb
341
+ expect(String).to validate("value")
342
+ expect(String).not_to validate(1)
343
+
344
+ expect(Storages::Memory.new).to conform_to(Storage)
345
+ expect(Object.new).not_to conform_to(Storage)
346
+ ```
347
+
348
+ When validation fails, `validate` uses `Strict::Violation` records to report root and nested
349
+ `Strict::DetailedValidator` failure paths.
350
+
351
+ `strict_double` builds an RSpec verifying double. For an interface, it stubs every exposed method to `nil` unless a
352
+ different result is provided, so the double conforms without extra setup:
353
+
354
+ ```rb
355
+ storage = strict_double(Storage, write: true, read: "contents")
356
+
357
+ expect(storage).to conform_to(Storage)
358
+ Storage.new(storage).read(key: "some/path")
359
+ # => "contents"
360
+ ```
361
+
362
+ RSpec instance doubles also satisfy Strict class validators for attributes, signed parameters, and return values. Plain
363
+ doubles remain invalid:
364
+
365
+ ```rb
366
+ class Item
367
+ include Strict::Value
368
+
369
+ attributes do
370
+ sku String
371
+ end
372
+ end
373
+
374
+ class Shipment
375
+ include Strict::Value
376
+
377
+ attributes do
378
+ item Item
379
+ end
380
+ end
381
+
382
+ item = instance_double(Item, sku: "item_123")
383
+ Shipment.new(item: item)
384
+ # => #<Shipment item=#<InstanceDouble(Item)>>
385
+
386
+ Shipment.new(item: double("item"))
387
+ # => Strict::InitializationError
388
+ ```
389
+
390
+ Matcher objects can also stand in for validated fields or elements of built-in collection validators when constructing
391
+ expected Strict values. RSpec recursively applies the nested matchers in argument expectations:
392
+
393
+ ```rb
394
+ expect(dispatcher).to have_received(:ship).with(
395
+ shipment: Shipment.new(
396
+ item: have_attributes(sku: "item_123")
397
+ )
398
+ )
399
+ ```
400
+
401
+ This composition does not change normal Strict value equality or hashing.
402
+
176
403
  ### Configuration
177
404
 
178
405
  Strict exposes some configuration options which can be configured globally via `Strict.configure { ... }` or overridden
@@ -192,10 +419,10 @@ Strict.configure do |c|
192
419
  end
193
420
 
194
421
  Strict.configure do |c|
195
- c.sample_rate = 0 # always run validation
422
+ c.sample_rate = 1 # always run validation
196
423
  end
197
424
 
198
- # Locally within the block (only applies to the current thread)
425
+ # Locally within the block (only applies to the current execution context)
199
426
 
200
427
  Strict.with_overrides(sample_rate: 0) do
201
428
  # Use Strict as you normally would
@@ -206,6 +433,9 @@ Strict.with_overrides(sample_rate: 0) do
206
433
  end
207
434
  ```
208
435
 
436
+ Overrides are local to the current execution context (fiber). They can be nested and are restored when a block returns
437
+ or raises. Neither a newly created fiber nor a new thread inherits an active override.
438
+
209
439
  #### `Strict.configuration.random`
210
440
 
211
441
  The instance of a `Random::Formatter` that Strict uses in tandom with the `sample_rate` to determine when validation
@@ -223,7 +453,9 @@ run validations. The `sample_rate` is used in tandem with `random` to determine
223
453
 
224
454
  ## Development
225
455
 
226
- 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.
456
+ 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.
457
+
458
+ 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.
227
459
 
228
460
  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).
229
461
 
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]