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
@@ -1,19 +1,26 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # rubocop:disable Metrics/BlockLength
4
+
3
5
  require "json"
4
6
  require "pathname"
5
7
 
6
8
  module Branchproof
7
9
  # Renders versioned terminal and JSON analysis reports.
8
10
  class Report
9
- SCHEMA_VERSION = "1.1"
11
+ SCHEMA_VERSION = "1.3"
10
12
  CRITERION_VERSION = "masking_occurrence_v1"
13
+ VIEWS = %i[decisions conditions tests decision_tables].freeze
14
+ DECISION_TABLE_LABELS = { "true" => "T", "false" => "F", "dont_care" => "-" }.freeze
15
+ DECISION_TABLE_STATUS_LABELS = { "covered" => "COVERED", "missing" => "MISSING",
16
+ "excluded" => "EXCLUDED" }.freeze
11
17
 
12
18
  def initialize(inventory:, evidence:, analysis:, minima:, baseline:, diagnostics:, level: 3, missing_only: false,
13
19
  view: :decisions, run_metadata: {}, saved_document: nil)
14
20
  raise ArgumentError, "level must be 1, 2, or 3" unless [1, 2, 3].include?(level.to_i)
15
- raise ArgumentError, "view must be :decisions, :conditions, or :tests" unless %i[decisions conditions
16
- tests].include?(view.to_sym)
21
+ unless VIEWS.include?(view.to_sym)
22
+ raise ArgumentError, "view must be :decisions, :conditions, :tests, or :decision_tables"
23
+ end
17
24
 
18
25
  @inventory = inventory || {}
19
26
  @evidence = evidence || {}
@@ -30,8 +37,8 @@ module Branchproof
30
37
 
31
38
  def self.from_document(document:, level: nil, view: :decisions, missing_only: false)
32
39
  data = document || {}
33
- new(inventory: data[:source_inventory] || data["source_inventory"] || data[:inventory] || data["inventory"],
34
- evidence: data[:observations] || data["observations"] || data[:evidence] || data["evidence"],
40
+ new(inventory: data[:source_inventory] || data["source_inventory"],
41
+ evidence: data[:observations] || data["observations"],
35
42
  analysis: data[:analysis] || data["analysis"], minima: data[:minima] || data["minima"],
36
43
  baseline: data[:baseline] || data["baseline"], diagnostics: data[:diagnostics] || data["diagnostics"],
37
44
  level: level || (data[:analysis] || data["analysis"] ? 3 : 1), view: view, missing_only: missing_only,
@@ -103,7 +110,9 @@ module Branchproof
103
110
  return 1 if status == "FAILED"
104
111
  return 2 unless status == "PASSED" && value(@baseline, :finalized) == true
105
112
  return 2 if @saved_document && (value(@saved_document, :completeness) || {}).values.include?(false)
106
- return 2 unless metrics[:eligible_conditions].positive?
113
+
114
+ eligible = metrics[:eligible_conditions].positive? || metrics[:eligible_alternatives].positive?
115
+ return 2 unless eligible
107
116
  return 2 unless valid_for_requested_level?
108
117
 
109
118
  0
@@ -118,7 +127,7 @@ module Branchproof
118
127
  tool_version: (defined?(Branchproof::VERSION) ? Branchproof::VERSION : "unknown"),
119
128
  criterion_version: CRITERION_VERSION, runtime: RUBY_DESCRIPTION,
120
129
  run_ids: Array(value(@evidence, :run_ids)),
121
- source_inventory: @inventory, baseline: @baseline, observations: @evidence,
130
+ source_inventory: inventory_with_default_kinds, baseline: @baseline, observations: @evidence,
122
131
  analysis: @analysis, minima: @minima, metrics: metrics,
123
132
  diagnostics: @diagnostics, completeness: completeness,
124
133
  run_metadata: @run_metadata)
@@ -127,7 +136,7 @@ module Branchproof
127
136
  def terminal_document
128
137
  unless @view == :decisions
129
138
  return FocusedReport.new(document: json_document, view: @view, level: @level,
130
- missing_only: @missing_only).render
139
+ missing_only: @missing_only, coordinator: self).render
131
140
  end
132
141
 
133
142
  @terminal_ids = terminal_ids
@@ -137,12 +146,13 @@ module Branchproof
137
146
  "Analysis: #{terminal_analysis_status}",
138
147
  "Decisions: #{metrics[:supported]} supported, #{metrics[:unsupported]} excluded, " \
139
148
  "#{metrics[:unexecuted]} unexecuted (#{metrics[:discovered]} discovered)",
149
+ decision_kind_summary_line,
140
150
  "Observations: #{metrics[:completed]} completed, #{metrics[:aborted]} aborted, " \
