branchproof 0.6.0 → 0.7.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.
@@ -36,6 +36,7 @@ module Branchproof
36
36
  end
37
37
 
38
38
  def pair?(decision_id:, condition_index:, left:, right:)
39
+ return false if alternative_decision_for_id?(decision_id)
39
40
  return false unless compatible_vectors?(left, right, decision_id)
40
41
  return false unless observed?(left, condition_index) && observed?(right, condition_index)
41
42
  return false if value(left, condition_index) == value(right, condition_index)
@@ -52,6 +53,8 @@ module Branchproof
52
53
  decision = records(@inventory, :decisions).find { id(_1, :id) == decision_id }
53
54
  return @missing_cache[cache_key] = nil unless decision
54
55
 
56
+ return @missing_cache[cache_key] = nil if alternative_decision?(decision)
57
+
55
58
  support_status = id(decision, :support_status).to_s
56
59
  return @missing_cache[cache_key] = nil unless support_status.empty? || support_status.upcase == "SUPPORTED"
57
60
 
@@ -94,6 +97,8 @@ module Branchproof
94
97
  private
95
98
 
96
99
  def analyze_decision(decision)
100
+ return analyze_alternative_decision(decision) if alternative_decision?(decision)
101
+
97
102
  decision_id = id(decision, :id)
98
103
  unless id(decision, :support_status).to_s.empty? || id(decision, :support_status).to_s.upcase == "SUPPORTED"
99
104
  return {
@@ -159,6 +164,73 @@ module Branchproof
159
164
  }
160
165
  end
161
166
 
167
+ def analyze_alternative_decision(decision)
168
+ decision_id = id(decision, :id)
169
+ unless id(decision, :support_status).to_s.empty? || id(decision, :support_status).to_s.upcase == "SUPPORTED"
170
+ return {
171
+ decision_id: decision_id, kind: id(decision, :kind), effective_masks_by_vector: {},
172
+ condition_results: [], witness_buckets: {},
173
+ conditions: [], alternatives: alternatives(decision), unsupported: true,
174
+ coverage: unsupported_alternative_coverage,
175
+ completeness: { observation: true, attribution: true, analysis: true },
176
+ diagnostics: [diagnostic("unsupported_decision", "warning", decision_id, nil)]
177
+ }
178
+ end
179
+
180
+ vectors = @vectors.filter_map { |vector| valid_vector(vector, decision) }
181
+ rows = alternatives(decision).map do |alternative|
182
+ index = id(alternative, :index).to_i
183
+ selected = vectors.select { |vector| value(vector, index) == true }
184
+ not_selected = vectors.select { |vector| value(vector, index) == false }
185
+ skipped = vectors.select { |vector| value(vector, index).nil? }
186
+ {
187
+ alternative_id: id(alternative, :id), index: id(alternative, :index),
188
+ expression: id(alternative, :expression),
189
+ selected: alternative_evidence_bucket(selected),
190
+ not_selected: alternative_evidence_bucket(not_selected),
191
+ skipped: alternative_evidence_bucket(skipped)
192
+ }
193
+ end
194
+ missing = rows.filter_map { |row| row[:alternative_id] unless row[:selected][:observed] }
195
+ covered = rows.count { |row| row[:selected][:observed] }
196
+ {
197
+ decision_id: decision_id, kind: id(decision, :kind), effective_masks_by_vector: {},
198
+ condition_results: [], witness_buckets: {},
199
+ conditions: [], alternatives: alternatives(decision), unsupported: false,
200
+ coverage: { alternative: { status: coverage_status(covered, rows.length), covered_alternatives: covered,
201
+ required_alternatives: rows.length, alternatives: rows,
202
+ missing_alternatives: missing },
203
+ mcdc: { status: "not_applicable" } },
204
+ completeness: { observation: true, attribution: true, analysis: !@analysis_invalid }, diagnostics: []
205
+ }
206
+ end
207
+
208
+ def unsupported_alternative_coverage
209
+ { alternative: { status: "unsupported", covered_alternatives: 0, required_alternatives: 0,
210
+ alternatives: [], missing_alternatives: [] }, mcdc: { status: "unsupported" } }
211
+ end
212
+
213
+ def alternative_evidence_bucket(vectors)
214
+ evidence = vectors.map { |vector| provenance(vector) }
215
+ { observed: !vectors.empty?, vector_ids: evidence.map { |item| item[:vector_id] }.uniq.sort,
216
+ test_ids: evidence.flat_map { |item| item[:test_ids] }.uniq.sort,
217
+ unattributed_count: evidence.sum { |item| item[:unattributed_count] } }
218
+ end
219
+
220
+ def alternative_decision?(decision)
221
+ kind = id(decision, :kind).to_s
222
+ !kind.empty? && kind != "boolean"
223
+ end
224
+
225
+ def alternative_decision_for_id?(decision_id)
226
+ decision = records(@inventory, :decisions).find { |item| id(item, :id) == decision_id }
227
+ decision && alternative_decision?(decision)
228
+ end
229
+
230
+ def alternatives(decision)
231
+ records(decision, :alternatives)
232
+ end
233
+
162
234
  def unsupported_coverage
