contracts-rb 0.1.2 → 0.4.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.
data/lib/contracts.rb CHANGED
@@ -1,971 +1,1063 @@
1
- # frozen_string_literal: true
2
-
1
+ # frozen_string_literal: true
2
+
3
3
  require "json"
4
4
  require "monitor"
5
5
  require "set"
6
6
  require_relative "contracts/version"
7
-
8
- # Runtime behavioral contracts. Include in classes or extend for singleton contracts.
9
- module Contracts
10
- SENSITIVE_NAMES = [/password/i, /token/i, /secret/i, /authorization/i, /api_key/i, /access_key/i, /credit_card/i,
11
- /ssn/i].freeze
12
-
13
- class Error < StandardError
14
- def to_h = { error: self.class.name, message: message }
15
- end
16
-
17
- class DefinitionError < Error; end
18
-
19
- class Violation < Error
20
- attr_reader :owner, :method_name, :contract_type, :description, :expected, :actual, :parameter, :context,
21
- :source_location, :original_exception
22
-
23
- def initialize(message = nil, owner: nil, method_name: nil, contract_type: nil, description: nil, expected: nil,
24
- actual: nil, parameter: nil, context: nil, source_location: nil, original_exception: nil)
25
- @owner = owner
26
- @method_name = method_name
27
- @contract_type = contract_type
28
- @description = description
29
- @expected = expected
30
- @actual = actual
31
- @parameter = parameter
32
- @context = context
33
- @source_location = source_location
34
- @original_exception = original_exception
35
- super(message || "#{owner}##{method_name} violated #{contract_type}: #{description || expected}")
36
- end
37
- end
38
- %i[Parameter Precondition Postcondition Return Invariant Mutation UnexpectedException Inheritance].each do |name|
39
- const_set("#{name}Violation", Class.new(Violation))
40
- end
41
- class SnapshotError < Error
42
- attr_reader :strategy, :field, :receiver_class, :original_exception
43
-
44
- def initialize(message = nil, strategy: nil, field: nil, receiver_class: nil, original_exception: nil)
45
- @strategy = strategy
46
- @field = field
47
- @receiver_class = receiver_class
48
- @original_exception = original_exception
49
- super(message)
50
- end
51
- end
52
-
53
- class StateObservationError < SnapshotError; end
54
-
55
- class CompositeViolation < Violation
56
- attr_reader :violations
57
-
58
- def initialize(violations:, original_exception: nil)
59
- @violations = violations.freeze
60
- super("multiple contract violations: #{violations.map(&:message).join('; ')}", original_exception: original_exception)
61
- end
62
-
63
- def primary_violation = violations.first
64
- end
65
-
66
- class Configuration
67
- attr_accessor :enabled, :failure_mode, :sample_rate, :logger, :include_values_in_errors, :capture_source_locations,
68
- :undeclared_exceptions, :invariant_checking, :inheritance_mode, :snapshot_strategy, :sampler, :redacted_parameters, :redactor, :check_invariant_after_exception, :check_invariants_after_initialize, :snapshot_provider, :allow_private_state_readers, :unsupported_deep_copy, :state_equality, :verify_state_after_exception, :allow_invariant_suppression
69
-
70
- def initialize
71
- @enabled = true
72
- @failure_mode = :raise
73
- @sample_rate = 1.0
74
- @logger = nil
75
- @include_values_in_errors = false
76
- @capture_source_locations = true
77
- @undeclared_exceptions = :ignore
78
- @invariant_checking = :contracted_methods
79
- @inheritance_mode = :merge
80
-
81
- @snapshot_strategy = :declared
82
- @sampler = nil
83
- @redacted_parameters = SENSITIVE_NAMES.dup
84
- @redactor = nil
85
- @check_invariant_after_exception = false
86
- @check_invariants_after_initialize = true
87
-
88
- @snapshot_provider = nil
89
- @allow_private_state_readers = true
90
- @unsupported_deep_copy = :reference
91
- @state_equality = :eql
92
- @verify_state_after_exception = false
93
- @allow_invariant_suppression = false
94
- end
95
-
96
- def profile(_name, &) = Profile.new(self).instance_eval(&)
97
-
98
- class Profile
99
- def initialize(config) = @config = config
100
- def method_missing(name, value = nil) = @config.public_send("#{name}=", value)
101
-
102
- def respond_to_missing?(name, include_private = false)
103
- @config.respond_to?("#{name}=", include_private) || super
104
- end
105
- end
106
- end
107
-
108
- class Context
109
- attr_accessor :result, :exception, :finished_at, :before
110
- attr_reader :receiver, :owner, :method_name, :arguments, :keyword_arguments, :block_given, :started_at,
111
- :source_location, :contract, :parent, :depth, :trace_id
112
-
113
- def initialize(receiver:, contract:, arguments:, keyword_arguments:, block_given:, parent: nil)
114
- @receiver = receiver
115
- @contract = contract
116
- @owner = contract.owner
117
- @method_name = contract.method_name
118
- @arguments = arguments.freeze
119
- @keyword_arguments = keyword_arguments.freeze
120
- @block_given = block_given
121
- @started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
122
- @source_location = contract.source_location
123
- @parent = parent
124
- @depth = parent ? parent.depth + 1 : 0
125
- @trace_id = parent ? parent.trace_id : "c#{object_id.to_s(36)}"
126
- end
127
-
128
- def duration = @finished_at && (@finished_at - @started_at)
129
- end
130
-
131
- class Snapshot
132
- attr_reader :metadata
133
-
134
- def initialize(values, metadata: {})
135
- @values = values.transform_keys(&:to_sym).freeze
136
- @metadata = metadata.freeze
137
- freeze
138
- end
139
-
140
- def [](key) = @values[key.to_sym]
7
+
8
+ # Runtime behavioral contracts. Include in classes or extend for singleton contracts.
9
+ module Contracts
10
+ SENSITIVE_NAMES = [/password/i, /token/i, /secret/i, /authorization/i, /api_key/i, /access_key/i, /credit_card/i,
11
+ /ssn/i].freeze
12
+
13
+ class Error < StandardError
14
+ def to_h = { error: self.class.name, message: message }
15
+ end
16
+
17
+ class DefinitionError < Error; end
18
+
19
+ class Violation < Error
20
+ attr_reader :owner, :method_name, :contract_type, :description, :expected, :actual, :parameter, :context,
21
+ :source_location, :original_exception
22
+
23
+ def initialize(message = nil, owner: nil, method_name: nil, contract_type: nil, description: nil, expected: nil,
24
+ actual: nil, parameter: nil, context: nil, source_location: nil, original_exception: nil)
25
+ @owner = owner
26
+ @method_name = method_name
27
+ @contract_type = contract_type
28
+ @description = description
29
+ @expected = expected
30
+ @actual = actual
31
+ @parameter = parameter
32
+ @context = context
33
+ @source_location = source_location
34
+ @original_exception = original_exception
35
+ super(message || "#{owner}##{method_name} violated #{contract_type}: #{description || expected}")
36
+ end
37
+ end
38
+ %i[Parameter Precondition Postcondition Return Invariant Mutation UnexpectedException Inheritance].each do |name|
39
+ const_set("#{name}Violation", Class.new(Violation))
40
+ end
41
+ class SnapshotError < Error
42
+ attr_reader :strategy, :field, :receiver_class, :original_exception
43
+
44
+ def initialize(message = nil, strategy: nil, field: nil, receiver_class: nil, original_exception: nil)
45
+ @strategy = strategy
46
+ @field = field
47
+ @receiver_class = receiver_class
48
+ @original_exception = original_exception
49
+ super(message)
50
+ end
51
+ end
52
+
53
+ class StateObservationError < SnapshotError; end
54
+
55
+ class CompositeViolation < Violation
56
+ attr_reader :violations
57
+
58
+ def initialize(violations:, original_exception: nil)
59
+ @violations = violations.freeze
60
+ super("multiple contract violations: #{violations.map(&:message).join('; ')}", original_exception: original_exception)
61
+ end
62
+
63
+ def primary_violation = violations.first
64
+ end
65
+
66
+ class Configuration
67
+ attr_accessor :enabled, :failure_mode, :sample_rate, :logger, :include_values_in_errors, :capture_source_locations,
68
+ :undeclared_exceptions, :invariant_checking, :inheritance_mode, :snapshot_strategy, :sampler, :redacted_parameters, :redactor, :check_invariant_after_exception, :check_invariants_after_initialize, :snapshot_provider, :allow_private_state_readers, :unsupported_deep_copy, :state_equality, :verify_state_after_exception, :allow_invariant_suppression
69
+
70
+ def initialize
71
+ @enabled = true
72
+ @failure_mode = :raise
73
+ @sample_rate = 1.0
74
+ @logger = nil
75
+ @include_values_in_errors = false
76
+ @capture_source_locations = true
77
+ @undeclared_exceptions = :ignore
78
+ @invariant_checking = :contracted_methods
79
+ @inheritance_mode = :merge
80
+
81
+ @snapshot_strategy = :declared
82
+ @sampler = nil
83
+ @redacted_parameters = SENSITIVE_NAMES.dup
84
+ @redactor = nil
85
+ @check_invariant_after_exception = false
86
+ @check_invariants_after_initialize = true
87
+
88
+ @snapshot_provider = nil
89
+ @allow_private_state_readers = true
90
+ @unsupported_deep_copy = :reference
91
+ @state_equality = :eql
92
+ @verify_state_after_exception = false
93
+ @allow_invariant_suppression = false
94
+ end
95
+
96
+ def profile(_name, &) = Profile.new(self).instance_eval(&)
97
+
98
+ class Profile
99
+ def initialize(config) = @config = config
100
+ def method_missing(name, value = nil) = @config.public_send("#{name}=", value)
101
+
102
+ def respond_to_missing?(name, include_private = false)
103
+ @config.respond_to?("#{name}=", include_private) || super
104
+ end
105
+ end
106
+ end
107
+
108
+ class Context
109
+ attr_accessor :result, :exception, :finished_at, :before
110
+ attr_reader :receiver, :owner, :method_name, :arguments, :keyword_arguments, :block_given, :started_at,
111
+ :source_location, :contract, :parent, :depth, :trace_id
112
+
113
+ def initialize(receiver:, contract:, arguments:, keyword_arguments:, block_given:, parent: nil)
114
+ @receiver = receiver
115
+ @contract = contract
116
+ @owner = contract.owner
117
+ @method_name = contract.method_name
118
+ @arguments = arguments.freeze
119
+ @keyword_arguments = keyword_arguments.freeze
120
+ @block_given = block_given
121
+ @started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
122
+ @source_location = contract.source_location
123
+ @parent = parent
124
+ @depth = parent ? parent.depth + 1 : 0
125
+ @trace_id = parent ? parent.trace_id : "c#{object_id.to_s(36)}"
126
+ end
127
+
128
+ def duration = @finished_at && (@finished_at - @started_at)
129
+ end
130
+
131
+ class Snapshot
132
+ attr_reader :metadata
133
+
134
+ def initialize(values, metadata: {})
135
+ @values = values.transform_keys(&:to_sym).freeze
136
+ @metadata = metadata.freeze
137
+ freeze
138
+ end
139
+
140
+ def [](key) = @values[key.to_sym]
141
141
  # Explicit splat parameters keep this compatible with Ruby 3.1.
