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
@@ -8,20 +8,32 @@ module Branchproof
8
8
  class Analyzer
9
9
  CRITERION = "masking_occurrence_v1"
10
10
 
11
- def initialize(inventory:, evidence:, limits:)
11
+ def initialize(inventory:, evidence:, limits:, reachability: true)
12
12
  @inventory = inventory || {}
13
13
  @evidence = evidence || {}
14
14
  @limits = limits || {}
15
+ @reachability = reachability
15
16
  @vectors = records(@evidence, :vectors)
17
+ @vectors_by_decision = @vectors.each_with_object(Hash.new { |hash, key| hash[key] = [] }) do |vector, hash|
18
+ hash[id(vector, :decision_id)] << vector
19
+ end
20
+ @decisions = records(@inventory, :decisions)
21
+ @decisions_by_id = @decisions.each_with_object({}) do |decision, hash|
22
+ key = id(decision, :id)
23
+ hash[key] = decision unless hash.key?(key)
24
+ end
16
25
  @constraint_states = Hash.new(0)
17
26
  @missing_cache = {}
18
27
  @invalid_diagnostics = []
19
28
  @analysis_invalid = false
20
29
  @diagnostic_keys = Set.new
30
+ # Keyed by vector object identity (not content), so a validated vector's
31
+ # values are checked once and reused on every later read.
32
+ @values_cache = {}.compare_by_identity
21
33
  end
22
34
 
23
35
  def call
24
- decisions = records(@inventory, :decisions).map { analyze_decision(_1) }
36
+ decisions = @decisions.map { analyze_decision(_1) }
25
37
  proven = decisions.sum { |decision| decision[:condition_results].count { |result| result[:status] == "PROVEN" } }
26
38
  eligible = decisions.sum { |decision| decision[:unsupported] ? 0 : decision[:conditions].length }
27
39
  {
@@ -35,27 +47,29 @@ module Branchproof
35
47
  }.freeze
36
48
  end
37
49
 
38
- def pair?(decision_id:, condition_index:, left:, right:)
50
+ def pair?(decision_id:, condition_index:, left:, right:, masks: nil)
51
+ return false if alternative_decision_for_id?(decision_id)
39
52
  return false unless compatible_vectors?(left, right, decision_id)
40
53
  return false unless observed?(left, condition_index) && observed?(right, condition_index)
41
54
  return false if value(left, condition_index) == value(right, condition_index)
42
55
  return false if outcome(left) == outcome(right)
43
56
 
44
- masks = [left, right].map { effective_mask(_1, decision_id) }
45
- masks.all? { |mask| mask&.anybits?(1 << condition_index) }
57
+ [left, right].all? { |vector| masked_bits(vector, decision_id, masks)&.anybits?(1 << condition_index) }
46
58
  end
47
59
 
48
60
  def missing(decision_id:, condition_index:)
49
61
  cache_key = [decision_id, condition_index]
50
62
  return @missing_cache[cache_key] if @missing_cache.key?(cache_key)
51
63
 
52
- decision = records(@inventory, :decisions).find { id(_1, :id) == decision_id }
64
+ decision = @decisions_by_id[decision_id]
53
65
  return @missing_cache[cache_key] = nil unless decision
54
66
 
67
+ return @missing_cache[cache_key] = nil if alternative_decision?(decision)
68
+
55
69
  support_status = id(decision, :support_status).to_s
56
70
  return @missing_cache[cache_key] = nil unless support_status.empty? || support_status.upcase == "SUPPORTED"
57
71
 
58
- relevant = @vectors.select { id(_1, :decision_id) == decision_id }
72
+ relevant = @vectors_by_decision[decision_id]
59
73
  valid = relevant.filter_map { valid_vector(_1, decision) }
60
74
  if valid.empty?
61
75
  return @missing_cache[cache_key] = {
@@ -64,14 +78,15 @@ module Branchproof
64
78
  feasibility_statement: "No completed structural observation exists."
65
79
  }
66
80
  end