163
235
  { decision: { status: "unsupported", true_observed: false, false_observed: false,
164
236
  covered_outcomes: 0, required_outcomes: 2, outcomes: [], missing_outcomes: [] },
@@ -254,29 +326,39 @@ module Branchproof
254
326
 
255
327
  def aggregate_coverage(decisions)
256
328
  supported = decisions.reject { |decision| decision[:unsupported] }
257
- decision_covered = supported.count do |decision|
329
+ boolean_supported = supported.reject { |decision| alternative_decision?(decision) }
330
+ decision_covered = boolean_supported.count do |decision|
258
331
  decision[:coverage][:decision][:status] == "covered"
259
332
  end
260
- condition_decision_covered = supported.count do |decision|
333
+ condition_decision_covered = boolean_supported.count do |decision|
261
334
  decision[:coverage][:condition_decision][:status] == "covered"
262
335
  end
263
- condition_count = supported.sum { |decision| decision[:coverage][:condition][:condition_count] }
264
- condition_values = supported.sum { |decision| decision[:coverage][:condition][:covered_values] }
265
- covered_conditions = supported.sum do |decision|
336
+ condition_count = boolean_supported.sum { |decision| decision[:coverage][:condition][:condition_count] }
337
+ condition_values = boolean_supported.sum { |decision| decision[:coverage][:condition][:covered_values] }
338
+ covered_conditions = boolean_supported.sum do |decision|
266
339
  decision[:coverage][:condition][:covered_conditions]
267
340
  end
268
- proven = supported.sum { |decision| decision[:coverage][:mcdc][:proven_conditions] }
341
+ proven = boolean_supported.sum { |decision| decision[:coverage][:mcdc][:proven_conditions] }
269
342
  percentage = ->(covered, required) { required.zero? ? nil : (covered.to_f / required * 100).round(2) }
270
- { decision: { covered_decisions: decision_covered, supported_decisions: supported.length,
271
- percentage: percentage.call(decision_covered, supported.length) },
343
+ { decision: { covered_decisions: decision_covered, supported_decisions: boolean_supported.length,
344
+ percentage: percentage.call(decision_covered, boolean_supported.length) },
272
345
  condition: { covered_values: condition_values, required_values: condition_count * 2,
273
346
  covered_conditions: covered_conditions, condition_count: condition_count,
274
347
  percentage: percentage.call(condition_values, condition_count * 2) },
275
348
  condition_decision: { covered_decisions: condition_decision_covered,
276
- supported_decisions: supported.length,
277
- percentage: percentage.call(condition_decision_covered, supported.length) },
349
+ supported_decisions: boolean_supported.length,
350
+ percentage: percentage.call(condition_decision_covered, boolean_supported.length) },
278
351
  mcdc: { proven_conditions: proven, supported_conditions: condition_count,
279
- percentage: percentage.call(proven, condition_count) } }
352
+ percentage: percentage.call(proven, condition_count) },
353
+ alternative: alternative_aggregate(decisions) }
354
+ end
355
+
356
+ def alternative_aggregate(decisions)
357
+ flow = decisions.select { |decision| !decision[:unsupported] && alternative_decision?(decision) }
358
+ required = flow.sum { |decision| decision.dig(:coverage, :alternative, :required_alternatives).to_i }
359
+ covered = flow.sum { |decision| decision.dig(:coverage, :alternative, :covered_alternatives).to_i }
360
+ { covered_alternatives: covered, required_alternatives: required,
361
+ supported_decisions: flow.length, percentage: required.zero? ? nil : (covered.to_f / required * 100).round(2) }
280
362
  end
281
363
 
282
364
  def valid_vector(vector, decision)
@@ -308,7 +390,11 @@ module Branchproof
308
390
  return nil
309
391
  end
310
392
 
