branchproof 0.7.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 (47) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +35 -0
  3. data/README.md +212 -11
  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 +3 -0
  10. data/doc/Branchproof/DecisionSyntax.md +6 -4
  11. data/doc/Branchproof/DecisionTable.md +153 -0
  12. data/doc/Branchproof/FocusedReport.md +5 -2
  13. data/doc/Branchproof/Loader.md +0 -4
  14. data/doc/Branchproof/MinitestAdapter.md +0 -3
  15. data/doc/Branchproof/Report.md +18 -0
  16. data/doc/Branchproof/Runtime.md +4 -0
  17. data/doc/Branchproof/SavedReport.md +15 -0
  18. data/doc/Branchproof.md +4 -1
  19. data/doc/CHANGELOG.md +35 -0
  20. data/doc/README.md +212 -11
  21. data/lib/branchproof/analyzer.rb +80 -44
  22. data/lib/branchproof/cli.rb +28 -19
  23. data/lib/branchproof/comparison.rb +207 -19
  24. data/lib/branchproof/comparison_report.rb +49 -1
  25. data/lib/branchproof/constraints.rb +363 -0
  26. data/lib/branchproof/coverage_index.rb +30 -1
  27. data/lib/branchproof/decision_syntax.rb +31 -15
  28. data/lib/branchproof/decision_table.rb +377 -0
  29. data/lib/branchproof/evidence.rb +51 -31
  30. data/lib/branchproof/flow_instrumentation.rb +17 -17
  31. data/lib/branchproof/focused_report.rb +77 -9
  32. data/lib/branchproof/instrumenter.rb +45 -34
  33. data/lib/branchproof/limits.rb +4 -1
  34. data/lib/branchproof/loader.rb +18 -6
  35. data/lib/branchproof/minimizer.rb +18 -13
  36. data/lib/branchproof/minitest_adapter.rb +11 -16
  37. data/lib/branchproof/records.rb +2 -0
  38. data/lib/branchproof/report.rb +301 -68
  39. data/lib/branchproof/runtime.rb +22 -30
  40. data/lib/branchproof/saved_report.rb +200 -14
  41. data/lib/branchproof/source.rb +90 -48
  42. data/lib/branchproof/version.rb +1 -1
  43. data/lib/branchproof/worker.rb +1 -4
  44. data/lib/branchproof.rb +2 -0
  45. data/llms.txt +4 -1
  46. data/sig/branchproof.rbs +47 -1
  47. metadata +6 -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,22 +47,21 @@ 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)
39
51
  return false if alternative_decision_for_id?(decision_id)
40
52
  return false unless compatible_vectors?(left, right, decision_id)
41
53
  return false unless observed?(left, condition_index) && observed?(right, condition_index)
42
54
  return false if value(left, condition_index) == value(right, condition_index)
43
55
  return false if outcome(left) == outcome(right)
44
56
 
45
- masks = [left, right].map { effective_mask(_1, decision_id) }
46
- masks.all? { |mask| mask&.anybits?(1 << condition_index) }
57
+ [left, right].all? { |vector| masked_bits(vector, decision_id, masks)&.anybits?(1 << condition_index) }
47
58
  end
48
59
 
49
60
  def missing(decision_id:, condition_index:)
50
61
  cache_key = [decision_id, condition_index]
51
62
  return @missing_cache[cache_key] if @missing_cache.key?(cache_key)
52
63
 
53
- decision = records(@inventory, :decisions).find { id(_1, :id) == decision_id }
64
+ decision = @decisions_by_id[decision_id]
54
65
  return @missing_cache[cache_key] = nil unless decision
55
66
 
56
67
  return @missing_cache[cache_key] = nil if alternative_decision?(decision)
@@ -58,7 +69,7 @@ module Branchproof
58
69
  support_status = id(decision, :support_status).to_s
59
70
  return @missing_cache[cache_key] = nil unless support_status.empty? || support_status.upcase == "SUPPORTED"
