branchproof 0.6.0 → 0.8.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 (52) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +45 -0
  3. data/README.md +279 -27
  4. data/doc/Branchproof/Analyzer.md +2 -2
  5. data/doc/Branchproof/CLI.md +4 -0
  6. data/doc/Branchproof/ComparisonReport.md +4 -0
  7. data/doc/Branchproof/Constraints/Solver.md +21 -0
  8. data/doc/Branchproof/Constraints.md +98 -0
  9. data/doc/Branchproof/CoverageIndex.md +6 -0
  10. data/doc/Branchproof/DecisionSyntax.md +22 -0
  11. data/doc/Branchproof/DecisionTable.md +153 -0
  12. data/doc/Branchproof/FlowInstrumentation.md +7 -0
  13. data/doc/Branchproof/FocusedReport.md +5 -2
  14. data/doc/Branchproof/Instrumenter.md +1 -0
  15. data/doc/Branchproof/Loader.md +0 -4
  16. data/doc/Branchproof/MinitestAdapter.md +0 -3
  17. data/doc/Branchproof/Report.md +18 -0
  18. data/doc/Branchproof/Runtime.md +23 -0
  19. data/doc/Branchproof/RuntimeFlow.md +27 -0
  20. data/doc/Branchproof/SavedReport.md +21 -0
  21. data/doc/Branchproof/Source.md +1 -0
  22. data/doc/Branchproof.md +9 -2
  23. data/doc/CHANGELOG.md +45 -0
  24. data/doc/README.md +279 -27
  25. data/lib/branchproof/analyzer.rb +222 -54
  26. data/lib/branchproof/cli.rb +28 -19
  27. data/lib/branchproof/comparison.rb +207 -19
  28. data/lib/branchproof/comparison_report.rb +49 -1
  29. data/lib/branchproof/constraints.rb +363 -0
  30. data/lib/branchproof/coverage_index.rb +120 -3
  31. data/lib/branchproof/decision_syntax.rb +310 -0
  32. data/lib/branchproof/decision_table.rb +377 -0
  33. data/lib/branchproof/evidence.rb +101 -36
  34. data/lib/branchproof/flow_instrumentation.rb +107 -0
  35. data/lib/branchproof/focused_report.rb +161 -26
  36. data/lib/branchproof/instrumenter.rb +65 -39
  37. data/lib/branchproof/limits.rb +4 -1
  38. data/lib/branchproof/loader.rb +18 -6
  39. data/lib/branchproof/minimizer.rb +18 -13
  40. data/lib/branchproof/minitest_adapter.rb +11 -16
  41. data/lib/branchproof/records.rb +2 -0
  42. data/lib/branchproof/report.rb +470 -66
  43. data/lib/branchproof/runtime.rb +25 -30
  44. data/lib/branchproof/runtime_flow.rb +58 -0
  45. data/lib/branchproof/saved_report.rb +438 -13
  46. data/lib/branchproof/source.rb +239 -41
  47. data/lib/branchproof/version.rb +1 -1
  48. data/lib/branchproof/worker.rb +1 -4
  49. data/lib/branchproof.rb +2 -0
  50. data/llms.txt +9 -2
  51. data/sig/branchproof.rbs +54 -1
  52. metadata +12 -1
@@ -20,10 +20,16 @@ module Branchproof
20
20
  raise ArgumentError, "run_id is required" if run_id.nil? || run_id.to_s.empty?
21
21
 
22
22
  @inventory = inventory
23
+ @decisions_by_id = Array(fetch_value(@inventory, :decisions)).each_with_object({}) do |raw, index|
24
+ decision = symbolize(raw)
25
+ index[decision[:id].to_s] = decision
26
+ end
23
27
  @limits = normalize_limits(limits)
24
28
  @run_id = run_id.to_s
25
29
  @run_ids = [@run_id]
26
30
  @vectors = {}
31
+ @vector_counts_by_decision = Hash.new(0)
32
+ @owner_associations_count = 0
27
33
  @tests = {}
28
34
  @run_payloads = {}
29
35
  @abort_counts = Hash.new(0)
@@ -68,12 +74,13 @@ module Branchproof
68
74
  end
69
75
  decision_id = value[:decision_id].to_s
70
76
  vector_values = condition_values(decision_id, value[:observations])
71
- if new_vector?(decision_id, vector_values,
72
- value[:outcome]) && vector_count(decision_id) >= @limits[:vectors_per_decision]
77
+ outcome = value[:outcome] ? true : false
78
+ vector_id = Branchproof::Records.id([decision_id, vector_values, outcome])
79
+ if new_vector?(vector_id) && vector_count(decision_id) >= @limits[:vectors_per_decision]
73
80
  @limited = true
74
81
  return status("limited", "vectors_per_decision reached")
75
82
  end