67
- return @missing_cache[cache_key] = nil if first_pair(valid, condition_index, decision_id)
81
+ masks = valid.to_h { |vector| [id(vector, :id), effective_mask(vector, decision_id)] }
82
+ return @missing_cache[cache_key] = nil if first_pair(valid, condition_index, decision_id, masks)
68
83
 
69
- existing = valid.find { |vector| effective_mask(vector, decision_id).anybits?(1 << condition_index) }
84
+ existing = valid.find { |vector| masks[id(vector, :id)].anybits?(1 << condition_index) }
70
85
  signs = existing ? [!value(existing, condition_index)] : [true, false]
71
86
  candidates = signs.filter_map { counterpart(decision, condition_index, _1) }
72
87
  candidates.select! do |candidate|
73
88
  !existing || pair?(decision_id: decision_id, condition_index: condition_index, left: existing,
74
- right: candidate[:vector])
89
+ right: candidate[:vector], masks: masks)
75
90
  end
76
91
  limited = @constraint_states[id(decision, :id)] > constraint_limit
77
92
  if limited
@@ -94,17 +109,21 @@ module Branchproof
94
109
  private
95
110
 
96
111
  def analyze_decision(decision)
112
+ return analyze_alternative_decision(decision) if alternative_decision?(decision)
113
+
97
114
  decision_id = id(decision, :id)
98
115
  unless id(decision, :support_status).to_s.empty? || id(decision, :support_status).to_s.upcase == "SUPPORTED"
99
116
  return {
100
- decision_id: decision_id, effective_masks_by_vector: {}, condition_results: [], witness_buckets: {},
117
+ decision_id: decision_id, effective_masks_by_vector: {}, condition_results: [],
101
118
  conditions: [], unsupported: true, coverage: unsupported_coverage,
119
+ decision_table: DecisionTable.build(decision: decision, vectors: [], limits: @limits,
120
+ reachability: @reachability),
102
121
  completeness: { observation: true, attribution: true, analysis: true },
103
122
  diagnostics: [diagnostic("unsupported_decision", "warning", decision_id, nil)]
104
123
  }
105
124
  end
106
125
 
107
- vectors = @vectors.filter_map { |vector| valid_vector(vector, decision) }
126
+ vectors = @vectors_by_decision[decision_id].filter_map { |vector| valid_vector(vector, decision) }
108
127
  masks = vectors.to_h { |vector| [id(vector, :id), effective_mask(vector, decision_id)] }
109
128
  buckets = {}
110
129
  conditions(decision).each do |condition|
@@ -127,7 +146,7 @@ module Branchproof
127
146
  true_ids = buckets[index][:true]
128
147
  false_ids = buckets[index][:false]
129
148
  # rubocop:enable Lint/BooleanSymbol
130
- pair = first_pair(vectors, index, decision_id)
149
+ pair = first_pair(vectors, index, decision_id, masks)
131
150
  {
132
151
  condition_id: id(condition, :id),
133
152
  status: pair ? "PROVEN" : "NOT_PROVEN",
@@ -144,28 +163,98 @@ module Branchproof
144
163
  decision_coverage = decision_coverage(vectors)
145
164
  condition_coverage_summary = condition_summary(results)
146
165
  mcdc = mcdc_coverage(results)
166
+ table = DecisionTable.build(decision: decision, vectors: vectors, limits: @limits,
167
+ reachability: @reachability)
147
168
  {
148
169
  decision_id: decision_id,
149
170
  effective_masks_by_vector: masks,
150
171
  condition_results: results,
151
- witness_buckets: buckets,
152
172
  edge_table: edge_table(decision[:tree]),
153
173
  conditions: conditions(decision),
154
174
  coverage: { decision: decision_coverage, condition: condition_coverage_summary,
155
175
  condition_decision: conjunction_coverage(decision_coverage, condition_coverage_summary),
156
- mcdc: mcdc },
176
+ mcdc: mcdc, decision_table: DecisionTable.coverage_entry(table) },
177
+ decision_table: table,
157
178
  completeness: { observation: true, attribution: true, analysis: !@analysis_invalid },
158
- diagnostics: []
179
+ diagnostics: table[:diagnostics]
159
180
  }
160
181
  end
161
182
 