141
151
  "#{metrics[:unattributed]} unattributed",
142
- "Values: T=true, F=false, -=short-circuited"]
152
+ values_legend]
143
153
  lines.concat(coverage_ladder_lines)
144
154
  lines << missing_summary_line if @missing_only
145
- lines << "Scope: supported decisions and conditions"
155
+ lines << "Scope: supported decisions, conditions, and alternatives"
146
156
  lines << ""
147
157
  decisions_to_render.each { |decision| render_decision(lines, decision) }
148
158
  render_minima(lines) unless @missing_only
@@ -159,20 +169,26 @@ module Branchproof
159
169
  decision_label = "Decision #{short_id(value(decision, :id))} #{filename}:#{value(decision, :line)}"
160
170
  lines << decision_label
161
171
  lines << " Decision: #{value(decision, :expression)}"
172
+ lines << " Kind: #{decision_kind(decision)}"
173
+ context = value(decision, :context)
174
+ lines << " Context: #{context}" unless context.nil? || context.to_s.empty?
162
175
  lines << " Status: #{value(decision, :support_status) || "SUPPORTED"}"
163
- conditions_to_render(decision).each do |condition|
164
- result = condition_result(decision, condition)
165
- detail = condition_detail(decision, condition, result)
166
- status = if @analysis.nil? || (@level == 1 && !coverage_available?)
167
- "NOT CALCULATED"
168
- else
169
- value(result, :status) || "NOT_PROVEN"
170
- end
171
- lines << " Condition #{value(condition, :index)}: #{value(condition, :expression)}"
172
- lines << " #{status}#{detail}"
173
- render_condition_coverage(lines, decision, condition, result) if @level >= 2 && coverage_available?
176
+ unless nonboolean_decision?(decision)
177
+ conditions_to_render(decision).each do |condition|
178
+ result = condition_result(decision, condition)
179
+ detail = condition_detail(decision, condition, result)
180
+ status = if @analysis.nil? || (@level == 1 && !coverage_available?)
181
+ "NOT CALCULATED"
182
+ else
183
+ value(result, :status) || "NOT_PROVEN"
184
+ end
185
+ lines << " Condition #{value(condition, :index)}: #{value(condition, :expression)}"
186
+ lines << " #{status}#{detail}"
187
+ render_condition_coverage(lines, decision, condition, result) if @level >= 2 && coverage_available?
188
+ end
174
189
  end
175
190
  render_decision_coverage(lines, decision) if coverage_available?
191
+ render_decision_table(lines, decision) if analysis_available? && !nonboolean_decision?(decision)
176
192
  vectors_to_render(decision).each do |vector|
177
193
  values = Array(value(vector, :values)).map do |item|
178
194
  if item.nil?
@@ -183,8 +199,23 @@ module Branchproof
183
199
  end.join
184
200
  owners = Array(value(vector, :test_ids)).map { |test_id| test_label(test_id) }
185
201
  owners << "unattributed" if value(vector, :unattributed_count).to_i.positive?
186
- vector_label = " Vector #{short_id(value(vector, :id))} [#{values}] => " \
187
- "#{value(vector, :outcome) ? "T" : "F"}"
202
+ vector_label = if nonboolean_decision?(decision)
203
+ selected = Array(value(vector, :values)).each_with_index.filter_map do |item, index|
204
+ next unless item
205
+
206
+ alternative = Array(value(decision, :alternatives))[index]
207
+ value(alternative, :expression) || "alternative #{index}"
208
+ end
209
+ " Vector #{short_id(value(vector, :id))} [#{values}] => selected: #{selected.join(", ")}"
210
+ else
211
+ " Vector #{short_id(value(vector,
212
+ :id))} [#{values}] => " + (if value(vector,
213
+ :outcome)
214
+ "T"
215
+ else
216
+ "F"
217
+ end).to_s
218
+ end
188
219
  lines << "#{vector_label} owners=#{owners.join(", ")}"
189
220
  end
190
221
  lines << ""
@@ -197,10 +228,15 @@ module Branchproof
197
228
  end
198
229
 
199
230
  def render_decision_coverage(lines, decision)
231
+ if nonboolean_decision?(decision)
232
+ render_alternative_coverage(lines, decision)
233
+ return
234
+ end
235
+
200
236
  decision_coverage = value(value(analysis_for(decision), :coverage), :decision)
201
237
  all_coverage = value(analysis_for(decision), :coverage) || {}
202
238
  statuses = [["D", :decision], ["C", :condition], ["C/D", :condition_decision],
203
- ["MC/DC", :mcdc]].filter_map do |label, key|
239
+ ["MC/DC", :mcdc], ["DT", :decision_table]].filter_map do |label, key|
204
240
  row = value(all_coverage, key)