76
- vector = vector_for(decision_id, vector_values, value[:outcome] ? true : false)
83
+ vector = vector_for(decision_id, vector_values, outcome, vector_id)
77
84
  new_owner = if value[:test_id]
78
85
  !vector[:test_ids].include?(value[:test_id].to_s)
79
86
  else
@@ -89,6 +96,7 @@ module Branchproof
89
96
  phase_map << value[:phase].to_s if phase_map && !phase_map.include?(value[:phase].to_s)
90
97
  vector[:unattributed_count] += 1 unless value[:test_id]
91
98
  vector[:count] += 1
99
+ @owner_associations_count += 1 if new_owner
92
100
  if value[:test_id] && @tests.key?(value[:test_id].to_s)
93
101
  test = @tests[value[:test_id].to_s]
94
102
  phase = value[:phase].to_s
@@ -194,25 +202,24 @@ module Branchproof
194
202
  def validate_execution(value)
195
203
  return "execution must be a hash" unless value.is_a?(Hash)
196
204
 
197
- %i[run_id execution_id decision_id test_id phase owner observations outcome status].each do |key|
205
+ %i[run_id decision_id test_id phase observations outcome status].each do |key|
198
206
  return "missing #{key}" unless value.key?(key)
199
207
  end
200
208
  return "run mismatch" unless value[:run_id].to_s == @run_id
201
- return "invalid owner" unless value[:owner].is_a?(Hash)
202
209
  return "invalid status" unless %w[completed aborted invalid].include?(value[:status].to_s)
203
210
  return "invalid phase" unless %w[setup body teardown suite unattributed].include?(value[:phase].to_s)
204
211
  return "invalid observations" unless value[:observations].is_a?(Array) && value[:observations].all? do |pair|
205
212
  pair.is_a?(Array) && pair.length == 2 && pair[0].is_a?(Integer) && [true, false].include?(pair[1])
206
213
  end
207
214
 
208
- decision = decisions.find { |d| d[:id].to_s == value[:decision_id].to_s }
215
+ decision = @decisions_by_id[value[:decision_id].to_s]
209
216
  return "unknown decision" unless decision
210
217
 
211
- conditions = Array(decision[:conditions])
212
- return "condition count exceeds limit" if conditions.length > @limits[:conditions_per_decision]
218
+ dimensions = alternative_decision?(decision) ? Array(decision[:alternatives]) : Array(decision[:conditions])
219
+ return "condition count exceeds limit" if !alternative_decision?(decision) && dimensions.length > @limits[:conditions_per_decision]
213
220
  return "invalid condition index" unless value[:observations].map(&:first).uniq == value[:observations].map(&:first) && value[:observations].all? do |index, _|
214
- conditions.any? do |c|
215
- c[:index].to_i == index
221
+ dimensions.any? do |dimension|
222
+ dimension[:index].to_i == index
216
223
  end
217
224
  end