183
+ def analyze_alternative_decision(decision)
184
+ decision_id = id(decision, :id)
185
+ unless id(decision, :support_status).to_s.empty? || id(decision, :support_status).to_s.upcase == "SUPPORTED"
186
+ return {
187
+ decision_id: decision_id, kind: id(decision, :kind), effective_masks_by_vector: {},
188
+ condition_results: [],
189
+ conditions: [], alternatives: alternatives(decision), unsupported: true,
190
+ coverage: unsupported_alternative_coverage,
191
+ completeness: { observation: true, attribution: true, analysis: true },
192
+ diagnostics: [diagnostic("unsupported_decision", "warning", decision_id, nil)]
193
+ }
194
+ end
195
+
196
+ vectors = @vectors_by_decision[decision_id].filter_map { |vector| valid_vector(vector, decision) }
197
+ rows = alternatives(decision).map do |alternative|
198
+ index = id(alternative, :index).to_i
199
+ selected = vectors.select { |vector| value(vector, index) == true }
200
+ not_selected = vectors.select { |vector| value(vector, index) == false }
201
+ skipped = vectors.select { |vector| value(vector, index).nil? }
202
+ {
203
+ alternative_id: id(alternative, :id), index: id(alternative, :index),
204
+ expression: id(alternative, :expression),
205
+ selected: alternative_evidence_bucket(selected),
206
+ not_selected: alternative_evidence_bucket(not_selected),
207
+ skipped: alternative_evidence_bucket(skipped)
208
+ }
209
+ end
210
+ missing = rows.filter_map { |row| row[:alternative_id] unless row[:selected][:observed] }
211
+ covered = rows.count { |row| row[:selected][:observed] }
212
+ {
213
+ decision_id: decision_id, kind: id(decision, :kind), effective_masks_by_vector: {},
214
+ condition_results: [],
215
+ conditions: [], alternatives: alternatives(decision), unsupported: false,
216
+ coverage: { alternative: { status: coverage_status(covered, rows.length), covered_alternatives: covered,
217
+ required_alternatives: rows.length, alternatives: rows,
218
+ missing_alternatives: missing },
219
+ mcdc: { status: "not_applicable" } },
220
+ completeness: { observation: true, attribution: true, analysis: !@analysis_invalid }, diagnostics: []
221
+ }
222
+ end
223
+
224
+ def unsupported_alternative_coverage
225
+ { alternative: { status: "unsupported", covered_alternatives: 0, required_alternatives: 0,
226
+ alternatives: [], missing_alternatives: [] }, mcdc: { status: "unsupported" } }
227
+ end
228
+
229
+ def alternative_evidence_bucket(vectors)
230
+ evidence = vectors.map { |vector| provenance(vector) }
231
+ { observed: !vectors.empty?, vector_ids: evidence.map { |item| item[:vector_id] }.uniq.sort,
232
+ test_ids: evidence.flat_map { |item| item[:test_ids] }.uniq.sort,
233
+ unattributed_count: evidence.sum { |item| item[:unattributed_count] } }
234
+ end
235
+
236
+ def alternative_decision?(decision)
237
+ kind = id(decision, :kind).to_s
238
+ !kind.empty? && kind != "boolean"
239
+ end
240
+
241
+ def alternative_decision_for_id?(decision_id)
242
+ decision = @decisions_by_id[decision_id]
243
+ decision && alternative_decision?(decision)
244
+ end
245
+
246
+ def alternatives(decision)
247
+ records(decision, :alternatives)
248
+ end
249
+
162
250
  def unsupported_coverage
163
251
  { decision: { status: "unsupported", true_observed: false, false_observed: false,
164
252
  covered_outcomes: 0, required_outcomes: 2, outcomes: [], missing_outcomes: [] },
165
253
  condition: { status: "unsupported", covered_values: 0, required_values: 0,
166
254
  covered_conditions: 0, condition_count: 0 },
167
255
  condition_decision: { status: "unsupported" },
168
- mcdc: { status: "unsupported", proven_conditions: 0, condition_count: 0 } }
256
+ mcdc: { status: "unsupported", proven_conditions: 0, condition_count: 0 },
257
+ decision_table: DecisionTable.unsupported_coverage_entry }
169
258
  end
