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,481 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require "prism"
5
+ require "timeout"
6
+
7
+ module Bparity
8
+ module Formal
9
+ module Deductive
10
+ class Term
11
+ attr_reader :smt, :sort
12
+
13
+ def initialize(smt, sort, &evaluator)
14
+ @smt = smt
15
+ @sort = sort
16
+ @evaluator = evaluator
17
+ end
18
+
19
+ def evaluate(context) = @evaluator.call(context)
20
+ end
21
+
22
+ Translation = Struct.new(:parameters, :term, :source, :method_name, keyword_init: true)
23
+
24
+ class FragmentChecker
25
+ FORBIDDEN_NODES = %i[class_variable_write_node global_variable_write_node instance_variable_write_node
26
+ call_and_write_node call_operator_write_node].freeze
27
+ FORBIDDEN_CALLS = %i[eval binding send public_send method_missing define_method rand sleep].freeze
28
+
29
+ def check_file(path, method_name)
30
+ result = Prism.parse_file(path)
31
+ return ["syntax error"] unless result.success?
32
+
33
+ method = nodes(result.value).find { |node| node.type == :def_node && node.name.to_s == method_name.to_s }
34
+ return ["method #{method_name} was not found"] unless method
35
+
36
+ nodes(method).filter_map do |node|
37
+ if FORBIDDEN_NODES.include?(node.type)
38
+ "#{node.type} at line #{node.location.start_line}"
39
+ elsif node.type == :call_node && FORBIDDEN_CALLS.include?(node.name)
40
+ "#{node.name} at line #{node.location.start_line}"
41
+ end
42
+ end
43
+ end
44
+
45
+ private
46
+
47
+ def nodes(root, &)
48
+ return enum_for(__method__, root) unless block_given?
49
+
50
+ yield root
51
+ root.compact_child_nodes.each { |child| nodes(child, &) }
52
+ end
53
+ end
54
+
55
+ class RubyToSmt
56
+ SORTS = { "Integer" => "Int", "Boolean" => "Bool", "String" => "String" }.freeze
57
+ OPERATORS = { :+ => "+", :- => "-", :* => "*", :== => "=", :!= => "distinct",
58
+ :> => ">", :>= => ">=", :< => "<", :<= => "<=" }.freeze
59
+
60
+ def translate_file(path, method_name, parameter_types:)
61
+ result = Prism.parse_file(path)
62
+ raise ConfigurationError, "Cannot parse #{path}. Fix its Ruby syntax." unless result.success?
63
+
64
+ method = find_method(result.value, method_name)
65
+ raise ConfigurationError, "Method #{method_name} was not found in #{path}." unless method
66
+
67
+ parameters = method.parameters.requireds.map(&:name)
68
+ if parameters.length != parameter_types.length
69
+ message = "Method #{method_name} has #{parameters.length} parameters, " \
70
+ "but #{parameter_types.length} types were given."
71
+ raise ConfigurationError,
72
+ message
73
+ end
74
+
75
+ environment = parameters.each_with_index.to_h do |name, index|
76
+ sort = SORTS.fetch(parameter_types[index]) do
77
+ raise ConfigurationError,
78
+ "F4 does not support #{parameter_types[index]}. Use Integer, Boolean, or String."
79
+ end
80
+ [name, Term.new("arg#{index}", sort) { |context| context.fetch(index) }]
81
+ end
82
+ body = translate_statements(method.body, environment)
83
+ Translation.new(parameters: parameter_types.each_with_index.map do |type, index|
84
+ ["arg#{index}", SORTS.fetch(type)]
85
+ end,
86
+ term: body, source: path, method_name: method_name.to_s)
87
+ end
88
+
89
+ private
90
+
91
+ def find_method(root, name)
92
+ return root if root.type == :def_node && root.name.to_s == name.to_s
93
+
94
+ root.compact_child_nodes.filter_map { |child| find_method(child, name) }.first
95
+ end
96
+
97
+ def translate_statements(node, environment)
98
+ statements = node.type == :statements_node ? node.body : [node]
99
+ final = nil
100
+ statements.each do |statement|
101
+ final = if statement.type == :local_variable_write_node
102
+ environment[statement.name] = translate(statement.value, environment)
103
+ else
104
+ translate(statement, environment)
105
+ end
106
+ end
107
+ final || Term.new("true", "Bool") { true }
108
+ end
109
+
110
+ def translate(node, environment)
111
+ case node.type
112
+ when :local_variable_read_node then environment.fetch(node.name)
113
+ when :integer_node then literal(node.value, "Int")
114
+ when :string_node then string_literal(node.unescaped)
115
+ when :true_node, :false_node then literal(node.type == :true_node, "Bool")
116
+ when :parentheses_node then translate_statements(node.body, environment.dup)
117
+ when :and_node then translate_boolean(node.left, node.right, environment, "and")
118
+ when :or_node then translate_boolean(node.left, node.right, environment, "or")
119
+ when :if_node then translate_if(node, environment)
120
+ when :return_node then translate(node.arguments.arguments.first, environment)
121
+ when :call_node then translate_call(node, environment)
122
+ else unsupported!(node)
123
+ end
124
+ end
125
+
126
+ def translate_if(node, environment)
127
+ unsupported!(node) unless node.subsequent
128
+
129
+ condition = translate(node.predicate, environment)
130
+ unless condition.sort == "Bool"
131
+ raise ConfigurationError,
132
+ "F4 condition at line #{node.location.start_line} must be Boolean. Update the pure fragment."
133
+ end
134
+ truthy = translate_statements(node.statements, environment.dup)
135
+ falsy = translate_else(node.subsequent, environment.dup)
136
+ ensure_sort!(truthy, falsy, node)
137
+ Term.new("(ite #{condition.smt} #{truthy.smt} #{falsy.smt})", truthy.sort) do |context|
138
+ condition.evaluate(context) ? truthy.evaluate(context) : falsy.evaluate(context)
139
+ end
140
+ end
141
+
142
+ def translate_else(node, environment)
143
+ return translate_if(node, environment) if node.type == :if_node
144
+
145
+ translate_statements(node.statements, environment)
146
+ end
147
+
148
+ def translate_call(node, environment)
149
+ receiver = translate(node.receiver, environment)
150
+ arguments = node.arguments&.arguments&.map { |argument| translate(argument, environment) } || []
151
+ return translate_not(receiver) if node.name == :! && arguments.empty?
152
+ if receiver.sort == "String" && !%i[== !=].include?(node.name)
153
+ return translate_string_call(node.name, receiver, arguments, node)
154
+ end
155
+ return translate_operator(node.name, receiver, arguments.first, node) if OPERATORS.key?(node.name)
156
+
157
+ unsupported!(node)
158
+ end
159
+
160
+ def translate_boolean(left_node, right_node, environment, operator)
161
+ left = translate(left_node, environment)
162
+ right = translate(right_node, environment)
163
+ unless left.sort == "Bool" && right.sort == "Bool"
164
+ raise ConfigurationError, "F4 #{operator} operands must be Boolean. Update the pure fragment."
165
+ end
166
+
167
+ Term.new("(#{operator} #{left.smt} #{right.smt})", "Bool") do |context|
168
+ if operator == "and"
169
+ left.evaluate(context) && right.evaluate(context)
170
+ else
171
+ left.evaluate(context) || right.evaluate(context)
172
+ end
173
+ end
174
+ end
175
+
176
+ def translate_not(term)
177
+ raise ConfigurationError, "F4 ! operand must be Boolean. Update the pure fragment." unless term.sort == "Bool"
178
+
179
+ Term.new("(not #{term.smt})", "Bool") { |context| !term.evaluate(context) }
180
+ end
181
+
182
+ def translate_operator(name, left, right, node)
183
+ ensure_sort!(left, right, node)
184
+ if !%i[== !=].include?(name) && left.sort != "Int"
185
+ raise ConfigurationError,
186
+ "F4 arithmetic at line #{node.location.start_line} requires Integer operands."
187
+ end
188
+ output_sort = %i[== != > >= < <=].include?(name) ? "Bool" : left.sort
189
+ Term.new("(#{OPERATORS.fetch(name)} #{left.smt} #{right.smt})", output_sort) do |context|
190
+ left.evaluate(context).public_send(name, right.evaluate(context))
191
+ end
192
+ end
193
+
194
+ def translate_string_call(name, receiver, arguments, node)
195
+ case name
196
+ when :+
197
+ argument = arguments.fetch(0)
198
+ ensure_sort!(receiver, argument, node)
199
+ Term.new("(str.++ #{receiver.smt} #{argument.smt})", "String") do |context|
200
+ receiver.evaluate(context) + argument.evaluate(context)
201
+ end
202
+ when :size, :length
203
+ Term.new("(str.len #{receiver.smt})", "Int") { |context| receiver.evaluate(context).length }
204
+ when :empty?
205
+ Term.new("(= #{receiver.smt} \"\")", "Bool") { |context| receiver.evaluate(context).empty? }
206
+ when :start_with?, :end_with?, :include?
207
+ string_predicate(name, receiver, arguments.fetch(0))
208
+ else unsupported!(node)
209
+ end
210
+ end
211
+
212
+ def string_predicate(name, receiver, argument)
213
+ smt_name = { start_with?: "str.prefixof", end_with?: "str.suffixof", include?: "str.contains" }.fetch(name)
214
+ smt_args = name == :include? ? [receiver, argument] : [argument, receiver]
215
+ Term.new("(#{smt_name} #{smt_args.map(&:smt).join(' ')})", "Bool") do |context|
216
+ receiver.evaluate(context).public_send(name, argument.evaluate(context))
217
+ end
218
+ end
219
+
220
+ def literal(value, sort)
221
+ smt = case value
222
+ when true then "true"
223
+ when false then "false"
224
+ else value.negative? ? "(- #{value.abs})" : value.to_s
225
+ end
226
+ Term.new(smt, sort) { value }
227
+ end
228
+
229
+ def string_literal(value)
230
+ escaped = value.gsub('"', '""')
231
+ Term.new(%("#{escaped}"), "String") { value }
232
+ end
233
+
234
+ def ensure_sort!(left, right, node)
235
+ return if left.sort == right.sort
236
+
237
+ raise ConfigurationError,
238
+ "F4 sort mismatch at line #{node.location.start_line}. Add compatible parameter types."
239
+ end
240
+
241
+ def unsupported!(node)
242
+ message = "F4 does not support #{node.type} at line #{node.location.start_line}. " \
243
+ "Use F2 or simplify the pure fragment."
244
+ raise ConfigurationError,
245
+ message
246
+ end
247
+ end
248
+
249
+ module ProductProgram
250
+ module_function
251
+
252
+ def call(old_translation, new_translation)
253
+ unless old_translation.parameters == new_translation.parameters &&
254
+ old_translation.term.sort == new_translation.term.sort
255
+ raise ConfigurationError, "The F4 translations have incompatible input or output sorts."
256
+ end
257
+
258
+ declarations = old_translation.parameters.map { |name, sort| "(declare-const #{name} #{sort})" }
259
+ (["(set-logic ALL)"] + declarations +
260
+ ["(assert (not (= #{old_translation.term.smt} #{new_translation.term.smt})))", "(check-sat)",
261
+ "(get-model)"]).join("\n") << "\n"
262
+ end
263
+ end
264
+
265
+ Validation = Struct.new(:valid, :cases, :mismatch, keyword_init: true)
266
+
267
+ class TranslationValidator
268
+ def validate(translation, callable, inputs)
269
+ inputs.each_with_index do |input, index|
270
+ ruby_value = observe { callable.call(*input) }
271
+ context = input.each_with_index.to_h { |value, position| [position, value] }
272
+ translated_value = observe { translation.term.evaluate(context) }
273
+ next if ruby_value == translated_value
274
+
275
+ mismatch = { "input" => input, "ruby" => ruby_value, "translation" => translated_value }
276
+ return Validation.new(valid: false, cases: index + 1, mismatch:)
277
+ end
278
+ Validation.new(valid: true, cases: inputs.length)
279
+ end
280
+
281
+ private
282
+
283
+ def observe
284
+ { "kind" => "return", "value" => yield }
285
+ rescue StandardError => e
286
+ { "kind" => "raise", "class" => e.class.name, "message" => Bparity.exception_message(e) }
287
+ end
288
+ end
289
+
290
+ class Z3
291
+ def initialize(timeout: 300, executable: ENV.fetch("BPARITY_Z3", "z3"))
292
+ @timeout = timeout
293
+ @executable = executable
294
+ end
295
+
296
+ def available?
297
+ _out, _err, status = Open3.capture3(@executable, "--version")
298
+ status.success?
299
+ rescue Errno::ENOENT
300
+ false
301
+ end
302
+
303
+ def solve(script)
304
+ output = error = status = nil
305
+ Open3.popen3(@executable, "-in") do |stdin, stdout, stderr, wait|
306
+ stdin.write(script)
307
+ stdin.close
308
+ Timeout.timeout(@timeout) do
309
+ output = stdout.read
310
+ error = stderr.read
311
+ status = wait.value
312
+ end
313
+ rescue Timeout::Error
314
+ Process.kill("TERM", wait.pid) unless wait.join(0.1)
315
+ return { verdict: :unknown, output: "z3 timed out after #{@timeout} seconds" }
316
+ end
317
+ first = output.lines.first&.strip
318
+ verdict = { "unsat" => :unsat, "sat" => :sat }.fetch(first, :unknown)
319
+ return { verdict:, output: output } unless verdict == :unknown
320
+
321
+ { verdict: :unknown, output: status.success? ? output : error }
322
+ rescue Errno::ENOENT
323
+ { verdict: :unavailable, output: "z3 executable not found" }
324
+ end
325
+ end
326
+
327
+ module ModelParser
328
+ module_function
329
+
330
+ def call(output)
331
+ expressions = parse_all(output.scan(/"(?:[^"]|"")*"|[()]|[^\s()]+/))
332
+ definitions = expressions.flat_map { |expression| find_definitions(expression) }
333
+ definitions.to_h do |definition|
334
+ [definition.fetch(1), ruby_value(definition.fetch(3), definition.fetch(4))]
335
+ end
336
+ rescue StandardError
337
+ {}
338
+ end
339
+
340
+ def parse_all(tokens)
341
+ values = []
342
+ values << parse(tokens) until tokens.empty?
343
+ values
344
+ end
345
+
346
+ def parse(tokens)
347
+ token = tokens.shift
348
+ return token unless token == "("
349
+
350
+ values = []
351
+ values << parse(tokens) until tokens.first == ")" || tokens.empty?
352
+ tokens.shift
353
+ values
354
+ end
355
+
356
+ def find_definitions(value)
357
+ return [] unless value.is_a?(Array)
358
+
359
+ found = value.first == "define-fun" ? [value] : []
360
+ found + value.flat_map { |child| find_definitions(child) }
361
+ end
362
+
363
+ def ruby_value(sort, value)
364
+ case sort
365
+ when "Int" then value.is_a?(Array) ? -Integer(value.fetch(1), 10) : Integer(value, 10)
366
+ when "Bool" then value == "true"
367
+ when "String" then value.delete_prefix('"').delete_suffix('"').gsub('""', '"')
368
+ end
369
+ end
370
+ end
371
+
372
+ class Runner
373
+ def initialize(old_translation:, new_translation:, old_callable:, new_callable:, validation_inputs:,
374
+ assumptions: %i[h1 h3], solver: Z3.new, validation_scope: nil)
375
+ @old_translation = old_translation
376
+ @new_translation = new_translation
377
+ @old_callable = old_callable
378
+ @new_callable = new_callable
379
+ @validation_inputs = validation_inputs
380
+ @assumptions = assumptions
381
+ @solver = solver
382
+ @validation_scope = validation_scope || { "cases" => validation_inputs.length,
383
+ "exhaustive" => true,
384
+ "domain" => "provided bounded validation inputs" }
385
+ end
386
+
387
+ def run
388
+ validations = validate_translations
389
+ return result(:inconclusive, validations, "translation validation failed") unless validations.all?(&:valid)
390
+ return result(:inconclusive, validations, "z3 executable not found") unless @solver.available?
391
+
392
+ solved = @solver.solve(ProductProgram.call(@old_translation, @new_translation))
393
+ case solved[:verdict]
394
+ when :unsat then result(:no_difference_found, validations, "Z3 returned unsat")
395
+ when :sat then result(:difference_found, validations, "Z3 returned sat", counterexample(solved[:output]))
396
+ else result(:inconclusive, validations, "Z3 returned unknown", solved[:output])
397
+ end
398
+ end
399
+
400
+ private
401
+
402
+ def validate_translations
403
+ validator = TranslationValidator.new
404
+ [validator.validate(@old_translation, @old_callable, @validation_inputs),
405
+ validator.validate(@new_translation, @new_callable, @validation_inputs)]
406
+ end
407
+
408
+ def result(verdict, validations, reason, counterexample = nil)
409
+ cases = validations.map(&:cases).min
410
+ Result.new(level: :f4, verdict:,
411
+ scope: Scope.new(size: "unbounded", depth: nil, cases:,
412
+ exhaustive: verdict == :no_difference_found),
413
+ assumptions: @assumptions,
414
+ out_of_scope: ["Ruby outside the declared pure fragment", "unsupported library semantics"],
415
+ counterexample:,
416
+ details: { "reason" => reason, "translation_validation" => validations.map(&:to_h),
417
+ "translation_validation_scope" => @validation_scope,
418
+ "translated_methods" => translated_methods })
419
+ end
420
+
421
+ def translated_methods
422
+ [@old_translation, @new_translation].map do |translation|
423
+ { "source" => translation.source, "method" => translation.method_name,
424
+ "input_sorts" => translation.parameters.map(&:last), "output_sort" => translation.term.sort }
425
+ end
426
+ end
427
+
428
+ def counterexample(output)
429
+ model = ModelParser.call(output)
430
+ input = @old_translation.parameters.map { |name, _sort| model[name] }
431
+ data = { "solver_model" => output }
432
+ return data if input.any?(&:nil?)
433
+
434
+ data.merge("input" => input, "expected" => observe(@old_callable, input),
435
+ "actual" => observe(@new_callable, input))
436
+ end
437
+
438
+ def observe(callable, input)
439
+ { "kind" => "return", "value" => Recording::Serializer.dump(callable.call(*input)) }
440
+ rescue StandardError => e
441
+ { "kind" => "raise", "class" => e.class.name, "message" => Bparity.exception_message(e),
442
+ "cause" => e.cause&.class&.name }
443
+ end
444
+ end
445
+
446
+ module CounterexampleRSpec
447
+ module_function
448
+
449
+ def call(result:, subject_name:, operation_name:)
450
+ counterexample = result.counterexample
451
+ input = counterexample.fetch("input")
452
+ expected = counterexample.fetch("expected")
453
+ <<~RUBY
454
+ # frozen_string_literal: true
455
+
456
+ require "bparity"
457
+ require ENV["BPARITY_REPLACEMENT"] if ENV["BPARITY_REPLACEMENT"]
458
+ load ENV.fetch("BPARITY_ADAPTER")
459
+
460
+ RSpec.describe "F4 counterexample for #{subject_name}#{operation_name}" do
461
+ it "reproduces solver input #{input.inspect}" do
462
+ binding = Bparity.adapter_definition.subjects.fetch(#{subject_name.inspect})
463
+ operation = binding.operations.fetch(#{operation_name.inspect})
464
+ begin
465
+ value = operation.invoke(binding.build({}), #{input.inspect}, {})
466
+ value = operation.map_return(value)
467
+ actual = { "kind" => "return", "value" => Bparity::Recording::Serializer.dump(value) }
468
+ rescue StandardError => error
469
+ mapped = operation.map_error(error).transform_keys(&:to_s)
470
+ mapped["cause"] = error.cause&.class&.name unless mapped.key?("cause")
471
+ actual = { "kind" => "raise", **mapped }
472
+ end
473
+ expect(actual).to eq(#{expected.inspect})
474
+ end
475
+ end
476
+ RUBY
477
+ end
478
+ end
479
+ end
480
+ end
481
+ end