218
225
  return "invalid trace" unless value[:status].to_s != "completed" || valid_trace?(decision, value[:observations],
@@ -222,7 +229,9 @@ module Branchproof
222
229
  end
223
230
 
224
231
  def valid_trace?(decision, observations, outcome)
225
- tree = symbolize(decision[:tree])
232
+ return valid_alternative_trace?(decision, observations, outcome) if alternative_decision?(decision)
233
+
234
+ tree = decision[:tree]
226
235
  unless tree
227
236
  return observations.map(&:first) == observations.map(&:first).sort &&
228
237
  (outcome ? true : false) == evaluate_fallback(observations)
@@ -237,7 +246,6 @@ module Branchproof
237
246
  end
238
247
 
239
248
  def replay_tree(node, observations, cursor)
240
- node = symbolize(node)
241
249
  type = node[:type].to_s
242
250
  if type == "atom"
243
251
  pair = observations[cursor]
@@ -245,6 +253,13 @@ module Branchproof
245
253
 
246
254
  return [pair[1], cursor + 1]
247
255
  end
256
+ if type == "not"
257
+ child = replay_tree(node.fetch(:child), observations, cursor)
258
+ return nil unless child
259
+
260
+ child_value, next_cursor = child
261
+ return [!child_value, next_cursor]
262
+ end
248
263
  left = replay_tree(node.fetch(:left), observations, cursor)
249
264
  return nil unless left
250
265
 
@@ -265,32 +280,48 @@ module Branchproof
265
280
  end
266
281
 
267
282
  def condition_values(decision_id, observations)
268
- count = decision_conditions(decision_id).length
283
+ count = decision_dimension_count(decision_id)
269
284
  values = Array.new(count)
270
285
  observations.each { |index, value| values[index] = value }
271
286
  values
272
287
  end
273
288
 
274
- def vector_for(decision_id, values, outcome)
275
- id = Branchproof::Records.id([decision_id, values, outcome])
276
- @vectors[id] ||= { id: id, decision_id: decision_id, values: values.dup, outcome: outcome,
277
- test_ids: [], phases_by_test: {}, unattributed_count: 0, count: 0 }
289
+ def vector_for(decision_id, values, outcome, id)
290
+ existing = @vectors[id]
291
+ return existing if existing
292
+
293
+ vector = { id: id, decision_id: decision_id, values: values.dup, outcome: outcome,
294
+ test_ids: [], phases_by_test: {}, unattributed_count: 0, count: 0 }
295
+ @vectors[id] = vector
296
+ @vector_counts_by_decision[decision_id] += 1
297
+ vector
278
298
  end
279
299
 
280
300
  def merge_vector(raw)
281
301
  vector = symbolize(raw)
282
- existing = @vectors[vector[:id].to_s]
302
+ id = vector[:id].to_s
303
+ existing = @vectors[id]
283
304
  if existing
284
- existing[:test_ids] |= Array(vector[:test_ids]).map(&:to_s)
305
+ new_test_ids = Array(vector[:test_ids]).map(&:to_s) - existing[:test_ids]
306
+ existing[:test_ids] |= new_test_ids
285
307
  vector.fetch(:phases_by_test, {}).each do |test, phases|
286
308
  existing[:phases_by_test][test.to_s] = (existing[:phases_by_test][test.to_s] || []) | phases
287
309
  end
310
+ was_unattributed = existing[:unattributed_count].positive?
288
311
  existing[:count] += vector[:count].to_i
289
312
  existing[:unattributed_count] += vector[:unattributed_count].to_i
313
+ @owner_associations_count += new_test_ids.length
314
+ @owner_associations_count += 1 if !was_unattributed && existing[:unattributed_count].positive?
290
315
  else
291
- @vectors[vector[:id].to_s] = { id: vector[:id].to_s, decision_id: vector[:decision_id].to_s,
292
- values: Array(vector[:values]).dup, outcome: !!vector[:outcome], test_ids: Array(vector[:test_ids]).map(&:to_s),
293
- phases_by_test: vector.fetch(:phases_by_test, {}).transform_keys(&:to_s), unattributed_count: vector[:unattributed_count].to_i, count: vector[:count].to_i }
316
+ decision_id = vector[:decision_id].to_s
317
+ test_ids = Array(vector[:test_ids]).map(&:to_s)
318
+ unattributed_count = vector[:unattributed_count].to_i
319
+ @vectors[id] = { id: id, decision_id: decision_id,
320
+ values: Array(vector[:values]).dup, outcome: !!vector[:outcome], test_ids: test_ids,
321
+ phases_by_test: vector.fetch(:phases_by_test, {}).transform_keys(&:to_s), unattributed_count: unattributed_count, count: vector[:count].to_i }
322
+ @vector_counts_by_decision[decision_id] += 1
323
+ @owner_associations_count += test_ids.length
324
+ @owner_associations_count += 1 if unattributed_count.positive?
294
325
  end
295
326
  end
296
327
 
@@ -305,10 +336,14 @@ module Branchproof
305
336
  end
306
337
 
307
338
  def condition_shapes
308
- decisions.to_h do |decision|
309
- [decision[:id].to_s, { conditions: Array(decision[:conditions]).map do |condition|
310
- symbolize(condition)
311
- end, tree: symbolize(decision[:tree]) }]
339
+ @decisions_by_id.transform_values do |decision|
340
+ shape = { conditions: Array(decision[:conditions]).map { |condition| symbolize(condition) },
341
+ tree: symbolize(decision[:tree]) }
342
+ if alternative_decision?(decision)
343
+ shape[:kind] = decision[:kind].to_s
344
+ shape[:alternatives] = Array(decision[:alternatives]).map { |alternative| symbolize(alternative) }
345
+ end
346
+ shape
312
347
  end
313
348
  end
314
349
 
@@ -335,9 +370,11 @@ module Branchproof
335
370
  vector = symbolize(raw)
336
371
  return "invalid vector" unless vector[:id] && vector[:decision_id] && vector[:values].is_a?(Array)
337
372
 
338
- decision = decisions.find { |item| item[:id].to_s == vector[:decision_id].to_s }
373
+ decision = @decisions_by_id[vector[:decision_id].to_s]
339
374
  return "unknown decision" unless decision
340
- return "invalid vector shape" unless vector[:values].length == Array(decision[:conditions]).length &&
375
+
376
+ expected_values = decision_dimension_count(decision)
377
+ return "invalid vector shape" unless vector[:values].length == expected_values &&
341
378
  vector[:values].all? { |item| item.nil? || item == true || item == false }
342
379
 
343
380
  expected = Branchproof::Records.id([vector[:decision_id].to_s, vector[:values], vector[:outcome] ? true : false])
@@ -375,17 +412,42 @@ module Branchproof
375
412
  nil
376
413
  end
377
414
 
378
- def decisions = Array(fetch_value(@inventory, :decisions)).map { symbolize(_1) }
379
- def decision_conditions(id) = (decisions.find { |d| d[:id].to_s == id.to_s } || {}).fetch(:conditions, [])
380
- def vector_count(id) = @vectors.values.count { |v| v[:decision_id] == id.to_s }
381
- def new_vector?(id, values, outcome) = !@vectors.key?(Branchproof::Records.id([id, values, outcome]))
415
+ def alternative_decision?(decision)
416
+ kind = decision[:kind].to_s
417
+ !kind.empty? && kind != "boolean"
418
+ end
419
+
420
+ def decision_dimension_count(decision_or_id)
421
+ decision = decision_or_id.is_a?(Hash) ? decision_or_id : @decisions_by_id[decision_or_id.to_s]
422
+ return 0 unless decision
382
423
 
383
- def owner_associations
384
- @vectors.values.sum do |v|
385
- v[:test_ids].length + (v[:unattributed_count].positive? ? 1 : 0)
424
+ alternative_decision?(decision) ? Array(decision[:alternatives]).length : Array(decision[:conditions]).length
425
+ end
426
+
427
+ def valid_alternative_trace?(decision, observations, outcome)
428
+ return false unless outcome == true
429
+
430
+ alternatives = Array(decision[:alternatives])
431
+ expected = alternatives.length
432
+ return false unless expected.positive?
433
+ if decision[:kind].to_s == "implicit"
434
+ return expected == 2 && observations.length == 2 &&
435
+ observations.map(&:first) == [0, 1] && observations.map(&:last).count(true) == 1
436
+ end
437
+
438
+ return false unless observations.length.between?(1, expected)
439
+
440
+ observations.each_with_index.all? do |(index, value), position|
441
+ index == position && value == (position == observations.length - 1)
386
442
  end
387
443
  end
388
444
 
445
+ def decision_conditions(id) = (@decisions_by_id[id.to_s] || {}).fetch(:conditions, [])
446
+ def vector_count(id) = @vector_counts_by_decision[id.to_s]
447
+ def new_vector?(id) = !@vectors.key?(id)
448
+
449
+ def owner_associations = @owner_associations_count
450
+
389
451
  def incoming_owner_associations(incoming)
390
452
  owners = @vectors.each_with_object({}) do |(_id, vector), result|
391
453
  vector[:test_ids].each { |test_id| result[[vector[:id], test_id]] = true }
@@ -455,7 +517,8 @@ module Branchproof
455
517
  def capture_state
456
518
  { vectors: deep_dup(@vectors), tests: deep_dup(@tests), run_ids: @run_ids.dup,
457
519
  run_payloads: @run_payloads.dup, diagnostics: deep_dup(@diagnostics),
458
- abort_counts: @abort_counts.dup, limited: @limited, attribution_complete: @attribution_complete }
520
+ abort_counts: @abort_counts.dup, limited: @limited, attribution_complete: @attribution_complete,
521
+ vector_counts_by_decision: @vector_counts_by_decision.dup, owner_associations_count: @owner_associations_count }
459
522
  end