60
71
 
61
- relevant = @vectors.select { id(_1, :decision_id) == decision_id }
72
+ relevant = @vectors_by_decision[decision_id]
62
73
  valid = relevant.filter_map { valid_vector(_1, decision) }
63
74
  if valid.empty?
64
75
  return @missing_cache[cache_key] = {
@@ -67,14 +78,15 @@ module Branchproof
67
78
  feasibility_statement: "No completed structural observation exists."
68
79
  }
69
80
  end
70
- 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)
71
83
 
72
- 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) }
73
85
  signs = existing ? [!value(existing, condition_index)] : [true, false]
74
86
  candidates = signs.filter_map { counterpart(decision, condition_index, _1) }
75
87
  candidates.select! do |candidate|
76
88
  !existing || pair?(decision_id: decision_id, condition_index: condition_index, left: existing,
77
- right: candidate[:vector])
89
+ right: candidate[:vector], masks: masks)
78
90
  end
79
91
  limited = @constraint_states[id(decision, :id)] > constraint_limit
80
92
  if limited
@@ -102,14 +114,16 @@ module Branchproof
102
114
  decision_id = id(decision, :id)
103
115
  unless id(decision, :support_status).to_s.empty? || id(decision, :support_status).to_s.upcase == "SUPPORTED"
104
116
  return {
105
- decision_id: decision_id, effective_masks_by_vector: {}, condition_results: [], witness_buckets: {},
117
+ decision_id: decision_id, effective_masks_by_vector: {}, condition_results: [],
106
118
  conditions: [], unsupported: true, coverage: unsupported_coverage,
119
+ decision_table: DecisionTable.build(decision: decision, vectors: [], limits: @limits,
120
+ reachability: @reachability),
107
121
  completeness: { observation: true, attribution: true, analysis: true },
108
122
  diagnostics: [diagnostic("unsupported_decision", "warning", decision_id, nil)]
109
123
  }
110
124
  end
111
125
 
112
- vectors = @vectors.filter_map { |vector| valid_vector(vector, decision) }
126
+ vectors = @vectors_by_decision[decision_id].filter_map { |vector| valid_vector(vector, decision) }
113
127
  masks = vectors.to_h { |vector| [id(vector, :id), effective_mask(vector, decision_id)] }
114
128
  buckets = {}
115
129
  conditions(decision).each do |condition|
@@ -132,7 +146,7 @@ module Branchproof
132
146
  true_ids = buckets[index][:true]
133
147
  false_ids = buckets[index][:false]
134
148
  # rubocop:enable Lint/BooleanSymbol
135
- pair = first_pair(vectors, index, decision_id)
149
+ pair = first_pair(vectors, index, decision_id, masks)
136
150
  {
137
151
  condition_id: id(condition, :id),
138
152
  status: pair ? "PROVEN" : "NOT_PROVEN",
@@ -149,18 +163,20 @@ module Branchproof
149
163
  decision_coverage = decision_coverage(vectors)
150
164
  condition_coverage_summary = condition_summary(results)
151
165
  mcdc = mcdc_coverage(results)
166
+ table = DecisionTable.build(decision: decision, vectors: vectors, limits: @limits,
167
+ reachability: @reachability)
152
168
  {
153
169
  decision_id: decision_id,
154
170
  effective_masks_by_vector: masks,
155
171
  condition_results: results,
156
- witness_buckets: buckets,
157
172
  edge_table: edge_table(decision[:tree]),
158
173
  conditions: conditions(decision),
159
174
  coverage: { decision: decision_coverage, condition: condition_coverage_summary,
160
175
  condition_decision: conjunction_coverage(decision_coverage, condition_coverage_summary),
161
- mcdc: mcdc },
176
+ mcdc: mcdc, decision_table: DecisionTable.coverage_entry(table) },
177
+ decision_table: table,
162
178
  completeness: { observation: true, attribution: true, analysis: !@analysis_invalid },
163
- diagnostics: []
179
+ diagnostics: table[:diagnostics]
164
180
  }