311
- effective_mask(vector, id(decision, :id), decision[:tree])
393
+ if alternative_decision?(decision)
394
+ handle_alternative_vector(vector, decision)
395
+ else
396
+ effective_mask(vector, id(decision, :id), decision[:tree])
397
+ end
312
398
  decision_source.nil? ? vector : vector.merge(source_id: decision_source)
313
399
  rescue ArgumentError
314
400
  @analysis_invalid = true
@@ -316,6 +402,36 @@ module Branchproof
316
402
  nil
317
403
  end
318
404
 
405
+ # rubocop:disable-next Naming/PredicateMethod -- raises on malformed flow vectors
406
+ def handle_alternative_vector(vector, decision)
407
+ values = values_for(vector)
408
+ expected = alternatives(decision).length
409
+ raise ArgumentError, "invalid alternative vector shape" unless values.length == expected
410
+
411
+ observations = values.each_with_index.filter_map { |item, index| [index, item] unless item.nil? }
412
+ raise ArgumentError, "invalid alternative trace" unless valid_alternative_trace?(decision, observations,
413
+ outcome(vector))
414
+
415
+ true
416
+ end
417
+
418
+ def valid_alternative_trace?(decision, observations, result)
419
+ return false unless result == true
420
+
421
+ expected = alternatives(decision).length
422
+ return false unless expected.positive?
423
+ if id(decision, :kind).to_s == "implicit"
424
+ return expected == 2 && observations.length == 2 &&
425
+ observations.map(&:first) == [0, 1] && observations.map(&:last).count(true) == 1
426
+ end
427
+
428
+ return false unless observations.length.between?(1, expected)
429
+
430
+ observations.each_with_index.all? do |(index, value), position|
431
+ index == position && value == (position == observations.length - 1)
432
+ end
433
+ end
434
+
319
435
  def effective_mask(vector, decision_id, tree = nil)
320
436
  decision = records(@inventory, :decisions).find { id(_1, :id) == decision_id } unless tree
321
437
  tree ||= decision && decision[:tree]
@@ -341,6 +457,10 @@ module Branchproof
341
457
 
342
458
  return [value, 1 << index, offset + 1]
343
459
  end
460
+ if type == :not
461
+ child_result, child_mask, consumed = replay(node.fetch(:child), values, offset)
462
+ return [!child_result, child_mask, consumed]
463
+ end
344
464
  left_result, left_mask, consumed = replay(node.fetch(:left), values, offset)
345
465
  return [left_result, left_mask, consumed] if (type == :and && !left_result) || (type == :or && left_result)
346
466
 
@@ -440,6 +560,8 @@ module Branchproof
440
560
  return { entry: entry, true_exits: [edges[0]], false_exits: [edges[1]], all_bits: 1 << index, edges: edges }
441
561
  end
442
562
  type = id(node, :type).to_sym
563
+ return build_graph(node.fetch(:child), false_destination, true_destination) if type == :not
564
+
443
565
  right = build_graph(node[:right], true_destination, false_destination)
444
566
  if type == :and
445
567
  left = build_graph(node[:left], right[:entry], false_destination)
@@ -455,7 +577,9 @@ module Branchproof
455
577
  end
456
578
 
457
579
  def impose_path(node, target, target_value, constraints)
458
- return true if id(node, :type).to_sym == :atom && id(node, :index) == target
580
+ type = id(node, :type).to_sym
581
+ return true if type == :atom && id(node, :index) == target
582
+ return impose_path(node.fetch(:child), target, target_value, constraints) if type == :not
459
583
 
460
584
  left = node[:left]
461
585
  right = node[:right]
@@ -480,6 +604,8 @@ module Branchproof
480
604
 
481
605
  constraints << { condition_index: id(node, :index), value: desired }
482
606
  true
607
+ elsif id(node, :type).to_sym == :not
608
+ impose_subtree(node.fetch(:child), !desired, constraints)
483
609
  elsif desired == (id(node, :type).to_sym == :and)
484
610
  impose_subtree(node[:left], desired, constraints) && impose_subtree(node[:right], desired, constraints)
485
611
  else
@@ -488,7 +614,9 @@ module Branchproof
488
614
  end
489
615
 
490
616
  def evaluate(node, values)
491
- return values[id(node, :index)] if id(node, :type).to_sym == :atom
617
+ type = id(node, :type).to_sym
618
+ return values[id(node, :index)] if type == :atom
619
+ return !evaluate(node.fetch(:child), values) if type == :not
492
620
 
493
621
  left = evaluate(node[:left], values)