460
523
 
461
524
  def restore_state(state)
@@ -465,6 +528,8 @@ module Branchproof
465
528
  @run_payloads = state[:run_payloads]
466
529
  @diagnostics = state[:diagnostics]
467
530
  @abort_counts = state[:abort_counts]
531
+ @vector_counts_by_decision = state[:vector_counts_by_decision]
532
+ @owner_associations_count = state[:owner_associations_count]
468
533
  @limited = state[:limited]
469
534
  @attribution_complete = state[:attribution_complete]
470
535
  end
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Keep each bounded source rewrite together so its evaluation order can be audited.
4
+ # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
5
+
6
+ module Branchproof
7
+ # Source-location edits around Ruby's native matching and assignment operations.
8
+ module FlowInstrumentation
9
+ private
10
+
11
+ def render_flow(bytes, decision, nested, encloses)
12
+ metadata = decision.fetch(:instrumentation)
13
+ case metadata.fetch(:type)
14
+ when "safe_navigation"
15
+ receiver = metadata.fetch(:receiver)
16
+ replacements = [flow_replacement(bytes, receiver, nested, encloses) do |expression|
17
+ "#{self.class::RUNTIME}.flow_receiver(#{decision[:id].inspect}, (#{expression}))"
18
+ end]
19
+ flow_fragments(bytes, decision, nested, replacements, encloses)
20
+ when "assignment"
21
+ rhs = metadata.fetch(:rhs)
22
+ replacements = [flow_replacement(bytes, rhs, nested, encloses) do |expression|
23
+ "(begin; #{self.class::RUNTIME}.flow_path(#{decision[:id].inspect}, 1); (#{expression}); end)"
24
+ end]
25
+ expression = flow_fragments(bytes, decision, nested, replacements, encloses)
26
+ flow_frame(decision[:id], expression, default_path: 0)
27
+ when "case"
28
+ render_case_flow(bytes, decision, nested, metadata, encloses)
29
+ when "case_match"
30
+ render_pattern_flow(bytes, decision, nested, metadata, encloses)
31
+ else
32
+ raise ArgumentError, "unknown instrumentation type: #{metadata[:type]}"
33
+ end
34
+ end
35
+
36
+ def render_case_flow(bytes, decision, nested, metadata, encloses)
37
+ identifier = decision[:id].inspect
38
+ runtime = self.class::RUNTIME
39
+ replacements = metadata.fetch(:candidates).map do |candidate|
40
+ flow_replacement(bytes, candidate, nested, encloses) do |expression|
41
+ "(begin; #{runtime}.flow_candidate(#{identifier}, #{candidate[:index]}); (#{expression}); end)"
42
+ end
43
+ end
44
+ metadata.fetch(:branches).each do |branch|
45
+ suffix = branch[:empty] ? "nil; " : ""
46
+ replacements << { start: branch[:insert_at], length: 0,
47
+ text: "; #{runtime}.flow_selected(#{identifier}); #{suffix}" }
48
+ end
49
+ if metadata[:else]
50
+ alternative = metadata[:else]
51
+ replacements << { start: alternative[:insert_at], length: 0,
52
+ text: "; #{runtime}.flow_select(#{identifier}, #{alternative[:index]}); " }
53
+ else
54
+ index = decision.fetch(:alternatives).length - 1
55
+ replacements << { start: metadata.fetch(:end_start), length: 0,
56
+ text: "else; #{runtime}.flow_select(#{identifier}, #{index}); nil; " }
57
+ end
58
+ flow_frame(decision[:id], flow_fragments(bytes, decision, nested, replacements, encloses))
59
+ end
60
+
61
+ def render_pattern_flow(bytes, decision, nested, metadata, encloses)
62
+ identifier = decision[:id].inspect
63
+ runtime = self.class::RUNTIME
64
+ replacements = metadata.fetch(:branches).map do |branch|
65
+ suffix = branch[:empty] ? "nil; " : ""
66
+ { start: branch[:insert_at], length: 0,
67
+ text: "; #{runtime}.flow_select(#{identifier}, #{branch[:index]}); #{suffix}" }
68
+ end
69
+ if metadata[:else]
70
+ alternative = metadata[:else]
71
+ replacements << { start: alternative[:insert_at], length: 0,
72
+ text: "; #{runtime}.flow_select(#{identifier}, #{alternative[:index]}); " }
73
+ end
74
+ flow_frame(decision[:id], flow_fragments(bytes, decision, nested, replacements, encloses))
75
+ end
76
+
77
+ def flow_replacement(bytes, location, nested, encloses)
78
+ start = location.fetch(:byte_start)
79
+ length = location.fetch(:byte_length)
80
+ { start: start, length: length, text: yield(render_children(bytes, start, length, nested, encloses)) }
81
+ end
82
+
83
+ def flow_fragments(bytes, decision, nested, replacements, encloses)
84
+ cursor = decision.fetch(:byte_start)
85
+ finish = cursor + decision.fetch(:byte_length)
86
+ chunks = []
87
+ replacements.sort_by { |edit| [edit[:start], edit[:length]] }.each do |edit|
88
+ raise ArgumentError, "overlapping flow edits" if edit[:start] < cursor
89
+
90
+ chunks << render_children(bytes, cursor, edit[:start] - cursor, nested, encloses)
91
+ chunks << edit[:text]
92
+ cursor = edit[:start] + edit[:length]
93
+ end
94
+ chunks << render_children(bytes, cursor, finish - cursor, nested, encloses)
95
+ chunks.join
96
+ end
97
+
98
+ def flow_frame(decision_id, expression, default_path: nil)
99
+ runtime = self.class::RUNTIME
100
+ "(begin; #{runtime}.enter(#{decision_id.inspect}); begin; " \
101
+ "#{runtime}.flow_finish(#{decision_id.inspect}, (#{expression}), #{default_path.inspect}); ensure; " \
102
+ "#{runtime}.leave(#{decision_id.inspect}); end; end)"
103
+ end
104
+ end
105
+ end
106
+
107
+ # rubocop:enable Metrics/AbcSize, Metrics/MethodLength
@@ -6,16 +6,23 @@ require "pathname"
6
6
  module Branchproof