170
259
 
171
260
  def provenance(vector)
@@ -254,29 +343,57 @@ module Branchproof
254
343
 
255
344
  def aggregate_coverage(decisions)
256
345
  supported = decisions.reject { |decision| decision[:unsupported] }
257
- decision_covered = supported.count do |decision|
346
+ boolean_supported = supported.reject { |decision| alternative_decision?(decision) }
347
+ decision_covered = boolean_supported.count do |decision|
258
348
  decision[:coverage][:decision][:status] == "covered"
259
349
  end
260
- condition_decision_covered = supported.count do |decision|
350
+ condition_decision_covered = boolean_supported.count do |decision|
261
351
  decision[:coverage][:condition_decision][:status] == "covered"
262
352
  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|
353
+ condition_count = boolean_supported.sum { |decision| decision[:coverage][:condition][:condition_count] }
354
+ condition_values = boolean_supported.sum { |decision| decision[:coverage][:condition][:covered_values] }
355
+ covered_conditions = boolean_supported.sum do |decision|
266
356
  decision[:coverage][:condition][:covered_conditions]
267
357
  end
268
- proven = supported.sum { |decision| decision[:coverage][:mcdc][:proven_conditions] }
269
- 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) },
358
+ proven = boolean_supported.sum { |decision| decision[:coverage][:mcdc][:proven_conditions] }
359
+ { decision: { covered_decisions: decision_covered, supported_decisions: boolean_supported.length,
360
+ percentage: DecisionTable.percentage(decision_covered, boolean_supported.length) },
272
361
  condition: { covered_values: condition_values, required_values: condition_count * 2,
273
362
  covered_conditions: covered_conditions, condition_count: condition_count,
274
- percentage: percentage.call(condition_values, condition_count * 2) },
363
+ percentage: DecisionTable.percentage(condition_values, condition_count * 2) },
275
364
  condition_decision: { covered_decisions: condition_decision_covered,
276
- supported_decisions: supported.length,
277
- percentage: percentage.call(condition_decision_covered, supported.length) },
365
+ supported_decisions: boolean_supported.length,
366
+ percentage: DecisionTable.percentage(condition_decision_covered,
367
+ boolean_supported.length) },
278
368
  mcdc: { proven_conditions: proven, supported_conditions: condition_count,
279
- percentage: percentage.call(proven, condition_count) } }
369
+ percentage: DecisionTable.percentage(proven, condition_count) },
370
+ decision_table: decision_table_aggregate(boolean_supported),
371
+ alternative: alternative_aggregate(decisions) }
372
+ end
373
+
374
+ # Rule coverage and fully covered decisions stay separate metrics: a
375
+ # decision can hold most of its rules and still fail the criterion.
376
+ def decision_table_aggregate(boolean_supported)
377
+ analyzed = boolean_supported.select { |decision| decision.dig(:decision_table, :status) == "calculated" }
378
+ not_calculated = boolean_supported.length - analyzed.length
379
+ covered = analyzed.sum { |decision| decision.dig(:decision_table, :covered_rules).to_i }
380
+ required = analyzed.sum { |decision| decision.dig(:decision_table, :required_rules).to_i }
381
+ generated = analyzed.sum { |decision| decision.dig(:decision_table, :generated_rules).to_i }
382
+ impossible = analyzed.sum { |decision| decision.dig(:decision_table, :impossible_rules).to_i }
383
+ fully_covered = analyzed.count { |decision| decision.dig(:decision_table, :coverage_status) == "covered" }
384
+ { decisions_analyzed: analyzed.length, not_calculated_decisions: not_calculated,
385
+ fully_covered_decisions: fully_covered, covered_rules: covered, required_rules: required,
386
+ generated_rules: generated, impossible_rules: impossible,
387
+ percentage: DecisionTable.percentage(covered, required),
388
+ decision_percentage: DecisionTable.percentage(fully_covered, analyzed.length) }
389
+ end
390
+
391
+ def alternative_aggregate(decisions)
392
+ flow = decisions.select { |decision| !decision[:unsupported] && alternative_decision?(decision) }
393
+ required = flow.sum { |decision| decision.dig(:coverage, :alternative, :required_alternatives).to_i }
394
+ covered = flow.sum { |decision| decision.dig(:coverage, :alternative, :covered_alternatives).to_i }
395
+ { covered_alternatives: covered, required_alternatives: required,
396
+ supported_decisions: flow.length, percentage: DecisionTable.percentage(covered, required) }
280
397
  end
