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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +19 -0
- data/CONTRIBUTING.md +3 -0
- data/LICENSE.txt +9 -0
- data/README.md +140 -0
- data/SECURITY.md +3 -0
- data/assets/contracts-rb-logo.png +0 -0
- data/docs/_config.yml +3 -0
- data/docs/assets/contracts-rb-logo.png +0 -0
- data/docs/contracts/contracts.md +59 -0
- data/docs/index.html +79 -0
- data/examples/banking/README.md +3 -0
- data/examples/banking/bank_account.rb +21 -0
- data/examples/banking/demo.rb +6 -0
- data/examples/banking/transfer_service.rb +15 -0
- data/exe/contracts +105 -0
- data/lib/contracts/rails.rb +20 -0
- data/lib/contracts/rspec/verifier.rb +25 -0
- data/lib/contracts/rspec.rb +42 -0
- data/lib/contracts/version.rb +5 -0
- data/lib/contracts.rb +971 -0
- data/sig/contracts.rbs +7 -0
- metadata +65 -0
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
|