494
622
  return left if id(node, :type).to_sym == :and && !left
@@ -498,11 +626,16 @@ module Branchproof
498
626
  end
499
627
 
500
628
  def evaluate_with_trace(node, values, trace = [])
501
- if id(node, :type).to_sym == :atom
629
+ type = id(node, :type).to_sym
630
+ if type == :atom
502
631
  value = !values[id(node, :index)].nil? && values[id(node, :index)] != false
503
632
  trace << [id(node, :index), value]
504
633
  return [value, trace]
505
634
  end
635
+ if type == :not
636
+ child, = evaluate_with_trace(node.fetch(:child), values, trace)
637
+ return [!child, trace]
638
+ end
506
639
  left, = evaluate_with_trace(node[:left], values, trace)
507
640
  return [left, trace] if id(node, :type).to_sym == :and && !left
508
641
  return [left, trace] if id(node, :type).to_sym == :or && left
@@ -511,13 +644,12 @@ module Branchproof
511
644
  end
512
645
 
513
646
  def contains?(node, target)
514
- if id(node,
515
- :type).to_sym == :atom
516
- id(node,
517
- :index) == target
647
+ if id(node, :type).to_sym == :atom
648
+ id(node, :index) == target
649
+ elsif id(node, :type).to_sym == :not
650
+ contains?(node.fetch(:child), target)
518
651
  else
519
- contains?(node[:left],
520
- target) || contains?(node[:right], target)
652
+ contains?(node[:left], target) || contains?(node[:right], target)
521
653
  end
522
654
  end
523
655
 
@@ -7,7 +7,7 @@ module Branchproof
7
7
  # Compares two complete report documents without loading or executing the project.
8
8
  class Comparison
9
9
  SCHEMA_VERSION = "1.0"
10
- SUPPORTED_REPORT_SCHEMAS = %w[1.0 1.1].freeze
10
+ SUPPORTED_REPORT_SCHEMAS = %w[1.0 1.1 1.2].freeze
11
11
  CRITERION_VERSION = "masking_occurrence_v1"
12
12
 
13
13
  def initialize(before:, after:)
@@ -7,7 +7,7 @@ require "pathname"
7
7
  module Branchproof
8
8
  # Derives condition- and test-oriented rows from one report document.
9
9
  class CoverageIndex
10
- attr_reader :conditions, :tests
10
+ attr_reader :conditions, :alternatives, :tests
11
11
 
12
12
  def initialize(document:)
13
13
  @document = symbolize(document || {})
@@ -27,6 +27,7 @@ module Branchproof
27
27
  @tests_by_id[id] = (@tests_by_id[id] || {}).merge(test) unless id.empty?
28
28
  end
29
29
  @conditions = build_conditions.freeze
30
+ @alternatives = build_alternatives.freeze
30
31
  @tests = build_tests.freeze
31
32
  end
32
33
 
@@ -62,7 +63,7 @@ module Branchproof
62
63
  id: condition[:id], decision_id: decision[:id], index: condition[:index],
63
64
  expression: condition[:expression],
64
65
  line: condition[:line], column: condition[:column], relative_path: relative_path(source[:relative_path]),
65
- decision_expression: decision[:expression],
66
+ decision_expression: decision[:expression], kind: decision_kind(decision), context: decision[:context],
66
67
  status: @document[:analysis] ? (result[:status] || "NOT_PROVEN") : "NOT CALCULATED",
67
68
  observed_true: test_ids(observations[true]), observed_false: test_ids(observations[false]),
68
69
  short_circuited: test_ids(observations[:short_circuited]),
@@ -80,12 +81,69 @@ module Branchproof
80
81
  end
81
82
  end
82
83
 