281
398
 
282
399
  def valid_vector(vector, decision)
@@ -308,7 +425,11 @@ module Branchproof
308
425
  return nil
309
426
  end
310
427
 
311
- effective_mask(vector, id(decision, :id), decision[:tree])
428
+ if alternative_decision?(decision)
429
+ handle_alternative_vector(vector, decision)
430
+ else
431
+ effective_mask(vector, id(decision, :id), decision[:tree])
432
+ end
312
433
  decision_source.nil? ? vector : vector.merge(source_id: decision_source)
313
434
  rescue ArgumentError
314
435
  @analysis_invalid = true
@@ -316,8 +437,38 @@ module Branchproof
316
437
  nil
317
438
  end
318
439
 
440
+ # rubocop:disable-next Naming/PredicateMethod -- raises on malformed flow vectors
441
+ def handle_alternative_vector(vector, decision)
442
+ values = values_for(vector)
443
+ expected = alternatives(decision).length
444
+ raise ArgumentError, "invalid alternative vector shape" unless values.length == expected
445
+
446
+ observations = values.each_with_index.filter_map { |item, index| [index, item] unless item.nil? }
447
+ raise ArgumentError, "invalid alternative trace" unless valid_alternative_trace?(decision, observations,
448
+ outcome(vector))
449
+
450
+ true
451
+ end
452
+
453
+ def valid_alternative_trace?(decision, observations, result)
454
+ return false unless result == true
455
+
456
+ expected = alternatives(decision).length
457
+ return false unless expected.positive?
458
+ if id(decision, :kind).to_s == "implicit"
459
+ return expected == 2 && observations.length == 2 &&
460
+ observations.map(&:first) == [0, 1] && observations.map(&:last).count(true) == 1
461
+ end
462
+
463
+ return false unless observations.length.between?(1, expected)
464
+
465
+ observations.each_with_index.all? do |(index, value), position|
466
+ index == position && value == (position == observations.length - 1)
467
+ end
468
+ end
469
+
319
470
  def effective_mask(vector, decision_id, tree = nil)
320
- decision = records(@inventory, :decisions).find { id(_1, :id) == decision_id } unless tree
471
+ decision = @decisions_by_id[decision_id] unless tree
321
472
  tree ||= decision && decision[:tree]
322
473
  raise ArgumentError, "unknown decision" unless tree
323
474
 
@@ -332,6 +483,15 @@ module Branchproof
332
483
  mask
333
484
  end
334
485
 
486
+ # Returns the effective mask for a vector, reusing a precomputed
487
+ # vector-id => mask hash when the caller has one, instead of
488
+ # replaying the whole Boolean tree again.
489
+ def masked_bits(vector, decision_id, masks)
490
+ return effective_mask(vector, decision_id) unless masks
491
+
492
+ masks[id(vector, :id)] || effective_mask(vector, decision_id)
493
+ end
494
+
335
495
  def replay(node, values, offset)
336
496
  type = id(node, :type).to_sym
337
497
  if type == :atom
@@ -341,6 +501,10 @@ module Branchproof
341
501
 
342
502
  return [value, 1 << index, offset + 1]
343
503
  end
504
+ if type == :not
505
+ child_result, child_mask, consumed = replay(node.fetch(:child), values, offset)
506
+ return [!child_result, child_mask, consumed]
507
+ end
344
508
  left_result, left_mask, consumed = replay(node.fetch(:left), values, offset)
345
509
  return [left_result, left_mask, consumed] if (type == :and && !left_result) || (type == :or && left_result)
346
510
 
@@ -352,9 +516,9 @@ module Branchproof
352
516
  end
353
517
  end
354
518
 
