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,487 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+ require "digest"
5
+ require "json"
6
+
7
+ module Bparity
8
+ module Synthesis
9
+ class InvariantMiner
10
+ def mine(records)
11
+ successful = records.filter_map do |record|
12
+ outcome = record["outcome"]
13
+ outcome["value"] if outcome&.fetch("kind", nil) == "return"
14
+ end
15
+ return [] if successful.length < 2
16
+
17
+ candidates(successful).map.with_index(1) do |expression, index|
18
+ { "id" => format("inv-%04d", index), "expr" => expression, "support" => successful.length,
19
+ "confidence" => 1.0, "provenance_level" => "B", "formal_level" => "F0" }
20
+ end
21
+ end
22
+
23
+ private
24
+
25
+ def candidates(values)
26
+ items = []
27
+ items << "return != nil" if values.none?(&:nil?)
28
+ classes = values.map(&:class).uniq
29
+ items << "return.is_a?(#{classes.first})" if classes.one? && %w[String Integer Array
30
+ Hash].include?(classes.first.name)
31
+ if values.all? { |value| value.is_a?(String) && value.match?(/\A[a-z0-9-]*\z/) }
32
+ items << "return.match?(/\\A[a-z0-9-]*\\z/)"
33
+ end
34
+ items
35
+ end
36
+ end
37
+
38
+ class MetamorphicDetector
39
+ def detect(callable, samples)
40
+ return [] if samples.empty?
41
+
42
+ relations = []
43
+ relations << "idempotent" if holds? do
44
+ samples.all? do |sample|
45
+ callable.call(callable.call(sample)) == callable.call(sample)
46
+ end
47
+ end
48
+ relations << "permutation_invariant" if samples.all?(Array) && holds? do
49
+ samples.all? { |sample| callable.call(sample.reverse) == callable.call(sample) }
50
+ end
51
+ if samples.all?(Numeric)
52
+ relations << "additive" if holds? do
53
+ samples.product(samples).all? do |left, right|
54
+ callable.call(left + right) == callable.call(left) + callable.call(right)
55
+ end
56
+ end
57
+ relations << "monotonic" if holds? do
58
+ samples.sort.each_cons(2).all? do |left, right|
59
+ callable.call(left) <= callable.call(right)
60
+ end
61
+ end
62
+ end
63
+ relations
64
+ end
65
+
66
+ private
67
+
68
+ def holds?
69
+ yield
70
+ rescue StandardError
71
+ false
72
+ end
73
+ end
74
+
75
+ class StaticExtractor
76
+ SPEC_CALLS = %i[describe context it specify].freeze
77
+ ASSERTIONS = %i[eq eql equal raise_error assert assert_equal assert_raises].freeze
78
+ UNSUPPORTED = Object.new.freeze
79
+
80
+ def extract_tests(paths)
81
+ Array(paths).flat_map { |path| extract_test_file(path) }
82
+ end
83
+
84
+ def extract_source(paths)
85
+ Array(paths).flat_map do |path|
86
+ result = Prism.parse_file(path)
87
+ raise Error, "Cannot parse #{path}. Fix its Ruby syntax and try again." unless result.success?
88
+
89
+ source_facts(result.value, path)
90
+ end
91
+ end
92
+
93
+ private
94
+
95
+ def extract_test_file(path)
96
+ result = Prism.parse_file(path)
97
+ raise Error, "Cannot parse #{path}. Fix its Ruby syntax and try again." unless result.success?
98
+
99
+ extract_spec_nodes(result.value, path, []) + extract_minitest_nodes(result.value, path)
100
+ end
101
+
102
+ def extract_minitest_nodes(node, path, owner = nil)
103
+ owner = node.constant_path.location.slice if node.type == :class_node
104
+ item = minitest_item(node, path, owner) if node.type == :def_node && node.name.to_s.start_with?("test_")
105
+ [item, *node.compact_child_nodes.flat_map { |child| extract_minitest_nodes(child, path, owner) }].compact
106
+ end
107
+
108
+ def minitest_item(node, path, owner)
109
+ assertions = nodes(node.body).filter_map do |child|
110
+ child.name.to_s if child.type == :call_node && ASSERTIONS.include?(child.name)
111
+ end.uniq
112
+ description = node.name.to_s.delete_prefix("test_").tr("_", " ")
113
+ { "subject" => owner, "description" => [owner, description].compact.join(" "),
114
+ "location" => "#{path}:#{node.location.start_line}", "assertions" => assertions }
115
+ .merge(extract_minitest_expectation(node.body) || {})
116
+ end
117
+
118
+ def extract_minitest_expectation(body)
119
+ nodes(body).filter_map { |node| minitest_expectation(node) }.first
120
+ end
121
+
122
+ def minitest_expectation(node)
123
+ return unless minitest_assertion?(node)
124
+
125
+ expected, invocation = minitest_parts(node)
126
+ return unless expected && invocation&.type == :call_node
127
+
128
+ arguments = invocation.arguments&.arguments || []
129
+ values = arguments.map { |argument| static_literal(argument) }
130
+ return if values.any? { |value| value.equal?(UNSUPPORTED) }
131
+
132
+ { "subject" => invocation_subject(invocation), "operation" => "##{invocation.name}",
133
+ "arguments" => values, "expected_outcome" => expected }
134
+ end
135
+
136
+ def minitest_assertion?(node)
137
+ node.type == :call_node && %i[assert_equal assert_raises].include?(node.name)
138
+ end
139
+
140
+ def minitest_parts(node)
141
+ arguments = node.arguments&.arguments || []
142
+ return [static_outcome_for(arguments.first), arguments.at(1)] if node.name == :assert_equal
143
+
144
+ [{ "kind" => "raise", "class" => arguments.first&.location&.slice }, block_call(node)]
145
+ end
146
+
147
+ def static_outcome_for(node)
148
+ value = node && static_literal(node)
149
+ return if value.equal?(UNSUPPORTED)
150
+
151
+ { "kind" => "return", "value" => Recording::Serializer.dump(value) }
152
+ end
153
+
154
+ def block_call(node)
155
+ body = node.block&.body
156
+ body&.type == :statements_node ? body.body.last : body
157
+ end
158
+
159
+ def invocation_subject(invocation)
160
+ receiver = invocation.receiver
161
+ receiver = receiver.receiver if receiver&.type == :call_node && receiver.name == :new
162
+ receiver&.location&.slice
163
+ end
164
+
165
+ def extract_spec_nodes(node, path, context)
166
+ return [] unless node
167
+
168
+ if node.type == :call_node && SPEC_CALLS.include?(node.name) && node.block
169
+ description = literal(node.arguments&.arguments&.first) || node.name.to_s
170
+ nested = context + [description]
171
+ return [spec_item(node, path, nested)] if %i[it specify].include?(node.name)
172
+
173
+ return node.block.body ? extract_spec_nodes(node.block.body, path, nested) : []
174
+ end
175
+ node.compact_child_nodes.flat_map { |child| extract_spec_nodes(child, path, context) }
176
+ end
177
+
178
+ def spec_item(node, path, context)
179
+ assertions = nodes(node.block.body).filter_map do |child|
180
+ child.name.to_s if child.type == :call_node && ASSERTIONS.include?(child.name)
181
+ end.uniq
182
+ { "subject" => context.first, "description" => context.join(" "),
183
+ "location" => "#{path}:#{node.location.start_line}", "assertions" => assertions }
184
+ .merge(extract_expectation(node.block.body) || {})
185
+ end
186
+
187
+ def extract_expectation(body)
188
+ nodes(body).filter_map { |node| expectation(node) }.first
189
+ end
190
+
191
+ def expectation(node)
192
+ return unless rspec_expectation?(node)
193
+
194
+ expect_call = node.receiver
195
+ matcher = node.arguments&.arguments&.first
196
+ return unless matcher&.type == :call_node
197
+
198
+ invocation = expected_invocation(expect_call)
199
+ return unless invocation&.type == :call_node
200
+
201
+ arguments = invocation.arguments&.arguments || []
202
+ values = arguments.map { |argument| static_literal(argument) }
203
+ return if values.any? { |value| value.equal?(UNSUPPORTED) }
204
+
205
+ outcome = static_outcome(matcher)
206
+ return unless outcome
207
+
208
+ { "operation" => "##{invocation.name}", "arguments" => values, "expected_outcome" => outcome }
209
+ end
210
+
211
+ def rspec_expectation?(node)
212
+ node.type == :call_node && node.name == :to && node.receiver&.type == :call_node &&
213
+ node.receiver.name == :expect
214
+ end
215
+
216
+ def expected_invocation(expect_call)
217
+ direct = expect_call.arguments&.arguments&.first
218
+ return direct if direct
219
+
220
+ body = expect_call.block&.body
221
+ body&.type == :statements_node ? body.body.last : body
222
+ end
223
+
224
+ def static_literal(node)
225
+ case node.type
226
+ when :string_node then node.unescaped
227
+ when :integer_node, :float_node then node.value
228
+ when :nil_node then nil
229
+ when :true_node then true
230
+ when :false_node then false
231
+ when :symbol_node then node.unescaped.to_sym
232
+ else UNSUPPORTED
233
+ end
234
+ end
235
+
236
+ def static_outcome(matcher)
237
+ argument = matcher.arguments&.arguments&.first
238
+ case matcher.name
239
+ when :eq, :eql, :equal
240
+ value = argument && static_literal(argument)
241
+ return if value.equal?(UNSUPPORTED)
242
+
243
+ { "kind" => "return", "value" => Recording::Serializer.dump(value) }
244
+ when :raise_error
245
+ return unless argument
246
+
247
+ { "kind" => "raise", "class" => argument.location.slice }
248
+ end
249
+ end
250
+
251
+ def nodes(root, &block)
252
+ return enum_for(__method__, root) unless block_given?
253
+
254
+ yield root
255
+ root.compact_child_nodes.each { |child| nodes(child, &block) }
256
+ end
257
+
258
+ def source_facts(node, path, owner = nil, method_name = nil, parameters = [])
259
+ return [] unless node
260
+
261
+ owner = source_owner(node, owner)
262
+ if node.type == :def_node
263
+ method_name = node.name
264
+ parameters = node.parameters ? node.parameters.requireds.map(&:name) : []
265
+ end
266
+ fact = guard_fact(node, path, owner, method_name, parameters)
267
+ [fact, *node.compact_child_nodes.flat_map do |child|
268
+ source_facts(child, path, owner, method_name, parameters)
269
+ end].compact
270
+ end
271
+
272
+ def source_owner(node, owner)
273
+ return owner unless %i[class_node module_node].include?(node.type)
274
+
275
+ name = node.constant_path.location.slice
276
+ name.include?("::") ? name : [owner, name].compact.join("::")
277
+ end
278
+
279
+ def guard_fact(node, path, owner, method_name, parameters)
280
+ return unless node.type == :if_node && method_name && contains_raise?(node.statements)
281
+
282
+ predicate = node.predicate.location.slice
283
+ parameters.each_with_index do |name, index|
284
+ predicate = predicate.gsub(/\b#{Regexp.escape(name.to_s)}\b/, "args[#{index}]")
285
+ end
286
+ keyword = node.if_keyword_loc&.slice
287
+ expression = keyword == "unless" ? predicate : "!(#{predicate})"
288
+ { "kind" => "precondition", "owner" => owner, "operation" => "##{method_name}",
289
+ "expr" => expression, "location" => "#{path}:#{node.location.start_line}",
290
+ "source" => node.location.slice }
291
+ end
292
+
293
+ def contains_raise?(node)
294
+ node && nodes(node).any? { |child| child.type == :call_node && child.name == :raise }
295
+ end
296
+
297
+ def literal(node)
298
+ return unless node
299
+ return node.unescaped if node.respond_to?(:unescaped)
300
+ return node.value.to_s if node.respond_to?(:value)
301
+
302
+ node.location.slice
303
+ end
304
+ end
305
+
306
+ class Synthesizer
307
+ MAX_EXAMPLES_PER_OPERATION = 50
308
+ ASSUMPTIONS = [
309
+ { "id" => "H1", "name" => "world_freeze", "enforced_by" => "runtime monitor" },
310
+ { "id" => "H2", "name" => "dynamic_methods_declared", "enforced_by" => "discovery" },
311
+ { "id" => "H3", "name" => "no_eval", "enforced_by" => "Prism static detection" },
312
+ { "id" => "H4", "name" => "runtime_identity_unobserved", "enforced_by" => "serializer" },
313
+ { "id" => "H5", "name" => "ieee_754", "enforced_by" => "declaration only" },
314
+ { "id" => "H6", "name" => "exceptions_are_outputs", "enforced_by" => "recorder" },
315
+ { "id" => "H7", "name" => "single_thread", "enforced_by" => "declaration only" }
316
+ ].freeze
317
+
318
+ def initialize(records:, static_examples: [], source_facts: [])
319
+ @records = records
320
+ @static_examples = static_examples
321
+ @source_facts = source_facts
322
+ @invariant_miner = InvariantMiner.new
323
+ end
324
+
325
+ def call
326
+ subject_items = subjects
327
+ {
328
+ "spec_bundle_version" => SpecBundle::VERSION,
329
+ "generated_at" => Time.now.utc.iso8601,
330
+ "conformance_mode" => "refinement",
331
+ "canonicalization" => canonicalization,
332
+ "verification_assumptions" => ASSUMPTIONS,
333
+ "subjects" => subject_items,
334
+ "lts" => learned_models,
335
+ "static_facts" => @source_facts.reject { |fact| fact["kind"] == "uncovered_branch" },
336
+ "gaps" => synthesis_gaps,
337
+ "waivers" => []
338
+ }
339
+ end
340
+
341
+ private
342
+
343
+ def canonicalization
344
+ configurations = @records.filter_map { |record| record["canonicalization"] }.uniq
345
+ if configurations.length > 1
346
+ raise Error, "The corpus contains conflicting canonicalization settings. Record the corpus again."
347
+ end
348
+
349
+ configurations.first || {}
350
+ end
351
+
352
+ def subjects
353
+ recorded = @records.group_by { |record| record["subject"] }.map do |name, records|
354
+ item = { "name" => short_name(name), "old_class" => name, "operations" => operations(name, records) }
355
+ item["lts_ref"] = lts_id(name) if stateful?(records)
356
+ item
357
+ end
358
+ recorded + static_subjects(recorded.map { |subject| subject["old_class"] })
359
+ end
360
+
361
+ def static_subjects(recorded_names)
362
+ executable = @static_examples.select { |example| example["operation"] && example["subject"] }
363
+ executable.group_by { |example| example["subject"] }.filter_map do |name, examples|
364
+ next if recorded_names.any? { |recorded| short_name(recorded) == short_name(name) }
365
+
366
+ { "name" => short_name(name), "old_class" => name, "operations" => static_operations(examples) }
367
+ end
368
+ end
369
+
370
+ def static_operations(examples)
371
+ examples.group_by { |example| example.fetch("operation") }.map do |name, operation_examples|
372
+ { "name" => name,
373
+ "params" => static_params(operation_examples),
374
+ "preconditions" => [], "invariants" => [],
375
+ "examples" => operation_examples.map { |example| static_example(example) } }
376
+ end
377
+ end
378
+
379
+ def static_params(examples)
380
+ count = examples.map { |example| example.fetch("arguments").length }.max || 0
381
+ count.times.map do |index|
382
+ values = examples.select { |example| example.fetch("arguments").length > index }
383
+ .map { |example| example.fetch("arguments")[index] }
384
+ { "name" => "arg#{index}", "types" => values.map { |value| value.class.name }.uniq,
385
+ "observed_values" => values.map { |value| Recording::Serializer.dump(value) }.uniq }
386
+ end
387
+ end
388
+
389
+ def static_example(example)
390
+ digest = Digest::SHA256.hexdigest(example.values_at("location", "description", "operation").join("\0"))[0, 12]
391
+ { "id" => "static-#{digest}", "provenance_level" => "C", "formal_level" => "F0",
392
+ "provenance" => example.slice("description", "location").merge("derived_from" => ["static_assertion"]),
393
+ "given" => { "args" => Recording::Serializer.dump(example.fetch("arguments")),
394
+ "kwargs" => Recording::Serializer.dump({}), "block_given" => false },
395
+ "expect" => { "outcome" => example.fetch("expected_outcome") } }
396
+ end
397
+
398
+ def synthesis_gaps
399
+ gaps = @source_facts.select { |fact| fact["kind"] == "uncovered_branch" }
400
+ return gaps unless @records.empty?
401
+
402
+ gaps + [{ "kind" => "no_runtime_recording",
403
+ "note" => "Only static assertions were extracted; unasserted legacy behavior is unknown." }]
404
+ end
405
+
406
+ def learned_models
407
+ @records.group_by { |record| record["subject"] }.filter_map do |name, records|
408
+ next unless stateful?(records)
409
+
410
+ Formal::PassiveLearner.new.learn(records).to_h.merge("id" => lts_id(name),
411
+ "learned_by" => "passive corpus projection")
412
+ end
413
+ end
414
+
415
+ def stateful?(records) = records.any? { |record| record["pre_state"] || record["post_state"] }
416
+ def lts_id(name) = "lts-#{short_name(name).downcase}"
417
+
418
+ def operations(subject_name, records)
419
+ records.group_by { |record| record["operation"] }.map do |name, calls|
420
+ sampled = representative_sample(calls)
421
+ { "name" => name, "params" => params(calls), "examples" => sampled.map { |call| example(call) },
422
+ "preconditions" => preconditions(subject_name, name), "invariants" => @invariant_miner.mine(calls) }
423
+ end
424
+ end
425
+
426
+ def representative_sample(calls)
427
+ signatures = {}
428
+ diverse = calls.select do |call|
429
+ signature = JSON.generate(call.values_at("args", "kwargs", "outcome", "post_state", "external_calls"))
430
+ !signatures.key?(signature) && (signatures[signature] = true)
431
+ end
432
+ (diverse + calls).uniq { |call| call["id"] }.first(MAX_EXAMPLES_PER_OPERATION)
433
+ end
434
+
435
+ def preconditions(subject_name, operation_name)
436
+ @source_facts.each_with_index.filter_map do |fact, index|
437
+ next unless fact["kind"] == "precondition" && fact["owner"] == subject_name &&
438
+ fact["operation"] == operation_name
439
+
440
+ fact.slice("expr", "location", "source").merge("id" => format("pre-%04d", index + 1),
441
+ "provenance_level" => "C", "formal_level" => "F1")
442
+ end
443
+ end
444
+
445
+ def params(calls)
446
+ count = calls.map { |call| call.fetch("args", []).length }.max || 0
447
+ count.times.map do |index|
448
+ serialized = calls.filter { |call| call.fetch("args", []).length > index }
449
+ .map { |call| call.fetch("args")[index] }
450
+ observed = serialized.map { |value| Recording::Serializer.load(value) }
451
+ { "name" => "arg#{index}", "types" => observed.map { |value| value.class.name }.uniq,
452
+ "observed_values" => serialized.uniq }
453
+ end
454
+ end
455
+
456
+ def example(call)
457
+ provenance = call["provenance"] || {}
458
+ static = @static_examples.find { |item| matching_static_evidence?(item, call, provenance) }
459
+ {
460
+ "id" => call["id"], "provenance_level" => static ? "A" : "B", "formal_level" => "F0",
461
+ "provenance" => provenance.merge("static_assertions" => static&.fetch("assertions", nil)).compact,
462
+ "given" => call.slice("pre_state", "args", "kwargs", "block_given"),
463
+ "expect" => call.slice("outcome", "post_state", "yields", "external_calls", "mutated_args")
464
+ }
465
+ end
466
+
467
+ def matching_static_evidence?(item, call, provenance)
468
+ item["description"] == provenance["description"] && item["operation"] == call["operation"] &&
469
+ matching_subject?(item["subject"].to_s, call["subject"]) &&
470
+ Recording::Serializer.dump(item.fetch("arguments", [])) == call.fetch("args", []) &&
471
+ matching_outcome?(item, call)
472
+ end
473
+
474
+ def matching_subject?(static_name, recorded_name)
475
+ static_name == recorded_name || (!static_name.include?("::") && static_name == short_name(recorded_name))
476
+ end
477
+
478
+ def matching_outcome?(item, call)
479
+ expected = item["expected_outcome"]
480
+ actual = call["outcome"]
481
+ expected.is_a?(Hash) && actual.is_a?(Hash) && expected == actual.slice(*expected.keys)
482
+ end
483
+
484
+ def short_name(name) = name.split("::").last
485
+ end
486
+ end
487
+ end