205
241
  status = value(row, :status)
206
242
  status ? criterion_status_text(label, row) : nil
@@ -212,11 +248,11 @@ module Branchproof
212
248
  return unless @level >= 2
213
249
 
214
250
  Array(value(coverage, :outcomes)).each do |outcome|
215
- value_label = value(outcome, :value) ? "true" : "false"
251
+ value_label = decision_outcome_label(decision, value(outcome, :value))
216
252
  owners = Array(value(outcome, :test_ids)).map { |id| test_label(id) }
217
253
  owners << "unattributed" if value(outcome, :unattributed_count).to_i.positive?
218
254
  evidence = owners.empty? ? "none recorded" : owners.uniq.join(", ")
219
- lines << " Outcome #{value_label}: #{value(outcome, :observed) ? "observed" : "missing"}; tests: #{evidence}"
255
+ lines << " #{value_label}: #{value(outcome, :observed) ? "observed" : "missing"}; tests: #{evidence}"
220
256
  end
221
257
  missing = Array(value(coverage, :missing_outcomes))
222
258
  return if missing.empty?
@@ -226,6 +262,209 @@ module Branchproof
226
262
  end.join(", ")}"
227
263
  end
228
264
 
265
+ # Renders the reduced decision table, its runtime overlay, and the
266
+ # actionable detail for rules that no observation reached.
267
+ def render_decision_table(lines, decision)
268
+ table = decision_table_for(decision)
269
+ return unless table
270
+
271
+ unless value(table, :status).to_s == "calculated"
272
+ return if value(table, :reason).to_s == "unsupported_decision" && unsupported?(decision)
273
+
274
+ lines << " Decision Table: NOT CALCULATED"
275
+ lines << " Reason: #{value(table, :reason)}"
276
+ return
277
+ end
278
+
279
+ covered = value(table, :covered_rules).to_i
280
+ required = value(table, :required_rules).to_i
281
+ percentage = value(table, :percentage)
282
+ lines << " Decision Table: #{covered}/#{required} rules covered" \
283
+ "#{" (#{percentage}%)" unless percentage.nil?}"
284
+ impossible = value(table, :impossible_rules).to_i
285
+ lines << " Statically impossible rules excluded: #{impossible}" if impossible.positive?
286
+ lines << " Reachability: not analyzed" unless value(table, :reachability_analyzed)
287
+ rules = decision_table_rules_to_render(table)
288
+ lines.concat(decision_table_rows(decision, rules))
289
+ return unless @level >= 2
290
+
291
+ rules.each { |rule| render_decision_table_rule(lines, decision, rule) }
292
+ end
293
+
294
+ def decision_table_rows(decision, rules)
295
+ return [] if rules.empty?
296
+
297
+ conditions = Array(value(decision, :conditions))
298
+ header = ["Rule"] + conditions.map { |condition| truncate(value(condition, :expression).to_s, 24) } +
299
+ %w[Result Status]
300
+ body = rules.map do |rule|
301
+ [value(rule, :label).to_s] +
302
+ Array(value(rule, :conditions)).map { |item| DECISION_TABLE_LABELS.fetch(item.to_s, item.to_s) } +
303
+ [value(rule, :outcome) ? "T" : "F", decision_table_rule_status(rule)]
304
+ end
305
+ return [] unless body.all? { |row| row.length == header.length }
306
+
307
+ widths = ([header] + body).transpose.map { |column| column.map(&:length).max }
308
+ ([header] + body).map do |row|
309
+ " #{row.each_with_index.map { |cell, index| cell.ljust(widths[index]) }.join(" ").rstrip}"
310
+ end
311
+ end
312
+
313
+ def decision_table_rule_status(rule)
314
+ DECISION_TABLE_STATUS_LABELS.fetch(value(rule, :coverage).to_s, value(rule, :coverage).to_s.upcase)
315
+ end
316
+
317
+ def render_decision_table_rule(lines, decision, rule)
318
+ case value(rule, :coverage).to_s
319
+ when "covered" then render_covered_rule(lines, rule)
320
+ when "excluded" then lines.concat(impossible_rule_lines(rule))
321
+ else lines.concat(missing_rule_lines(decision, rule))
322
+ end
323
+ end
324
+
325
+ def render_covered_rule(lines, rule)
326
+ owners = Array(value(rule, :tests)).map { |id| test_label(id) }
327
+ owners << "unattributed" if value(rule, :unattributed_count).to_i.positive?
328
+ lines << " #{value(rule, :label)} tests: #{owners.empty? ? "none recorded" : owners.uniq.join(", ")}"
329
+ return unless value(rule, :impossible_withdrawn)
330
+
331
+ lines << " #{value(rule, :label)} note: runtime evidence overrides the static impossibility claim " \
332
+ "(#{value(rule, :withdrawn_reason)}); diagnostic constraint_model_conflict"
333
+ end
334
+
335
+ # Impossible rules stay visible even though they leave the denominator.
336
+ def impossible_rule_lines(rule)
337
+ [" #{value(rule, :label)} #{rule_signature(rule)}",
338
+ " Status:", " EXCLUDED",
339
+ " Reachability:", " STATICALLY IMPOSSIBLE",
340
+ " Reason:", " #{Constraints.message(value(rule, :reachability_reason))}"]
341
+ end
342
+
343
+ # Condition values only: Branchproof never claims which application inputs
344
+ # would produce them.
345
+ def missing_rule_lines(decision, rule)
346
+ requirements = decision_table_requirements(decision, rule)
347
+ width = requirements.map { |name, _| name.length }.max.to_i
348
+ lines = [" #{value(rule, :label)}", " Need:"]
349
+ requirements.each { |name, requirement| lines << " #{name.ljust(width)} #{requirement}" }
350
+ lines << " #{decision_table_expected_heading(decision)}"
351
+ lines << " #{value(rule, :outcome) ? "true" : "false"}"
352
+ lines << " Reachability:"
353
+ lines << " #{decision_table_reachability(rule)}"
354
+ lines
355
+ end
356
+
357
+ def decision_table_requirements(decision, rule)
358
+ Array(value(rule, :conditions)).each_with_index.map do |required, index|
359
+ expression = condition_expression(decision, index) || "condition #{index}"
360
+ requirement = decision_table_requirement(required) || "not evaluated (short-circuited)"
361
+ [expression.to_s, "= #{requirement}"]
362
+ end
363
+ end
364
+
365
+ def decision_table_requirement(value)
366
+ case value.to_s
367
+ when "true" then "truthy"
368
+ when "false" then "falsey"
369
+ end
370
+ end
371
+
372
+ def decision_table_reachability(rule)
373
+ return "STATICALLY IMPOSSIBLE" if value(rule, :reachability).to_s == "statically_impossible"
374
+
375
+ value(rule, :reachability).to_s
376
+ end
377
+
378
+ def decision_outcome_label(decision, outcome)
379
+ prefix = %w[unless until].include?(value(decision, :context).to_s) ? "Predicate" : "Outcome"
380
+ "#{prefix} #{outcome ? "true" : "false"}"
381
+ end
382
+
383
+ def decision_table_expected_heading(decision)
384
+ %w[unless until].include?(value(decision, :context).to_s) ? "Expected predicate:" : "Expected decision:"
385
+ end
386
+
387
+ def rule_signature(rule)
388
+ values = Array(value(rule, :conditions)).map { |item| DECISION_TABLE_LABELS.fetch(item.to_s, item.to_s) }
389
+ "#{values.join} => #{value(rule, :outcome) ? "T" : "F"}"
390
+ end
391
+
392
+ def decision_table_rules_to_render(table)
393
+ rules = Array(value(table, :rules))
394
+ return rules unless @missing_only
395
+
396
+ rules.select { |rule| value(rule, :coverage).to_s == "missing" }
397
+ end
398
+
399
+ def decision_table_for(decision)
400
+ value(analysis_for(decision), :decision_table)
401
+ end
402
+
403
+ def missing_decision_table_rules(decision)
404
+ table = decision_table_for(decision)
405
+ return [] unless table && value(table, :status).to_s == "calculated"
406
+
407
+ Array(value(table, :rules)).select { |rule| value(rule, :coverage).to_s == "missing" }
408
+ end
409
+
410
+ def decision_table_unavailable?(decision)
411
+ table = decision_table_for(decision)
412
+ table && value(table, :status).to_s != "calculated"
413
+ end
414
+
415
+ def unavailable_decision_table_count
416
+ inventory_decisions.count { |decision| decision_table_unavailable?(decision) }
417
+ end
418
+
419
+ def truncate(text, width)
420
+ text.length <= width ? text : "#{text[0, width - 3]}..."
421
+ end
422
+
423
+ def render_alternative_coverage(lines, decision)
424
+ coverage = value(value(analysis_for(decision), :coverage), :alternative) || {}
425
+ status = value(coverage, :status)
426
+ required = value(coverage, :required_alternatives) || Array(value(decision, :alternatives)).length
427
+ covered = value(coverage, :covered_alternatives) || 0
428
+ lines << " Alternatives: #{status || "NOT_CALCULATED"} (#{covered}/#{required})"
429
+ lines << " MC/DC: N/A (not applicable)"
430
+ rows = Array(value(coverage, :alternatives))
431
+ rows_by_id = rows.to_h { |row| [value(row, :alternative_id).to_s, row] }
432
+ alternatives_to_render(decision).each do |alternative|
433
+ id = value(alternative, :id)
434
+ row = rows_by_id[id.to_s] || {}
435
+ lines << " Alternative #{value(alternative, :index)}: #{value(alternative, :expression)}"
436
+ render_alternative_state(lines, "Selected", value(row, :selected)) if @level >= 2
437
+ render_alternative_state(lines, "Not selected", value(row, :not_selected)) if @level >= 2
438
+ render_alternative_state(lines, "Skipped", value(row, :skipped)) if @level >= 2
439
+ end
440
+ missing = Array(value(coverage, :missing_alternatives))
441
+ return if missing.empty?
442
+
443
+ labels = missing.map do |id|
444
+ alternative = Array(value(decision, :alternatives)).find { |item| value(item, :id).to_s == id.to_s }
445
+ value(alternative, :expression) || short_id(id)
446
+ end
447
+ label = labels.join(", ")
448
+ lines << " Missing alternatives: #{label}"
449
+ lines << " Need selection of: #{label}"
450
+ end
451
+
452
+ def render_alternative_state(lines, label, evidence)
453
+ evidence ||= {}
454
+ owners = Array(value(evidence, :test_ids)).map { |id| test_label(id) }
455
+ owners << "unattributed" if value(evidence, :unattributed_count).to_i.positive?
456
+ owners = owners.uniq
457
+ status = if value(evidence, :observed)
458
+ "observed"
459
+ elsif label == "Selected"
460
+ "missing"
461
+ else
462
+ "none recorded"
463
+ end
464
+ detail = owners.empty? ? "none recorded" : owners.join(", ")
465
+ lines << " #{label}: #{status}; tests: #{detail}"
466
+ end
467
+
229
468
  def render_condition_coverage(lines, _decision, condition, result)
230
469
  coverage = value(result, :coverage)
231
470
  return unless coverage
@@ -417,8 +656,15 @@ module Branchproof
417
656
  end
418
657
 
419
658
  def condition_result(decision, condition)
420
- Array(value(analysis_for(decision), :condition_results)).find do |item|
421
- value(item, :condition_id).to_s == value(condition, :id).to_s
659
+ condition_results_index.dig(value(decision, :id).to_s, value(condition, :id).to_s)
660
+ end
661
+
662
+ def condition_results_index
663
+ @condition_results_index ||= Array(value(@analysis, :decisions)).each_with_object({}) do |item, index|
664
+ by_condition_id = index[value(item, :decision_id).to_s] ||= {}
665
+ Array(value(item, :condition_results)).each do |result|
666
+ by_condition_id[value(result, :condition_id).to_s] = result
667
+ end
422
668
  end
423
669
  end
424
670
 
@@ -517,34 +763,92 @@ module Branchproof
517
763
  id = value(constraint, :existing_vector_id).to_s
518
764
  return if id.empty?
519
765
 
520
- vectors.find { |vector| value(vector, :id).to_s == id }
766
+ vectors_by_id[id]
767
+ end
768
+
769
+ def vectors_by_id
770
+ @vectors_by_id ||= vectors.to_h { |vector| [value(vector, :id).to_s, vector] }
771
+ end
772
+
773
+ def values_legend
774
+ return "Values: T=true, F=false, -=short-circuited" unless inventory_decisions.any? do |decision|
775
+ nonboolean_decision?(decision)
776
+ end
777
+
778
+ "Values: Boolean T=true/F=false; flow T=selected, F=not-selected, -=skipped"
779
+ end
780
+
781
+ def inventory_with_default_kinds
782
+ decisions = inventory_decisions.map do |decision|
783
+ next decision if value(decision, :kind)
784
+
785
+ decision.respond_to?(:key?) ? decision.merge(kind: "boolean") : decision
786
+ end
787
+ return @inventory unless value(@inventory, :decisions)
788
+
789
+ @inventory.merge(decisions: decisions)
790
+ end
791
+
792
+ def decision_kind(decision)
793
+ kind = value(decision, :kind).to_s
794
+ kind.empty? ? "boolean" : kind
795
+ end
796
+
797
+ def nonboolean_decision?(decision)
798
+ %w[implicit multiway pattern exception].include?(decision_kind(decision))
799
+ end
800
+
801
+ def decision_kind_summary_line
802
+ counts = metrics[:kind_counts]
803
+ return "Decision kinds: none" if counts.empty?
804
+
805
+ contexts = metrics[:context_counts]
806
+ suffix = if contexts.empty?
807
+ ""
808
+ else
809
+ "; contexts: #{contexts.map do |context, count|
810
+ "#{context}=#{count}"
811
+ end.join(", ")}"
812
+ end
813
+ "Decision kinds: #{counts.map { |kind, count| "#{kind}=#{count}" }.join(", ")}#{suffix}"
521
814
  end