355
- def first_pair(vectors, index, decision_id)
519
+ def first_pair(vectors, index, decision_id, masks = nil)
356
520
  observed_vectors = vectors.select do |vector|
357
- observed?(vector, index) && effective_mask(vector, decision_id).anybits?(1 << index)
521
+ observed?(vector, index) && masked_bits(vector, decision_id, masks)&.anybits?(1 << index)
358
522
  end
359
523
  observed_vectors.sort_by! { |vector| id(vector, :id).to_s }
360
524
  grouped = observed_vectors.group_by do |vector|
@@ -371,7 +535,9 @@ module Branchproof
371
535
 
372
536
  left = left_vectors.first
373
537
  right = right_vectors.first
374
- return [left, right] if pair?(decision_id: decision_id, condition_index: index, left: left, right: right)
538
+ if pair?(decision_id: decision_id, condition_index: index, left: left, right: right, masks: masks)
539
+ return [left, right]
540
+ end
375
541
  end
376
542
  nil
377
543
  end
@@ -440,6 +606,8 @@ module Branchproof
440
606
  return { entry: entry, true_exits: [edges[0]], false_exits: [edges[1]], all_bits: 1 << index, edges: edges }
441
607
  end
442
608
  type = id(node, :type).to_sym
609
+ return build_graph(node.fetch(:child), false_destination, true_destination) if type == :not
610
+
443
611
  right = build_graph(node[:right], true_destination, false_destination)
444
612
  if type == :and
445
613
  left = build_graph(node[:left], right[:entry], false_destination)
@@ -455,7 +623,9 @@ module Branchproof
455
623
  end
456
624
 
457
625
  def impose_path(node, target, target_value, constraints)
458
- return true if id(node, :type).to_sym == :atom && id(node, :index) == target
626
+ type = id(node, :type).to_sym
627
+ return true if type == :atom && id(node, :index) == target
628
+ return impose_path(node.fetch(:child), target, target_value, constraints) if type == :not
459
629
 
460
630
  left = node[:left]
461
631
  right = node[:right]
@@ -480,6 +650,8 @@ module Branchproof
480
650
 
481
651
  constraints << { condition_index: id(node, :index), value: desired }
482
652
  true
653
+ elsif id(node, :type).to_sym == :not
654
+ impose_subtree(node.fetch(:child), !desired, constraints)
483
655
  elsif desired == (id(node, :type).to_sym == :and)
484
656
  impose_subtree(node[:left], desired, constraints) && impose_subtree(node[:right], desired, constraints)
485
657
  else
@@ -487,22 +659,17 @@ module Branchproof
487
659
  end
488
660
  end
489
661
 
490
- def evaluate(node, values)
491
- return values[id(node, :index)] if id(node, :type).to_sym == :atom
492
-
493
- left = evaluate(node[:left], values)
494
- return left if id(node, :type).to_sym == :and && !left
495
- return left if id(node, :type).to_sym == :or && left
496
-
497
- evaluate(node[:right], values)
498
- end
499
-
500
662
  def evaluate_with_trace(node, values, trace = [])
501
- if id(node, :type).to_sym == :atom
663
+ type = id(node, :type).to_sym
664
+ if type == :atom
502
665
  value = !values[id(node, :index)].nil? && values[id(node, :index)] != false
503
666
  trace << [id(node, :index), value]
504
667
  return [value, trace]
505
668
  end
669
+ if type == :not
670
+ child, = evaluate_with_trace(node.fetch(:child), values, trace)
671
+ return [!child, trace]
672
+ end
506
673
  left, = evaluate_with_trace(node[:left], values, trace)
507
674
  return [left, trace] if id(node, :type).to_sym == :and && !left
508
675
  return [left, trace] if id(node, :type).to_sym == :or && left
@@ -511,24 +678,25 @@ module Branchproof
511
678
  end
512
679
 
513
680
  def contains?(node, target)
514
- if id(node,
515
- :type).to_sym == :atom
516
- id(node,
517
- :index) == target
681
+ if id(node, :type).to_sym == :atom
682
+ id(node, :index) == target
683
+ elsif id(node, :type).to_sym == :not
684
+ contains?(node.fetch(:child), target)
518
685
  else