165
181
  end
166
182
 
@@ -169,7 +185,7 @@ module Branchproof
169
185
  unless id(decision, :support_status).to_s.empty? || id(decision, :support_status).to_s.upcase == "SUPPORTED"
170
186
  return {
171
187
  decision_id: decision_id, kind: id(decision, :kind), effective_masks_by_vector: {},
172
- condition_results: [], witness_buckets: {},
188
+ condition_results: [],
173
189
  conditions: [], alternatives: alternatives(decision), unsupported: true,
174
190
  coverage: unsupported_alternative_coverage,
175
191
  completeness: { observation: true, attribution: true, analysis: true },
@@ -177,7 +193,7 @@ module Branchproof
177
193
  }
178
194
  end
179
195
 
180
- vectors = @vectors.filter_map { |vector| valid_vector(vector, decision) }
196
+ vectors = @vectors_by_decision[decision_id].filter_map { |vector| valid_vector(vector, decision) }
181
197
  rows = alternatives(decision).map do |alternative|
182
198
  index = id(alternative, :index).to_i
183
199
  selected = vectors.select { |vector| value(vector, index) == true }
@@ -195,7 +211,7 @@ module Branchproof
195
211
  covered = rows.count { |row| row[:selected][:observed] }
196
212
  {
197
213
  decision_id: decision_id, kind: id(decision, :kind), effective_masks_by_vector: {},
198
- condition_results: [], witness_buckets: {},
214
+ condition_results: [],
199
215
  conditions: [], alternatives: alternatives(decision), unsupported: false,
200
216
  coverage: { alternative: { status: coverage_status(covered, rows.length), covered_alternatives: covered,
201
217
  required_alternatives: rows.length, alternatives: rows,
@@ -223,7 +239,7 @@ module Branchproof
223
239
  end
224
240
 
225
241
  def alternative_decision_for_id?(decision_id)
226
- decision = records(@inventory, :decisions).find { |item| id(item, :id) == decision_id }
242
+ decision = @decisions_by_id[decision_id]
227
243
  decision && alternative_decision?(decision)
228
244
  end
229
245
 
@@ -237,7 +253,8 @@ module Branchproof
237
253
  condition: { status: "unsupported", covered_values: 0, required_values: 0,
238
254
  covered_conditions: 0, condition_count: 0 },
239
255
  condition_decision: { status: "unsupported" },
240
- 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 }
241
258
  end
242
259
 
243
260
  def provenance(vector)
@@ -339,26 +356,44 @@ module Branchproof
339
356
  decision[:coverage][:condition][:covered_conditions]
340
357
  end
341
358
  proven = boolean_supported.sum { |decision| decision[:coverage][:mcdc][:proven_conditions] }
342
- percentage = ->(covered, required) { required.zero? ? nil : (covered.to_f / required * 100).round(2) }
343
359
  { decision: { covered_decisions: decision_covered, supported_decisions: boolean_supported.length,
344
- percentage: percentage.call(decision_covered, boolean_supported.length) },
360
+ percentage: DecisionTable.percentage(decision_covered, boolean_supported.length) },
345
361
  condition: { covered_values: condition_values, required_values: condition_count * 2,
346
362
  covered_conditions: covered_conditions, condition_count: condition_count,
347
- percentage: percentage.call(condition_values, condition_count * 2) },
363
+ percentage: DecisionTable.percentage(condition_values, condition_count * 2) },
348
364
  condition_decision: { covered_decisions: condition_decision_covered,
349
365
  supported_decisions: boolean_supported.length,
350
- percentage: percentage.call(condition_decision_covered, boolean_supported.length) },
366
+ percentage: DecisionTable.percentage(condition_decision_covered,
367
+ boolean_supported.length) },
351
368
  mcdc: { proven_conditions: proven, supported_conditions: condition_count,
352
- percentage: percentage.call(proven, condition_count) },
369
+ percentage: DecisionTable.percentage(proven, condition_count) },
370
+ decision_table: decision_table_aggregate(boolean_supported),
353
371
  alternative: alternative_aggregate(decisions) }
