contracts-rb 0.1.2

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 ADDED
@@ -0,0 +1,971 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "monitor"
5
+ require "set"
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]
141
+ # Explicit splat parameters keep this compatible with Ruby 3.1.
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
147
+ def dig(*arguments) = @values.dig(*arguments)
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