522
815
 
523
816
  def metrics
524
- decisions = inventory_decisions
525
- unsupported, supported = decisions.partition { |decision| unsupported?(decision) }
526
- eligible = supported.sum { |decision| Array(value(decision, :conditions)).length }
527
- observed = vectors.map { |vector| value(vector, :decision_id).to_s }.uniq
528
- proven = @analysis ? value(@analysis, :proven_count).to_i : 0
529
- { discovered: decisions.length, supported: supported.length, unsupported: unsupported.length,
530
- unsupported_conditions: unsupported.sum do |decision|
531
- discovered_conditions(decision)
532
- end, eligible_conditions: eligible,
533
- opaque: decisions.sum { |decision| Array(value(decision, :opaque_ranges)).length },
534
- unexecuted: supported.count { |decision| !observed.include?(value(decision, :id).to_s) },
535
- completed: vectors.sum do |vector|
536
- value(vector, :count).to_i
537
- end, aborted: numeric_hash_value(@evidence, :abort_counts),
538
- unattributed: vectors.sum { |vector| value(vector, :unattributed_count).to_i }, limited: incomplete? ? 1 : 0,
539
- proven: proven, percentage: percentage(eligible, proven) }
817
+ @metrics ||= begin
818
+ decisions = inventory_decisions
819
+ unsupported, supported = decisions.partition { |decision| unsupported?(decision) }
820
+ eligible = supported.sum { |decision| Array(value(decision, :conditions)).length }
821
+ observed = vectors.map { |vector| value(vector, :decision_id).to_s }.uniq
822
+ proven = @analysis ? value(@analysis, :proven_count).to_i : 0
823
+ kind_counts = decisions.group_by { |decision| decision_kind(decision) }.transform_values(&:length)
824
+ context_counts = decisions.group_by { |decision| value(decision, :context).to_s }
825
+ context_counts = context_counts.reject { |context, _| context.empty? }.transform_values(&:length)
826
+ { discovered: decisions.length, supported: supported.length, unsupported: unsupported.length,
827
+ unsupported_conditions: unsupported.sum do |decision|
828
+ discovered_conditions(decision)
829
+ end, eligible_conditions: eligible,
830
+ opaque: decisions.sum { |decision| Array(value(decision, :opaque_ranges)).length },
831
+ unexecuted: supported.count { |decision| !observed.include?(value(decision, :id).to_s) },
832
+ eligible_alternatives: supported.sum do |decision|
833
+ nonboolean_decision?(decision) ? Array(value(decision, :alternatives)).length : 0
834
+ end,
835
+ completed: vectors.sum do |vector|
836
+ value(vector, :count).to_i
837
+ end, aborted: numeric_hash_value(@evidence, :abort_counts),
838
+ unattributed: vectors.sum { |vector| value(vector, :unattributed_count).to_i }, limited: incomplete? ? 1 : 0,
839
+ proven: proven, percentage: percentage(eligible, proven), kind_counts: kind_counts,
840
+ decision_kinds: kind_counts, context_counts: context_counts }
841
+ end
540
842
  end