84
+ def build_alternatives
85
+ records(@inventory, :decisions).flat_map do |decision|
86
+ next [] if decision[:support_status].to_s == "UNSUPPORTED"
87
+ next [] if boolean_decision?(decision)
88
+
89
+ source = @source_units_by_id[decision[:source_id].to_s] || {}
90
+ coverage = symbolize(analysis_for(decision)[:coverage] || {})[:alternative] || {}
91
+ coverage_rows = records(coverage, :alternatives).to_h { |row| [row[:alternative_id].to_s, row] }
92
+ vectors = @vectors_by_decision[decision[:id].to_s] || []
93
+ records(decision, :alternatives).map do |alternative|
94
+ alternative = symbolize(alternative)
95
+ evidence = { selected: [], not_selected: [], skipped: [] }
96
+ vectors.each do |vector|
97
+ value = vector_values(vector)[alternative[:index].to_i]
98
+ state = if value.nil?
99
+ :skipped
100
+ else
101
+ (value ? :selected : :not_selected)
102
+ end
103
+ evidence[state] << vector
104
+ end
105
+ row = symbolize(coverage_rows[alternative[:id].to_s] || {})
106
+ {
107
+ id: alternative[:id], alternative_id: alternative[:id], decision_id: decision[:id],
108
+ index: alternative[:index], expression: alternative[:expression],
109
+ line: alternative[:line], column: alternative[:column],
110
+ relative_path: relative_path(source[:relative_path]),
111
+ decision_expression: decision[:expression], kind: decision_kind(decision),
112
+ context: decision[:context],
113
+ status: @document[:analysis] ? alternative_status(row, evidence) : "NOT CALCULATED",
114
+ selected: row[:selected] || evidence_bucket(evidence[:selected]),
115
+ not_selected: row[:not_selected] || evidence_bucket(evidence[:not_selected]),
116
+ skipped: row[:skipped] || evidence_bucket(evidence[:skipped]),
117
+ vectors: evidence, missing: missing_alternative?(coverage, alternative)
118
+ }
119
+ end
120
+ end
121
+ end
122
+
83
123
  def build_tests
84
124
  by_id = @tests_by_id.transform_values do |test|
85
125
  { id: test[:id], name: display_name(test), relative_path: test_location(test)[0], line: test_location(test)[1],
86
126
  status: test[:status], phases: Array(test[:phase_counts]).to_h.keys.sort,
87
127
  observations: [], owns_witness: false }
88
128
  end
129
+ @alternatives.each do |alternative|
130
+ alternative[:vectors].each do |kind, vectors|
131
+ vectors.each do |vector|
132
+ Array(vector[:test_ids]).each do |id|
133
+ row = by_id[id.to_s] ||= { id: id, name: id, relative_path: nil, line: nil, status: "unknown",
134
+ phases: [], observations: [], owns_witness: false }
135
+ phase_map = symbolize(vector[:phases_by_test] || {})
136
+ phases = phase_map[id.to_s] || phase_map[id.to_sym] || []
137
+ row[:phases] |= Array(phases).map(&:to_s)
138
+ row[:observations] << { alternative_id: alternative[:id], expression: alternative[:expression],
139
+ relative_path: alternative[:relative_path], line: alternative[:line],
140
+ value: kind == :skipped ? nil : (kind == :selected), kind: :alternative,
141
+ alternative_state: kind.to_s, phases: phases.map(&:to_s).sort,
142
+ owns_witness: false }
143
+ end
144
+ end
145
+ end
146
+ end
89
147
  @conditions.each do |condition|
90
148
  condition[:vectors].each do |kind, vectors|
91
149
  next if kind == :unattributed
@@ -118,6 +176,36 @@ module Branchproof
118
176
  @analysis_by_decision[decision[:id].to_s] || {}
119
177
  end
120
178
 
179
+ def boolean_decision?(decision)
180
+ decision_kind(decision) == "boolean"
181
+ end
182
+
183
+ def decision_kind(decision)
184
+ kind = decision[:kind].to_s
185
+ kind.empty? ? "boolean" : kind
186
+ end
187
+
188
+ def evidence_bucket(vectors)
189
+ evidence = vectors.map do |vector|
190
+ { vector_id: vector[:id].to_s, test_ids: Array(vector[:test_ids]).map(&:to_s).uniq.sort,
191
+ unattributed_count: vector[:unattributed_count].to_i }
192
+ end
193
+ { observed: !vectors.empty?, vector_ids: evidence.map { |item| item[:vector_id] }.uniq.sort,
194
+ test_ids: evidence.flat_map { |item| item[:test_ids] }.uniq.sort,
195
+ unattributed_count: evidence.sum { |item| item[:unattributed_count] } }
196
+ end
197
+
198
+ def missing_alternative?(coverage, alternative)
199
+ Array(coverage[:missing_alternatives]).map(&:to_s).include?(alternative[:id].to_s)
200
+ end
201
+
202
+ def alternative_status(row, evidence)
203
+ status = row[:status].to_s
204
+ return status unless status.empty?
205
+
206
+ evidence[:selected].empty? ? "unexecuted" : "covered"
207
+ end
208
+
121
209
  def supporting_set_for(decision_id)
122
210
  Array(@document[:minima] || @document["minima"]).filter_map do |minimum|
123
211
  minimum = symbolize(minimum)