142
142
  def fetch(key, *arguments) = @values.fetch(key.to_sym, *arguments)
143
- def to_h = @values.dup
144
- def key?(key) = @values.key?(key.to_sym)
145
- def keys = @values.keys
146
- def values = @values.values
143
+ def to_h = @values.dup
144
+ def key?(key) = @values.key?(key.to_sym)
145
+ def keys = @values.keys
146
+ def values = @values.values
147
147
  def dig(*arguments) = @values.dig(*arguments)
148
148
  def method_missing(name, *arguments) = @values.fetch(name) { super }
149
- def respond_to_missing?(name, include_private = false) = @values.key?(name) || super
150
- end
151
-
152
- class ExecutionGuard
153
- def self.stack
154
- stores = Thread.current[:contracts_execution_guard] ||= {}
155
- stores[Fiber.current] ||= []
156
- end
157
-
158
- def self.active?(key) = stack.include?(key)
159
- def self.depth = stack.length
160
- def self.current_stack = stack.dup.freeze
161
-
162
- def self.enter(key)
163
- return yield(false) if active?(key)
164
-
165
- stack << key
166
- yield(true)
167
- ensure
168
- stack.pop if stack.last == key
169
- end
170
- end
171
-
172
- Observation = Struct.new(:name, :reader, :deep, :compare_with, keyword_init: true) do
173
- def to_h = { name: name, deep: deep, comparator: compare_with }
174
- end
175
- Invariant = Struct.new(:id, :owner, :description, :predicate, :source_location, :options, :inherited_from,
176
- keyword_init: true)
177
- class MutationReport
178
- attr_reader :changed_fields, :unchanged_fields, :permitted_changes, :unexpected_changes, :missing_required_changes,
179
- :before_values, :after_values
180
-
181
- def initialize(before:, after:, permitted:, required:, observations:)
182
- @before_values = before.to_h.freeze
183
- @after_values = after.to_h.freeze
184
- @permitted_changes = permitted.freeze
185
- fields = @before_values.keys | @after_values.keys
186
- @changed_fields = fields.reject do |field|
187
- Contracts.equal_state?(@before_values[field], @after_values[field], observations[field]&.compare_with)
188
- end.freeze
189
- @unchanged_fields = (fields - @changed_fields).freeze
190
- @unexpected_changes = (@changed_fields - permitted).freeze
191
- @missing_required_changes = (required - @changed_fields).freeze
192
- end
193
-
194
- def passed? = unexpected_changes.empty? && missing_required_changes.empty?
195
-
196
- def to_h
197
- { passed: passed?, changed_fields: changed_fields, unchanged_fields: unchanged_fields,
198
- permitted_changes: permitted_changes, unexpected_changes: unexpected_changes, missing_required_changes: missing_required_changes, before_values: before_values, after_values: after_values }
199
- end
200
- end
201
-
202
- module Constraints
203
- class Base
204
- def to_h = { type: self.class.name.split("::").last.downcase, description: description }
205
- end
206
-
207
- class Type < Base
208
- def initialize(type) = @type = type
209
- def matches?(value) = value.is_a?(@type)
210
- def description = @type.is_a?(Module) ? @type.name : @type.to_s
211
- end
212
-
213
- class Union < Base
214
- def initialize(*items) = @items = items.map { |item| Constraints.coerce(item) }
215
- def matches?(value) = @items.any? { |item| item.matches?(value) }
216
- def description = @items.map(&:description).join(" or ")
217
- end
218
-
219
- class Nilable < Union
220
- def initialize(item) = super(NilClass, item)
221
- end
222
-
223
- class Predicate < Base
224
- def initialize(description, &block)
225
- (@description = description
226
- @block = block)
227
- end
228
-
229
- def matches?(value) = @block.call(value)
230
- attr_reader :description
231
- end
232
-
233
- class Regex < Base
234
- def initialize(regex) = @regex = regex
235
- def matches?(value) = value.is_a?(String) && @regex.match?(value)
236
- def description = "matching #{@regex.inspect}"
237
- end
238
-
239
- class Range < Base
240
- def initialize(range) = @range = range
241
- def matches?(value) = @range.cover?(value)
242
- def description = "in #{@range.inspect}"
243
- end
244
-
245
- class OneOf < Base
246
- def initialize(*values) = @values = values.freeze
247
- def matches?(value) = @values.include?(value)
248
- def description = "one of #{@values.inspect}"
249
- end
250
-
251
- class ArrayOf < Base
252
- def initialize(item) = @item = Constraints.coerce(item)
253
- def matches?(value) = value.is_a?(Array) && value.all? { |v| @item.matches?(v) }
254
- def description = "Array<#{@item.description}>"
255
- end
256
-
257
- class HashOf < Base
258
- def initialize(key, value)
259
- (@key = Constraints.coerce(key)
260
- @value = Constraints.coerce(value))
261
- end
262
-
263
- def matches?(value) = value.is_a?(Hash) && value.all? { |k, v| @key.matches?(k) && @value.matches?(v) }
264
- def description = "Hash<#{@key.description}, #{@value.description}>"
265
- end
266
-
267
- class RespondTo < Base
268
- def initialize(*methods) = @methods = methods
269
- def matches?(value) = @methods.all? { |method| value.respond_to?(method) }
270
- def description = "responding to #{@methods.join(', ')}"
271
- end
272
-
273
- class DuckType < RespondTo; end
274
-
275
- class Anything < Base
276
- def matches?(_) = true
277
- def description = "anything"
278
- end
279
-
280
- class Nothing < Base
281
- def matches?(_) = false
282
- def description = "nothing"
283
- end
284
-
285
- module_function
286
-
287
- def coerce(value) = value.respond_to?(:matches?) && value.respond_to?(:description) ? value : Type.new(value)
288
- end
289
-
290
- Condition = Struct.new(:description, :block, keyword_init: true)
291
- ExceptionRule = Struct.new(:type, :condition, :handler, keyword_init: true)
292
- class Contract
293
- attr_accessor :method_source_location
294
- attr_reader :id, :owner, :method_name, :method_type, :parameters, :positionals, :preconditions, :postconditions,
295
- :return_constraint, :invariants, :allowed_exceptions, :mutation_policy, :observed, :snapshot_block, :source_location, :options, :examples, :required_changes, :unchanged_on_raise_types
296
-
297
- def initialize(owner:, method_name:, source_location:, method_type: :instance, options: {})
298
- @id = "#{owner.name || owner.object_id}:#{method_type}:#{method_name}".freeze
299
-
300
- @owner = owner
301
- @method_name = method_name.to_sym
302
- @method_type = method_type
303
- @source_location = source_location
304
- @options = options.freeze
305
- @parameters = {}
306
-
307
- @positionals = []
308
- @preconditions = []
309
- @postconditions = []
310
- @allowed_exceptions = []
311
- @invariants = []
312
- @observed = []
313
- @examples = []
314
- @required_changes = []
315
- @unchanged_on_raise_types = []
316
- @mutation_policy = :unspecified
317
- end
318
-
319
- def parameters=(value)
320
- @parameters = value.transform_keys(&:to_sym).transform_values { |v| Constraints.coerce(v) }.freeze
321
- end
322
-
323
- def positionals=(value)
324
- @positionals = value.map { |v| Constraints.coerce(v) }.freeze
325
- end
326
-
327
- def return_constraint=(value)
328
- @return_constraint = value && Constraints.coerce(value)
329
- end
330
-
331
- def to_h
332
- { id: id, owner: owner.name, method_name: method_name, method_type: method_type, parameters: parameters.transform_values(&:description), positional: positionals.map(&:description), preconditions: preconditions.map(&:description), postconditions: postconditions.map(&:description), return_constraint: return_constraint&.description, invariants: Contracts.invariants_for(owner).map(&:description), allowed_exceptions: allowed_exceptions.map do |r|
333
- r.type.name
334
- end, mutation_policy: mutation_policy, observed: observed.map(&:to_h), permitted_changes: permitted_changes, required_changes: required_changes, source_location: source_location, method_source_location: method_source_location, options: options }
335
- end
336
-
337
- def to_json(*) = JSON.generate(to_h)
338
- def observed_fields = observed.map(&:name).freeze
339
- def permitted_changes = mutation_policy == :pure ? [] : (@permitted_changes || []).freeze
340
- def pure? = mutation_policy == :pure
341
- def all_invariants = Contracts.invariants_for(owner)
342
- def own_invariants = Contracts.invariants_for(owner).select { |invariant| invariant.owner == owner }
343
- def inherited_invariants = all_invariants - own_invariants
344
-
345
- def permitted_changes=(values)
346
- @permitted_changes = values.map(&:to_sym).freeze
347
- end
348
- end
349
-
350
- class ContractBuilder
351
- def initialize(contract) = @contract = contract
352
- def params(**items) = @contract.parameters = items
353
- def positional(*items) = @contract.positionals = items
354
- def requires(description = "precondition", &block) = add(@contract.preconditions, description, block)
355
- def ensures(description = "postcondition", &block) = add(@contract.postconditions, description, block)
356
- def returns(constraint) = @contract.return_constraint = constraint
357
- def returns!(constraint) = @contract.return_constraint = Constraints::Predicate.new("non-nil #{Constraints.coerce(constraint).description}") { |v| !v.nil? && Constraints.coerce(constraint).matches?(v) }
358
-
359
- def raises(*types, &block)
360
- types.each do |type|
361
- @contract.allowed_exceptions << ExceptionRule.new(type: type, condition: block)
362
- end
363
- end
364
-
365
- def on_raise(type, &block) = @contract.allowed_exceptions << ExceptionRule.new(type: type, handler: block)
366
-
367
- def changes(*attributes)
368
- validate_mutation_mode!(:changes)
369
- observe(*attributes.reject do |attribute|
370
- @contract.observed.any? do |item|
371
- item.name == attribute.to_sym
372
- end
373
- end)
374
- @contract.instance_variable_set(:@mutation_policy, :changes)
375
- @contract.permitted_changes = attributes
376
- end
377
-
378
- def observe(*attributes, deep: false, compare_with: nil, &reader)
379
- attributes.each do |attribute|
380
- existing = @contract.observed.find { |item| item.name == attribute.to_sym }
381
- raise DefinitionError, "duplicate observation for #{attribute}" if existing
382
-
383
- @contract.observed << Observation.new(name: attribute.to_sym, reader: reader, deep: deep,
384
- compare_with: compare_with)
385
- end
386
- end
387
-
388
- def must_change(*attributes, from: nil, to: nil)
389
- # Retain range constraints for the public DSL; enforcement is intentionally deferred.
390
- validate_mutation_mode!(:must_change)
391
- @contract.instance_variable_set(:@required_change_bounds, { attributes: attributes.map(&:to_sym), from: from, to: to }.freeze)
392
- observe(*attributes.reject do |attribute|
393
- @contract.observed.any? do |item|
394
- item.name == attribute.to_sym
395
- end
396
- end)
397
- @contract.required_changes.concat(attributes.map(&:to_sym)).uniq!
398
- end
399
-
400
- def pure(scope: :receiver)
401
- raise DefinitionError, "unsupported purity scope #{scope.inspect}" unless %i[receiver observed].include?(scope)
402
-
403
- validate_mutation_mode!(:pure)
404
- @contract.instance_variable_set(:@mutation_policy, :pure)
405
- end
406
- alias changes_nothing pure
407
- def snapshot(&block) = @contract.instance_variable_set(:@snapshot_block, block)
408
- def unchanged_on_raise(*types) = @contract.unchanged_on_raise_types.concat(types.empty? ? [StandardError] : types).uniq!
409
- def example(**value) = @contract.examples << value.freeze
410
-
411
- private
412
-
413
- def add(collection, description, block)
414
- raise DefinitionError, "a contract condition needs a block" unless block
415
-
416
- collection << Condition.new(description: description, block: block)
417
- end
418
-
419
- def validate_mutation_mode!(mode)
420
- current = @contract.mutation_policy
421
- return unless current != :unspecified && current != mode && !(current == :changes && mode == :must_change)
422
-
423
- raise DefinitionError,
424
- "#{mode} conflicts with #{current}"
425
- end
426
- end
427
-
428
- class Registry
429
- def initialize
430
- (@lock = Monitor.new
431
- @contracts = {})
432
- end
433
-
434
- def register(contract)
435
- @lock.synchronize do
436
- @contracts[[contract.owner, contract.method_type, contract.method_name]] = contract
437
- end
438
- end
439
-
440
- def find(owner, method_name, method_type: :instance)
441
- @lock.synchronize do
442
- @contracts[[owner, method_type, method_name.to_sym]] || inherited(owner, method_name, method_type)
443
- end
444
- end
445
-
446
- def for_class(owner) = @lock.synchronize { @contracts.values.select { |c| c.owner == owner }.dup.freeze }
447
- def all = @lock.synchronize { @contracts.values.dup.freeze }
448
- private
449
-
450
- def inherited(owner, method_name, type)
451
- return nil if Contracts.configuration.inheritance_mode == :independent
452
-
453
- owner.ancestors.drop(1).filter_map { |ancestor| @contracts[[ancestor, type, method_name.to_sym]] }.first
454
- end
455
- end
456
-
457
- class << self
458
- def configuration = @configuration ||= Configuration.new
459
- def configure = yield(configuration)
460
- def registry = @registry ||= Registry.new
461
-
462
- def contract_for(owner, method_name,
463
- method_type: :instance)
464
- registry.find(owner, method_name, method_type: method_type)
465
- end
466
-
467
- def invariants_for(owner)
468
- owner.ancestors.flat_map do |ancestor|
469
- registry.for_class(ancestor).flat_map(&:invariants)
470
- end.freeze
471
- end
472
-
473
- def check_invariants(object)
474
- invariants_for(object.class).map do |invariant|
475
- { passed: !!object.instance_exec(&invariant.predicate), type: :invariant, description: invariant.description,
476
- invariant_id: invariant.id }.freeze
477
- end.freeze
478
- rescue StandardError => e
479
- [{ passed: false, type: :invariant, description: e.message, error: e }.freeze].freeze
480
- end
481
-
482
- def check_invariants!(object)
483
- failed = check_invariants(object).find { |result| !result[:passed] }
484
- if failed
485
- raise InvariantViolation.new(owner: object.class, method_name: :__invariant__, contract_type: :invariant,
486
- description: failed[:description])
487
- end
488
-
489
- true
490
- end
491
-
492
- def register_comparator(name, &block) = (comparators[name.to_sym] = block)
493
- def comparators = (@comparators ||= {})
494
-
495
- def equal_state?(before, after, comparator = nil)
496
- comparator = comparators[comparator] if comparator.is_a?(Symbol)
497
- return comparator.call(before, after) if comparator.respond_to?(:call)
498
-
499
- if configuration.state_equality == :identity
500
- before.equal?(after)
501
- else
502
- configuration.state_equality == :equal ? before == after : before.eql?(after)
503
- end
504
- end
505
-
506
- def describe(owner, method_name = nil)
507
- contracts = method_name ? [contract_for(owner, method_name)].compact : registry.for_class(owner)
508
- contracts.map(&:to_h)
509
- end
510
-
511
- def any(*items) = Constraints::Union.new(*items)
512
- def nilable(item) = Constraints::Nilable.new(item)
513
- def matching(regex) = Constraints::Regex.new(regex)
514
- def range(value) = Constraints::Range.new(value)
515
- def one_of(*values) = Constraints::OneOf.new(*values)
516
- def array_of(item) = Constraints::ArrayOf.new(item)
517
- def hash_of(key, value) = Constraints::HashOf.new(key, value)
518
- def predicate(description, &) = Constraints::Predicate.new(description, &)
519
- def respond_to(*methods) = Constraints::RespondTo.new(*methods)
520
- def duck_type(*methods) = Constraints::DuckType.new(*methods)
521
- def anything = Constraints::Anything.new
522
- def nothing = Constraints::Nothing.new
523
-
524
- def invoke(receiver, contract, args, kwargs, block)
525
- return yield unless active?(contract, receiver, args, kwargs)
526
-
527
- parent = Thread.current[:contracts_context]
528
-
529
- context = Context.new(receiver: receiver, contract: contract, arguments: args, keyword_arguments: kwargs,
530
- block_given: !block.nil?, parent: parent)
531
- Thread.current[:contracts_context] = context
532
- validate_parameters(contract, context)
533
-
534
- check_contract_invariants(receiver, contract, context, :before)
535
- context.before = capture(receiver, contract)
536
- check_conditions(contract.preconditions, context, :precondition)
537
- begin
538
- context.result = yield
539
- rescue Exception => e # rubocop:disable Lint/RescueException
540
- context.exception = e
541
- if configuration.verify_state_after_exception || !contract.unchanged_on_raise_types.empty?
542
- after = capture(receiver, contract)
543
- report = MutationReport.new(before: context.before, after: after, permitted: [], required: [], observations: contract.observed.to_h do |o|
544
- [o.name, o]
545
- end)
546
- if !contract.unchanged_on_raise_types.empty? && contract.unchanged_on_raise_types.any? do |type|
547
- e.is_a?(type)
548
- end && !report.changed_fields.empty?
549
- fail!(MutationViolation, context,
550
- description: "state changed after exception: #{report.changed_fields.join(', ')}", actual: report.to_h, original_exception: e)
551
- end
552
- check_contract_invariants(receiver, contract, context, :after_exception)
553
- end
554
- handle_exception(contract, context)
555
-
556
- check_contract_invariants(receiver, contract, context, :after) if configuration.check_invariant_after_exception
557
- raise
558
- else
559
- validate_return(contract, context)
560
-
561
- check_conditions(contract.postconditions, context, :postcondition)
562
- validate_mutation(contract, context)
563
- check_contract_invariants(receiver, contract, context, :after)
564
- context.result
565
- ensure
566
- context.finished_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
567
-
568
- Thread.current[:contracts_context] = parent
569
- end
570
- end
571
-
572
- def active?(contract, receiver, args, kwargs)
573
- return false unless configuration.enabled
574
- if configuration.sampler
575
- return configuration.sampler.call(Context.new(receiver: receiver, contract: contract, arguments: args,
576
- keyword_arguments: kwargs, block_given: false))
577
- end
578
-
579
- rate = contract.options.fetch(:sample_rate, configuration.sample_rate)
580
- rate >= 1 || (rate.positive? && rand < rate)
581
- end
582
-
583
- def fail!(klass, context, description:, expected: nil, actual: nil, parameter: nil, original_exception: nil)
584
- error = klass.new(owner: context.owner, method_name: context.method_name,
585
- contract_type: klass.name.split("::").last.sub("Violation", "").downcase, description: description, expected: expected, actual: actual, parameter: parameter, context: context, source_location: context.source_location, original_exception: original_exception)
586
- instrument_violation(error)
587
- case configuration.failure_mode
588
- when :raise then raise error
589
- when :warn then warn error.message
590
- when :log then configuration.logger&.error(error.message)
591
- when :collect then (Thread.current[:contracts_violations] ||= []) << error
592
- else raise DefinitionError, "unknown failure_mode #{configuration.failure_mode.inspect}"
593
- end
594
- error
595
- end
596
-
597
- def instrument_violation(error)
598
- return unless defined?(ActiveSupport::Notifications)
599
-
600
- ActiveSupport::Notifications.instrument(
601
- "contracts.violation",
602
- owner: error.owner,
603
- method_name: error.method_name,
604
- contract_type: error.contract_type,
605
- description: error.description,
606
- duration: error.context&.duration,
607
- source_location: error.source_location
608
- )
609
- end
610
-
611
- private
612
-
613
- def validate_parameters(contract, context)
614
- contract.positionals.each_with_index do |constraint, index|
615
- validate_constraint(constraint, context.arguments[index], context, "argument #{index}", index)
616
- end
617
- contract.parameters.each do |name, constraint|
618
- validate_constraint(constraint, context.keyword_arguments[name], context, name, name)
619
- end
620
- end
621
-
622
- def validate_constraint(constraint, value, context, label, parameter)
623
- return if constraint.matches?(value)
624
-
625
- actual = configuration.include_values_in_errors ? redact(parameter, value) : value.class.name
626
- fail!(ParameterViolation, context, description: "#{label} does not satisfy #{constraint.description}",
627
- expected: constraint.description, actual: actual, parameter: parameter)
628
- end
629
-
630
- def validate_return(contract, context)
631
- return unless contract.return_constraint && !contract.return_constraint.matches?(context.result)
632
-
633
- fail!(ReturnViolation, context,
634
- description: "return value does not satisfy #{contract.return_constraint.description}", expected: contract.return_constraint.description, actual: context.result.class.name)
635
- end
636
-
637
- def check_conditions(conditions, context, kind)
638
- conditions.each do |condition|
639
- result = call_condition(condition.block, context)
640
- unless result
641
- fail!(kind == :precondition ? PreconditionViolation : PostconditionViolation, context,
642
- description: condition.description)
643
- end
644
- end
645
- end
646
-
647
- def call_condition(block, context)
648
- params = block.parameters
649
- return context.receiver.instance_exec(context: context, &block) if params.any? { |(_, name)| name == :context }
650
-
651
- accepted_keys = params.filter_map { |kind, name| name if %i[key keyreq keyrest].include?(kind) }
652
- accepts_all_keys = params.any? { |kind, _| kind == :keyrest }
653
- available = context.keyword_arguments.merge(before: context.before)
654
- kwargs = accepts_all_keys ? available : available.slice(*accepted_keys)
655
- if params.empty? then context.receiver.instance_exec(&block)
656
- elsif params.first&.last == :result then context.receiver.instance_exec(context.result, **kwargs, &block)
657
- elsif context.result && params.any? do |(_, name)|
658
- name == :before
659
- end then context.receiver.instance_exec(context.result, **kwargs, &block)
660
- elsif context.result && params.length == 1 && params.first.first != :keyreq then context.receiver.instance_exec(
661
- context.result, &block
662
- )
663
- else context.receiver.instance_exec(*context.arguments, **kwargs, &block)
664
- end
665
- end
666
-
667
- def capture(receiver, contract)
668
- strategy = configuration.snapshot_strategy
669
- return Snapshot.new({}, metadata: snapshot_metadata(receiver, contract, strategy, [])) if strategy == :none
670
-
671
- data = if contract.snapshot_block then receiver.instance_exec(&contract.snapshot_block)
672
- elsif configuration.snapshot_provider then configuration.snapshot_provider.call(receiver, contract, nil)
673
- else
674
- observations = contract.observed
675
- if observations.empty? && strategy == :instance_variables
676
- observations = receiver.instance_variables.map do |value|
677
- Observation.new(name: value.to_s.delete_prefix("@").to_sym,
678
- deep: false)
679
- end
680
- end
681
- observations.to_h { |observation| [observation.name, read_observation(receiver, observation)] }
682
- end
683
- unless data.respond_to?(:to_h)
684
- raise SnapshotError.new("snapshot provider must return a Hash", strategy: strategy,
685
- receiver_class: receiver.class)
686
- end
687
-
688
- values = data.to_h.transform_keys(&:to_sym)
689
- contract.observed.each do |observation|
690
- if (observation.deep || contract.pure?) && values.key?(observation.name)
691
- values[observation.name] =
692
- deep_copy(values[observation.name])
693
- end
694
- end
695
- Snapshot.new(values, metadata: snapshot_metadata(receiver, contract, strategy, contract.observed))
696
- rescue SnapshotError
697
- raise
698
- rescue StandardError => e
699
- raise SnapshotError.new("could not capture snapshot: #{e.message}", strategy: strategy,
700
- receiver_class: receiver.class, original_exception: e)
701
- end
702
-
703
- def copy(value)
704
- immutable = value.nil? || value.is_a?(Numeric) || value.is_a?(Symbol) || value == true || value == false || value.frozen?
705
- immutable ? value : value.dup.freeze
706
- rescue TypeError
707
- value
708
- end
709
-
710
- def deep_copy(value)
711
- case value
712
- when Hash then value.transform_values { |item| deep_copy(item) }.freeze
713
- when Array then value.map { |item| deep_copy(item) }.freeze
714
- when Set then value.to_set { |item| deep_copy(item) }.freeze
715
- when String then value.dup.freeze
716
- when Numeric, Symbol, TrueClass, FalseClass, NilClass then value
717
- else
718
- return value if configuration.unsupported_deep_copy == :reference
719
- if configuration.unsupported_deep_copy == :error
720
- raise SnapshotError,
721
- "unsupported deep snapshot value #{value.class}"
722
- end
723
-
724
- copy(value)
725
- end
726
- end
727
-
728
- def read_observation(receiver, observation)
729
- return receiver.instance_exec(receiver, &observation.reader) if observation.reader
730
- return receiver.public_send(observation.name) if receiver.respond_to?(observation.name)
731
- return receiver.send(observation.name) if configuration.allow_private_state_readers && receiver.respond_to?(
732
- observation.name, true
733
- )
734
-
735
- variable = "@#{observation.name}"
736
- return receiver.instance_variable_get(variable) if receiver.instance_variable_defined?(variable)
737
-
738
- raise StateObservationError.new("cannot observe #{observation.name}", field: observation.name,
739
- receiver_class: receiver.class)
740
- end
741
-
742
- def snapshot_metadata(receiver, contract, strategy, observations)
743
- { receiver_class: receiver.class.name, receiver_object_id: receiver.object_id, captured_at: Time.now,
744
- strategy: strategy, observed_fields: observations.map(&:name).freeze, deep_fields: observations.select(&:deep).map(&:name).freeze, contract_id: contract.id }
745
- end
746
-
747
- def validate_mutation(contract, context)
748
- return if contract.mutation_policy == :unspecified
749
-
750
- after = capture(context.receiver, contract)
751
- permitted = contract.mutation_policy == :pure ? [] : contract.permitted_changes
752
- report = MutationReport.new(before: context.before, after: after, permitted: permitted, required: contract.required_changes, observations: contract.observed.to_h do |item|
753
- [item.name, item]
754
- end)
755
- return if report.passed?
756
-
757
- fail!(MutationViolation, context,
758
- description: "unexpected changes: #{report.unexpected_changes.join(', ')}; missing changes: #{report.missing_required_changes.join(', ')}", expected: permitted, actual: report.to_h)
759
- end
760
-
761
- def check_contract_invariants(receiver, _contract, context, _phase)
762
- return unless configuration.invariant_checking == :contracted_methods
763
-
764
- invariants = invariants_for(receiver.class)
765
- return if invariants.empty?
766
-
767
- key = [receiver.object_id, :invariant]
768
- ExecutionGuard.enter(key) do |entered|
769
- if entered
770
- check_conditions(invariants.map do |item|
771
- Condition.new(description: item.description, block: item.predicate)
772
- end, context, :invariant)
773
- end
774
- end
775
- end
776
-
777
- def handle_exception(contract, context)
778
- matches = contract.allowed_exceptions.select do |rule|
779
- context.exception.is_a?(rule.type) && (!rule.condition || call_condition(rule.condition, context))
780
- end
781
- matches.each do |rule|
782
- next unless rule.handler
783
-
784
- passed = call_exception_condition(rule.handler, context)
785
- unless passed
786
- fail!(PostconditionViolation, context, description: "exception postcondition for #{rule.type} failed",
787
- original_exception: context.exception)
788
- end
789
- end
790
- return unless matches.empty?
791
-
792
- case configuration.undeclared_exceptions
793
- when :violate then fail!(UnexpectedExceptionViolation, context,
794
- description: "undeclared exception #{context.exception.class}", original_exception: context.exception)
795
- when :warn then configuration.logger ? configuration.logger.warn("undeclared exception #{context.exception.class} in #{context.owner}##{context.method_name}") : warn("undeclared exception #{context.exception.class} in #{context.owner}##{context.method_name}")
796
- end
797
- end
798
-
799
- def call_exception_condition(block, context)
800
- params = block.parameters
801
- return context.receiver.instance_exec(context: context, &block) if params.any? { |_, name| name == :context }
802
-
803
- kwargs = context.keyword_arguments.merge(before: context.before)
804
- accepted = params.filter_map { |kind, name| name if %i[key keyreq keyrest].include?(kind) }
805
- kwargs = kwargs.slice(*accepted) unless params.any? { |kind, _| kind == :keyrest }
806
- context.receiver.instance_exec(context.exception, **kwargs, &block)
807
- end
808
-
809
- def redact(name, value)
810
- return configuration.redactor.call(name, value) if configuration.redactor
811
- return "[REDACTED]" if configuration.redacted_parameters.any? { |pattern| pattern.match?(name.to_s) }
812
-
813
- value.inspect
814
- end
815
- end
816
-
817
- def self.included(base)
818
- base.extend(ClassMethods)
819
- base.include(InstanceMethods)
820
- end
821
-
822
- def self.extended(base) = base.extend(SingletonClassMethods)
823
-
824
- module ClassMethods
825
- def contract(name = nil, **options, &block)
826
- if name.nil? then @__contracts_pending = [options, block, caller_locations(1, 1).first]
827
- return
828
- end
829
- declare_contract(name, :instance, options, &block)
830
- end
831
-
832
- def invariant(description = "invariant", &block)
833
- location = caller_locations(1, 1).first
834
- contract = Contracts.registry.find(self,
835
- :__invariant__) || Contract.new(owner: self, method_name: :__invariant__,
836
- source_location: location)
837
- contract.invariants << Invariant.new(id: "#{name || object_id}:#{caller_locations(1, 1).first.lineno}",
838
- owner: self, description: description, predicate: block, source_location: caller_locations(1, 1).first, options: {})
839
-
840
- Contracts.registry.register(contract)
841
- @__contracts_has_invariants = true
842
- wrap_initialize_for_invariants if method_defined?(:initialize,
843
- false) || private_method_defined?(:initialize, false)
844
- end
845
-
846
- def snapshot(&block) = (@__contracts_snapshot = block)
847
-
848
- def method_added(name)
849
- return if @__contracts_hook
850
-
851
- if (pending = @__contracts_pending)
852
- @__contracts_pending = nil
853
-
854
- declare_contract(name, :instance, pending[0], source_location: pending[2], &pending[1])
855
- elsif (contract = Contracts.registry.find(self, name, method_type: :instance)) && contract.owner == self
856
- wrap_contract(name, contract)
857
- elsif (contract = Contracts.registry.find(self, name, method_type: :singleton)) && contract.owner == self
858
- wrap_contract(name, contract)
859
- end
860
- wrap_initialize_for_invariants if name == :initialize && @__contracts_has_invariants
861
- super
862
- end
863
-
864
- def declare_contract(name, type, options, source_location: caller_locations(2, 1).first, &block)
865
- contract = Contract.new(owner: self, method_name: name, method_type: type, source_location: source_location,
866
- options: options)
867
- ContractBuilder.new(contract).instance_eval(&block) if block
868
- merge_parent_contract!(contract) unless Contracts.configuration.inheritance_mode == :independent
869
- if contract.snapshot_block.nil? && @__contracts_snapshot
870
- contract.instance_variable_set(:@snapshot_block,
871
- @__contracts_snapshot)
872
- end
873
- Contracts.registry.register(contract)
874
-
875
- wrap_contract(name, contract) if method_defined?(name,
876
- false) || private_method_defined?(name,
877
- false) || protected_method_defined?(
878
- name, false
879
- )
880
- contract
881
- end
882
-
883
- def merge_parent_contract!(contract)
884
- parent = ancestors.drop(1).lazy.map do |ancestor|
885
- Contracts.registry.for_class(ancestor).find do |candidate|
886
- candidate.method_name == contract.method_name && candidate.method_type == contract.method_type
887
- end
888
- end.find(&:itself)
889
- return unless parent
890
-
891
- if Contracts.configuration.inheritance_mode == :strict
892
- parent.parameters.each do |name, constraint|
893
- child = contract.parameters[name]
894
- if child && child.description != constraint.description
895
- raise InheritanceViolation.new(owner: self, method_name: contract.method_name, contract_type: :inheritance,
896
- description: "parameter #{name} changes parent constraint #{constraint.description}")
897
- end
898
- end
899
- end
900
- contract.parameters = parent.parameters.merge(contract.parameters) unless parent.parameters.empty?
901
- contract.positionals = parent.positionals if contract.positionals.empty?
902
- contract.preconditions.unshift(*parent.preconditions)
903
- contract.postconditions.unshift(*parent.postconditions)
904
- contract.return_constraint = parent.return_constraint unless contract.return_constraint
905
- contract.allowed_exceptions.unshift(*parent.allowed_exceptions)
906
- contract.observed.unshift(*parent.observed.reject do |observation|
907
- contract.observed.any? do |own|
908
- own.name == observation.name
909
- end
910
- end)
911
- end
912
-
913
- def wrap_contract(name, contract)
914
- return if contract.instance_variable_defined?(:@wrapped)
915
-
916
- original = instance_method(name)
917
-
918
- contract.method_source_location = original.source_location
919
- visibility = if private_method_defined?(name)
920
- :private
921
- else
922
- protected_method_defined?(name) ? :protected : :public
923
- end
924
- @__contracts_hook = true
925
- define_method(name) do |*args, **kwargs, &block|
926
- Contracts.invoke(self, contract, args, kwargs, block) do
927
- original.bind_call(self, *args, **kwargs, &block)
928
- end
929
- end
930
- send(visibility, name)
931
- contract.instance_variable_set(:@wrapped, true)
932
- ensure
933
- @__contracts_hook = false
934
- end
935
-
936
- def wrap_initialize_for_invariants
937
- return if @__contracts_initialize_wrapped
938
-
939
- original = instance_method(:initialize)
940
- @__contracts_hook = true
941
- define_method(:initialize) do |*args, **kwargs, &block|
942
- original.bind_call(self, *args, **kwargs, &block).tap do
943
- Contracts.check_invariants!(self) if Contracts.configuration.check_invariants_after_initialize && Contracts.configuration.invariant_checking != :disabled
944
- end
945
- end
946
- private :initialize
947
- @__contracts_initialize_wrapped = true
948
- ensure
949
- @__contracts_hook = false
950
- end
951
- end
952
-
953
- module InstanceMethods
954
- def check_contract_invariants! = Contracts.check_invariants!(self)
955
- end
956
-
957
- module SingletonClassMethods
958
- def contract_singleton(name, **options, &)
959
- singleton_class.extend(ClassMethods)
960
- singleton_class.declare_contract(name, :singleton, options, &)
961
- end
962
-
963
- def singleton_method_added(name)
964
- return if @__contracts_hook
965
-
966
- contract = Contracts.registry.find(singleton_class, name, method_type: :singleton)
967
- singleton_class.wrap_contract(name, contract) if contract && contract.owner == singleton_class
968
- super
969
- end
970
- end
971
- end
149
+ def respond_to_missing?(name, include_private = false) = @values.key?(name) || super
150
+ end
151
+
152
+ class ExecutionGuard
153
+ def self.stack
154
+ stores = Thread.current[:contracts_execution_guard] ||= {}
155
+ stores[Fiber.current] ||= []
156
+ end
157
+
158
+ def self.active?(key) = stack.include?(key)
159
+ def self.depth = stack.length
160
+ def self.current_stack = stack.dup.freeze
161
+
162
+ def self.enter(key)
163
+ return yield(false) if active?(key)
164
+
165
+ stack << key
166
+ yield(true)
167
+ ensure
168
+ stack.pop if stack.last == key
169
+ end
170
+ end
171
+
172
+ Observation = Struct.new(:name, :reader, :deep, :compare_with, keyword_init: true) do
173
+ def to_h = { name: name, deep: deep, comparator: compare_with }
174
+ end
175
+ Invariant = Struct.new(:id, :owner, :description, :predicate, :source_location, :options, :inherited_from,
176
+ keyword_init: true)
177
+ class MutationReport
178
+ attr_reader :changed_fields, :unchanged_fields, :permitted_changes, :unexpected_changes, :missing_required_changes,
179
+ :before_values, :after_values
180
+
181
+ def initialize(before:, after:, permitted:, required:, observations:)
182
+ @before_values = before.to_h.freeze
183
+ @after_values = after.to_h.freeze
184
+ @permitted_changes = permitted.freeze
185
+ fields = @before_values.keys | @after_values.keys
186
+ @changed_fields = fields.reject do |field|
187
+ Contracts.equal_state?(@before_values[field], @after_values[field], observations[field]&.compare_with)
188
+ end.freeze
189
+ @unchanged_fields = (fields - @changed_fields).freeze
190
+ @unexpected_changes = (@changed_fields - permitted).freeze
191
+ @missing_required_changes = (required - @changed_fields).freeze
192
+ end
193
+
194
+ def passed? = unexpected_changes.empty? && missing_required_changes.empty?
195
+
196
+ def to_h
197
+ { passed: passed?, changed_fields: changed_fields, unchanged_fields: unchanged_fields,
198
+ permitted_changes: permitted_changes, unexpected_changes: unexpected_changes, missing_required_changes: missing_required_changes, before_values: before_values, after_values: after_values }
199
+ end
200
+ end
201
+
202
+ module Constraints
203
+ class Base
204
+ def to_h = { type: self.class.name.split("::").last.downcase, description: description }
205
+ end
206
+
207
+ class Type < Base
208
+ def initialize(type) = @type = type
209
+ def matches?(value) = value.is_a?(@type)
210
+ def description = @type.is_a?(Module) ? @type.name : @type.to_s
211
+ end
212
+
213
+ class Union < Base
214
+ def initialize(*items) = @items = items.map { |item| Constraints.coerce(item) }
215
+ def matches?(value) = @items.any? { |item| item.matches?(value) }
216
+ def description = @items.map(&:description).join(" or ")
217
+ end
218
+
219
+ class Nilable < Union
220
+ def initialize(item) = super(NilClass, item)
221
+ end
222
+
223
+ class Predicate < Base
224
+ def initialize(description, &block)
225
+ (@description = description
226
+ @block = block)
227
+ end
228
+
229
+ def matches?(value) = @block.call(value)
230
+ attr_reader :description
231
+ end
232
+
233
+ class Regex < Base
234
+ def initialize(regex) = @regex = regex
235
+ def matches?(value) = value.is_a?(String) && @regex.match?(value)
236
+ def description = "matching #{@regex.inspect}"
237
+ end
238
+
239
+ class Range < Base
240
+ def initialize(range) = @range = range
241
+ def matches?(value) = @range.cover?(value)
242
+ def description = "in #{@range.inspect}"
243
+ end
244
+
245
+ class OneOf < Base
246
+ def initialize(*values) = @values = values.freeze
247
+ def matches?(value) = @values.include?(value)
248
+ def description = "one of #{@values.inspect}"
249
+ end
250
+
251
+ class ArrayOf < Base
252
+ def initialize(item) = @item = Constraints.coerce(item)
253
+ def matches?(value) = value.is_a?(Array) && value.all? { |v| @item.matches?(v) }
254
+ def description = "Array<#{@item.description}>"
255
+ end
256
+
257
+ class HashOf < Base
258
+ def initialize(key, value)
259
+ (@key = Constraints.coerce(key)
260
+ @value = Constraints.coerce(value))
261
+ end
262
+
263
+ def matches?(value) = value.is_a?(Hash) && value.all? { |k, v| @key.matches?(k) && @value.matches?(v) }
264
+ def description = "Hash<#{@key.description}, #{@value.description}>"
265
+ end
266
+
267
+ class RespondTo < Base
268
+ def initialize(*methods) = @methods = methods
269
+ def matches?(value) = @methods.all? { |method| value.respond_to?(method) }
270
+ def description = "responding to #{@methods.join(', ')}"
271
+ end
272
+
273
+ class DuckType < RespondTo; end
274
+
275
+ class Anything < Base
276
+ def matches?(_) = true
277
+ def description = "anything"
278
+ end
279
+
280
+ class Nothing < Base
281
+ def matches?(_) = false
282
+ def description = "nothing"
283
+ end
284
+
285
+ class All < Base
286
+ def initialize(*items) = @items = items.map { |item| Constraints.coerce(item) }
287
+ def matches?(value) = @items.all? { |item| item.matches?(value) }
288
+ def description = @items.map(&:description).join(" and ")
289
+ def to_h = super.merge(items: @items.map(&:description))
290
+ end
291
+
292
+ class Length < Base
293
+ attr_reader :min, :max
294
+
295
+ def initialize(min: nil, max: nil, exactly: nil)
296
+ if exactly
297
+ raise DefinitionError, "length exactly cannot be combined with min or max" unless min.nil? && max.nil?
298
+
299
+ @min = @max = Integer(exactly)
300
+ else
301
+ @min = min && Integer(min)
302
+ @max = max && Integer(max)
303
+ end
304
+ raise DefinitionError, "length requires min, max, or exactly" if @min.nil? && @max.nil?
305
+ raise DefinitionError, "length min cannot exceed max" if @min && @max && @min > @max
306
+ end
307
+
308
+ def matches?(value)
309
+ size = length_of(value)
310
+ return false unless size
311
+ return false if min && size < min
312
+ return false if max && size > max
313
+
314
+ true
315
+ end
316
+
317
+ def description
318
+ return "length #{min}" if min && max && min == max
319
+ return "length >= #{min}" if min && max.nil?
320
+ return "length <= #{max}" if max && min.nil?
321
+
322
+ "length #{min}..#{max}"
323
+ end
324
+
325
+ def to_h = super.merge(min: min, max: max)
326
+
327
+ private
328
+
329
+ def length_of(value)
330
+ return nil if value.is_a?(Numeric)
331
+ return value.length if value.respond_to?(:length)
332
+ return value.size if value.respond_to?(:size)
333
+
334
+ nil
335
+ end
336
+ end
337
+
338
+ module_function
339
+
340
+ def coerce(value) = value.respond_to?(:matches?) && value.respond_to?(:description) ? value : Type.new(value)
341
+ end
342
+
343
+ Condition = Struct.new(:description, :block, keyword_init: true)
344
+ ExceptionRule = Struct.new(:type, :condition, :handler, keyword_init: true)
345
+ class Contract
346
+ attr_accessor :method_source_location
347
+ attr_reader :id, :owner, :method_name, :method_type, :parameters, :positionals, :preconditions, :postconditions,
348
+ :return_constraint, :invariants, :allowed_exceptions, :mutation_policy, :observed, :snapshot_block, :source_location, :options, :examples, :required_changes, :unchanged_on_raise_types
349
+
350
+ def initialize(owner:, method_name:, source_location:, method_type: :instance, options: {})
351
+ @id = "#{owner.name || owner.object_id}:#{method_type}:#{method_name}".freeze
352
+
353
+ @owner = owner
354
+ @method_name = method_name.to_sym
355
+ @method_type = method_type
356
+ @source_location = source_location
357
+ @options = options.freeze
358
+ @parameters = {}
359
+
360
+ @positionals = []
361
+ @preconditions = []
362
+ @postconditions = []
363
+ @allowed_exceptions = []
364
+ @invariants = []
365
+ @observed = []
366
+ @examples = []
367
+ @required_changes = []
368
+ @unchanged_on_raise_types = []
369
+ @mutation_policy = :unspecified
370
+ end
371
+
372
+ def parameters=(value)
373
+ @parameters = value.transform_keys(&:to_sym).transform_values { |v| Constraints.coerce(v) }.freeze
374
+ end
375
+
376
+ def positionals=(value)
377
+ @positionals = value.map { |v| Constraints.coerce(v) }.freeze
378
+ end
379
+
380
+ def return_constraint=(value)
381
+ @return_constraint = value && Constraints.coerce(value)
382
+ end
383
+
384
+ def to_h
385
+ { id: id, owner: owner.name, method_name: method_name, method_type: method_type, parameters: parameters.transform_values(&:description), positional: positionals.map(&:description), preconditions: preconditions.map(&:description), postconditions: postconditions.map(&:description), return_constraint: return_constraint&.description, invariants: Contracts.invariants_for(owner).map(&:description), allowed_exceptions: allowed_exceptions.map do |r|
386
+ r.type.name
387
+ end, mutation_policy: mutation_policy, observed: observed.map(&:to_h), permitted_changes: permitted_changes, required_changes: required_changes, source_location: source_location, method_source_location: method_source_location, options: options }
388
+ end
389
+
390
+ def to_json(*) = JSON.generate(to_h)
391
+ def observed_fields = observed.map(&:name).freeze
392
+ def permitted_changes = mutation_policy == :pure ? [] : (@permitted_changes || []).freeze
393
+ def pure? = mutation_policy == :pure
394
+ def all_invariants = Contracts.invariants_for(owner)
395
+ def own_invariants = Contracts.invariants_for(owner).select { |invariant| invariant.owner == owner }
396
+ def inherited_invariants = all_invariants - own_invariants
397
+
398
+ def permitted_changes=(values)
399
+ @permitted_changes = values.map(&:to_sym).freeze
400
+ end
401
+
402
+ def required_change_bounds
403
+ @required_change_bounds || {}.freeze
404
+ end
405
+ end
406
+
407
+ class ContractBuilder
408
+ def initialize(contract) = @contract = contract
409
+ def params(**items) = @contract.parameters = items
410
+ def positional(*items) = @contract.positionals = items
411
+ def requires(description = "precondition", &block) = add(@contract.preconditions, description, block)
412
+ def ensures(description = "postcondition", &block) = add(@contract.postconditions, description, block)
413
+ def returns(constraint) = @contract.return_constraint = constraint
414
+ def returns!(constraint) = @contract.return_constraint = Constraints::Predicate.new("non-nil #{Constraints.coerce(constraint).description}") { |v| !v.nil? && Constraints.coerce(constraint).matches?(v) }
415
+
416
+ def raises(*types, &block)
417
+ types.each do |type|
418
+ @contract.allowed_exceptions << ExceptionRule.new(type: type, condition: block)
419
+ end
420
+ end
421
+
422
+ def on_raise(type, &block) = @contract.allowed_exceptions << ExceptionRule.new(type: type, handler: block)
423
+
424
+ def changes(*attributes)
425
+ validate_mutation_mode!(:changes)
426
+ observe(*attributes.reject do |attribute|
427
+ @contract.observed.any? do |item|
428
+ item.name == attribute.to_sym
429
+ end
430
+ end)
431
+ @contract.instance_variable_set(:@mutation_policy, :changes)
432
+ @contract.permitted_changes = attributes
433
+ end
434
+
435
+ def observe(*attributes, deep: false, compare_with: nil, &reader)
436
+ attributes.each do |attribute|
437
+ existing = @contract.observed.find { |item| item.name == attribute.to_sym }
438
+ raise DefinitionError, "duplicate observation for #{attribute}" if existing
439
+
440
+ @contract.observed << Observation.new(name: attribute.to_sym, reader: reader, deep: deep,
441
+ compare_with: compare_with)
442
+ end
443
+ end
444
+
445
+ def must_change(*attributes, from: nil, to: nil)
446
+ validate_mutation_mode!(:must_change)
447
+ bounds = @contract.instance_variable_get(:@required_change_bounds) || {}
448
+ attributes.each do |attribute|
449
+ bounds[attribute.to_sym] = { from: from, to: to }.freeze
450
+ end
451
+ @contract.instance_variable_set(:@required_change_bounds, bounds.freeze)
452
+ observe(*attributes.reject do |attribute|
453
+ @contract.observed.any? do |item|
454
+ item.name == attribute.to_sym
455
+ end
456
+ end)
457
+ @contract.required_changes.concat(attributes.map(&:to_sym)).uniq!
458
+ end
459
+
460
+ def pure(scope: :receiver)
461
+ raise DefinitionError, "unsupported purity scope #{scope.inspect}" unless %i[receiver observed].include?(scope)
462
+
463
+ validate_mutation_mode!(:pure)
464
+ @contract.instance_variable_set(:@mutation_policy, :pure)
465
+ end
466
+ alias changes_nothing pure
467
+ def snapshot(&block) = @contract.instance_variable_set(:@snapshot_block, block)
468
+ def unchanged_on_raise(*types) = @contract.unchanged_on_raise_types.concat(types.empty? ? [StandardError] : types).uniq!
469
+ def example(**value) = @contract.examples << value.freeze
470
+
471
+ private
472
+
473
+ def add(collection, description, block)
474
+ raise DefinitionError, "a contract condition needs a block" unless block
475
+
476
+ collection << Condition.new(description: description, block: block)
477
+ end
478
+
479
+ def validate_mutation_mode!(mode)
480
+ current = @contract.mutation_policy
481
+ return unless current != :unspecified && current != mode && !(current == :changes && mode == :must_change)
482
+
483
+ raise DefinitionError,
484
+ "#{mode} conflicts with #{current}"
485
+ end
486
+ end
487
+
488
+ class Registry
489
+ def initialize
490
+ (@lock = Monitor.new
491
+ @contracts = {})
492
+ end
493
+
494
+ def register(contract)
495
+ @lock.synchronize do
496
+ @contracts[[contract.owner, contract.method_type, contract.method_name]] = contract
497
+ end
498
+ end
499
+
500
+ def find(owner, method_name, method_type: :instance)
501
+ @lock.synchronize do
502
+ @contracts[[owner, method_type, method_name.to_sym]] || inherited(owner, method_name, method_type)
503
+ end
504
+ end
505
+
506
+ def for_class(owner) = @lock.synchronize { @contracts.values.select { |c| c.owner == owner }.dup.freeze }
507
+ def all = @lock.synchronize { @contracts.values.dup.freeze }
508
+ private
509
+
510
+ def inherited(owner, method_name, type)
511
+ return nil if Contracts.configuration.inheritance_mode == :independent
512
+
513
+ owner.ancestors.drop(1).filter_map { |ancestor| @contracts[[ancestor, type, method_name.to_sym]] }.first
514
+ end
515
+ end
516
+
517
+ class << self
518
+ def configuration = @configuration ||= Configuration.new
519
+ def configure = yield(configuration)
520
+ def registry = @registry ||= Registry.new
521
+
522
+ def contract_for(owner, method_name,
523
+ method_type: :instance)
524
+ registry.find(owner, method_name, method_type: method_type)
525
+ end
526
+
527
+ def invariants_for(owner)
528
+ owner.ancestors.flat_map do |ancestor|
529
+ registry.for_class(ancestor).flat_map(&:invariants)
530
+ end.freeze
531
+ end
532
+
533
+ def check_invariants(object)
534
+ invariants_for(object.class).map do |invariant|
535
+ { passed: !!object.instance_exec(&invariant.predicate), type: :invariant, description: invariant.description,
536
+ invariant_id: invariant.id }.freeze
537
+ end.freeze
538
+ rescue StandardError => e
539
+ [{ passed: false, type: :invariant, description: e.message, error: e }.freeze].freeze
540
+ end
541
+
542
+ def check_invariants!(object)
543
+ failed = check_invariants(object).find { |result| !result[:passed] }
544
+ if failed
545
+ raise InvariantViolation.new(owner: object.class, method_name: :__invariant__, contract_type: :invariant,
546
+ description: failed[:description])
547
+ end
548
+
549
+ true
550
+ end
551
+
552
+ def register_comparator(name, &block) = (comparators[name.to_sym] = block)
553
+ def comparators = (@comparators ||= {})
554
+
555
+ def equal_state?(before, after, comparator = nil)
556
+ comparator = comparators[comparator] if comparator.is_a?(Symbol)
557
+ return comparator.call(before, after) if comparator.respond_to?(:call)
558
+
559
+ if configuration.state_equality == :identity
560
+ before.equal?(after)
561
+ else
562
+ configuration.state_equality == :equal ? before == after : before.eql?(after)
563
+ end
564
+ end
565
+
566
+ def describe(owner, method_name = nil)
567
+ contracts = method_name ? [contract_for(owner, method_name)].compact : registry.for_class(owner)
568
+ contracts.map(&:to_h)
569
+ end
570
+
571
+ def any(*items) = Constraints::Union.new(*items)
572
+ def nilable(item) = Constraints::Nilable.new(item)
573
+ def matching(regex) = Constraints::Regex.new(regex)
574
+ def range(value) = Constraints::Range.new(value)
575
+ def one_of(*values) = Constraints::OneOf.new(*values)
576
+ def array_of(item) = Constraints::ArrayOf.new(item)
577
+ def hash_of(key, value) = Constraints::HashOf.new(key, value)
578
+ def predicate(description, &) = Constraints::Predicate.new(description, &)
579
+ def respond_to(*methods) = Constraints::RespondTo.new(*methods)
580
+ def duck_type(*methods) = Constraints::DuckType.new(*methods)
581
+ def anything = Constraints::Anything.new
582
+ def nothing = Constraints::Nothing.new
583
+ def all(*items) = Constraints::All.new(*items)
584
+ def length(min: nil, max: nil, exactly: nil) = Constraints::Length.new(min: min, max: max, exactly: exactly)
585
+
586
+ def invoke(receiver, contract, args, kwargs, block)
587
+ return yield unless active?(contract, receiver, args, kwargs)
588
+
589
+ parent = Thread.current[:contracts_context]
590
+
591
+ context = Context.new(receiver: receiver, contract: contract, arguments: args, keyword_arguments: kwargs,
592
+ block_given: !block.nil?, parent: parent)
593
+ Thread.current[:contracts_context] = context
594
+ validate_parameters(contract, context)
595
+
596
+ check_contract_invariants(receiver, contract, context, :before)
597
+ context.before = capture(receiver, contract)
598
+ check_conditions(contract.preconditions, context, :precondition)
599
+ begin
600
+ context.result = yield
601
+ rescue Exception => e # rubocop:disable Lint/RescueException
602
+ context.exception = e
603
+ if configuration.verify_state_after_exception || !contract.unchanged_on_raise_types.empty?
604
+ after = capture(receiver, contract)
605
+ report = MutationReport.new(before: context.before, after: after, permitted: [], required: [], observations: contract.observed.to_h do |o|
606
+ [o.name, o]
607
+ end)
608
+ if !contract.unchanged_on_raise_types.empty? && contract.unchanged_on_raise_types.any? do |type|
609
+ e.is_a?(type)
610
+ end && !report.changed_fields.empty?
611
+ fail!(MutationViolation, context,
612
+ description: "state changed after exception: #{report.changed_fields.join(', ')}", actual: report.to_h, original_exception: e)
613
+ end
614
+ check_contract_invariants(receiver, contract, context, :after_exception)
615
+ end
616
+ handle_exception(contract, context)
617
+
618
+ check_contract_invariants(receiver, contract, context, :after) if configuration.check_invariant_after_exception
619
+ raise
620
+ else
621
+ validate_return(contract, context)
622
+
623
+ check_conditions(contract.postconditions, context, :postcondition)
624
+ validate_mutation(contract, context)
625
+ check_contract_invariants(receiver, contract, context, :after)
626
+ context.result
627
+ ensure
628
+ context.finished_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
629
+
630
+ Thread.current[:contracts_context] = parent
631
+ end
632
+ end
633
+
634
+ def active?(contract, receiver, args, kwargs)
635
+ return false unless configuration.enabled
636
+ if configuration.sampler
637
+ return configuration.sampler.call(Context.new(receiver: receiver, contract: contract, arguments: args,
638
+ keyword_arguments: kwargs, block_given: false))
639
+ end
640
+
641
+ rate = contract.options.fetch(:sample_rate, configuration.sample_rate)
642
+ rate >= 1 || (rate.positive? && rand < rate)
643
+ end
644
+
645
+ def fail!(klass, context, description:, expected: nil, actual: nil, parameter: nil, original_exception: nil)
646
+ error = klass.new(owner: context.owner, method_name: context.method_name,
647
+ contract_type: klass.name.split("::").last.sub("Violation", "").downcase, description: description, expected: expected, actual: actual, parameter: parameter, context: context, source_location: context.source_location, original_exception: original_exception)
648
+ instrument_violation(error)
649
+ case configuration.failure_mode
650
+ when :raise then raise error
651
+ when :warn then warn error.message
652
+ when :log then configuration.logger&.error(error.message)
653
+ when :collect then (Thread.current[:contracts_violations] ||= []) << error
654
+ else raise DefinitionError, "unknown failure_mode #{configuration.failure_mode.inspect}"
655
+ end
656
+ error
657
+ end
658
+
659
+ def instrument_violation(error)
660
+ return unless defined?(ActiveSupport::Notifications)
661
+
662
+ ActiveSupport::Notifications.instrument(
663
+ "contracts.violation",
664
+ owner: error.owner,
665
+ method_name: error.method_name,
666
+ contract_type: error.contract_type,
667
+ description: error.description,
668
+ duration: error.context&.duration,
669
+ source_location: error.source_location
670
+ )
671
+ end
672
+
673
+ private
674
+
675
+ def validate_parameters(contract, context)
676
+ contract.positionals.each_with_index do |constraint, index|
677
+ validate_constraint(constraint, context.arguments[index], context, "argument #{index}", index)
678
+ end
679
+ contract.parameters.each do |name, constraint|
680
+ validate_constraint(constraint, context.keyword_arguments[name], context, name, name)
681
+ end
682
+ end
683
+
684
+ def validate_constraint(constraint, value, context, label, parameter)
685
+ return if constraint.matches?(value)
686
+
687
+ actual = configuration.include_values_in_errors ? redact(parameter, value) : value.class.name
688
+ fail!(ParameterViolation, context, description: "#{label} does not satisfy #{constraint.description}",
689
+ expected: constraint.description, actual: actual, parameter: parameter)
690
+ end
691
+
692
+ def validate_return(contract, context)
693
+ return unless contract.return_constraint && !contract.return_constraint.matches?(context.result)
694
+
695
+ fail!(ReturnViolation, context,
696
+ description: "return value does not satisfy #{contract.return_constraint.description}", expected: contract.return_constraint.description, actual: context.result.class.name)
697
+ end
698
+
699
+ def check_conditions(conditions, context, kind)
700
+ conditions.each do |condition|
701
+ result = call_condition(condition.block, context)
702
+ unless result
703
+ fail!(kind == :precondition ? PreconditionViolation : PostconditionViolation, context,
704
+ description: condition.description)
705
+ end
706
+ end
707
+ end
708
+
709
+ def call_condition(block, context)
710
+ params = block.parameters
711
+ return context.receiver.instance_exec(context: context, &block) if params.any? { |(_, name)| name == :context }
712
+
713
+ accepted_keys = params.filter_map { |kind, name| name if %i[key keyreq keyrest].include?(kind) }
714
+ accepts_all_keys = params.any? { |kind, _| kind == :keyrest }
715
+ available = context.keyword_arguments.merge(before: context.before)
716
+ kwargs = accepts_all_keys ? available : available.slice(*accepted_keys)
717
+ if params.empty? then context.receiver.instance_exec(&block)
718
+ elsif params.first&.last == :result then context.receiver.instance_exec(context.result, **kwargs, &block)
719
+ elsif context.result && params.any? do |(_, name)|
720
+ name == :before
721
+ end then context.receiver.instance_exec(context.result, **kwargs, &block)
722
+ elsif context.result && params.length == 1 && params.first.first != :keyreq then context.receiver.instance_exec(
723
+ context.result, &block
724
+ )
725
+ else context.receiver.instance_exec(*context.arguments, **kwargs, &block)
726
+ end
727
+ end
728
+
729
+ def capture(receiver, contract)
730
+ strategy = configuration.snapshot_strategy
731
+ return Snapshot.new({}, metadata: snapshot_metadata(receiver, contract, strategy, [])) if strategy == :none
732
+
733
+ data = if contract.snapshot_block then receiver.instance_exec(&contract.snapshot_block)
734
+ elsif configuration.snapshot_provider then configuration.snapshot_provider.call(receiver, contract, nil)
735
+ else
736
+ observations = contract.observed
737
+ if observations.empty? && strategy == :instance_variables
738
+ observations = receiver.instance_variables.map do |value|
739
+ Observation.new(name: value.to_s.delete_prefix("@").to_sym,
740
+ deep: false)
741
+ end
742
+ end
743
+ observations.to_h { |observation| [observation.name, read_observation(receiver, observation)] }
744
+ end
745
+ unless data.respond_to?(:to_h)
746
+ raise SnapshotError.new("snapshot provider must return a Hash", strategy: strategy,
747
+ receiver_class: receiver.class)
748
+ end
749
+
750
+ values = data.to_h.transform_keys(&:to_sym)
751
+ contract.observed.each do |observation|
752
+ if (observation.deep || contract.pure?) && values.key?(observation.name)
753
+ values[observation.name] =
754
+ deep_copy(values[observation.name])
755
+ end
756
+ end
757
+ Snapshot.new(values, metadata: snapshot_metadata(receiver, contract, strategy, contract.observed))
758
+ rescue SnapshotError
759
+ raise
760
+ rescue StandardError => e
761
+ raise SnapshotError.new("could not capture snapshot: #{e.message}", strategy: strategy,
762
+ receiver_class: receiver.class, original_exception: e)
763
+ end
764
+
765
+ def copy(value)
766
+ immutable = value.nil? || value.is_a?(Numeric) || value.is_a?(Symbol) || value == true || value == false || value.frozen?
767
+ immutable ? value : value.dup.freeze
768
+ rescue TypeError
769
+ value
770
+ end
771
+
772
+ def deep_copy(value)
773
+ case value
774
+ when Hash then value.transform_values { |item| deep_copy(item) }.freeze
775
+ when Array then value.map { |item| deep_copy(item) }.freeze
776
+ when Set then value.to_set { |item| deep_copy(item) }.freeze
777
+ when String then value.dup.freeze
778
+ when Numeric, Symbol, TrueClass, FalseClass, NilClass then value
779
+ else
780
+ return value if configuration.unsupported_deep_copy == :reference
781
+ if configuration.unsupported_deep_copy == :error
782
+ raise SnapshotError,
783
+ "unsupported deep snapshot value #{value.class}"
784
+ end
785
+
786
+ copy(value)
787
+ end
788
+ end
789
+
790
+ def read_observation(receiver, observation)
791
+ return receiver.instance_exec(receiver, &observation.reader) if observation.reader
792
+ return receiver.public_send(observation.name) if receiver.respond_to?(observation.name)
793
+ return receiver.send(observation.name) if configuration.allow_private_state_readers && receiver.respond_to?(
794
+ observation.name, true
795
+ )
796
+
797
+ variable = "@#{observation.name}"
798
+ return receiver.instance_variable_get(variable) if receiver.instance_variable_defined?(variable)
799
+
800
+ raise StateObservationError.new("cannot observe #{observation.name}", field: observation.name,
801
+ receiver_class: receiver.class)
802
+ end
803
+
804
+ def snapshot_metadata(receiver, contract, strategy, observations)
805
+ { receiver_class: receiver.class.name, receiver_object_id: receiver.object_id, captured_at: Time.now,
806
+ strategy: strategy, observed_fields: observations.map(&:name).freeze, deep_fields: observations.select(&:deep).map(&:name).freeze, contract_id: contract.id }
807
+ end
808
+
809
+ def validate_mutation(contract, context)
810
+ return if contract.mutation_policy == :unspecified
811
+
812
+ after = capture(context.receiver, contract)
813
+ permitted = contract.mutation_policy == :pure ? [] : contract.permitted_changes
814
+ report = MutationReport.new(before: context.before, after: after, permitted: permitted, required: contract.required_changes, observations: contract.observed.to_h do |item|
815
+ [item.name, item]
816
+ end)
817
+ bounds_violations = required_change_bound_violations(contract, context.before, after, report)
818
+ return if report.passed? && bounds_violations.empty?
819
+
820
+ parts = []
821
+ parts << "unexpected changes: #{report.unexpected_changes.join(', ')}; missing changes: #{report.missing_required_changes.join(', ')}" unless report.passed?
822
+ parts.concat(bounds_violations)
823
+ fail!(MutationViolation, context,
824
+ description: parts.join("; "), expected: permitted, actual: report.to_h)
825
+ end
826
+
827
+ def required_change_bound_violations(contract, before, after, report)
828
+ contract.required_change_bounds.each_with_object([]) do |(field, bounds), violations|
829
+ next unless report.changed_fields.include?(field)
830
+
831
+ from = bounds[:from]
832
+ to = bounds[:to]
833
+ violations << "#{field} must change from #{bound_description(from)} (was #{bound_value_label(before[field])})" if from && !bound_value_matches?(before[field], from)
834
+ violations << "#{field} must change to #{bound_description(to)} (got #{bound_value_label(after[field])})" if to && !bound_value_matches?(after[field], to)
835
+ end
836
+ end
837
+
838
+ def bound_value_matches?(value, bound)
839
+ case bound
840
+ when Array then bound.any? { |item| equal_state?(value, item) }
841
+ else equal_state?(value, bound)
842
+ end
843
+ end
844
+
845
+ def bound_description(bound)
846
+ bound.inspect
847
+ end
848
+
849
+ def bound_value_label(value)
850
+ value.inspect
851
+ end
852
+
853
+ def check_contract_invariants(receiver, _contract, context, _phase)
854
+ return unless configuration.invariant_checking == :contracted_methods
855
+
856
+ invariants = invariants_for(receiver.class)
857
+ return if invariants.empty?
858
+
859
+ key = [receiver.object_id, :invariant]
860
+ ExecutionGuard.enter(key) do |entered|
861
+ if entered
862
+ check_conditions(invariants.map do |item|
863
+ Condition.new(description: item.description, block: item.predicate)
864
+ end, context, :invariant)
865
+ end
866
+ end
867
+ end
868
+
869
+ def handle_exception(contract, context)
870
+ matches = contract.allowed_exceptions.select do |rule|
871
+ context.exception.is_a?(rule.type) && (!rule.condition || call_condition(rule.condition, context))
872
+ end
873
+ matches.each do |rule|
874
+ next unless rule.handler
875
+
876
+ passed = call_exception_condition(rule.handler, context)
877
+ unless passed
878
+ fail!(PostconditionViolation, context, description: "exception postcondition for #{rule.type} failed",
879
+ original_exception: context.exception)
880
+ end
881
+ end
882
+ return unless matches.empty?
883
+
884
+ case configuration.undeclared_exceptions
885
+ when :violate then fail!(UnexpectedExceptionViolation, context,
886
+ description: "undeclared exception #{context.exception.class}", original_exception: context.exception)
887
+ when :warn then configuration.logger ? configuration.logger.warn("undeclared exception #{context.exception.class} in #{context.owner}##{context.method_name}") : warn("undeclared exception #{context.exception.class} in #{context.owner}##{context.method_name}")
888
+ end
889
+ end
890
+
891
+ def call_exception_condition(block, context)
892
+ params = block.parameters
893
+ return context.receiver.instance_exec(context: context, &block) if params.any? { |_, name| name == :context }
894
+
895
+ kwargs = context.keyword_arguments.merge(before: context.before)
896
+ accepted = params.filter_map { |kind, name| name if %i[key keyreq keyrest].include?(kind) }
897
+ kwargs = kwargs.slice(*accepted) unless params.any? { |kind, _| kind == :keyrest }
898
+ context.receiver.instance_exec(context.exception, **kwargs, &block)
899
+ end
900
+
901
+ def redact(name, value)
902
+ return configuration.redactor.call(name, value) if configuration.redactor
903
+ return "[REDACTED]" if configuration.redacted_parameters.any? { |pattern| pattern.match?(name.to_s) }
904
+
905
+ value.inspect
906
+ end
907
+ end
908
+
909
+ def self.included(base)
910
+ base.extend(ClassMethods)
911
+ base.include(InstanceMethods)
912
+ end
913
+
914
+ def self.extended(base) = base.extend(SingletonClassMethods)
915
+
916
+ module ClassMethods
917
+ def contract(name = nil, **options, &block)
918
+ if name.nil? then @__contracts_pending = [options, block, caller_locations(1, 1).first]
919
+ return
920
+ end
921
+ declare_contract(name, :instance, options, &block)
922
+ end
923
+
924
+ def invariant(description = "invariant", &block)
925
+ location = caller_locations(1, 1).first
926
+ contract = Contracts.registry.find(self,
927
+ :__invariant__) || Contract.new(owner: self, method_name: :__invariant__,
928
+ source_location: location)
929
+ contract.invariants << Invariant.new(id: "#{name || object_id}:#{caller_locations(1, 1).first.lineno}",
930
+ owner: self, description: description, predicate: block, source_location: caller_locations(1, 1).first, options: {})
931
+
932
+ Contracts.registry.register(contract)
933
+ @__contracts_has_invariants = true
934
+ wrap_initialize_for_invariants if method_defined?(:initialize,
935
+ false) || private_method_defined?(:initialize, false)
936
+ end
937
+
938
+ def snapshot(&block) = (@__contracts_snapshot = block)
939
+
940
+ def method_added(name)
941
+ return if @__contracts_hook
942
+
943
+ if (pending = @__contracts_pending)
944
+ @__contracts_pending = nil
945
+
946
+ declare_contract(name, :instance, pending[0], source_location: pending[2], &pending[1])
947
+ elsif (contract = Contracts.registry.find(self, name, method_type: :instance)) && contract.owner == self
948
+ wrap_contract(name, contract)
949
+ elsif (contract = Contracts.registry.find(self, name, method_type: :singleton)) && contract.owner == self
950
+ wrap_contract(name, contract)
951
+ end
952
+ wrap_initialize_for_invariants if name == :initialize && @__contracts_has_invariants
953
+ super
954
+ end
955
+
956
+ def declare_contract(name, type, options, source_location: caller_locations(2, 1).first, &block)
957
+ contract = Contract.new(owner: self, method_name: name, method_type: type, source_location: source_location,
958
+ options: options)
959
+ ContractBuilder.new(contract).instance_eval(&block) if block
960
+ merge_parent_contract!(contract) unless Contracts.configuration.inheritance_mode == :independent
961
+ if contract.snapshot_block.nil? && @__contracts_snapshot
962
+ contract.instance_variable_set(:@snapshot_block,
963
+ @__contracts_snapshot)
964
+ end
965
+ Contracts.registry.register(contract)
966
+
967
+ wrap_contract(name, contract) if method_defined?(name,
968
+ false) || private_method_defined?(name,
969
+ false) || protected_method_defined?(
970
+ name, false
971
+ )
972
+ contract
973
+ end
974
+
975
+ def merge_parent_contract!(contract)
976
+ parent = ancestors.drop(1).lazy.map do |ancestor|
977
+ Contracts.registry.for_class(ancestor).find do |candidate|
978
+ candidate.method_name == contract.method_name && candidate.method_type == contract.method_type
979
+ end
980
+ end.find(&:itself)
981
+ return unless parent
982
+
983
+ if Contracts.configuration.inheritance_mode == :strict
984
+ parent.parameters.each do |name, constraint|
985
+ child = contract.parameters[name]
986
+ if child && child.description != constraint.description
987
+ raise InheritanceViolation.new(owner: self, method_name: contract.method_name, contract_type: :inheritance,
988
+ description: "parameter #{name} changes parent constraint #{constraint.description}")
989
+ end
990
+ end
991
+ end
992
+ contract.parameters = parent.parameters.merge(contract.parameters) unless parent.parameters.empty?
993
+ contract.positionals = parent.positionals if contract.positionals.empty?
994
+ contract.preconditions.unshift(*parent.preconditions)
995
+ contract.postconditions.unshift(*parent.postconditions)
996
+ contract.return_constraint = parent.return_constraint unless contract.return_constraint
997
+ contract.allowed_exceptions.unshift(*parent.allowed_exceptions)
998
+ contract.observed.unshift(*parent.observed.reject do |observation|
999
+ contract.observed.any? do |own|
1000
+ own.name == observation.name
1001
+ end
1002
+ end)
1003
+ end
1004
+
1005
+ def wrap_contract(name, contract)
1006
+ return if contract.instance_variable_defined?(:@wrapped)
1007
+
1008
+ original = instance_method(name)
1009
+
1010
+ contract.method_source_location = original.source_location
1011
+ visibility = if private_method_defined?(name)
1012
+ :private
1013
+ else
1014
+ protected_method_defined?(name) ? :protected : :public
1015
+ end
1016
+ @__contracts_hook = true
1017
+ define_method(name) do |*args, **kwargs, &block|
1018
+ Contracts.invoke(self, contract, args, kwargs, block) do
1019
+ original.bind_call(self, *args, **kwargs, &block)
1020
+ end
1021
+ end
1022
+ send(visibility, name)
1023
+ contract.instance_variable_set(:@wrapped, true)
1024
+ ensure
1025
+ @__contracts_hook = false
1026
+ end
1027
+
1028
+ def wrap_initialize_for_invariants
1029
+ return if @__contracts_initialize_wrapped
1030
+
1031
+ original = instance_method(:initialize)
1032
+ @__contracts_hook = true
1033
+ define_method(:initialize) do |*args, **kwargs, &block|
1034
+ original.bind_call(self, *args, **kwargs, &block).tap do
1035
+ Contracts.check_invariants!(self) if Contracts.configuration.check_invariants_after_initialize && Contracts.configuration.invariant_checking != :disabled
1036
+ end
1037
+ end
1038
+ private :initialize
1039
+ @__contracts_initialize_wrapped = true
1040
+ ensure
1041
+ @__contracts_hook = false
1042
+ end
1043
+ end
1044
+
1045
+ module InstanceMethods
1046
+ def check_contract_invariants! = Contracts.check_invariants!(self)
1047
+ end
1048
+
1049
+ module SingletonClassMethods
1050
+ def contract_singleton(name, **options, &)
1051
+ singleton_class.extend(ClassMethods)
1052
+ singleton_class.declare_contract(name, :singleton, options, &)
1053
+ end
1054
+
1055
+ def singleton_method_added(name)
1056
+ return if @__contracts_hook
1057
+
1058
+ contract = Contracts.registry.find(singleton_class, name, method_type: :singleton)
1059
+ singleton_class.wrap_contract(name, contract) if contract && contract.owner == singleton_class
1060
+ super
1061
+ end
1062
+ end
1063
+ end