7
7
  # Terminal renderings grouped around conditions or tests.
8
8
  class FocusedReport
9
- def initialize(document:, view:, level:, missing_only: false)
9
+ DECISION_TABLE_LABELS = { "true" => "T", "false" => "F", "dont_care" => "-" }.freeze
10
+
11
+ def initialize(document:, view:, level:, missing_only: false, coordinator: nil)
10
12
  @document = document || {}
11
13
  @view = view.to_sym
12
- raise ArgumentError, "view must be :conditions or :tests" unless %i[conditions tests].include?(@view)
14
+ unless (Report::VIEWS - [:decisions]).include?(@view)
15
+ raise ArgumentError, "view must be :conditions, :tests, or :decision_tables"
16
+ end
13
17
 
14
18
  @level = level.to_i
15
19
  @missing_only = missing_only ? true : false
16
20
  @index = CoverageIndex.new(document: @document)
17
21
  @tests = @index.tests.to_h { |test| [test[:id].to_s, test] }
18
- @coordinator = Report.from_document(document: @document, level: @level)
22
+ @test_name_counts = @index.tests.each_with_object(Hash.new(0)) do |test, counts|
23
+ counts[[test[:name], test[:relative_path], test[:line]]] += 1
24
+ end
25
+ @coordinator = coordinator || Report.from_document(document: @document, level: @level)
19
26
  end
