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,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