bparity 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.rubocop.yml +61 -0
- data/LICENSE.txt +21 -0
- data/README.md +146 -0
- data/Rakefile +36 -0
- data/docs/application_example.md +25 -0
- data/docs/formal_assurance_limits.md +21 -0
- data/exe/bparity +7 -0
- data/fixtures/scenarios/01_pure_function/adapter.rb +13 -0
- data/fixtures/scenarios/01_pure_function/boundary.rb +17 -0
- data/fixtures/scenarios/01_pure_function/legacy/dead_gem.rb +9 -0
- data/fixtures/scenarios/01_pure_function/legacy/slugifier.rb +17 -0
- data/fixtures/scenarios/01_pure_function/replacement/broken.rb +15 -0
- data/fixtures/scenarios/01_pure_function/replacement/good.rb +19 -0
- data/fixtures/scenarios/01_pure_function/spec/slugifier_spec.rb +20 -0
- data/fixtures/scenarios/01_pure_function/test/slugifier_test.rb +10 -0
- data/fixtures/scenarios/02_stateful_client/adapter.rb +19 -0
- data/fixtures/scenarios/02_stateful_client/boundary.rb +10 -0
- data/fixtures/scenarios/02_stateful_client/legacy/client.rb +29 -0
- data/fixtures/scenarios/02_stateful_client/replacement/broken.rb +14 -0
- data/fixtures/scenarios/02_stateful_client/replacement/good.rb +23 -0
- data/fixtures/scenarios/02_stateful_client/spec/client_spec.rb +31 -0
- data/fixtures/scenarios/03_external_boundary/adapter.rb +13 -0
- data/fixtures/scenarios/03_external_boundary/boundary.rb +11 -0
- data/fixtures/scenarios/03_external_boundary/legacy/dead_formatter.rb +7 -0
- data/fixtures/scenarios/03_external_boundary/legacy/receipt.rb +11 -0
- data/fixtures/scenarios/03_external_boundary/replacement/broken.rb +11 -0
- data/fixtures/scenarios/03_external_boundary/replacement/good.rb +11 -0
- data/fixtures/scenarios/03_external_boundary/spec/receipt_spec.rb +10 -0
- data/fixtures/scenarios/04_intentional_divergence/adapter.rb +12 -0
- data/fixtures/scenarios/04_intentional_divergence/adapter_unwaived.rb +10 -0
- data/fixtures/scenarios/04_intentional_divergence/boundary.rb +8 -0
- data/fixtures/scenarios/04_intentional_divergence/legacy/dead_identity.rb +7 -0
- data/fixtures/scenarios/04_intentional_divergence/legacy/identity.rb +9 -0
- data/fixtures/scenarios/04_intentional_divergence/replacement/broken.rb +9 -0
- data/fixtures/scenarios/04_intentional_divergence/replacement/good.rb +9 -0
- data/fixtures/scenarios/04_intentional_divergence/spec/identity_spec.rb +10 -0
- data/fixtures/scenarios/05_formal_negative/adapter.rb +15 -0
- data/fixtures/scenarios/05_formal_negative/boundary.rb +16 -0
- data/fixtures/scenarios/05_formal_negative/legacy/dead_lock.rb +5 -0
- data/fixtures/scenarios/05_formal_negative/legacy/turnstile.rb +24 -0
- data/fixtures/scenarios/05_formal_negative/replacement/broken.rb +18 -0
- data/fixtures/scenarios/05_formal_negative/replacement/formal_broken.rb +24 -0
- data/fixtures/scenarios/05_formal_negative/replacement/good.rb +24 -0
- data/fixtures/scenarios/05_formal_negative/spec/turnstile_spec.rb +21 -0
- data/lib/bparity/adapter.rb +115 -0
- data/lib/bparity/adequacy.rb +78 -0
- data/lib/bparity/boundary.rb +97 -0
- data/lib/bparity/cli/formal_commands.rb +428 -0
- data/lib/bparity/cli/verification_commands.rb +242 -0
- data/lib/bparity/cli.rb +262 -0
- data/lib/bparity/corpus.rb +45 -0
- data/lib/bparity/errors.rb +12 -0
- data/lib/bparity/formal/assumptions.rb +131 -0
- data/lib/bparity/formal/bounded.rb +543 -0
- data/lib/bparity/formal/contract.rb +81 -0
- data/lib/bparity/formal/deductive.rb +481 -0
- data/lib/bparity/formal/lts.rb +344 -0
- data/lib/bparity/formal/result.rb +50 -0
- data/lib/bparity/formal.rb +8 -0
- data/lib/bparity/recording.rb +412 -0
- data/lib/bparity/reporting.rb +128 -0
- data/lib/bparity/spec_bundle.rb +208 -0
- data/lib/bparity/synthesis.rb +487 -0
- data/lib/bparity/verification.rb +310 -0
- data/lib/bparity/version.rb +5 -0
- data/lib/bparity.rb +42 -0
- metadata +125 -0
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "time"
|
|
6
|
+
require "coverage"
|
|
7
|
+
require "date"
|
|
8
|
+
|
|
9
|
+
module Bparity
|
|
10
|
+
module Recording
|
|
11
|
+
module Context
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
def external_stack = Thread.current[:bparity_external_stack] ||= []
|
|
15
|
+
def append_external(call) = external_stack.last&.push(call)
|
|
16
|
+
def provenance = Thread.current[:bparity_provenance]
|
|
17
|
+
|
|
18
|
+
def provenance=(value)
|
|
19
|
+
Thread.current[:bparity_provenance] = value
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
module Serializer
|
|
24
|
+
module_function
|
|
25
|
+
|
|
26
|
+
def dump(value, projections: {}, seen: nil)
|
|
27
|
+
seen ||= {}.compare_by_identity
|
|
28
|
+
projection = projections[value.class.name]
|
|
29
|
+
if projection
|
|
30
|
+
return { "$unserializable" => value.class.name, "$reason" => "cyclic projection" } if seen.key?(value)
|
|
31
|
+
|
|
32
|
+
return dump(projection.call(value), projections:, seen: with_seen(seen, value))
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
if recursive?(value)
|
|
36
|
+
return { "$unserializable" => value.class.name, "$reason" => "cyclic reference" } if seen.key?(value)
|
|
37
|
+
|
|
38
|
+
seen = with_seen(seen, value)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
case value
|
|
42
|
+
when nil, true, false, Integer then value
|
|
43
|
+
when String
|
|
44
|
+
if value.valid_encoding?
|
|
45
|
+
value
|
|
46
|
+
else
|
|
47
|
+
{ "$string_bytes" => [value.b].pack("m0"), "$encoding" => value.encoding.name }
|
|
48
|
+
end
|
|
49
|
+
when Float then value.finite? ? value : { "$float" => value.to_s }
|
|
50
|
+
when Symbol then { "$symbol" => value.to_s }
|
|
51
|
+
when Time then { "$time" => value.utc.iso8601(9) }
|
|
52
|
+
when Array then value.map { |item| dump(item, projections:, seen:) }
|
|
53
|
+
when Hash
|
|
54
|
+
{ "$hash" => value.map do |key, item|
|
|
55
|
+
[dump(key, projections:, seen:), dump(item, projections:, seen:)]
|
|
56
|
+
end }
|
|
57
|
+
else
|
|
58
|
+
dump_object(value, projections, seen)
|
|
59
|
+
end
|
|
60
|
+
rescue StandardError
|
|
61
|
+
{ "$unserializable" => value.class.name, "$digest" => safe_digest(value) }
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def load(value)
|
|
65
|
+
return value.map { |item| load(item) } if value.is_a?(Array)
|
|
66
|
+
return value unless value.is_a?(Hash)
|
|
67
|
+
return value["$symbol"].to_sym if value.key?("$symbol")
|
|
68
|
+
if value.key?("$string_bytes")
|
|
69
|
+
return value.fetch("$string_bytes").unpack1("m0").force_encoding(value.fetch("$encoding"))
|
|
70
|
+
end
|
|
71
|
+
return Time.iso8601(value["$time"]) if value.key?("$time")
|
|
72
|
+
return Float(value["$float"]) if value.key?("$float")
|
|
73
|
+
return value["$hash"].to_h { |key, item| [load(key), load(item)] } if value.key?("$hash")
|
|
74
|
+
return load_object(value) if value.key?("$class") && value.key?("$ivars")
|
|
75
|
+
|
|
76
|
+
value.transform_values { |item| load(item) }
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def load_object(value)
|
|
80
|
+
klass = Bparity.constantize(value.fetch("$class"))
|
|
81
|
+
klass.allocate.tap do |object|
|
|
82
|
+
value.fetch("$ivars").each do |name, item|
|
|
83
|
+
object.instance_variable_set(name, load(item))
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
rescue ConfigurationError, TypeError
|
|
87
|
+
value.transform_values { |item| load(item) }
|
|
88
|
+
end
|
|
89
|
+
private_class_method :load_object
|
|
90
|
+
|
|
91
|
+
def dump_object(value, projections, seen)
|
|
92
|
+
ivars = value.instance_variables.sort.to_h do |name|
|
|
93
|
+
[name.to_s, dump(value.instance_variable_get(name), projections:, seen:)]
|
|
94
|
+
end
|
|
95
|
+
return { "$class" => value.class.name, "$ivars" => ivars } unless ivars.empty?
|
|
96
|
+
|
|
97
|
+
readers = value.class.public_instance_methods(false).select do |name|
|
|
98
|
+
value.method(name).arity.zero? && !%i[to_s inspect hash].include?(name)
|
|
99
|
+
end
|
|
100
|
+
attributes = readers.sort.to_h do |name|
|
|
101
|
+
[name.to_s, dump(value.public_send(name), projections:, seen:)]
|
|
102
|
+
end
|
|
103
|
+
return { "$class" => value.class.name, "$attributes" => attributes } unless attributes.empty?
|
|
104
|
+
|
|
105
|
+
{ "$unserializable" => value.class.name, "$digest" => safe_digest(value) }
|
|
106
|
+
end
|
|
107
|
+
private_class_method :dump_object
|
|
108
|
+
|
|
109
|
+
def recursive?(value) = value.is_a?(Array) || value.is_a?(Hash) || !primitive?(value)
|
|
110
|
+
private_class_method :recursive?
|
|
111
|
+
|
|
112
|
+
def primitive?(value)
|
|
113
|
+
value.nil? || value.equal?(true) || value.equal?(false) || value.is_a?(String) || value.is_a?(Numeric) ||
|
|
114
|
+
value.is_a?(Symbol) || value.is_a?(Time)
|
|
115
|
+
end
|
|
116
|
+
private_class_method :primitive?
|
|
117
|
+
|
|
118
|
+
def with_seen(seen, value)
|
|
119
|
+
copy = seen.dup
|
|
120
|
+
copy.compare_by_identity
|
|
121
|
+
copy[value] = true
|
|
122
|
+
copy
|
|
123
|
+
end
|
|
124
|
+
private_class_method :with_seen
|
|
125
|
+
|
|
126
|
+
def safe_digest(value)
|
|
127
|
+
Digest::SHA256.hexdigest(value.inspect)
|
|
128
|
+
rescue Exception # rubocop:disable Lint/RescueException -- this is the terminal serializer fallback
|
|
129
|
+
Digest::SHA256.hexdigest(value.class.name)
|
|
130
|
+
end
|
|
131
|
+
private_class_method :safe_digest
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
class Canonicalizer
|
|
135
|
+
UUID = /\b[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\b/i
|
|
136
|
+
|
|
137
|
+
def initialize(config = {})
|
|
138
|
+
@config = config
|
|
139
|
+
@ids = {}
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def call(value)
|
|
143
|
+
case value
|
|
144
|
+
when String then canonical_string(value)
|
|
145
|
+
when Float then canonical_float(value)
|
|
146
|
+
when Array then value.map { |item| call(item) }
|
|
147
|
+
when Hash
|
|
148
|
+
return { "$time" => @config[:freeze_time] } if value.key?("$time") && @config[:freeze_time]
|
|
149
|
+
|
|
150
|
+
value.to_h { |key, item| [key, call(item)] }
|
|
151
|
+
else value
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
private
|
|
156
|
+
|
|
157
|
+
def canonical_string(value)
|
|
158
|
+
return value unless @config[:uuid_placeholder]
|
|
159
|
+
|
|
160
|
+
value.gsub(UUID) { |id| @ids[id] ||= "<ID:#{@ids.length}>" }
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def canonical_float(value)
|
|
164
|
+
tolerance = @config[:float_tolerance]
|
|
165
|
+
if tolerance && !tolerance.positive?
|
|
166
|
+
raise ConfigurationError, "Float tolerance must be positive. Update the boundary configuration."
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
tolerance ? (value / tolerance).round * tolerance : value
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
module Determinism
|
|
174
|
+
TIME_KEY = :bparity_frozen_time
|
|
175
|
+
RANDOM_KEY = :bparity_previous_random_seed
|
|
176
|
+
|
|
177
|
+
module_function
|
|
178
|
+
|
|
179
|
+
def apply(config)
|
|
180
|
+
Thread.current[RANDOM_KEY] = srand(config[:random_seed]) if config[:random_seed]
|
|
181
|
+
return unless config[:freeze_time]
|
|
182
|
+
|
|
183
|
+
install_time_hook
|
|
184
|
+
install_date_hook
|
|
185
|
+
Thread.current[TIME_KEY] = Time.parse(config[:freeze_time].to_s)
|
|
186
|
+
rescue ArgumentError
|
|
187
|
+
raise ConfigurationError, "Frozen time is invalid. Use an ISO 8601 value in the boundary configuration."
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def clear
|
|
191
|
+
Thread.current[TIME_KEY] = nil
|
|
192
|
+
previous_seed = Thread.current[RANDOM_KEY]
|
|
193
|
+
srand(previous_seed) if previous_seed
|
|
194
|
+
Thread.current[RANDOM_KEY] = nil
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def install_time_hook
|
|
198
|
+
return if Time.singleton_class.instance_variable_defined?(:@bparity_time_hook)
|
|
199
|
+
|
|
200
|
+
key = TIME_KEY
|
|
201
|
+
Time.singleton_class.prepend(Module.new do
|
|
202
|
+
define_method(:now) { |*args, **kwargs| Thread.current[key] || super(*args, **kwargs) }
|
|
203
|
+
end)
|
|
204
|
+
Time.singleton_class.instance_variable_set(:@bparity_time_hook, true)
|
|
205
|
+
end
|
|
206
|
+
private_class_method :install_time_hook
|
|
207
|
+
|
|
208
|
+
def install_date_hook
|
|
209
|
+
return if Date.singleton_class.instance_variable_defined?(:@bparity_date_hook)
|
|
210
|
+
|
|
211
|
+
key = TIME_KEY
|
|
212
|
+
Date.singleton_class.prepend(Module.new do
|
|
213
|
+
define_method(:today) { Thread.current[key]&.to_date || super() }
|
|
214
|
+
end)
|
|
215
|
+
Date.singleton_class.instance_variable_set(:@bparity_date_hook, true)
|
|
216
|
+
end
|
|
217
|
+
private_class_method :install_date_hook
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
module CoverageTracker
|
|
221
|
+
module_function
|
|
222
|
+
|
|
223
|
+
def running? = Coverage.running?
|
|
224
|
+
|
|
225
|
+
def start
|
|
226
|
+
return if Coverage.running?
|
|
227
|
+
|
|
228
|
+
Coverage.start(lines: true, branches: true)
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def finish(path)
|
|
232
|
+
return unless Coverage.running?
|
|
233
|
+
|
|
234
|
+
root = "#{File.expand_path(Dir.pwd)}#{File::SEPARATOR}"
|
|
235
|
+
files = Coverage.result.filter_map do |file, data|
|
|
236
|
+
next unless File.expand_path(file).start_with?(root)
|
|
237
|
+
|
|
238
|
+
branches = data.fetch(:branches, {}).flat_map do |_base, children|
|
|
239
|
+
children.map do |location, count|
|
|
240
|
+
{ "type" => location[0].to_s, "start_line" => location[2],
|
|
241
|
+
"end_line" => location[4], "count" => count }
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
{ "path" => file, "lines" => data.fetch(:lines, []), "branches" => branches }
|
|
245
|
+
end
|
|
246
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
247
|
+
File.write(path, JSON.pretty_generate("files" => files))
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def gaps(path, source_paths: [])
|
|
251
|
+
allowed = source_paths.map { |source| File.expand_path(source) }
|
|
252
|
+
JSON.parse(File.read(path)).fetch("files").flat_map do |file|
|
|
253
|
+
next [] unless allowed.empty? || allowed.include?(File.expand_path(file.fetch("path")))
|
|
254
|
+
|
|
255
|
+
file.fetch("branches").filter_map do |branch|
|
|
256
|
+
next unless branch.fetch("count").zero?
|
|
257
|
+
|
|
258
|
+
{ "kind" => "uncovered_branch", "location" => "#{file.fetch('path')}:#{branch.fetch('start_line')}" }
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
rescue Errno::ENOENT
|
|
262
|
+
raise ConfigurationError, "Cannot read coverage file #{path}. Run `bparity record` first."
|
|
263
|
+
rescue JSON::ParserError, KeyError
|
|
264
|
+
raise ConfigurationError, "Coverage file #{path} is invalid. Run `bparity record` again."
|
|
265
|
+
end
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
module MinitestDriver
|
|
269
|
+
module_function
|
|
270
|
+
|
|
271
|
+
def install!
|
|
272
|
+
return if Minitest::Test < TestHook
|
|
273
|
+
|
|
274
|
+
Minitest::Test.prepend(TestHook)
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
module TestHook
|
|
278
|
+
def run
|
|
279
|
+
location = method(name).source_location&.join(":")
|
|
280
|
+
Context.provenance = { "example_id" => "#{self.class}##{name}", "description" => name,
|
|
281
|
+
"location" => location }
|
|
282
|
+
super
|
|
283
|
+
ensure
|
|
284
|
+
Context.provenance = nil
|
|
285
|
+
end
|
|
286
|
+
end
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
class Recorder
|
|
290
|
+
attr_reader :writer
|
|
291
|
+
|
|
292
|
+
def initialize(boundary:, writer:)
|
|
293
|
+
@boundary = boundary
|
|
294
|
+
@writer = writer
|
|
295
|
+
@sequence = 0
|
|
296
|
+
@canonicalizer = Canonicalizer.new(boundary.canonicalization)
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
def install!
|
|
300
|
+
@boundary.subjects.each_value { |subject| install_subject(subject) }
|
|
301
|
+
@boundary.externals.each_value { |external| install_external(external) }
|
|
302
|
+
self
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
def capture(subject, receiver, operation, args, kwargs, block)
|
|
306
|
+
projections = subject.return_projections
|
|
307
|
+
before_args = Serializer.dump(args)
|
|
308
|
+
yields = []
|
|
309
|
+
wrapped = block && proc { |*items|
|
|
310
|
+
yields << Serializer.dump(items)
|
|
311
|
+
block.call(*items)
|
|
312
|
+
}
|
|
313
|
+
pre_state = project_state(subject, receiver)
|
|
314
|
+
Context.external_stack << []
|
|
315
|
+
outcome = yield(wrapped)
|
|
316
|
+
serialized = Serializer.dump(outcome, projections:)
|
|
317
|
+
external_calls = Context.external_stack.pop
|
|
318
|
+
write(subject, receiver, operation, args, kwargs, before_args, yields, pre_state, block, external_calls,
|
|
319
|
+
result_value: serialized)
|
|
320
|
+
outcome
|
|
321
|
+
rescue Exception => e # rubocop:disable Lint/RescueException -- recording must preserve every observable exception
|
|
322
|
+
external_calls = Context.external_stack.pop || []
|
|
323
|
+
write(subject, receiver, operation, args, kwargs, before_args, yields, pre_state, block, external_calls,
|
|
324
|
+
error: { "class" => e.class.name, "message" => Bparity.exception_message(e),
|
|
325
|
+
"cause" => e.cause&.class&.name })
|
|
326
|
+
raise
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
private
|
|
330
|
+
|
|
331
|
+
def install_subject(subject)
|
|
332
|
+
target = Bparity.constantize(subject.name)
|
|
333
|
+
recorder = self
|
|
334
|
+
mod = Module.new
|
|
335
|
+
subject.observed_methods(target).each do |method_name|
|
|
336
|
+
mod.define_method(method_name) do |*args, **kwargs, &block|
|
|
337
|
+
recorder.capture(subject, self, method_name, args, kwargs, block) do |wrapped|
|
|
338
|
+
super(*args, **kwargs, &wrapped || block)
|
|
339
|
+
end
|
|
340
|
+
end
|
|
341
|
+
end
|
|
342
|
+
target.prepend(mod)
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
def install_external(external)
|
|
346
|
+
target = Bparity.constantize(external.name)
|
|
347
|
+
mod = Module.new
|
|
348
|
+
methods = external.method_names.empty? ? target.public_instance_methods(false) : external.method_names
|
|
349
|
+
methods.each do |method_name|
|
|
350
|
+
mod.define_method(method_name) do |*args, **kwargs, &block|
|
|
351
|
+
value = super(*args, **kwargs, &block)
|
|
352
|
+
Context.append_external({ "target" => external.name, "method" => method_name.to_s,
|
|
353
|
+
"args" => Serializer.dump(args), "kwargs" => Serializer.dump(kwargs),
|
|
354
|
+
"outcome" => { "kind" => "return", "value" => Serializer.dump(value) } })
|
|
355
|
+
value
|
|
356
|
+
rescue StandardError => e
|
|
357
|
+
Context.append_external({ "target" => external.name, "method" => method_name.to_s,
|
|
358
|
+
"args" => Serializer.dump(args), "kwargs" => Serializer.dump(kwargs),
|
|
359
|
+
"outcome" => { "kind" => "raise", "class" => e.class.name,
|
|
360
|
+
"message" => Bparity.exception_message(e) } })
|
|
361
|
+
raise
|
|
362
|
+
end
|
|
363
|
+
end
|
|
364
|
+
target.prepend(mod)
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
def write(subject, receiver, operation, args, kwargs, before_args, yields, pre_state, block, external_calls,
|
|
368
|
+
result_value: nil, error: nil)
|
|
369
|
+
@sequence += 1
|
|
370
|
+
writer.write(@canonicalizer.call({
|
|
371
|
+
"id" => format("bc-%06d", @sequence), "seq" => @sequence,
|
|
372
|
+
"subject" => subject.name,
|
|
373
|
+
"canonicalization" => @boundary.canonicalization.transform_keys(&:to_s),
|
|
374
|
+
"operation" => "##{operation}", "provenance" => provenance,
|
|
375
|
+
"pre_state" => pre_state, "args" => Serializer.dump(args),
|
|
376
|
+
"kwargs" => Serializer.dump(kwargs),
|
|
377
|
+
"block_given" => !block.nil?, "yields" => yields,
|
|
378
|
+
"outcome" => outcome(error, result_value),
|
|
379
|
+
"post_state" => project_state(subject, receiver),
|
|
380
|
+
"external_calls" => external_calls,
|
|
381
|
+
"mutated_args" => mutated_indices(before_args, Serializer.dump(args))
|
|
382
|
+
}))
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
def mutated_indices(before, after)
|
|
386
|
+
[before.length, after.length].max.times.reject { |index| before[index] == after[index] }
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
def outcome(error, result_value)
|
|
390
|
+
return { "kind" => "raise", **error } if error
|
|
391
|
+
|
|
392
|
+
{ "kind" => "return", "value" => result_value }
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
def project_state(subject, receiver)
|
|
396
|
+
Serializer.dump(subject.state_projection&.call(receiver))
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
def provenance
|
|
400
|
+
example = defined?(RSpec) && RSpec.respond_to?(:current_example) && RSpec.current_example
|
|
401
|
+
return Context.provenance if Context.provenance
|
|
402
|
+
unless example
|
|
403
|
+
return { "example_id" => nil, "description" => nil,
|
|
404
|
+
"location" => caller_locations(4, 1).first.to_s }
|
|
405
|
+
end
|
|
406
|
+
|
|
407
|
+
{ "example_id" => example.id, "description" => example.full_description,
|
|
408
|
+
"location" => example.metadata[:location] }
|
|
409
|
+
end
|
|
410
|
+
end
|
|
411
|
+
end
|
|
412
|
+
end
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "cgi"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module Bparity
|
|
7
|
+
module Reporting
|
|
8
|
+
class AssuranceMatrix
|
|
9
|
+
PROVENANCE = %w[A B C D].freeze
|
|
10
|
+
FORMAL = %w[F0 F1 F2 F3 F4].freeze
|
|
11
|
+
|
|
12
|
+
def initialize(bundle)
|
|
13
|
+
@bundle = bundle
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def to_h
|
|
17
|
+
matrix = PROVENANCE.to_h { |provenance| [provenance, FORMAL.to_h { |formal| [formal, 0] }] }
|
|
18
|
+
items.each do |item|
|
|
19
|
+
provenance = item.fetch("provenance_level", "D")
|
|
20
|
+
formal = item.fetch("formal_level", "F0")
|
|
21
|
+
matrix.fetch(provenance).fetch(formal)
|
|
22
|
+
matrix[provenance][formal] += 1
|
|
23
|
+
end
|
|
24
|
+
matrix
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def items
|
|
30
|
+
@bundle.fetch("subjects", []).flat_map { |subject| subject.fetch("operations", []) }
|
|
31
|
+
.flat_map { |operation| operation.fetch("examples", []) + operation.fetch("invariants", []) }
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
class Reporter
|
|
36
|
+
FORMATS = %w[markdown json junit html].freeze
|
|
37
|
+
|
|
38
|
+
def initialize(results, bundle: nil)
|
|
39
|
+
@results = results
|
|
40
|
+
@bundle = bundle
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def summary
|
|
44
|
+
counts = @results.group_by(&:status).transform_values(&:count)
|
|
45
|
+
{ "total" => @results.count, "pass" => counts.fetch(:pass, 0), "fail" => counts.fetch(:fail, 0),
|
|
46
|
+
"waived" => counts.fetch(:waived, 0), "skipped" => counts.fetch(:skipped, 0),
|
|
47
|
+
"results" => @results.map(&:to_h),
|
|
48
|
+
"assumptions" => @bundle&.fetch("verification_assumptions", []),
|
|
49
|
+
"assurance_matrix" => @bundle ? AssuranceMatrix.new(@bundle).to_h : {},
|
|
50
|
+
"five_point_assessment" => @bundle ? Adequacy::Analyzer.new(bundle: @bundle, results: @results).call : {} }
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def json = JSON.pretty_generate(summary)
|
|
54
|
+
|
|
55
|
+
def markdown
|
|
56
|
+
data = summary
|
|
57
|
+
lines = ["# bparity conformance report", "",
|
|
58
|
+
"Total: #{data['total']} | PASS: #{data['pass']} | " \
|
|
59
|
+
"FAIL: #{data['fail']} | WAIVED: #{data['waived']} | SKIPPED: #{data['skipped']}", ""]
|
|
60
|
+
@results.each do |result|
|
|
61
|
+
lines << "- **#{result.status.to_s.upcase}** `#{result.id}` #{result.description}"
|
|
62
|
+
if result.waiver
|
|
63
|
+
lines << " - Waiver: #{result.waiver.reason} " \
|
|
64
|
+
"(approved by #{result.waiver.approved_by} on #{result.waiver.approved_at})"
|
|
65
|
+
end
|
|
66
|
+
result.differences.each do |difference|
|
|
67
|
+
lines << " - `#{difference['path']}` expected `#{difference['expected'].inspect}`, " \
|
|
68
|
+
"got `#{difference['actual'].inspect}`"
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
assessment = data.fetch("five_point_assessment")
|
|
72
|
+
unless assessment.empty?
|
|
73
|
+
lines.push("", "## Five-point assessment", "",
|
|
74
|
+
"1. Specification coverage: #{assessment.fetch('specification_coverage')}",
|
|
75
|
+
"2. Generated checks: #{assessment.fetch('generated_checks')}",
|
|
76
|
+
"3. Formal assurance: #{JSON.generate(assessment.fetch('formal_assurance'))}",
|
|
77
|
+
"4. Constraint strength: #{assessment.fetch('constraint_strength').fetch('status')}",
|
|
78
|
+
"5. Residual risks: #{residual_risks(assessment)}")
|
|
79
|
+
end
|
|
80
|
+
lines.join("\n")
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def junit
|
|
84
|
+
failures = @results.count { |result| result.status == :fail }
|
|
85
|
+
cases = @results.map do |result|
|
|
86
|
+
failure = if result.status == :fail
|
|
87
|
+
details = CGI.escapeHTML(JSON.generate(result.differences))
|
|
88
|
+
"<failure message=\"Behavior differs\">#{details}</failure>"
|
|
89
|
+
end
|
|
90
|
+
"<testcase name=\"#{CGI.escapeHTML(result.id)}\">#{failure}</testcase>"
|
|
91
|
+
end.join
|
|
92
|
+
header = %(<?xml version="1.0" encoding="UTF-8"?>)
|
|
93
|
+
suite = %(<testsuite tests="#{@results.length}" failures="#{failures}">#{cases}</testsuite>)
|
|
94
|
+
header + suite
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def html
|
|
98
|
+
rows = @results.map do |result|
|
|
99
|
+
details = CGI.escapeHTML(JSON.pretty_generate(result.differences))
|
|
100
|
+
"<tr><td>#{result.status.to_s.upcase}</td><td>#{CGI.escapeHTML(result.id)}</td>" \
|
|
101
|
+
"<td><pre>#{details}</pre></td></tr>"
|
|
102
|
+
end.join
|
|
103
|
+
data = summary
|
|
104
|
+
matrix = matrix_html(data.fetch("assurance_matrix"))
|
|
105
|
+
assessment = CGI.escapeHTML(JSON.pretty_generate(data.fetch("five_point_assessment")))
|
|
106
|
+
"<!doctype html><html lang=\"en\"><meta charset=\"utf-8\"><title>bparity report</title>" \
|
|
107
|
+
"<h1>bparity conformance report</h1>#{matrix}<h2>Five-point assessment</h2><pre>#{assessment}</pre>" \
|
|
108
|
+
"<table><thead><tr><th>Status</th><th>ID</th><th>Details</th></tr>" \
|
|
109
|
+
"</thead><tbody>#{rows}</tbody></table></html>"
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
private
|
|
113
|
+
|
|
114
|
+
def matrix_html(matrix)
|
|
115
|
+
header = "<tr><th>Provenance</th>#{AssuranceMatrix::FORMAL.map { |level| "<th>#{level}</th>" }.join}</tr>"
|
|
116
|
+
rows = matrix.map do |provenance, levels|
|
|
117
|
+
"<tr><th>#{provenance}</th>#{levels.values.map { |count| "<td>#{count}</td>" }.join}</tr>"
|
|
118
|
+
end.join
|
|
119
|
+
"<h2>Provenance × formal assurance</h2><table>#{header}#{rows}</table>"
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def residual_risks(assessment)
|
|
123
|
+
risks = assessment.fetch("residual_risks")
|
|
124
|
+
risks.empty? ? "none identified" : risks.first(5).join("; ")
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|