20
27
 
21
28
  def render
@@ -23,7 +30,11 @@ module Branchproof
23
30
  completeness = fetch(@document, :completeness) || {}
24
31
  evidence = fetch(@document, :observations) || {}
25
32
  lines = ["Branchproof focused view: #{@view}", "Tests: #{fetch(baseline, :status) || "INCOMPLETE"}",
26
- "Values: T=true, F=false, -=short-circuited"]
33
+ (if @index.alternatives.empty?
34
+ "Values: T=true, F=false, -=short-circuited"
35
+ else
36
+ "Values: Boolean T=true/F=false; flow T=selected, F=not-selected, -=skipped"
37
+ end)]
27
38
  if completeness.values.include?(false) || fetch(baseline, :status).to_s != "PASSED"
28
39
  lines << "Warning: failed or incomplete run; observations and proof evidence may be unavailable."
29
40
  end
@@ -35,9 +46,19 @@ module Branchproof
35
46
  lines << "Empty groups mean no recorded completed observation."
36
47
  lines << ""
37
48
  lines.concat(@coordinator.coverage_ladder_lines)
38
- @view == :conditions ? render_conditions(lines) : render_tests(lines)
39
- render_unowned(lines)
40
- render_unsupported(lines)
49
+ case @view
50
+ when :conditions
51
+ render_conditions(lines)
52
+ render_alternatives(lines)
53
+ when :decision_tables
54
+ render_decision_tables(lines)
55
+ else
56
+ render_tests(lines)
57
+ end
58
+ unless @view == :decision_tables
59
+ render_unowned(lines)
60
+ render_unsupported(lines)
61
+ end
41
62
  Array(fetch(@document, :diagnostics)).each do |diagnostic|
42
63
  lines << "Diagnostic: #{@coordinator.diagnostic_message(diagnostic)}"
43
64
  end
@@ -53,6 +74,8 @@ module Branchproof
53
74
  lines << "Condition: #{row[:expression]}"
54
75
  lines << "Location: #{location(row[:relative_path], row[:line], unavailable: "condition line unavailable")}"
55
76
  lines << "Decision: #{row[:decision_expression]} (condition #{row[:index]})"
77
+ lines << "Kind: #{row[:kind]}"
78
+ lines << "Context: #{row[:context]}" unless row[:context].to_s.empty?
56
79
  status = if @level == 1 && !coverage_available?
57
80
  "NOT CALCULATED"
58
81
  else
@@ -106,16 +129,110 @@ module Branchproof
106
129
  end
107
130
  end
108
131
 