354
372
  end
355
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
+
356
391
  def alternative_aggregate(decisions)
357
392
  flow = decisions.select { |decision| !decision[:unsupported] && alternative_decision?(decision) }
358
393
  required = flow.sum { |decision| decision.dig(:coverage, :alternative, :required_alternatives).to_i }
359
394
  covered = flow.sum { |decision| decision.dig(:coverage, :alternative, :covered_alternatives).to_i }
360
395
  { covered_alternatives: covered, required_alternatives: required,
361
- supported_decisions: flow.length, percentage: required.zero? ? nil : (covered.to_f / required * 100).round(2) }
396
+ supported_decisions: flow.length, percentage: DecisionTable.percentage(covered, required) }
362
397
  end
363
398
 
364
399
  def valid_vector(vector, decision)
@@ -433,7 +468,7 @@ module Branchproof
433
468
  end
434
469
 
435
470
  def effective_mask(vector, decision_id, tree = nil)
436
- decision = records(@inventory, :decisions).find { id(_1, :id) == decision_id } unless tree
471
+ decision = @decisions_by_id[decision_id] unless tree
437
472
  tree ||= decision && decision[:tree]
438
473
  raise ArgumentError, "unknown decision" unless tree
439
474
 
@@ -448,6 +483,15 @@ module Branchproof
448
483
  mask
449
484
  end
450
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
+
451
495
  def replay(node, values, offset)
452
496
  type = id(node, :type).to_sym
453
497
  if type == :atom
@@ -472,9 +516,9 @@ module Branchproof
472
516
  end
473
517
  end
474
518
 
475
- def first_pair(vectors, index, decision_id)
519
+ def first_pair(vectors, index, decision_id, masks = nil)
476
520
  observed_vectors = vectors.select do |vector|
477
- observed?(vector, index) && effective_mask(vector, decision_id).anybits?(1 << index)
521
+ observed?(vector, index) && masked_bits(vector, decision_id, masks)&.anybits?(1 << index)
478
522
  end
479
523
  observed_vectors.sort_by! { |vector| id(vector, :id).to_s }
480
524
  grouped = observed_vectors.group_by do |vector|
@@ -491,7 +535,9 @@ module Branchproof
491
535
 
492
536
  left = left_vectors.first
493
537
  right = right_vectors.first
494
- 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
495
541
  end
496
542
  nil
497
543
  end
@@ -613,18 +659,6 @@ module Branchproof
613
659
  end
614
660
  end
615
661
 
616
- def evaluate(node, values)
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
620
-
621
- left = evaluate(node[:left], values)
622
- return left if id(node, :type).to_sym == :and && !left
623
- return left if id(node, :type).to_sym == :or && left
624
-
625
- evaluate(node[:right], values)
626
- end
627
-
628
662
  def evaluate_with_trace(node, values, trace = [])
629
663
  type = id(node, :type).to_sym
630
664
  if type == :atom
@@ -654,13 +688,15 @@ module Branchproof
654
688
  end
655
689
 
656
690
  def values_for(vector)
691
+ return @values_cache[vector] if @values_cache.key?(vector)
692
+
657
693
  raw = vector[:values] || vector["values"]
658
694
  raise ArgumentError, "observation must contain booleans or nil" unless raw.is_a?(Array)
659
695
  unless raw.all? { |value| value.nil? || value == true || value == false }
660
696
  raise ArgumentError, "observation must contain booleans or nil"
661
697
  end
662
698
 
663
- raw
699
+ @values_cache[vector] = raw
664
700
  end
665
701
 
666
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)