541
843
 
542
844
  def completeness
543
- evidence = value(@evidence, :completeness) || {}
544
- analysis = value(@analysis, :completeness) || {}
545
- { observation: completeness_value?(evidence, analysis, :observation),
546
- attribution: completeness_value?(evidence, analysis, :attribution),
547
- analysis: @analysis ? value(analysis, :analysis) == true : value(evidence, :analysis) == true }
845
+ @completeness ||= begin
846
+ evidence = value(@evidence, :completeness) || {}
847
+ analysis = value(@analysis, :completeness) || {}
848
+ { observation: completeness_value?(evidence, analysis, :observation),
849
+ attribution: completeness_value?(evidence, analysis, :attribution),
850
+ analysis: @analysis ? value(analysis, :analysis) == true : value(evidence, :analysis) == true }
851
+ end
548
852
  end
549
853
 
550
854
  def completeness_value?(evidence, analysis, key)
@@ -583,9 +887,11 @@ module Branchproof
583
887
  end
584
888
 
585
889
  def vectors_for(decision)
586
- vectors.select do |vector|
587
- value(vector, :decision_id).to_s == value(decision, :id).to_s
588
- end
890
+ vectors_by_decision_id[value(decision, :id).to_s] || []
891
+ end
892
+
893
+ def vectors_by_decision_id
894
+ @vectors_by_decision_id ||= vectors.group_by { |vector| value(vector, :decision_id).to_s }
589
895
  end