519
- contains?(node[:left],
520
- target) || contains?(node[:right], target)
686
+ contains?(node[:left], target) || contains?(node[:right], target)
521
687
  end
522
688
  end
523
689
 
524
690
  def values_for(vector)
691
+ return @values_cache[vector] if @values_cache.key?(vector)
692
+
525
693
  raw = vector[:values] || vector["values"]
526
694
  raise ArgumentError, "observation must contain booleans or nil" unless raw.is_a?(Array)
527
695
  unless raw.all? { |value| value.nil? || value == true || value == false }
528
696
  raise ArgumentError, "observation must contain booleans or nil"
529
697
  end
530
698
 
531
- raw
699
+ @values_cache[vector] = raw
532
700
  end
533
701
 
534
702
  def outcome(vector) = vector[:outcome].nil? ? vector["outcome"] : vector[:outcome]
@@ -15,6 +15,9 @@ require "time"
15
15
  module Branchproof
16
16
  # Coordinates source inventory, isolated test execution, and report output.
17
17
  class CLI
18
+ VIEWS = Report::VIEWS.to_h { |view| [view.to_s.tr("_", "-"), view] }
19
+ .merge("decision_tables" => :decision_tables).freeze
20
+
18
21
  def initialize(stdout:, stderr:)
19
22
  @stdout = stdout
20
23
  @stderr = stderr
@@ -25,7 +28,7 @@ module Branchproof
25
28
  return help if [["--help"], ["help"], ["analyze", "--help"]].include?(argv)
26
29
  return offline(argv) if %w[report compare].include?(argv.first)
27
30
 
28
- options = parse(Array(argv))
31
+ options = parse(argv)
29
32
  return usage_error("expected analyze, report, or compare; use branchproof --help") unless options
30
33
 
31
34
  inventory = build_inventory(options)
@@ -49,30 +52,29 @@ module Branchproof
49
52
  message: merge_status[:reason].to_s }]
50
53
  end
51
54
  end
55
+ snapshot = evidence.snapshot
52
56
  if value(baseline, :status).to_s == "PASSED"
53
- analysis = Analyzer.new(inventory: inventory, evidence: evidence.snapshot, limits: options[:limits]).call
57
+ analysis = Analyzer.new(inventory: inventory, evidence: snapshot, limits: options[:limits],
58
+ reachability: options[:reachability]).call
54
59
  baseline[:analysis] = analysis
55
60
  if options[:level] >= 2
56
61
  ids = Array(value(inventory, :decisions)).map { |decision| value(decision, :id) }
62
+ minimizer = Minimizer.new(analysis: analysis, evidence: snapshot, limits: options[:limits])
57
63
  baseline[:minima] = ids.filter_map do |decision_id|
58
- Minimizer.new(analysis: analysis, evidence: evidence.snapshot, limits: options[:limits]).call(objective: :vectors, decision_ids: [decision_id])
64
+ minimizer.call(objective: :vectors, decision_ids: [decision_id])
59
65
  end
60
66
  baseline[:minima] += ids.filter_map do |decision_id|
61
- Minimizer.new(analysis: analysis, evidence: evidence.snapshot, limits: options[:limits]).call(
62
- objective: :tests, decision_ids: [decision_id]
63
- )
67
+ minimizer.call(objective: :tests, decision_ids: [decision_id])
64
68
  end
65
- baseline[:minima] << Minimizer.new(analysis: analysis, evidence: evidence.snapshot, limits: options[:limits]).call(
66
- objective: :tests, decision_ids: ids
67
- )
69
+ baseline[:minima] << minimizer.call(objective: :tests, decision_ids: ids)
68
70
  end
69
71
  end
70
72
  diagnostics = Array(value(inventory, :diagnostics)) + Array(value(baseline, :diagnostics)) +
