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,428 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bparity
4
+ module FormalCommands # rubocop:disable Metrics/ModuleLength -- one private CLI command family
5
+ private
6
+
7
+ def command_prove(argv)
8
+ options = formal_options(argv)
9
+ validate_formal_options!(options)
10
+ options[:requires].each { |path| require File.expand_path(path) }
11
+ bundle = SpecBundle::Loader.load(options[:spec])
12
+ Bparity.reset!
13
+ load File.expand_path(options[:adapter])
14
+ raise ConfigurationError, "The adapter file did not call Bparity.adapter." unless Bparity.adapter_definition
15
+
16
+ result = case options[:level]
17
+ when "f2" then run_f2(bundle, Bparity.adapter_definition, options)
18
+ when "f3" then run_f3(bundle, Bparity.adapter_definition, options)
19
+ else run_f4(bundle, Bparity.adapter_definition, options)
20
+ end
21
+ promote_f2_invariants(bundle, options, result)
22
+ @out.puts(JSON.pretty_generate(result.to_h))
23
+ result.success? || result.details["skipped"] ? 0 : 1
24
+ end
25
+
26
+ def formal_options(argv)
27
+ options = { spec: ".bparity/spec_bundle.yml", adapter: ".bparity/adapter.rb", level: "f2",
28
+ size: 3, depth: 2, max_cases: 100_000, timebox: 300, requires: [], relation: "trace" }
29
+ OptionParser.new do |opts|
30
+ opts.on("--spec PATH") { |value| options[:spec] = value }
31
+ opts.on("--adapter PATH") { |value| options[:adapter] = value }
32
+ opts.on("--level LEVEL") { |value| options[:level] = value }
33
+ add_formal_target_options(opts, options)
34
+ opts.on("--scope SCOPE") { |value| parse_scope(value, options) }
35
+ opts.on("--max-cases N", Integer) { |value| options[:max_cases] = value }
36
+ opts.on("--timebox SECONDS", Integer) { |value| options[:timebox] = value }
37
+ opts.on("--state-limit N", Integer) { |value| options[:state_limit] = value }
38
+ opts.on("--equivalence RELATION") { |value| options[:relation] = value }
39
+ opts.on("--export-lts PREFIX") { |value| options[:export_lts] = value }
40
+ opts.on("--counterexample-out PATH") { |value| options[:counterexample_out] = value }
41
+ add_formal_validation_options(opts, options)
42
+ opts.on("--old-source PATH") { |value| options[:old_source] = value }
43
+ opts.on("--new-source PATH") { |value| options[:new_source] = value }
44
+ opts.on("--old-method NAME") { |value| options[:old_method] = value }
45
+ opts.on("--new-method NAME") { |value| options[:new_method] = value }
46
+ opts.on("--types TYPES") { |value| options[:types] = value.split(",") }
47
+ opts.on("--solver NAME") { |value| options[:solver] = value }
48
+ opts.on("--require PATH") { |value| options[:requires] << value }
49
+ end.parse!(argv)
50
+ options
51
+ end
52
+
53
+ def promote_f2_invariants(bundle, options, result)
54
+ return unless options[:level] == "f2" && options[:promote_invariants] && result.success? &&
55
+ result.scope.exhaustive
56
+
57
+ _subject, operation = select_formal_operation(bundle, options)
58
+ invariants = operation.fetch("invariants", [])
59
+ invariants.each { |invariant| invariant["formal_level"] = "F2" }
60
+ result.details["promoted_invariants"] = invariants.map { |invariant| invariant["id"] }
61
+ SpecBundle::Writer.write(options[:spec], bundle)
62
+ end
63
+
64
+ def add_formal_target_options(parser, options)
65
+ parser.on("--subject NAME") { |value| options[:subject] = value }
66
+ parser.on("--operation NAME") { |value| options[:operation] = value }
67
+ end
68
+
69
+ def add_formal_validation_options(parser, options)
70
+ parser.on("--validate-translation") { options[:validate_translation] = true }
71
+ parser.on("--promote-invariants") { options[:promote_invariants] = true }
72
+ end
73
+
74
+ def validate_formal_options!(options)
75
+ unless %w[f2 f3 f4].include?(options[:level])
76
+ raise ConfigurationError, "Formal level #{options[:level]} is not available. Use f2, f3, or f4."
77
+ end
78
+ unless options.values_at(:size, :depth).all? { |value| value >= 0 } &&
79
+ options.values_at(:max_cases, :timebox).all?(&:positive?) &&
80
+ (!options[:state_limit] || options[:state_limit].positive?)
81
+ raise ConfigurationError,
82
+ "Formal limits are invalid. Use non-negative size/depth and positive case, time, and state limits."
83
+ end
84
+ return unless options[:level] == "f4" && !options[:validate_translation]
85
+
86
+ raise ConfigurationError, "F4 requires --validate-translation. Run F2 first and enable translation validation."
87
+ end
88
+
89
+ def run_f2(bundle, adapter, options)
90
+ spec_subject, operation_spec = select_formal_operation(bundle, options)
91
+ binding, operation = formal_binding(adapter, spec_subject, operation_spec)
92
+ old_class = legacy_class(spec_subject)
93
+ domains = f2_domains(operation_spec, options)
94
+ method_name = operation_spec.fetch("name").delete_prefix("#")
95
+ validate_legacy_operation!(old_class, method_name) if old_class
96
+ old_callable = ->(*args) { old_class.new.public_send(method_name, *args) } if old_class
97
+ new_callable = ->(*args) { operation.invoke(binding.build({}), args, {}) }
98
+ runner = build_f2_runner(old_callable, new_callable, domains, operation, operation_spec, options)
99
+ violations = f2_static_violations(old_class, method_name, operation)
100
+ unless violations.empty?
101
+ return Formal::Result.new(level: :f2, verdict: :inconclusive,
102
+ scope: Formal::Scope.new(size: options[:size], depth: options[:depth],
103
+ cases: 0, exhaustive: false,
104
+ timebox: options[:timebox]),
105
+ assumptions: %i[h1 h3 h7], out_of_scope: ["dynamic Ruby"],
106
+ details: { "assumption_violations" => violations })
107
+ end
108
+ target = binding.build({})
109
+ monitored_class = target.is_a?(Module) ? target.singleton_class : target.class
110
+ result, violations = Formal::Assumptions::WorldFreeze.new([old_class, monitored_class].compact).check do
111
+ runner.run
112
+ end
113
+ if violations.empty?
114
+ write_f2_counterexample(result, options, spec_subject, operation_spec)
115
+ return result
116
+ end
117
+
118
+ Formal::Result.new(level: :f2, verdict: :inconclusive, scope: result.scope,
119
+ assumptions: result.assumptions, out_of_scope: result.out_of_scope,
120
+ details: { "assumption_violations" => violations })
121
+ end
122
+
123
+ def legacy_class(subject)
124
+ Bparity.constantize(subject.fetch("old_class"))
125
+ rescue ConfigurationError => e
126
+ raise unless e.message.start_with?("Cannot find ")
127
+
128
+ nil
129
+ end
130
+
131
+ def validate_legacy_operation!(old_class, method_name)
132
+ unless old_class.public_method_defined?(method_name)
133
+ raise ConfigurationError,
134
+ "Legacy class #{old_class} has no public method #{method_name}. Fix the Spec Bundle target."
135
+ end
136
+ old_class.new
137
+ rescue ConfigurationError
138
+ raise
139
+ rescue StandardError => e
140
+ raise ConfigurationError,
141
+ "Legacy class #{old_class} cannot be constructed without arguments: #{e.message}. " \
142
+ "Use contract-only F2 or record a construct mapping."
143
+ end
144
+
145
+ def f2_static_violations(old_class, method_name, operation)
146
+ old_source = old_class.instance_method(method_name).source_location&.first if old_class
147
+ paths = [old_source, operation.invoker&.source_location&.first].compact.uniq
148
+ Formal::Assumptions::DynamicCodeDetector.new.scan(paths)
149
+ end
150
+
151
+ def write_f2_counterexample(result, options, subject, operation)
152
+ return unless result.counterexample && options[:counterexample_out]
153
+
154
+ content = Formal::BoundedCounterexampleRSpec.call(result:, subject_name: subject.fetch("name"),
155
+ operation_name: operation.fetch("name"))
156
+ FileUtils.mkdir_p(File.dirname(options[:counterexample_out]))
157
+ File.write(options[:counterexample_out], content)
158
+ end
159
+
160
+ def f2_domains(operation_spec, options)
161
+ limit = options.fetch(:max_cases, Formal::ExhaustiveRunner::DEFAULT_MAX_CASES)
162
+ operation_spec.fetch("params", []).map do |param|
163
+ observed = param.fetch("observed_values", []).map { |value| Recording::Serializer.load(value) }
164
+ alphabet = observed.grep(String).flat_map(&:chars).uniq.first(8)
165
+ enumerator = Formal::ValueEnumerator.new(size: options[:size], depth: options[:depth],
166
+ alphabet: alphabet.empty? ? %w[a b] : alphabet, observed:,
167
+ limit:)
168
+ types = param.fetch("types", [])
169
+ if types.empty?
170
+ raise ConfigurationError,
171
+ "F2 parameter #{param['name']} has no inferred type. Record values or add an explicit input domain."
172
+ end
173
+ type_domains = types.map { |type| enumerator.values(type) }
174
+ values = (type_domains.flat_map(&:to_a) + [nil]).uniq
175
+ truncated = type_domains.any?(&:truncated) || values.length > limit
176
+ Formal::Domain.new(values.first(limit), truncated:)
177
+ end
178
+ end
179
+
180
+ def select_formal_operation(bundle, options)
181
+ subjects = bundle.fetch("subjects")
182
+ subjects = subjects.select { |subject| subject["name"] == options[:subject] } if options[:subject]
183
+ if subjects.length != 1
184
+ names = bundle.fetch("subjects").map { |subject| subject.fetch("name") }.join(", ")
185
+ raise ConfigurationError, "Select one formal subject with --subject NAME. Available subjects: #{names}."
186
+ end
187
+
188
+ subject = subjects.first
189
+ operations = subject.fetch("operations")
190
+ operations = operations.select { |operation| operation["name"] == options[:operation] } if options[:operation]
191
+ if operations.length != 1
192
+ names = subject.fetch("operations").map { |operation| operation.fetch("name") }.join(", ")
193
+ raise ConfigurationError, "Select one formal operation with --operation NAME. Available operations: #{names}."
194
+ end
195
+ [subject, operations.first]
196
+ end
197
+
198
+ def formal_binding(adapter, subject, operation = nil)
199
+ binding = adapter.subjects[subject.fetch("name")]
200
+ unless binding
201
+ raise ConfigurationError,
202
+ "Adapter subject #{subject.fetch('name')} is missing. Add it to the adapter file."
203
+ end
204
+ return binding unless operation
205
+
206
+ mapped = binding.operations[operation.fetch("name")]
207
+ unless mapped
208
+ raise ConfigurationError,
209
+ "Adapter operation #{subject.fetch('name')}#{operation.fetch('name')} is missing. Add it to the adapter."
210
+ end
211
+ [binding, mapped]
212
+ end
213
+
214
+ def build_f2_runner(old_callable, new_callable, domains, operation, operation_spec, options)
215
+ contracts = operation_spec.fetch("postconditions", []) + operation_spec.fetch("invariants", [])
216
+ Formal::ExhaustiveRunner.new(old_callable:, new_callable:, domains:, size: options[:size],
217
+ depth: options[:depth], assumptions: %i[h1 h3 h7],
218
+ contracts:, preconditions: operation_spec.fetch("preconditions", []),
219
+ max_cases: options[:max_cases], timebox: options[:timebox],
220
+ new_error_mapper: operation.method(:map_error))
221
+ end
222
+
223
+ def parse_scope(value, options)
224
+ value.split(",").each do |entry|
225
+ key, number = entry.split("=", 2)
226
+ raise ConfigurationError, "Invalid scope #{entry}. Use size=N,depth=N." unless %w[size depth].include?(key)
227
+
228
+ options[key.to_sym] = Integer(number, 10)
229
+ end
230
+ rescue ArgumentError
231
+ raise ConfigurationError, "Invalid scope #{value}. Use size=N,depth=N."
232
+ end
233
+
234
+ def run_f3(bundle, adapter, options)
235
+ spec_subject = select_f3_subject(bundle, options)
236
+ old_data = bundle.fetch("lts").find { |model| model["id"] == spec_subject["lts_ref"] }
237
+ old_lts = Formal::LTS.from_h(old_data)
238
+ binding = formal_binding(adapter, spec_subject)
239
+ unless binding.state_projection
240
+ raise ConfigurationError,
241
+ "Adapter subject #{binding.name} needs a state block for F3."
242
+ end
243
+
244
+ static_violations = f3_static_violations(spec_subject, binding)
245
+ return inconclusive_f3(old_lts, options, static_violations) unless static_violations.empty?
246
+
247
+ learned, runtime_violations = learn_f3(binding, options)
248
+ export_lts(options[:export_lts], old_lts, learned.lts) if options[:export_lts]
249
+ result = Formal::LtsEquivalence.new.compare(old_lts, learned.lts, relation: options[:relation],
250
+ exact: learned.complete)
251
+ unless runtime_violations.empty?
252
+ return Formal::Result.new(level: :f3, verdict: :inconclusive, scope: result.scope,
253
+ assumptions: result.assumptions, out_of_scope: result.out_of_scope,
254
+ details: result.details.merge("assumption_violations" => runtime_violations))
255
+ end
256
+ result.details["skipped"] = "state limit exceeded" unless learned.complete || result.counterexample
257
+ write_lts_counterexample(result, options, old_lts, spec_subject)
258
+ result
259
+ end
260
+
261
+ def select_f3_subject(bundle, options)
262
+ stateful = bundle.fetch("subjects").select { |subject| subject["lts_ref"] }
263
+ stateful.select! { |subject| subject["name"] == options[:subject] } if options[:subject]
264
+ if stateful.empty?
265
+ raise ConfigurationError, "The Spec Bundle has no stateful subject. Record with a state projection first."
266
+ end
267
+
268
+ if stateful.length > 1
269
+ names = stateful.map { |subject| subject.fetch("name") }.join(", ")
270
+ raise ConfigurationError, "Select one F3 subject with --subject NAME. Available subjects: #{names}."
271
+ end
272
+ stateful.first
273
+ end
274
+
275
+ def f3_static_violations(spec_subject, binding)
276
+ parameterized = spec_subject.fetch("operations").filter_map do |operation|
277
+ next if operation.fetch("params", []).empty?
278
+
279
+ { "assumption" => "H6", "location" => operation.fetch("name"),
280
+ "reason" => "F3 requires a declared finite input alphabet for parameterized operations" }
281
+ end
282
+ paths = binding.operations.values.filter_map { |operation| operation.invoker&.source_location&.first }.uniq
283
+ parameterized + Formal::Assumptions::DynamicCodeDetector.new.scan(paths)
284
+ end
285
+
286
+ def learn_f3(binding, options)
287
+ operations = binding.operations.to_h { |name, operation| [name, observed_operation(operation)] }
288
+ learner = Formal::ActiveLearner.new(factory: -> { binding.build({}) },
289
+ state_projection: binding.state_projection, operations:,
290
+ state_limit: options.fetch(:state_limit, 500))
291
+ target = binding.build({})
292
+ monitored_class = target.is_a?(Module) ? target.singleton_class : target.class
293
+ Formal::Assumptions::WorldFreeze.new([monitored_class]).check { learner.learn }
294
+ end
295
+
296
+ def inconclusive_f3(old_lts, options, violations)
297
+ Formal::Result.new(level: :f3, verdict: :inconclusive,
298
+ scope: Formal::Scope.new(size: old_lts.states.length, depth: nil, cases: 0,
299
+ exhaustive: false, timebox: options[:timebox]),
300
+ assumptions: %i[h1 h3 h6 h7],
301
+ out_of_scope: ["replacement model exploration"],
302
+ details: { "assumption_violations" => violations })
303
+ end
304
+
305
+ def observed_operation(operation)
306
+ lambda do |subject|
307
+ value = operation.invoke(subject, [], {})
308
+ Formal::ObservedOutput.new({ "kind" => "return",
309
+ "value" => Recording::Serializer.dump(operation.map_return(value)) })
310
+ rescue StandardError => e
311
+ Formal::ObservedOutput.new({ "kind" => "raise", **operation.map_error(e) })
312
+ end
313
+ end
314
+
315
+ def write_lts_counterexample(result, options, old_lts, spec_subject)
316
+ return unless result.counterexample && options[:counterexample_out]
317
+
318
+ spec = Formal::CounterexampleRSpec.call(lts: old_lts,
319
+ sequence: result.counterexample.fetch("sequence"),
320
+ subject_name: spec_subject.fetch("name"))
321
+ FileUtils.mkdir_p(File.dirname(options[:counterexample_out]))
322
+ File.write(options[:counterexample_out], spec)
323
+ end
324
+
325
+ def export_lts(prefix, old_lts, new_lts)
326
+ FileUtils.mkdir_p(File.dirname(prefix))
327
+ File.write("#{prefix}_old.aut", Formal::AldebaranExporter.call(old_lts))
328
+ File.write("#{prefix}_new.aut", Formal::AldebaranExporter.call(new_lts))
329
+ end
330
+
331
+ def run_f4(bundle, adapter, options)
332
+ validate_f4_options!(options)
333
+ fragment_violations = Formal::Deductive::FragmentChecker.new.then do |checker|
334
+ checker.check_file(options[:old_source], options[:old_method]) +
335
+ checker.check_file(options[:new_source], options[:new_method])
336
+ end
337
+ unless fragment_violations.empty?
338
+ return inconclusive_f4(options, "verifiable fragment rejected",
339
+ fragment_violations)
340
+ end
341
+
342
+ subject_spec, operation_spec = select_formal_operation(bundle, options)
343
+ binding, operation = formal_binding(adapter, subject_spec, operation_spec)
344
+ runner, old_class = build_f4_runner(subject_spec, binding, operation, options)
345
+ target = binding.build({})
346
+ target_class = target.is_a?(Module) ? target.singleton_class : target.class
347
+ result, violations = Formal::Assumptions::WorldFreeze.new([old_class, target_class]).check { runner.run }
348
+ if violations.empty?
349
+ write_f4_counterexample(result, options, subject_spec, operation_spec)
350
+ return result
351
+ end
352
+
353
+ Formal::Result.new(level: :f4, verdict: :inconclusive, scope: result.scope,
354
+ assumptions: result.assumptions, out_of_scope: result.out_of_scope,
355
+ details: result.details.merge("assumption_violations" => violations))
356
+ rescue ConfigurationError => e
357
+ raise unless e.message.start_with?("F4 does not support")
358
+
359
+ inconclusive_f4(options, e.message, [])
360
+ end
361
+
362
+ def build_f4_runner(subject, binding, operation, options)
363
+ old_class = Bparity.constantize(subject.fetch("old_class"))
364
+ translator = Formal::Deductive::RubyToSmt.new
365
+ old_translation = translator.translate_file(options[:old_source], options[:old_method],
366
+ parameter_types: options[:types])
367
+ new_translation = translator.translate_file(options[:new_source], options[:new_method],
368
+ parameter_types: options[:types])
369
+ validation_inputs = f4_inputs(options[:types], options)
370
+ runner = Formal::Deductive::Runner.new(
371
+ old_translation:, new_translation:,
372
+ old_callable: ->(*args) { old_class.new.public_send(options[:old_method], *args) },
373
+ new_callable: ->(*args) { operation.invoke(binding.build({}), args, {}) },
374
+ validation_inputs:,
375
+ validation_scope: { "size" => options[:size], "depth" => options[:depth],
376
+ "cases" => validation_inputs.length, "exhaustive" => true },
377
+ solver: Formal::Deductive::Z3.new(timeout: options[:timebox])
378
+ )
379
+ [runner, old_class]
380
+ end
381
+
382
+ def write_f4_counterexample(result, options, subject, operation)
383
+ return unless result.counterexample&.key?("input") && options[:counterexample_out]
384
+
385
+ content = Formal::Deductive::CounterexampleRSpec.call(
386
+ result:, subject_name: subject.fetch("name"), operation_name: operation.fetch("name")
387
+ )
388
+ FileUtils.mkdir_p(File.dirname(options[:counterexample_out]))
389
+ File.write(options[:counterexample_out], content)
390
+ end
391
+
392
+ def inconclusive_f4(options, reason, violations)
393
+ Formal::Result.new(level: :f4, verdict: :inconclusive,
394
+ scope: Formal::Scope.new(size: options[:size], depth: options[:depth], cases: 0,
395
+ exhaustive: false, timebox: options[:timebox]),
396
+ assumptions: %i[h1 h3], out_of_scope: ["Ruby outside the declared pure fragment"],
397
+ details: { "reason" => reason, "fragment_violations" => violations })
398
+ end
399
+
400
+ def validate_f4_options!(options)
401
+ required = %i[old_source new_source old_method new_method types]
402
+ missing = required.reject { |key| options[key] }
403
+ unless missing.empty?
404
+ raise ConfigurationError,
405
+ "F4 is missing #{missing.join(', ')}. Provide both sources, method names, and --types."
406
+ end
407
+ return unless options[:solver] && options[:solver] != "z3"
408
+
409
+ raise ConfigurationError, "Unsupported F4 solver #{options[:solver]}. Use z3."
410
+ end
411
+
412
+ def f4_inputs(types, options)
413
+ domains = types.map do |type|
414
+ Formal::ValueEnumerator.new(size: options[:size], depth: options[:depth]).values(type)
415
+ end
416
+ return [[]] if domains.empty?
417
+
418
+ cases = domains.reduce(1) { |count, domain| count * domain.length }
419
+ if cases > options[:max_cases]
420
+ raise ConfigurationError,
421
+ "F4 does not support truncated translation validation. Increase --max-cases above #{cases} or " \
422
+ "reduce --scope."
423
+ end
424
+
425
+ domains.first.product(*domains.drop(1))
426
+ end
427
+ end # rubocop:enable Metrics/ModuleLength
428
+ end
@@ -0,0 +1,242 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "shellwords"
4
+
5
+ module Bparity
6
+ module VerificationCommands
7
+ private
8
+
9
+ def command_verify(argv)
10
+ options = verify_options(argv)
11
+ validate_verify_options!(options)
12
+ options[:requires].each { |path| require File.expand_path(path) }
13
+ bundle = SpecBundle::Loader.load(options[:spec])
14
+ adapter = load_verify_adapter(bundle, options[:adapter], options[:adapter_explicit])
15
+ results = options[:runners].flat_map { |runner| run_verify_runner(runner, bundle, adapter, options) }
16
+ write_verification_report(results, bundle, options)
17
+ verify_success?(results, options[:fail_under]) ? 0 : 1
18
+ end
19
+
20
+ def verify_options(argv)
21
+ options = { spec: ".bparity/spec_bundle.yml", adapter: ".bparity/adapter.rb", format: "markdown",
22
+ runners: ["replay"], fail_under: 100.0, size: 3, depth: 2, max_cases: 1_000,
23
+ timebox: 300, state_limit: 500, relation: "trace", requires: [] }
24
+ OptionParser.new do |opts|
25
+ add_verify_core_options(opts, options)
26
+ add_verify_generation_options(opts, options)
27
+ end.parse!(argv)
28
+ options
29
+ end
30
+
31
+ def add_verify_core_options(parser, options)
32
+ parser.on("--spec PATH") { |value| options[:spec] = value }
33
+ parser.on("--adapter PATH") do |value|
34
+ options[:adapter] = value
35
+ options[:adapter_explicit] = true
36
+ end
37
+ parser.on("--mode MODE") { |value| options[:mode] = value }
38
+ parser.on("--runners LIST") { |value| options[:runners] = value.split(",") }
39
+ parser.on("--fail-under PERCENT", Float) { |value| options[:fail_under] = value }
40
+ parser.on("--format FORMAT") { |value| options[:format] = value }
41
+ parser.on("--out PATH") { |value| options[:out] = value }
42
+ parser.on("--require PATH") { |value| options[:requires] << value }
43
+ end
44
+
45
+ def add_verify_generation_options(parser, options)
46
+ parser.on("--scope SCOPE") { |value| parse_scope(value, options) }
47
+ parser.on("--max-cases N", Integer) { |value| options[:max_cases] = value }
48
+ parser.on("--timebox SECONDS", Integer) { |value| options[:timebox] = value }
49
+ parser.on("--state-limit N", Integer) { |value| options[:state_limit] = value }
50
+ parser.on("--equivalence RELATION") { |value| options[:relation] = value }
51
+ parser.on("--subject NAME") { |value| options[:subject] = value }
52
+ parser.on("--operation NAME") { |value| options[:operation] = value }
53
+ parser.on("--old-command COMMAND") { |value| options[:old_command] = Shellwords.split(value) }
54
+ parser.on("--new-command COMMAND") { |value| options[:new_command] = Shellwords.split(value) }
55
+ end
56
+
57
+ def validate_verify_options!(options)
58
+ unless Reporting::Reporter::FORMATS.include?(options[:format])
59
+ raise ConfigurationError,
60
+ "Unknown report format #{options[:format]}. Use markdown, json, junit, or html."
61
+ end
62
+ allowed = %w[replay property model differential]
63
+ unknown = options[:runners] - allowed
64
+ unless unknown.empty?
65
+ raise ConfigurationError,
66
+ "Unknown verification runner #{unknown.join(', ')}. Use replay, property, model, or differential."
67
+ end
68
+ raise ConfigurationError, "At least one verification runner is required." if options[:runners].empty?
69
+ unless (0.0..100.0).cover?(options[:fail_under])
70
+ raise ConfigurationError, "Verification threshold must be between 0 and 100."
71
+ end
72
+ unless options.values_at(:size, :depth).all? { |value| value >= 0 } &&
73
+ options.values_at(:max_cases, :timebox, :state_limit).all?(&:positive?)
74
+ raise ConfigurationError,
75
+ "Verification limits are invalid. Use non-negative size/depth and positive case, time, and state limits."
76
+ end
77
+ return unless options[:runners].include?("differential") &&
78
+ (!options[:old_command] || !options[:new_command])
79
+
80
+ raise ConfigurationError,
81
+ "Differential verification requires --old-command and --new-command. " \
82
+ "Each command must read JSON from stdin."
83
+ end
84
+
85
+ def load_verify_adapter(bundle, path, explicit)
86
+ if File.exist?(path)
87
+ Bparity.reset!
88
+ load File.expand_path(path)
89
+ return Bparity.adapter_definition || raise(ConfigurationError,
90
+ "The adapter file did not call Bparity.adapter.")
91
+ end
92
+ if explicit
93
+ raise ConfigurationError,
94
+ "Adapter file #{path} was not found. Correct the path or omit --adapter for direct replay."
95
+ end
96
+
97
+ direct_adapter(bundle)
98
+ end
99
+
100
+ def direct_adapter(bundle)
101
+ Adapter::Definition.new.tap do |definition|
102
+ bundle.fetch("subjects").each do |spec_subject|
103
+ name = spec_subject.fetch("name")
104
+ definition.subject(name) do
105
+ construct do
106
+ target = Bparity.constantize(name)
107
+ target.is_a?(Class) ? target.new : target
108
+ end
109
+ spec_subject.fetch("operations").each do |spec_operation|
110
+ operation(spec_operation.fetch("name"))
111
+ end
112
+ end
113
+ end
114
+ end
115
+ end
116
+
117
+ def run_verify_runner(name, bundle, adapter, options)
118
+ case name
119
+ when "replay" then Verification::Runner.new(bundle:, adapter:, mode: options[:mode]).run
120
+ when "property" then run_property_verification(bundle, adapter, options)
121
+ when "model" then [formal_verification_result(run_f3(bundle, adapter, options), "model")]
122
+ else run_differential_verification(bundle, options)
123
+ end
124
+ end
125
+
126
+ def run_property_verification(bundle, adapter, options)
127
+ checks = bundle.fetch("subjects").flat_map do |subject|
128
+ subject.fetch("operations").filter_map do |operation_spec|
129
+ contracts = operation_spec.fetch("postconditions", []) + operation_spec.fetch("invariants", [])
130
+ next if contracts.empty?
131
+
132
+ binding, operation = formal_binding(adapter, subject, operation_spec)
133
+ callable = lambda do |*args|
134
+ operation.map_return(operation.invoke(binding.build({}), args, {}))
135
+ end
136
+ counterexample = Formal::PropertyRunner.new(
137
+ callable:, invariants: contracts, inputs: generated_inputs(operation_spec, options),
138
+ preconditions: operation_spec.fetch("preconditions", [])
139
+ ).run
140
+ property_result(subject, operation_spec, counterexample)
141
+ end
142
+ end
143
+ return checks unless checks.empty?
144
+
145
+ raise ConfigurationError,
146
+ "Property verification found no contracts. Synthesize invariants or select the replay runner."
147
+ end
148
+
149
+ def run_differential_verification(bundle, options)
150
+ subject, operation = select_formal_operation(bundle, options)
151
+ differences = Formal::DifferentialRunner.new(
152
+ old_command: options[:old_command], new_command: options[:new_command],
153
+ inputs: generated_inputs(operation, options)
154
+ ).run
155
+ if differences.empty?
156
+ return [Verification::Result.new(id: "differential:#{subject['name']}#{operation['name']}",
157
+ status: :pass, description: "Generated differential inputs matched",
158
+ differences: [], provenance: { "runner" => "differential" })]
159
+ end
160
+
161
+ differences.each_with_index.map do |difference, index|
162
+ Verification::Result.new(id: "differential:#{subject['name']}#{operation['name']}:#{index + 1}",
163
+ status: :fail, description: "Generated differential input differed",
164
+ differences: difference.fetch("differences"),
165
+ provenance: { "runner" => "differential",
166
+ "input" => Recording::Serializer.dump(difference.fetch("input")) })
167
+ end
168
+ end
169
+
170
+ def generated_inputs(operation, options)
171
+ domains = operation.fetch("params", []).map { |param| generated_domain(param, options) }
172
+ Enumerator.new do |items|
173
+ if domains.empty?
174
+ items << []
175
+ else
176
+ domains.first.product(*domains.drop(1)) { |input| items << input }
177
+ end
178
+ end.take(options[:max_cases])
179
+ end
180
+
181
+ def generated_domain(param, options)
182
+ observed = param.fetch("observed_values", []).map { |value| Recording::Serializer.load(value) }
183
+ alphabet = observed.grep(String).flat_map(&:chars).uniq.first(8)
184
+ generator = Formal::InputGenerator.new(size: options[:size], depth: options[:depth], observed:,
185
+ alphabet: alphabet.empty? ? %w[a b] : alphabet)
186
+ types = param.fetch("types", [])
187
+ if types.empty?
188
+ raise ConfigurationError,
189
+ "Property parameter #{param['name']} has no inferred type. Record values or add an input domain."
190
+ end
191
+ types.flat_map { |type| generator.values(type) }.uniq
192
+ end
193
+
194
+ def property_result(subject, operation, counterexample)
195
+ differences = []
196
+ if counterexample
197
+ differences = counterexample.fetch("violations").map do |violation|
198
+ { "path" => "$.contracts.#{violation['id']}", "expected" => true,
199
+ "actual" => violation["error"] || false }
200
+ end
201
+ end
202
+ Verification::Result.new(id: "property:#{subject['name']}#{operation['name']}",
203
+ status: differences.empty? ? :pass : :fail,
204
+ description: "Generated property inputs satisfy declared contracts",
205
+ differences:, provenance: { "runner" => "property",
206
+ "input" => Recording::Serializer.dump(
207
+ counterexample&.fetch("input", nil)
208
+ ) })
209
+ end
210
+
211
+ def formal_verification_result(result, name)
212
+ skipped = result.details["skipped"]
213
+ differences = if result.success? || skipped
214
+ []
215
+ else
216
+ [{ "path" => "$.#{name}", "expected" => "no difference",
217
+ "actual" => result.counterexample || result.details }]
218
+ end
219
+ status = if skipped then :skipped
220
+ elsif differences.empty? then :pass
221
+ else :fail
222
+ end
223
+ Verification::Result.new(id: name, status:, description: skipped || "Finite-state model conformance",
224
+ differences:, provenance: { "runner" => name, "formal_result" => result.to_h })
225
+ end
226
+
227
+ def write_verification_report(results, bundle, options)
228
+ report = Reporting::Reporter.new(results, bundle:).public_send(options[:format])
229
+ return @out.puts(report) unless options[:out]
230
+
231
+ FileUtils.mkdir_p(File.dirname(options[:out]))
232
+ File.write(options[:out], report)
233
+ end
234
+
235
+ def verify_success?(results, threshold)
236
+ eligible = results.reject { |result| result.status == :skipped }
237
+ return false if eligible.empty? || eligible.any? { |result| result.status == :fail }
238
+
239
+ 100.0 * eligible.count { |result| %i[pass waived].include?(result.status) } / eligible.length >= threshold
240
+ end
241
+ end
242
+ end