590
896
 
591
897
  def decisions_to_render
@@ -594,14 +900,36 @@ module Branchproof
594
900
 
595
901
  inventory_decisions
596
902
  .reject { |decision| unsupported?(decision) }
597
- .select { |decision| conditions_to_render(decision).any? }
903
+ .select do |decision|
904
+ if nonboolean_decision?(decision)
905
+ missing_alternatives_for(decision).any?
906
+ else
907
+ conditions_to_render(decision).any? || missing_decision_table_rules(decision).any? ||
908
+ decision_table_unavailable?(decision)
909
+ end
910
+ end
911
+ end
912
+
913
+ def alternatives_to_render(decision)
914
+ alternatives = Array(value(decision, :alternatives))
915
+ return alternatives unless @missing_only && analysis_available?
916
+
917
+ missing_ids = missing_alternatives_for(decision).map(&:to_s)
918
+ alternatives.select { |alternative| missing_ids.include?(value(alternative, :id).to_s) }
919
+ end
920
+
921
+ def missing_alternatives_for(decision)
922
+ coverage = value(value(analysis_for(decision), :coverage), :alternative)
923
+ Array(value(coverage, :missing_alternatives))
598
924
  end
599
925
 
600
926
  def conditions_to_render(decision)
601
927
  conditions = Array(value(decision, :conditions))