132
+ # One block per Boolean decision that carries a decision table. With
133
+ # --missing-only only uncovered, non-impossible rules remain: a rule proven
134
+ # impossible is not a missing obligation.
135
+ def render_decision_tables(lines)
136
+ rows = @index.decision_tables
137
+ rendered = 0
138
+ impossible = 0
139
+ rows.each do |row|
140
+ impossible += row[:impossible_rules].to_i
141
+ rules = @missing_only ? row[:rules].select { |rule| rule[:coverage].to_s == "missing" } : row[:rules]
142
+ next if @missing_only && rules.empty? && row[:status].to_s == "calculated"
143
+
144
+ rendered += 1
145
+ lines << "Decision: #{row[:decision_expression]}"
146
+ lines << "Location: #{location(row[:relative_path], row[:line], unavailable: "decision line unavailable")}"
147
+ lines << "Context: #{row[:context]}" unless row[:context].to_s.empty?
148
+ if row[:status].to_s != "calculated"
149
+ lines << "Decision Table: NOT CALCULATED"
150
+ lines << "Reason: #{row[:reason]}"
151
+ lines << ""
152
+ next
153
+ end
154
+ lines << "Decision Table: #{row[:covered_rules]}/#{row[:required_rules]} rules covered" \
155
+ "#{" (#{row[:percentage]}%)" unless row[:percentage].nil?}"
156
+ lines << "Reachability: not analyzed" unless row[:reachability_analyzed]
157
+ rules.each { |rule| render_decision_table_rule(lines, row, rule) }
158
+ lines << ""
159
+ end
160
+ lines << "No missing decision-table rules" if @missing_only && rendered.zero?
161
+ return unless impossible.positive?
162
+
163
+ lines << "#{impossible} statically impossible rule#{"s" unless impossible == 1} excluded"
164
+ end
165
+
166
+ def render_decision_table_rule(lines, row, rule)
167
+ signature = rule[:conditions].map { |item| DECISION_TABLE_LABELS.fetch(item.to_s, item.to_s) }.join
168
+ status = rule[:coverage].to_s.upcase
169
+ lines << " #{rule[:label]} #{signature} => #{rule[:outcome] ? "T" : "F"} #{status}"
170
+ row[:conditions].each_with_index do |expression, index|
171
+ requirement = @coordinator.decision_table_requirement(rule[:conditions][index])
172
+ next if requirement.nil?
173
+
174
+ lines << " #{expression} = #{requirement}"
175
+ end
176
+ lines << " #{@coordinator.decision_table_expected_heading(row)} #{rule[:outcome] ? "true" : "false"}"
177
+ if rule[:coverage].to_s == "covered"
178
+ owners = Array(rule[:tests]).map { |id| test_label(id) }
179
+ owners << "unattributed" if rule[:unattributed_count].to_i.positive?
180
+ lines << " Tests: #{owners.empty? ? "none recorded" : owners.uniq.join(", ")}"
181
+ else
182
+ lines << " Tests: NOT COVERED"
183
+ end
184
+ lines << " Reachability: #{@coordinator.decision_table_reachability(rule)}"
185
+ return if rule[:reachability_reason].to_s.empty?
186
+
187
+ lines << " Reason: #{Constraints.message(rule[:reachability_reason])}"
188
+ end
189
+
190
+ def render_alternatives(lines)
191
+ rows = @index.alternatives
192
+ rows = rows.select { |row| row[:missing] || row[:status].to_s != "covered" } if @missing_only && @level > 1
193
+ rows.each do |row|
194
+ lines << "Alternative #{row[:index]}: #{row[:expression]}"
195
+ lines << "Location: #{location(row[:relative_path], row[:line],
196
+ unavailable: "alternative location unavailable")}"
197
+ lines << "Decision: #{row[:decision_expression]} (alternative #{row[:index]})"
198
+ lines << "Kind: #{row[:kind]}"
199
+ lines << "Context: #{row[:context]}" unless row[:context].to_s.empty?
200
+ lines << "Selection: #{row[:status] || "NOT_CALCULATED"}"
201
+ lines << "MC/DC: N/A (not applicable)"
202
+ render_alternative_group(lines, "Selected by", row[:selected])
203
+ render_alternative_group(lines, "Not selected by", row[:not_selected])
204
+ render_alternative_group(lines, "Skipped in", row[:skipped])
205
+ if row[:missing]
206
+ lines << " Missing alternative: #{row[:expression]}"
207
+ lines << " Need selection of: #{row[:expression]}"
208
+ end
209
+ lines << ""
210
+ end
211
+ lines << "No missing alternatives" if @missing_only && rows.empty?
212
+ end
213
+
214
+ def render_alternative_group(lines, heading, evidence)
215
+ evidence ||= {}
216
+ ids = Array(evidence[:test_ids]).map { |id| test_label(id) }
217
+ ids << "unattributed" if evidence[:unattributed_count].to_i.positive?
218
+ lines << " #{heading}: #{ids.empty? ? "none recorded" : ids.uniq.join(", ")}"
219
+ end
220
+
109
221
  def render_tests(lines)
110
222
  rows = @index.tests
111
223
  missing_ids = @index.conditions.reject { |row| row[:status].to_s.upcase == "PROVEN" }.map { |row| row[:id] }
112
- rows.each { |row| render_test_row(lines, row, missing_ids) }
224
+ missing_alternative_ids = @index.alternatives.select { |row| row[:missing] || row[:status].to_s != "covered" }
225
+ .map { |row| row[:alternative_id] }
226
+ rows.each { |row| render_test_row(lines, row, missing_ids, missing_alternative_ids) }
113
227
  end
114
228
 
115
- def render_test_row(lines, row, missing_ids)
229
+ def render_test_row(lines, row, missing_ids, missing_alternative_ids = [])
116
230
  observations = row[:observations]
117
231
  if @missing_only