71
- Array(value(evidence.snapshot,
73
+ Array(value(snapshot,
72
74
  :diagnostics)) + Array(value(value(baseline, :analysis), :diagnostics))
73
75
  analysis = value(baseline, :analysis)
74
76
  minima = options[:level] == 1 ? [] : Array(value(baseline, :minima))
75
- report = Report.new(inventory: inventory, evidence: value(baseline, :evidence) || evidence.snapshot,
77
+ report = Report.new(inventory: inventory, evidence: value(baseline, :evidence) || snapshot,
76
78
  analysis: analysis, minima: minima, baseline: baseline, diagnostics: diagnostics,
77
79
  level: options[:level], missing_only: options[:missing_only], view: options[:view],
78
80
  run_metadata: run_metadata(options, baseline))
@@ -92,21 +94,24 @@ module Branchproof
92
94
  @stdout.write(<<~HELP)
93
95
  Usage:
94
96
  branchproof analyze [SOURCE_GLOB ...] [--test TEST_GLOB] [--project auto|ruby|rails]
95
- [--view decisions|conditions|tests] [--level 1|2|3] [--missing-only]
96
- [--format terminal|json] [--output PATH] [--limits PATH] [-- RUNNER_ARGS]
97
- branchproof report SNAPSHOT [--view decisions|conditions|tests] [--level 1|2|3]
98
- [--missing-only] [--format terminal|json] [--output PATH]
97
+ [--view decisions|conditions|tests|decision-tables] [--level 1|2|3] [--missing-only]
98
+ [--format terminal|json] [--output PATH] [--limits PATH] [--no-reachability] [-- RUNNER_ARGS]
99
+ branchproof report SNAPSHOT [--view decisions|conditions|tests|decision-tables]
100
+ [--level 1|2|3] [--missing-only] [--format terminal|json] [--output PATH]
99
101
  branchproof compare BEFORE AFTER [--format terminal|json] [--output PATH] [--fail-on-regression]
100
102
  mcdc accepts the same commands as a compatibility alias.
101
103
  JSON always contains full evidence; --view requires terminal output.
104
+ decision_tables is also accepted as an alias for the decision-tables view.
105
+ --no-reachability keeps every generated decision-table rule as a coverage obligation.
102
106
  HELP
103
107
  0
104
108
  end
105
109
 
106
110
  def parse_view(view)
107
- raise ArgumentError, "view must be decisions, conditions, or tests" unless %w[decisions conditions tests].include?(view)
111
+ resolved = VIEWS[view.to_s]
112
+ raise ArgumentError, "view must be decisions, conditions, tests, or decision-tables" unless resolved
108
113
 
109
- view.to_sym
114
+ resolved
110
115
  end
111
116
 
112
117
  def validate_view!(options)
@@ -204,7 +209,8 @@ module Branchproof
204
209
  project_root: root, source_patterns: options[:source_patterns].map { |path| relative_path(path, root) },
205
210
  test_patterns: options[:test_patterns].map { |path| relative_path(path, root) },
206
211
  test_files: options[:tests].map { |path| relative_path(path, root) }, runner_args: options[:runner_args],
207
- seed: value(baseline, :seed), limits: options[:limits], test_locations: locations }
212
+ seed: value(baseline, :seed), limits: options[:limits], test_locations: locations,
213
+ reachability: options[:reachability] }
208
214
  end
209
215
 
210
216
  def relative_path(path, root)
@@ -221,7 +227,8 @@ module Branchproof
221
227
  runner_args = delimiter ? args[(delimiter + 1)..] : []
222
228
  args = args[0...delimiter] if delimiter
223
229
  options = { level: 3, format: :terminal, output: nil, tests: [], source_paths: [], limits: Limits.default,
224
- runner_args: runner_args, project: nil, missing_only: false, view: :decisions }
230
+ runner_args: runner_args, project: nil, missing_only: false, view: :decisions,
231
+ reachability: true }
225
232
  until args.empty?
226
233
  token = args.shift
227
234
  case token
@@ -230,6 +237,8 @@ module Branchproof
230
237
  options[:explicit_view] = true
231
238
  when "--missing-only"
232
239
  options[:missing_only] = true
240
+ when "--no-reachability"
241
+ options[:reachability] = false
233
242
  when "--level"
234
243
  level = Integer(args.shift.to_s, 10)
235
244
  raise ArgumentError, "level must be 1, 2, or 3" unless (1..3).cover?(level)