bparity 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.rubocop.yml +61 -0
- data/LICENSE.txt +21 -0
- data/README.md +146 -0
- data/Rakefile +36 -0
- data/docs/application_example.md +25 -0
- data/docs/formal_assurance_limits.md +21 -0
- data/exe/bparity +7 -0
- data/fixtures/scenarios/01_pure_function/adapter.rb +13 -0
- data/fixtures/scenarios/01_pure_function/boundary.rb +17 -0
- data/fixtures/scenarios/01_pure_function/legacy/dead_gem.rb +9 -0
- data/fixtures/scenarios/01_pure_function/legacy/slugifier.rb +17 -0
- data/fixtures/scenarios/01_pure_function/replacement/broken.rb +15 -0
- data/fixtures/scenarios/01_pure_function/replacement/good.rb +19 -0
- data/fixtures/scenarios/01_pure_function/spec/slugifier_spec.rb +20 -0
- data/fixtures/scenarios/01_pure_function/test/slugifier_test.rb +10 -0
- data/fixtures/scenarios/02_stateful_client/adapter.rb +19 -0
- data/fixtures/scenarios/02_stateful_client/boundary.rb +10 -0
- data/fixtures/scenarios/02_stateful_client/legacy/client.rb +29 -0
- data/fixtures/scenarios/02_stateful_client/replacement/broken.rb +14 -0
- data/fixtures/scenarios/02_stateful_client/replacement/good.rb +23 -0
- data/fixtures/scenarios/02_stateful_client/spec/client_spec.rb +31 -0
- data/fixtures/scenarios/03_external_boundary/adapter.rb +13 -0
- data/fixtures/scenarios/03_external_boundary/boundary.rb +11 -0
- data/fixtures/scenarios/03_external_boundary/legacy/dead_formatter.rb +7 -0
- data/fixtures/scenarios/03_external_boundary/legacy/receipt.rb +11 -0
- data/fixtures/scenarios/03_external_boundary/replacement/broken.rb +11 -0
- data/fixtures/scenarios/03_external_boundary/replacement/good.rb +11 -0
- data/fixtures/scenarios/03_external_boundary/spec/receipt_spec.rb +10 -0
- data/fixtures/scenarios/04_intentional_divergence/adapter.rb +12 -0
- data/fixtures/scenarios/04_intentional_divergence/adapter_unwaived.rb +10 -0
- data/fixtures/scenarios/04_intentional_divergence/boundary.rb +8 -0
- data/fixtures/scenarios/04_intentional_divergence/legacy/dead_identity.rb +7 -0
- data/fixtures/scenarios/04_intentional_divergence/legacy/identity.rb +9 -0
- data/fixtures/scenarios/04_intentional_divergence/replacement/broken.rb +9 -0
- data/fixtures/scenarios/04_intentional_divergence/replacement/good.rb +9 -0
- data/fixtures/scenarios/04_intentional_divergence/spec/identity_spec.rb +10 -0
- data/fixtures/scenarios/05_formal_negative/adapter.rb +15 -0
- data/fixtures/scenarios/05_formal_negative/boundary.rb +16 -0
- data/fixtures/scenarios/05_formal_negative/legacy/dead_lock.rb +5 -0
- data/fixtures/scenarios/05_formal_negative/legacy/turnstile.rb +24 -0
- data/fixtures/scenarios/05_formal_negative/replacement/broken.rb +18 -0
- data/fixtures/scenarios/05_formal_negative/replacement/formal_broken.rb +24 -0
- data/fixtures/scenarios/05_formal_negative/replacement/good.rb +24 -0
- data/fixtures/scenarios/05_formal_negative/spec/turnstile_spec.rb +21 -0
- data/lib/bparity/adapter.rb +115 -0
- data/lib/bparity/adequacy.rb +78 -0
- data/lib/bparity/boundary.rb +97 -0
- data/lib/bparity/cli/formal_commands.rb +428 -0
- data/lib/bparity/cli/verification_commands.rb +242 -0
- data/lib/bparity/cli.rb +262 -0
- data/lib/bparity/corpus.rb +45 -0
- data/lib/bparity/errors.rb +12 -0
- data/lib/bparity/formal/assumptions.rb +131 -0
- data/lib/bparity/formal/bounded.rb +543 -0
- data/lib/bparity/formal/contract.rb +81 -0
- data/lib/bparity/formal/deductive.rb +481 -0
- data/lib/bparity/formal/lts.rb +344 -0
- data/lib/bparity/formal/result.rb +50 -0
- data/lib/bparity/formal.rb +8 -0
- data/lib/bparity/recording.rb +412 -0
- data/lib/bparity/reporting.rb +128 -0
- data/lib/bparity/spec_bundle.rb +208 -0
- data/lib/bparity/synthesis.rb +487 -0
- data/lib/bparity/verification.rb +310 -0
- data/lib/bparity/version.rb +5 -0
- data/lib/bparity.rb +42 -0
- metadata +125 -0
|
@@ -0,0 +1,543 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "open3"
|
|
5
|
+
|
|
6
|
+
module Bparity
|
|
7
|
+
module Formal
|
|
8
|
+
class Domain < Array
|
|
9
|
+
attr_reader :truncated
|
|
10
|
+
|
|
11
|
+
def initialize(values, truncated: false)
|
|
12
|
+
super(values)
|
|
13
|
+
@truncated = truncated
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
class ValueEnumerator
|
|
18
|
+
def initialize(size:, depth:, alphabet: %w[a b], observed: [], limit: nil)
|
|
19
|
+
@size = size
|
|
20
|
+
@depth = depth
|
|
21
|
+
@alphabet = alphabet
|
|
22
|
+
@observed = observed
|
|
23
|
+
@limit = limit
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def values(type)
|
|
27
|
+
generated = case type.to_s
|
|
28
|
+
when "Integer" then integers
|
|
29
|
+
when "String" then strings
|
|
30
|
+
when "Symbol" then (@observed.grep(Symbol) + @alphabet.map(&:to_sym)).uniq
|
|
31
|
+
when "Array" then arrays(inferred_array_type)
|
|
32
|
+
when "Hash" then hashes(*inferred_hash_types)
|
|
33
|
+
when "NilClass", "nil" then [nil]
|
|
34
|
+
when "TrueClass", "FalseClass", "Boolean" then [false, true]
|
|
35
|
+
else return user_structures(type)
|
|
36
|
+
end
|
|
37
|
+
klass = constant_class(type)
|
|
38
|
+
bounded(Enumerator.new do |items|
|
|
39
|
+
generated.each { |value| items << value }
|
|
40
|
+
@observed.grep(klass).each { |value| items << value } if klass
|
|
41
|
+
end)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def arrays(element_type)
|
|
45
|
+
return [[]] if @depth.zero?
|
|
46
|
+
|
|
47
|
+
elements = values(element_type)
|
|
48
|
+
Enumerator.new do |items|
|
|
49
|
+
(0..@size).each { |length| elements.repeated_permutation(length) { |value| items << value } }
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def hashes(key_type, value_type)
|
|
54
|
+
return [{}] if @depth.zero?
|
|
55
|
+
|
|
56
|
+
pairs = []
|
|
57
|
+
values(key_type).product(values(value_type)) do |pair|
|
|
58
|
+
pairs << pair
|
|
59
|
+
break if @limit && pairs.length >= @limit
|
|
60
|
+
end
|
|
61
|
+
Enumerator.new do |results|
|
|
62
|
+
results << {}
|
|
63
|
+
(1..@size).each do |length|
|
|
64
|
+
pairs.combination(length) do |items|
|
|
65
|
+
hash = items.to_h
|
|
66
|
+
results << hash if hash.length == length
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
def integers = (-@size..@size).to_a
|
|
75
|
+
|
|
76
|
+
def strings
|
|
77
|
+
Enumerator.new do |items|
|
|
78
|
+
(0..@size).each do |length|
|
|
79
|
+
@alphabet.repeated_permutation(length) { |characters| items << characters.join }
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def bounded(values)
|
|
85
|
+
unique = []
|
|
86
|
+
seen = {}
|
|
87
|
+
values.each do |value|
|
|
88
|
+
next if seen[value]
|
|
89
|
+
|
|
90
|
+
seen[value] = true
|
|
91
|
+
unique << value
|
|
92
|
+
return Domain.new(unique.first(@limit), truncated: true) if @limit && unique.length > @limit
|
|
93
|
+
end
|
|
94
|
+
Domain.new(unique)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def inferred_array_type
|
|
98
|
+
@observed.grep(Array).flatten.first&.class&.name || "String"
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def inferred_hash_types
|
|
102
|
+
pair = @observed.grep(Hash).flat_map(&:to_a).first
|
|
103
|
+
pair ? pair.map { |item| item.class.name } : %w[String String]
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def user_structures(type)
|
|
107
|
+
klass = Bparity.constantize(type.to_s)
|
|
108
|
+
examples = @observed.grep(klass)
|
|
109
|
+
if examples.empty?
|
|
110
|
+
raise ConfigurationError,
|
|
111
|
+
"Cannot enumerate #{type}. Add observed values or an explicit input domain."
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
fields = examples.flat_map(&:instance_variables).uniq
|
|
115
|
+
domains = fields.to_h do |field|
|
|
116
|
+
[field, (examples.map { |example| example.instance_variable_get(field) } + [nil]).uniq]
|
|
117
|
+
end
|
|
118
|
+
KoratEnumerator.new(klass:, fields:, domains:, limit: @limit).values
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def constant_class(type)
|
|
122
|
+
Bparity.constantize(type.to_s)
|
|
123
|
+
rescue ConfigurationError
|
|
124
|
+
nil
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
class InputGenerator
|
|
129
|
+
def initialize(size:, depth:, observed: [], alphabet: %w[a b])
|
|
130
|
+
@enumerator = ValueEnumerator.new(size:, depth:, observed:, alphabet:)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def values(type)
|
|
134
|
+
(@enumerator.values(type) + boundaries(type)).uniq
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
private
|
|
138
|
+
|
|
139
|
+
def boundaries(type)
|
|
140
|
+
case type.to_s
|
|
141
|
+
when "Integer" then [0, -1, 2**62, -(2**62)]
|
|
142
|
+
when "String" then ["", " ", "\xFF".b.force_encoding(Encoding::UTF_8)]
|
|
143
|
+
else [nil]
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
class KoratEnumerator
|
|
149
|
+
def initialize(klass:, fields:, domains:, predicate: ->(_value) { true }, limit: nil)
|
|
150
|
+
@klass = klass
|
|
151
|
+
@fields = fields.map(&:to_sym)
|
|
152
|
+
@domains = domains.transform_keys(&:to_sym)
|
|
153
|
+
@predicate = predicate
|
|
154
|
+
@limit = limit
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def values
|
|
158
|
+
seen = {}
|
|
159
|
+
results = []
|
|
160
|
+
vectors.each do |vector|
|
|
161
|
+
candidate = build(vector)
|
|
162
|
+
next unless @predicate.call(candidate)
|
|
163
|
+
|
|
164
|
+
signature = JSON.generate(shape(candidate))
|
|
165
|
+
next if seen[signature]
|
|
166
|
+
|
|
167
|
+
seen[signature] = true
|
|
168
|
+
return Domain.new(results, truncated: true) if @limit && results.length >= @limit
|
|
169
|
+
|
|
170
|
+
results << candidate
|
|
171
|
+
end
|
|
172
|
+
Domain.new(results)
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
private
|
|
176
|
+
|
|
177
|
+
def vectors
|
|
178
|
+
return [[]] if @fields.empty?
|
|
179
|
+
|
|
180
|
+
Enumerator.new do |items|
|
|
181
|
+
@domains.fetch(@fields.first).product(*@fields.drop(1).map { |field| @domains.fetch(field) }) do |vector|
|
|
182
|
+
items << vector
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def build(vector)
|
|
188
|
+
@klass.allocate.tap do |candidate|
|
|
189
|
+
@fields.zip(vector).each { |field, value| candidate.instance_variable_set(field, value) }
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def shape(value, seen = {}.compare_by_identity)
|
|
194
|
+
return Recording::Serializer.dump(value) unless value.is_a?(@klass)
|
|
195
|
+
return { "$ref" => seen.fetch(value) } if seen.key?(value)
|
|
196
|
+
|
|
197
|
+
seen[value] = seen.length
|
|
198
|
+
{ "$class" => @klass.name,
|
|
199
|
+
"$fields" => @fields.to_h { |field| [field, shape(value.instance_variable_get(field), seen)] } }
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
class ExhaustiveRunner
|
|
204
|
+
DEFAULT_MAX_CASES = 100_000
|
|
205
|
+
DEFAULT_TIMEBOX = 300
|
|
206
|
+
|
|
207
|
+
def initialize(new_callable:, domains:, size:, depth:, assumptions:, old_callable: nil,
|
|
208
|
+
contracts: [], preconditions: [], max_cases: DEFAULT_MAX_CASES,
|
|
209
|
+
timebox: DEFAULT_TIMEBOX, comparator: Verification::Comparator.new(mode: :strict),
|
|
210
|
+
new_error_mapper: nil)
|
|
211
|
+
@old_callable = old_callable
|
|
212
|
+
@new_callable = new_callable
|
|
213
|
+
@domains = domains
|
|
214
|
+
@size = size
|
|
215
|
+
@depth = depth
|
|
216
|
+
@assumptions = assumptions
|
|
217
|
+
@max_cases = max_cases
|
|
218
|
+
@timebox = timebox
|
|
219
|
+
@comparator = comparator
|
|
220
|
+
@new_error_mapper = new_error_mapper
|
|
221
|
+
@contracts = contracts
|
|
222
|
+
@preconditions = preconditions
|
|
223
|
+
@checker = ContractChecker.new
|
|
224
|
+
return unless @old_callable.nil? && @contracts.empty?
|
|
225
|
+
|
|
226
|
+
raise ConfigurationError,
|
|
227
|
+
"F2 needs a runnable legacy implementation or declared postconditions/invariants."
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def run
|
|
231
|
+
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
232
|
+
count = 0
|
|
233
|
+
visited = filtered = 0
|
|
234
|
+
counterexample = nil
|
|
235
|
+
fallback = @domains.any? { |domain| domain.respond_to?(:truncated) && domain.truncated } ||
|
|
236
|
+
total_cases > @max_cases
|
|
237
|
+
inputs = fallback ? pairwise_inputs : exhaustive_inputs
|
|
238
|
+
timed_out = false
|
|
239
|
+
inputs.first(@max_cases).each do |input|
|
|
240
|
+
if Process.clock_gettime(Process::CLOCK_MONOTONIC) - started >= @timebox
|
|
241
|
+
timed_out = true
|
|
242
|
+
break
|
|
243
|
+
end
|
|
244
|
+
visited += 1
|
|
245
|
+
unless admissible?(input)
|
|
246
|
+
filtered += 1
|
|
247
|
+
next
|
|
248
|
+
end
|
|
249
|
+
count += 1
|
|
250
|
+
counterexample = compare(input)
|
|
251
|
+
break if counterexample
|
|
252
|
+
end
|
|
253
|
+
complete = !fallback && !timed_out && visited == total_cases && count.positive?
|
|
254
|
+
result(count, complete, counterexample, fallback ? count : 0, filtered)
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
private
|
|
258
|
+
|
|
259
|
+
def exhaustive_inputs
|
|
260
|
+
return [[]].each if @domains.empty?
|
|
261
|
+
|
|
262
|
+
Enumerator.new do |items|
|
|
263
|
+
@domains.first.product(*@domains.drop(1)) { |input| items << input }
|
|
264
|
+
end
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def total_cases = @domains.empty? ? 1 : @domains.reduce(1) { |count, domain| count * domain.length }
|
|
268
|
+
|
|
269
|
+
def observe(callable, input, error_mapper)
|
|
270
|
+
{ "kind" => "return", "value" => Recording::Serializer.dump(callable.call(*input)) }
|
|
271
|
+
rescue StandardError => e
|
|
272
|
+
mapped = error_mapper ? error_mapper.call(e) : { class: e.class.name, message: Bparity.exception_message(e) }
|
|
273
|
+
normalized = mapped.to_h { |key, value| [key.to_s, value] }.compact
|
|
274
|
+
{ "kind" => "raise", **normalized }
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
def compare(input)
|
|
278
|
+
return compare_contract(input) unless @old_callable
|
|
279
|
+
|
|
280
|
+
expected = observe(@old_callable, input, nil)
|
|
281
|
+
actual = observe(@new_callable, input, @new_error_mapper)
|
|
282
|
+
differences = @comparator.compare(expected, actual)
|
|
283
|
+
return if differences.empty?
|
|
284
|
+
|
|
285
|
+
{ "input" => input, "expected" => expected, "actual" => actual,
|
|
286
|
+
"differences" => differences }
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
def compare_contract(input)
|
|
290
|
+
actual = observe(@new_callable, input, @new_error_mapper)
|
|
291
|
+
result = actual["kind"] == "return" ? Recording::Serializer.load(actual["value"]) : nil
|
|
292
|
+
violations = if actual["kind"] == "raise"
|
|
293
|
+
[{ "id" => "execution", "error" => "replacement raised #{actual['class']}" }]
|
|
294
|
+
else
|
|
295
|
+
@checker.check(@contracts, result:, args: input, kwargs: {})
|
|
296
|
+
end
|
|
297
|
+
return if violations.empty?
|
|
298
|
+
|
|
299
|
+
{ "input" => input, "actual" => actual, "contracts" => @contracts, "violations" => violations,
|
|
300
|
+
"differences" => violations.map do |violation|
|
|
301
|
+
{ "path" => "$.contracts.#{violation['id']}", "expected" => violation["expression"] || "no exception",
|
|
302
|
+
"actual" => violation["error"] || false }
|
|
303
|
+
end }
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
def admissible?(input)
|
|
307
|
+
violations = @checker.check(@preconditions, result: nil, args: input, kwargs: {})
|
|
308
|
+
error = violations.find { |violation| violation["error"] }
|
|
309
|
+
if error
|
|
310
|
+
raise ConfigurationError,
|
|
311
|
+
"Cannot evaluate F2 precondition #{error['id']}: #{error['error']}. Fix the Spec Bundle."
|
|
312
|
+
end
|
|
313
|
+
violations.empty?
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
def pairwise_inputs
|
|
317
|
+
return exhaustive_inputs if @domains.length < 2
|
|
318
|
+
|
|
319
|
+
Enumerator.new do |items|
|
|
320
|
+
seen = {}
|
|
321
|
+
@domains.each_index.to_a.combination(2).each do |left, right|
|
|
322
|
+
@domains[left].product(@domains[right]) do |left_value, right_value|
|
|
323
|
+
input = @domains.map.with_index do |domain, index|
|
|
324
|
+
{ left => left_value, right => right_value }.fetch(index, domain.first)
|
|
325
|
+
end
|
|
326
|
+
next if seen[input]
|
|
327
|
+
|
|
328
|
+
seen[input] = true
|
|
329
|
+
items << input
|
|
330
|
+
end
|
|
331
|
+
end
|
|
332
|
+
end
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
def result(count, complete, counterexample, fallback_count, filtered_count)
|
|
336
|
+
verdict = if counterexample then :difference_found
|
|
337
|
+
elsif complete then :no_difference_found
|
|
338
|
+
else :inconclusive
|
|
339
|
+
end
|
|
340
|
+
incomplete_note = if count.zero?
|
|
341
|
+
"no admissible input was checked; exhaustive claim withheld"
|
|
342
|
+
else
|
|
343
|
+
"case limit or timebox reached; exhaustive claim withheld"
|
|
344
|
+
end
|
|
345
|
+
details = if complete
|
|
346
|
+
{ "filtered_by_preconditions" => filtered_count }
|
|
347
|
+
else
|
|
348
|
+
{ "fallback" => "pairwise", "fallback_cases" => fallback_count,
|
|
349
|
+
"filtered_by_preconditions" => filtered_count,
|
|
350
|
+
"note" => incomplete_note }
|
|
351
|
+
end
|
|
352
|
+
legacy_scope = if @old_callable
|
|
353
|
+
"legacy behavior outside the selected operation"
|
|
354
|
+
else
|
|
355
|
+
"legacy behavior beyond declared contracts"
|
|
356
|
+
end
|
|
357
|
+
Result.new(level: :f2, verdict:,
|
|
358
|
+
scope: Scope.new(size: @size, depth: @depth, cases: count,
|
|
359
|
+
exhaustive: complete && counterexample.nil?,
|
|
360
|
+
timebox: @timebox),
|
|
361
|
+
assumptions: @assumptions,
|
|
362
|
+
out_of_scope: [legacy_scope, "values outside the explicitly enumerated domains",
|
|
363
|
+
"values larger than size #{@size}", "structures deeper than #{@depth}"],
|
|
364
|
+
counterexample:, details: details.merge("domains" => domain_summary))
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
def domain_summary
|
|
368
|
+
@domains.map do |domain|
|
|
369
|
+
{ "count" => domain.length, "sample" => Recording::Serializer.dump(domain.first(5)),
|
|
370
|
+
"truncated" => domain.respond_to?(:truncated) && domain.truncated }
|
|
371
|
+
end
|
|
372
|
+
end
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
module BoundedCounterexampleRSpec
|
|
376
|
+
module_function
|
|
377
|
+
|
|
378
|
+
def call(result:, subject_name:, operation_name:)
|
|
379
|
+
return contract_spec(result, subject_name, operation_name) if result.counterexample["contracts"]
|
|
380
|
+
|
|
381
|
+
input = result.counterexample.fetch("input")
|
|
382
|
+
expected = result.counterexample.fetch("expected")
|
|
383
|
+
<<~RUBY
|
|
384
|
+
# frozen_string_literal: true
|
|
385
|
+
|
|
386
|
+
require "bparity"
|
|
387
|
+
require ENV["BPARITY_REPLACEMENT"] if ENV["BPARITY_REPLACEMENT"]
|
|
388
|
+
load ENV.fetch("BPARITY_ADAPTER")
|
|
389
|
+
|
|
390
|
+
RSpec.describe "F2 counterexample for #{subject_name}#{operation_name}" do
|
|
391
|
+
it "matches the legacy observation for #{input.inspect}" do
|
|
392
|
+
binding = Bparity.adapter_definition.subjects.fetch(#{subject_name.inspect})
|
|
393
|
+
operation = binding.operations.fetch(#{operation_name.inspect})
|
|
394
|
+
begin
|
|
395
|
+
value = operation.invoke(binding.build({}), #{input.inspect}, {})
|
|
396
|
+
value = operation.map_return(value)
|
|
397
|
+
actual = { "kind" => "return", "value" => Bparity::Recording::Serializer.dump(value) }
|
|
398
|
+
rescue StandardError => error
|
|
399
|
+
mapped = operation.map_error(error).transform_keys(&:to_s)
|
|
400
|
+
mapped["cause"] = error.cause&.class&.name unless mapped.key?("cause")
|
|
401
|
+
actual = { "kind" => "raise", **mapped }
|
|
402
|
+
end
|
|
403
|
+
expect(actual).to eq(#{expected.inspect})
|
|
404
|
+
end
|
|
405
|
+
end
|
|
406
|
+
RUBY
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
def contract_spec(result, subject_name, operation_name)
|
|
410
|
+
input = result.counterexample.fetch("input")
|
|
411
|
+
contracts = result.counterexample.fetch("contracts")
|
|
412
|
+
<<~RUBY
|
|
413
|
+
# frozen_string_literal: true
|
|
414
|
+
|
|
415
|
+
require "bparity"
|
|
416
|
+
require ENV["BPARITY_REPLACEMENT"] if ENV["BPARITY_REPLACEMENT"]
|
|
417
|
+
load ENV.fetch("BPARITY_ADAPTER")
|
|
418
|
+
|
|
419
|
+
RSpec.describe "F2 contract counterexample for #{subject_name}#{operation_name}" do
|
|
420
|
+
it "satisfies the declared contracts for #{input.inspect}" do
|
|
421
|
+
binding = Bparity.adapter_definition.subjects.fetch(#{subject_name.inspect})
|
|
422
|
+
operation = binding.operations.fetch(#{operation_name.inspect})
|
|
423
|
+
result = operation.map_return(operation.invoke(binding.build({}), #{input.inspect}, {}))
|
|
424
|
+
violations = Bparity::Formal::ContractChecker.new.check(#{contracts.inspect},
|
|
425
|
+
result: result, args: #{input.inspect}, kwargs: {})
|
|
426
|
+
expect(violations).to be_empty
|
|
427
|
+
end
|
|
428
|
+
end
|
|
429
|
+
RUBY
|
|
430
|
+
end
|
|
431
|
+
private_class_method :contract_spec
|
|
432
|
+
end
|
|
433
|
+
|
|
434
|
+
class PropertyRunner
|
|
435
|
+
def initialize(callable:, invariants:, inputs:, preconditions: [], checker: ContractChecker.new)
|
|
436
|
+
@callable = callable
|
|
437
|
+
@invariants = invariants
|
|
438
|
+
@inputs = inputs
|
|
439
|
+
@preconditions = preconditions
|
|
440
|
+
@checker = checker
|
|
441
|
+
end
|
|
442
|
+
|
|
443
|
+
def run
|
|
444
|
+
checked = 0
|
|
445
|
+
@inputs.each do |args|
|
|
446
|
+
next unless preconditions_hold?(args)
|
|
447
|
+
|
|
448
|
+
checked += 1
|
|
449
|
+
violations = violations_for(args)
|
|
450
|
+
unless violations.empty?
|
|
451
|
+
input = minimize(args)
|
|
452
|
+
return { "input" => input, "violations" => violations_for(input) }
|
|
453
|
+
end
|
|
454
|
+
end
|
|
455
|
+
if checked.zero?
|
|
456
|
+
raise ConfigurationError,
|
|
457
|
+
"Property verification generated no input satisfying the preconditions. Expand the input domain."
|
|
458
|
+
end
|
|
459
|
+
nil
|
|
460
|
+
end
|
|
461
|
+
|
|
462
|
+
private
|
|
463
|
+
|
|
464
|
+
def minimize(args)
|
|
465
|
+
minimized = args.dup
|
|
466
|
+
args.each_index do |index|
|
|
467
|
+
shrink_candidates(args[index]).each do |candidate|
|
|
468
|
+
trial = minimized.dup
|
|
469
|
+
trial[index] = candidate
|
|
470
|
+
minimized = trial if preconditions_hold?(trial) && !violations_for(trial).empty?
|
|
471
|
+
rescue ConfigurationError
|
|
472
|
+
raise
|
|
473
|
+
rescue StandardError
|
|
474
|
+
next
|
|
475
|
+
end
|
|
476
|
+
end
|
|
477
|
+
minimized
|
|
478
|
+
end
|
|
479
|
+
|
|
480
|
+
def violations_for(args)
|
|
481
|
+
@checker.check(@invariants, result: @callable.call(*args), args:)
|
|
482
|
+
rescue StandardError => e
|
|
483
|
+
[{ "id" => "execution", "error" => Bparity.exception_message(e), "input" => { args: } }]
|
|
484
|
+
end
|
|
485
|
+
|
|
486
|
+
def preconditions_hold?(args)
|
|
487
|
+
violations = @checker.check(@preconditions, result: nil, args:)
|
|
488
|
+
error = violations.find { |violation| violation["error"] }
|
|
489
|
+
if error
|
|
490
|
+
raise ConfigurationError,
|
|
491
|
+
"Property precondition #{error['id']} could not be evaluated: #{error['error']}."
|
|
492
|
+
end
|
|
493
|
+
violations.empty?
|
|
494
|
+
end
|
|
495
|
+
|
|
496
|
+
def shrink_candidates(value)
|
|
497
|
+
candidates = case value
|
|
498
|
+
when String then ["", value[0]]
|
|
499
|
+
when Array then [[], value.first(1)]
|
|
500
|
+
when Integer then [0, value <=> 0]
|
|
501
|
+
else []
|
|
502
|
+
end
|
|
503
|
+
candidates.uniq - [value]
|
|
504
|
+
end
|
|
505
|
+
end
|
|
506
|
+
|
|
507
|
+
class DifferentialRunner
|
|
508
|
+
def initialize(old_command:, new_command:, inputs:)
|
|
509
|
+
@old_command = old_command
|
|
510
|
+
@new_command = new_command
|
|
511
|
+
@inputs = inputs
|
|
512
|
+
end
|
|
513
|
+
|
|
514
|
+
def run
|
|
515
|
+
@inputs.filter_map do |input|
|
|
516
|
+
expected = observe(@old_command, input)
|
|
517
|
+
actual = observe(@new_command, input)
|
|
518
|
+
differences = Verification::Differ.call(expected, actual)
|
|
519
|
+
unless differences.empty?
|
|
520
|
+
{ "input" => input, "expected" => expected, "actual" => actual,
|
|
521
|
+
"differences" => differences }
|
|
522
|
+
end
|
|
523
|
+
end
|
|
524
|
+
end
|
|
525
|
+
|
|
526
|
+
private
|
|
527
|
+
|
|
528
|
+
def observe(command, input)
|
|
529
|
+
payload = Recording::Serializer.dump(input)
|
|
530
|
+
output, error, status = Open3.capture3(*command, stdin_data: JSON.generate(payload))
|
|
531
|
+
unless status.success?
|
|
532
|
+
raise ConfigurationError,
|
|
533
|
+
"Differential process failed: #{error.strip}. Fix the command and run the comparison again."
|
|
534
|
+
end
|
|
535
|
+
|
|
536
|
+
JSON.parse(output)
|
|
537
|
+
rescue JSON::ParserError
|
|
538
|
+
raise ConfigurationError,
|
|
539
|
+
"Differential process returned invalid JSON. Make it print one JSON observation and try again."
|
|
540
|
+
end
|
|
541
|
+
end
|
|
542
|
+
end
|
|
543
|
+
end
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
module Bparity
|
|
6
|
+
module Formal
|
|
7
|
+
class ContractCompiler
|
|
8
|
+
VARIABLES = %i[result args kwargs pre_state post_state].freeze
|
|
9
|
+
CONSTANTS = { "Array" => Array, "Hash" => Hash, "String" => String, "Integer" => Integer,
|
|
10
|
+
"Float" => Float, "Symbol" => Symbol, "NilClass" => NilClass }.freeze
|
|
11
|
+
CALLS = %i[== != > >= < <= + - * / % [] size length nil? empty? is_a? between? match?
|
|
12
|
+
start_with? end_with? include? uniq sort strip !].freeze
|
|
13
|
+
|
|
14
|
+
def compile(expression)
|
|
15
|
+
source = expression.gsub(/\breturn\b/, "result")
|
|
16
|
+
prefix = VARIABLES.map { |name| "#{name} = nil" }.join("; ")
|
|
17
|
+
parsed = Prism.parse("#{prefix}; #{source}")
|
|
18
|
+
unless parsed.success?
|
|
19
|
+
raise ConfigurationError,
|
|
20
|
+
"Invalid contract expression: #{expression}. Fix the Spec Bundle."
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
node = parsed.value.statements.body.last
|
|
24
|
+
validate!(node)
|
|
25
|
+
->(context) { evaluate(node, context.transform_keys(&:to_sym)) }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def validate!(node)
|
|
31
|
+
node.compact_child_nodes.each { |child| validate!(child) }
|
|
32
|
+
return unless node.type == :call_node && !CALLS.include?(node.name)
|
|
33
|
+
|
|
34
|
+
raise ConfigurationError, "Contract method #{node.name} is not allowed. Use a supported first-order predicate."
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def evaluate(node, context)
|
|
38
|
+
case node.type
|
|
39
|
+
when :local_variable_read_node then context.fetch(node.name)
|
|
40
|
+
when :integer_node, :float_node then node.value
|
|
41
|
+
when :string_node then node.unescaped
|
|
42
|
+
when :symbol_node then node.unescaped.to_sym
|
|
43
|
+
when :regular_expression_node then Regexp.new(node.unescaped)
|
|
44
|
+
when :nil_node then nil
|
|
45
|
+
when :true_node, :false_node then node.type == :true_node
|
|
46
|
+
when :array_node then node.elements.map { |element| evaluate(element, context) }
|
|
47
|
+
when :constant_read_node then CONSTANTS.fetch(node.name.to_s)
|
|
48
|
+
when :and_node then evaluate(node.left, context) && evaluate(node.right, context)
|
|
49
|
+
when :or_node then evaluate(node.left, context) || evaluate(node.right, context)
|
|
50
|
+
when :parentheses_node then evaluate(node.body.body.last, context)
|
|
51
|
+
when :call_node then evaluate_call(node, context)
|
|
52
|
+
else raise ConfigurationError, "Contract syntax #{node.type} is not supported. Simplify the expression."
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def evaluate_call(node, context)
|
|
57
|
+
receiver = evaluate(node.receiver, context)
|
|
58
|
+
arguments = node.arguments&.arguments&.map { |argument| evaluate(argument, context) } || []
|
|
59
|
+
receiver.public_send(node.name, *arguments)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
class ContractChecker
|
|
64
|
+
def initialize(compiler: ContractCompiler.new)
|
|
65
|
+
@compiler = compiler
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def check(invariants, context)
|
|
69
|
+
invariants.filter_map do |invariant|
|
|
70
|
+
predicate = @compiler.compile(invariant.fetch("expr"))
|
|
71
|
+
next if predicate.call(context)
|
|
72
|
+
|
|
73
|
+
{ "id" => invariant["id"], "expression" => invariant["expr"], "input" => context }
|
|
74
|
+
rescue StandardError => e
|
|
75
|
+
{ "id" => invariant["id"], "expression" => invariant["expr"], "error" => e.message,
|
|
76
|
+
"input" => context }
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|