118
- observations = observations.select { |observation| missing_ids.include?(observation[:condition_id]) }
232
+ observations = observations.select do |observation|
233
+ missing_ids.include?(observation[:condition_id]) ||
234
+ missing_alternative_ids.include?(observation[:alternative_id])
235
+ end
119
236
  return if observations.empty?
120
237
  end
121
238
  lines << "Test: #{row[:name]}"
@@ -131,31 +248,51 @@ module Branchproof
131
248
  lines << " No recorded completed condition observations"
132
249
  return
133
250
  end
134
- observations.group_by { |observation| observation[:condition_id] }.each_value do |items|
251
+ grouped = observations.group_by { |observation| observation[:alternative_id] || observation[:condition_id] }
252
+ grouped.each_value do |items|
135
253
  observation = items.first
136
- observed_values = items.map { |item| item[:value] }.uniq
137
- label = observed_values.all?(&:nil?) ? "short-circuited-only" : "evaluated"
138
- label += "; owns canonical witness evidence" if @level > 1 && items.any? { |item| item[:owns_witness] }
139
254
  phases = items.flat_map { |item| item[:phases] }.uniq.sort.join(", ")
140
- signs = values(values: observed_values)
141
- condition_location = location(observation[:relative_path], observation[:line],
142
- unavailable: "condition line unavailable")
143
- lines << " #{observation[:expression]} (#{condition_location}): #{signs}; #{label}; " \
144
- "phases: #{phases}"
255
+ if observation[:alternative_id]
256
+ states = items.map { |item| item[:alternative_state] }.uniq.join(", ")
257
+ alternative_location = location(observation[:relative_path], observation[:line],
258
+ unavailable: "alternative location unavailable")
259
+ lines << " Alternative #{observation[:alternative_id]}: #{observation[:expression]} " \
260
+ "(#{alternative_location}): #{states}; phases: #{phases}"
261
+ else
262
+ observed_values = items.map { |item| item[:value] }.uniq
263
+ label = observed_values.all?(&:nil?) ? "short-circuited-only" : "evaluated"
264
+ label += "; owns canonical witness evidence" if @level > 1 && items.any? { |item| item[:owns_witness] }
265
+ signs = values(values: observed_values)
266
+ condition_location = location(observation[:relative_path], observation[:line],
267
+ unavailable: "condition line unavailable")
268
+ lines << " #{observation[:expression]} (#{condition_location}): #{signs}; #{label}; " \
269
+ "phases: #{phases}"
270
+ end
145
271
  end
146
272
  end
147
273
 
148
274
  def render_unowned(lines)
149
275
  rows = @index.conditions
150
276
  rows = rows.reject { |row| row[:status] == "PROVEN" } if @missing_only
151
- { "Unexecuted conditions" => rows.select { |row| row[:unexecuted] },
152
- "Unattributed evidence" => rows.select { |row| row[:unattributed].positive? } }.each do |heading, conditions|
277
+ groups = { "Unexecuted conditions" => rows.select { |row| row[:unexecuted] },
278
+ "Unattributed evidence" => rows.select { |row| row[:unattributed].positive? } }
279
+ alternative_rows = @index.alternatives
280
+ alternative_rows = alternative_rows.reject { |row| row[:status].to_s == "covered" } if @missing_only
281
+ unless alternative_rows.empty?
282
+ groups["Unexecuted alternatives"] = alternative_rows.select do |row|
283
+ row[:selected][:observed] == false && row[:not_selected][:observed] == false
284
+ end
285
+ groups["Unattributed alternative evidence"] = alternative_rows.select do |row|
286
+ %i[selected not_selected skipped].any? { |state| row[state][:unattributed_count].to_i.positive? }
287
+ end
288
+ end
289
+ groups.each do |heading, conditions|
153
290
  next if conditions.empty?
154
291
 
155
292
  lines << "#{heading}:"
156
293
  conditions.each do |row|
157
- lines << " #{row[:expression]} (#{location(row[:relative_path], row[:line],
158
- unavailable: "condition line unavailable")})"
294
+ unavailable = row[:alternative_id] ? "alternative location unavailable" : "condition line unavailable"
295
+ lines << " #{row[:expression]} (#{location(row[:relative_path], row[:line], unavailable: unavailable)})"
159
296
  end
160
297
  end
161
298
  end
@@ -204,9 +341,7 @@ module Branchproof
204
341
  return id.to_s unless test
205
342
 
206
343
  label = "#{test[:name]} (#{location(test[:relative_path], test[:line])})"
207
- duplicates = @index.tests.count do |row|
208
- [row[:name], row[:relative_path], row[:line]] == [test[:name], test[:relative_path], test[:line]]
209
- end
344
+ duplicates = @test_name_counts[[test[:name], test[:relative_path], test[:line]]]
210
345
  duplicates > 1 ? "#{label} [#{id}]" : label
211
346
  end
212
347