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.
Files changed (68) hide show
  1. checksums.yaml +7 -0
  2. data/.rubocop.yml +61 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +146 -0
  5. data/Rakefile +36 -0
  6. data/docs/application_example.md +25 -0
  7. data/docs/formal_assurance_limits.md +21 -0
  8. data/exe/bparity +7 -0
  9. data/fixtures/scenarios/01_pure_function/adapter.rb +13 -0
  10. data/fixtures/scenarios/01_pure_function/boundary.rb +17 -0
  11. data/fixtures/scenarios/01_pure_function/legacy/dead_gem.rb +9 -0
  12. data/fixtures/scenarios/01_pure_function/legacy/slugifier.rb +17 -0
  13. data/fixtures/scenarios/01_pure_function/replacement/broken.rb +15 -0
  14. data/fixtures/scenarios/01_pure_function/replacement/good.rb +19 -0
  15. data/fixtures/scenarios/01_pure_function/spec/slugifier_spec.rb +20 -0
  16. data/fixtures/scenarios/01_pure_function/test/slugifier_test.rb +10 -0
  17. data/fixtures/scenarios/02_stateful_client/adapter.rb +19 -0
  18. data/fixtures/scenarios/02_stateful_client/boundary.rb +10 -0
  19. data/fixtures/scenarios/02_stateful_client/legacy/client.rb +29 -0
  20. data/fixtures/scenarios/02_stateful_client/replacement/broken.rb +14 -0
  21. data/fixtures/scenarios/02_stateful_client/replacement/good.rb +23 -0
  22. data/fixtures/scenarios/02_stateful_client/spec/client_spec.rb +31 -0
  23. data/fixtures/scenarios/03_external_boundary/adapter.rb +13 -0
  24. data/fixtures/scenarios/03_external_boundary/boundary.rb +11 -0
  25. data/fixtures/scenarios/03_external_boundary/legacy/dead_formatter.rb +7 -0
  26. data/fixtures/scenarios/03_external_boundary/legacy/receipt.rb +11 -0
  27. data/fixtures/scenarios/03_external_boundary/replacement/broken.rb +11 -0
  28. data/fixtures/scenarios/03_external_boundary/replacement/good.rb +11 -0
  29. data/fixtures/scenarios/03_external_boundary/spec/receipt_spec.rb +10 -0
  30. data/fixtures/scenarios/04_intentional_divergence/adapter.rb +12 -0
  31. data/fixtures/scenarios/04_intentional_divergence/adapter_unwaived.rb +10 -0
  32. data/fixtures/scenarios/04_intentional_divergence/boundary.rb +8 -0
  33. data/fixtures/scenarios/04_intentional_divergence/legacy/dead_identity.rb +7 -0
  34. data/fixtures/scenarios/04_intentional_divergence/legacy/identity.rb +9 -0
  35. data/fixtures/scenarios/04_intentional_divergence/replacement/broken.rb +9 -0
  36. data/fixtures/scenarios/04_intentional_divergence/replacement/good.rb +9 -0
  37. data/fixtures/scenarios/04_intentional_divergence/spec/identity_spec.rb +10 -0
  38. data/fixtures/scenarios/05_formal_negative/adapter.rb +15 -0
  39. data/fixtures/scenarios/05_formal_negative/boundary.rb +16 -0
  40. data/fixtures/scenarios/05_formal_negative/legacy/dead_lock.rb +5 -0
  41. data/fixtures/scenarios/05_formal_negative/legacy/turnstile.rb +24 -0
  42. data/fixtures/scenarios/05_formal_negative/replacement/broken.rb +18 -0
  43. data/fixtures/scenarios/05_formal_negative/replacement/formal_broken.rb +24 -0
  44. data/fixtures/scenarios/05_formal_negative/replacement/good.rb +24 -0
  45. data/fixtures/scenarios/05_formal_negative/spec/turnstile_spec.rb +21 -0
  46. data/lib/bparity/adapter.rb +115 -0
  47. data/lib/bparity/adequacy.rb +78 -0
  48. data/lib/bparity/boundary.rb +97 -0
  49. data/lib/bparity/cli/formal_commands.rb +428 -0
  50. data/lib/bparity/cli/verification_commands.rb +242 -0
  51. data/lib/bparity/cli.rb +262 -0
  52. data/lib/bparity/corpus.rb +45 -0
  53. data/lib/bparity/errors.rb +12 -0
  54. data/lib/bparity/formal/assumptions.rb +131 -0
  55. data/lib/bparity/formal/bounded.rb +543 -0
  56. data/lib/bparity/formal/contract.rb +81 -0
  57. data/lib/bparity/formal/deductive.rb +481 -0
  58. data/lib/bparity/formal/lts.rb +344 -0
  59. data/lib/bparity/formal/result.rb +50 -0
  60. data/lib/bparity/formal.rb +8 -0
  61. data/lib/bparity/recording.rb +412 -0
  62. data/lib/bparity/reporting.rb +128 -0
  63. data/lib/bparity/spec_bundle.rb +208 -0
  64. data/lib/bparity/synthesis.rb +487 -0
  65. data/lib/bparity/verification.rb +310 -0
  66. data/lib/bparity/version.rb +5 -0
  67. data/lib/bparity.rb +42 -0
  68. 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