602
928
  return conditions unless @missing_only && analysis_available?
603
929
 
604
- conditions.reject { |condition| value(condition_result(decision, condition), :status).to_s.upcase == "PROVEN" }
930
+ conditions.reject do |condition|
931
+ value(condition_result(decision, condition), :status).to_s.upcase == "PROVEN"
932
+ end
605
933
  end
606
934
 
607
935
  def vectors_to_render(decision)
@@ -650,6 +978,32 @@ module Branchproof
650
978
  end
651
979
  lines << " #{label} (#{description}): #{shown} (#{numerator}/#{denominator} #{denominator_label})#{qualifier}"
652
980
  end
981
+ table = value(aggregate, :decision_table)
982
+ if table
983
+ covered = value(table, :covered_rules) || 0
984
+ required = value(table, :required_rules) || 0
985
+ percentage = value(table, :percentage)
986
+ shown = percentage.nil? ? "N/A" : "#{percentage}%"
987
+ qualifier = incomplete? ? " (lower bound)" : ""
988
+ lines << " DT (Decision table coverage): #{shown} (#{covered}/#{required} rules)#{qualifier}"
989
+ fully_covered = value(table, :fully_covered_decisions) || 0
990
+ analyzed = value(table, :decisions_analyzed) || 0
991
+ decision_percentage = value(table, :decision_percentage)
992
+ lines << " Decision tables fully covered: #{fully_covered}/#{analyzed} decisions" \
993
+ "#{" (#{decision_percentage}%)" unless decision_percentage.nil?}"
994
+ impossible = value(table, :impossible_rules).to_i
995
+ lines << " Statically impossible rules excluded: #{impossible}" if impossible.positive?
996
+ not_calculated = value(table, :not_calculated_decisions).to_i
997
+ lines << " Decision tables not calculated: #{not_calculated} decisions" if not_calculated.positive?
998
+ end
999
+ alternative = value(aggregate, :alternative)
1000
+ if alternative
1001
+ covered = value(alternative, :covered_alternatives) || 0
1002
+ required = value(alternative, :required_alternatives) || 0
1003
+ percentage = value(alternative, :percentage)
1004
+ shown = percentage.nil? ? "N/A" : "#{percentage}%"
1005
+ lines << " Alternative coverage: #{covered}/#{required} alternatives (#{shown})"
1006
+ end
653
1007
  lines << ""
654
1008
  lines
655
1009
  end
@@ -667,6 +1021,10 @@ module Branchproof
667
1021
  "#{value(row, :covered_conditions) || 0}/#{value(row, :condition_count) || 0} conditions fully covered)"
668
1022
  when "MC/DC"
669
1023
  "#{label}=#{status} (#{value(row, :proven_conditions) || 0}/#{value(row, :condition_count) || 0} conditions)"
1024
+ when "DT"
1025
+ return "#{label}=NOT CALCULATED (#{value(row, :reason)})" if raw_status == "not_calculated"
1026
+
1027
+ "#{label}=#{status} (#{value(row, :covered_rules) || 0}/#{value(row, :required_rules) || 0} rules)"
670
1028
  else
671
1029
  "#{label}=#{status}"
672
1030
  end
@@ -683,27 +1041,70 @@ module Branchproof
683
1041
  def missing_summary_line
684
1042
  return "Missing conditions: Cannot identify missing conditions (analysis unavailable)" unless analysis_available?
685
1043
  return "Missing conditions: Cannot identify missing conditions (analysis incomplete)" unless analysis_complete?
686
- return "No eligible conditions" if metrics[:eligible_conditions].zero?
687
- return "No missing conditions" if analysis_complete? && missing_condition_count.zero?
1044
+
1045
+ unavailable = unavailable_decision_table_count
1046
+ unavailable_suffix = if unavailable.positive?
1047
+ "; decision-table analysis unavailable for #{unavailable} " \
1048
+ "#{unavailable == 1 ? "decision" : "decisions"}"
1049
+ else
1050
+ ""
1051
+ end
1052
+ if missing_condition_count.zero? && missing_alternatives_count.zero? && missing_rule_count.zero?
1053
+ return "No missing conditions, alternatives, or decision-table rules#{unavailable_suffix}"
1054
+ end
688
1055
 
689
1056
  count = decisions_to_render.length
690
- "Missing conditions: #{missing_condition_count} across #{count} #{count == 1 ? "decision" : "decisions"}"
1057
+ label = count == 1 ? "decision" : "decisions"
1058
+ suffix = missing_rule_count.positive? ? "; decision-table rules: #{missing_rule_count}" : ""
1059
+ summary = if missing_condition_count.positive? && missing_alternatives_count.positive?
1060
+ "Missing conditions: #{missing_condition_count}; alternatives: #{missing_alternatives_count} " \
1061
+ "across #{count} #{label}#{suffix}"
1062
+ elsif missing_condition_count.positive?
1063
+ "Missing conditions: #{missing_condition_count} across #{count} #{label}#{suffix}"
1064
+ elsif missing_alternatives_count.positive?
1065
+ "Missing alternatives: #{missing_alternatives_count} across #{count} #{label}#{suffix}"
1066
+ else
1067
+ "Missing decision-table rules: #{missing_rule_count} across #{count} #{label}"
1068
+ end
1069
+ "#{summary}#{unavailable_suffix}"
1070
+ end
1071
+
1072
+ def missing_rule_count
1073
+ decisions_to_render.sum { |decision| missing_decision_table_rules(decision).length }
1074
+ end
1075
+
1076
+ def missing_alternatives_count
1077
+ decisions_to_render.sum { |decision| missing_alternatives_for(decision).length }
691
1078
  end
692
1079
 
693
1080
  def analysis_for(decision)
694
- Array(value(@analysis, :decisions)).find do |item|
695
- value(item, :decision_id).to_s == value(decision, :id).to_s
1081
+ analysis_by_decision_id[value(decision, :id).to_s]
1082
+ end
1083
+
1084
+ def analysis_by_decision_id
1085
+ @analysis_by_decision_id ||= Array(value(@analysis, :decisions)).to_h do |item|
1086
+ [value(item, :decision_id).to_s, item]
696
1087
  end
697
1088
  end
698
1089
 
699
1090
  def source_for(decision)
700
- Array(value(@inventory, :source_units)).find do |source|
701
- value(source, :source_id).to_s == value(decision, :source_id).to_s
702
- end || decision
1091
+ source_by_source_id[value(decision, :source_id).to_s] || decision
1092
+ end
1093
+
1094
+ def source_by_source_id
1095
+ @source_by_source_id ||= Array(value(@inventory, :source_units)).to_h do |source|
1096
+ [value(source, :source_id).to_s, source]
1097
+ end
1098
+ end
1099
+
1100
+ def inventory_decisions
1101
+ @inventory_decisions ||= Array(value(@inventory, :decisions)).freeze
1102
+ end
1103
+
1104
+ def vectors
1105
+ @vectors ||= Array(value(@evidence, :vectors)).freeze
703
1106
  end
704
1107
 
705
- def inventory_decisions = Array(value(@inventory, :decisions))
706
- def vectors = Array(value(@evidence, :vectors))
707
1108
  def unsupported?(decision) = value(decision, :support_status).to_s.upcase == "UNSUPPORTED"
708
1109
 
709
1110
  def discovered_conditions(decision)
@@ -749,6 +1150,9 @@ module Branchproof
749
1150
  def normalize_unknown(object)
750
1151
  object.to_s
751
1152
  end
752
- public :condition_coverage_evidence, :coverage_ladder_lines, :coverage_status_label
1153
+ public :condition_coverage_evidence, :coverage_ladder_lines, :coverage_status_label,
1154
+ :decision_table_requirement, :decision_table_reachability, :decision_table_expected_heading
753
1155
  end
754
1156
  end
1157
+
1158
+ # rubocop:enable Metrics/BlockLength