branchproof 0.10.0 → 0.11.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.
@@ -0,0 +1,169 @@
1
+ # frozen_string_literal: true
2
+
3
+ # rubocop:disable Metrics/ClassLength, Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity, Metrics/ParameterLists
4
+
5
+ module Branchproof
6
+ # Evaluates configured coverage thresholds against the aggregate Analyzer
7
+ # coverage counts without relying on rounded percentage fields.
8
+ class CoveragePolicy
9
+ CRITERIA = {
10
+ "decision" => { numerator: "covered_decisions", denominator: "supported_decisions" },
11
+ "condition" => { numerator: "covered_values", denominator: "required_values" },
12
+ "condition_decision" => { numerator: "covered_decisions", denominator: "supported_decisions" },
13
+ "mcdc" => { numerator: "proven_conditions", denominator: "supported_conditions" },
14
+ "decision_table" => { numerator: "covered_rules", denominator: "required_rules" }
15
+ }.freeze
16
+ COMPLETENESS_FIELDS = %w[observation attribution analysis].freeze
17
+
18
+ class << self
19
+ def normalize(minimum)
20
+ raise ArgumentError, "minimum must be a hash" unless minimum.is_a?(Hash)
21
+
22
+ minimum.each_with_object({}) do |(key, threshold), normalized|
23
+ unless key.is_a?(String) || key.is_a?(Symbol)
24
+ raise ArgumentError, "unknown coverage criterion: #{key.inspect}"
25
+ end
26
+
27
+ criterion = key.to_s
28
+ raise ArgumentError, "unknown coverage criterion: #{criterion}" unless CRITERIA.key?(criterion)
29
+ raise ArgumentError, "duplicate coverage criterion: #{criterion}" if normalized.key?(criterion)
30
+
31
+ validate_threshold!(criterion, threshold)
32
+ normalized[criterion] = threshold
33
+ end
34
+ end
35
+
36
+ private
37
+
38
+ def validate_threshold!(criterion, threshold)
39
+ valid_type = threshold.is_a?(Integer) || threshold.is_a?(Float)
40
+ valid_float = !threshold.is_a?(Float) || threshold.finite?
41
+ unless valid_type && valid_float
42
+ raise ArgumentError, "coverage minimum for #{criterion} must be a finite number"
43
+ end
44
+ return if (0..100).cover?(threshold)
45
+
46
+ raise ArgumentError, "coverage minimum for #{criterion} must be between 0 and 100"
47
+ end
48
+ end
49
+
50
+ def initialize(minimum:)
51
+ @minimum = self.class.normalize(minimum)
52
+ end
53
+
54
+ def call(document:)
55
+ return { minimum: @minimum.dup, status: "passed", gates: [] } if @minimum.empty?
56
+
57
+ global_reason = availability_reason(document)
58
+ gates = @minimum.map do |criterion, threshold|
59
+ evaluate_gate(criterion, threshold, document, global_reason)
60
+ end
61
+
62
+ status = if gates.any? { |gate| gate[:status] == "unavailable" }
63
+ "unavailable"
64
+ elsif gates.any? { |gate| gate[:status] == "failed" }
65
+ "failed"
66
+ else
67
+ "passed"
68
+ end
69
+
70
+ { minimum: @minimum.dup, status: status, gates: gates }
71
+ end
72
+
73
+ private
74
+
75
+ def availability_reason(document)
76
+ return "document_unavailable" unless document.is_a?(Hash)
77
+
78
+ baseline = value(document, :baseline)
79
+ baseline_passed = baseline.is_a?(Hash) && value(baseline, :status).to_s.upcase == "PASSED"
80
+ return "baseline_unavailable" unless baseline_passed && value(baseline, :finalized) == true
81
+ return "incomplete_document" unless complete?(value(document, :completeness))
82
+
83
+ observations = value(document, :observations) || value(document, :evidence)
84
+ return "incomplete_observations" if incomplete_section?(observations)
85
+
86
+ analysis = value(document, :analysis)
87
+ return "incomplete_analysis" if incomplete_section?(analysis)
88
+
89
+ nil
90
+ end
91
+
92
+ def complete?(completeness)
93
+ return false unless completeness.is_a?(Hash)
94
+
95
+ COMPLETENESS_FIELDS.all? { |field| value(completeness, field) == true }
96
+ end
97
+
98
+ def incomplete_section?(section)
99
+ section.is_a?(Hash) && key_present?(section, :completeness) && !complete?(value(section, :completeness))
100
+ end
101
+
102
+ def evaluate_gate(criterion, threshold, document, global_reason)
103
+ return gate(criterion, threshold, nil, nil, "unavailable", global_reason) if global_reason
104
+
105
+ coverage = value(value(document, :analysis), :coverage)
106
+ row = value(coverage, criterion)
107
+ return gate(criterion, threshold, nil, nil, "unavailable", "missing_coverage_count") unless row.is_a?(Hash)
108
+
109
+ mapping = CRITERIA.fetch(criterion)
110
+ numerator = value(row, mapping.fetch(:numerator))
111
+ denominator = value(row, mapping.fetch(:denominator))
112
+
113
+ if criterion == "decision_table"
114
+ not_calculated = value(row, :not_calculated_decisions)
115
+ unless not_calculated.nil?
116
+ unless valid_count?(not_calculated)
117
+ return gate(criterion, threshold, numerator, denominator, "unavailable", "invalid_coverage_count")
118
+ end
119
+ if not_calculated.positive?
120
+ return gate(criterion, threshold, numerator, denominator, "unavailable", "decision_table_not_calculated")
121
+ end
122
+ end
123
+ end
124
+
125
+ if numerator.nil? || denominator.nil?
126
+ return unavailable_gate(criterion, threshold, numerator, denominator, "missing_coverage_count")
127
+ end
128
+ unless valid_count?(numerator) && valid_count?(denominator)
129
+ return unavailable_gate(criterion, threshold, numerator, denominator, "invalid_coverage_count")
130
+ end
131
+ return unavailable_gate(criterion, threshold, numerator, denominator, "zero_denominator") if denominator.zero?
132
+ if numerator > denominator
133
+ return unavailable_gate(criterion, threshold, numerator, denominator, "invalid_coverage_count")
134
+ end
135
+
136
+ status = numerator * 100 >= denominator * Rational(threshold.to_s) ? "passed" : "failed"
137
+ gate(criterion, threshold, numerator, denominator, status, nil)
138
+ end
139
+
140
+ def valid_count?(value)
141
+ value.is_a?(Integer) && value >= 0
142
+ end
143
+
144
+ def gate(criterion, threshold, numerator, denominator, status, reason)
145
+ { criterion: criterion, numerator: numerator, denominator: denominator, minimum: threshold,
146
+ status: status, reason: reason }
147
+ end
148
+
149
+ def unavailable_gate(criterion, threshold, numerator, denominator, reason)
150
+ gate(criterion, threshold, numerator, denominator, "unavailable", reason)
151
+ end
152
+
153
+ def value(hash, key)
154
+ return nil unless hash.respond_to?(:key?)
155
+ return hash[key] if hash.key?(key)
156
+
157
+ alternate = key.is_a?(Symbol) ? key.to_s : key.to_sym
158
+ return hash[alternate] if hash.key?(alternate)
159
+
160
+ nil
161
+ end
162
+
163
+ def key_present?(hash, key)
164
+ hash.key?(key) || hash.key?(key.to_s) || hash.key?(key.to_sym)
165
+ end
166
+ end
167
+ end
168
+
169
+ # rubocop:enable Metrics/ClassLength, Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity, Metrics/ParameterLists
@@ -50,7 +50,9 @@ module Branchproof
50
50
  current = @tests[id] || { id: id, adapter: "unknown", name: id, source: nil, class_name: nil,
51
51
  method_name: nil, status: "unknown", phase_counts: {} }
52
52
  merged = current.merge(value).merge(id: id)
53
- merged[:phase_counts] = (current[:phase_counts] || {}).merge(value[:phase_counts] || {})
53
+ merged[:phase_counts] = normalize_phase_counts(current[:phase_counts]).merge(
54
+ normalize_phase_counts(value[:phase_counts])
55
+ )
54
56
  @tests[id] = deep_dup(merged)
55
57
  status("registered", nil)
56
58
  end
@@ -172,7 +174,7 @@ module Branchproof
172
174
  @run_payloads[id] = Branchproof::Records.id(payload)
173
175
  end
174
176
  @run_ids |= incoming_runs
175
- incoming.fetch(:tests, []).each { register_test(test: _1) }
177
+ incoming.fetch(:tests, []).each { merge_test(_1) }
176
178
  incoming.fetch(:vectors, []).each { merge_vector(_1) }
177
179
  @diagnostics.concat(incoming.fetch(:diagnostics, []))
178
180
  @abort_counts.merge!(incoming.fetch(:abort_counts, {})) { |_k, a, b| a.to_i + b.to_i }
@@ -337,6 +339,39 @@ module Branchproof
337
339
  end
338
340
  end
339
341
 
342
+ def merge_test(raw)
343
+ value = symbolize(raw)
344
+ value[:phase_counts] = normalize_phase_counts(value[:phase_counts])
345
+ id = (value[:id] || test_id(value)).to_s
346
+ unless @tests.key?(id)
347
+ register_test(test: value)
348
+ return unless @tests.key?(id)
349
+
350
+ return
351
+ end
352
+
353
+ current = @tests.fetch(id)
354
+ merged = current.merge(value).merge(id: id)
355
+ merged[:phase_counts] = sum_phase_counts(current[:phase_counts], value[:phase_counts])
356
+ @tests[id] = deep_dup(merged)
357
+ end
358
+
359
+ def normalize_phase_counts(value)
360
+ (value || {}).each_with_object({}) do |(phase, count), result|
361
+ key = phase.to_s
362
+ result[key] = result.fetch(key, 0) + count
363
+ end
364
+ end
365
+
366
+ def sum_phase_counts(*counts)
367
+ counts.each_with_object({}) do |phase_counts, result|
368
+ normalize_phase_counts(phase_counts).each do |phase, count|
369
+ result[phase] = result.fetch(phase, 0) + count
370
+ end
371
+ result
372
+ end
373
+ end
374
+
340
375
  def merge_error(incoming)
341
376
  return "unsupported schema" unless incoming[:schema_version].to_s == SCHEMA_VERSION
342
377
  return "criterion mismatch" unless incoming[:criterion_version].to_s == CRITERION_VERSION
@@ -1,14 +1,17 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # rubocop:disable Metrics/ClassLength, Metrics/AbcSize, Metrics/BlockLength, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
3
+ # rubocop:disable Metrics/ClassLength, Metrics/AbcSize, Metrics/BlockLength, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/ParameterLists, Metrics/PerceivedComplexity
4
4
  require "pathname"
5
+ require_relative "coverage_index"
6
+ require_relative "report_selection"
5
7
 
6
8
  module Branchproof
7
9
  # Terminal renderings grouped around conditions or tests.
8
10
  class FocusedReport
9
11
  DECISION_TABLE_LABELS = { "true" => "T", "false" => "F", "dont_care" => "-" }.freeze
10
12
 
11
- def initialize(document:, view:, level:, missing_only: false, coordinator: nil)
13
+ def initialize(document:, view:, level:, missing_only: false, coordinator: nil, focus: nil, top: nil,
14
+ selection: nil)
12
15
  @document = document || {}
13
16
  @view = view.to_sym
14
17
  unless (Report::VIEWS - [:decisions]).include?(@view)
@@ -17,12 +20,14 @@ module Branchproof
17
20
 
18
21
  @level = level.to_i
19
22
  @missing_only = missing_only ? true : false
23
+ @selection = selection || ReportSelection.new(focus: focus, top: top)
20
24
  @index = CoverageIndex.new(document: @document)
21
25
  @tests = @index.tests.to_h { |test| [test[:id].to_s, test] }
22
26
  @test_name_counts = @index.tests.each_with_object(Hash.new(0)) do |test, counts|
23
27
  counts[[test[:name], test[:relative_path], test[:line]]] += 1
24
28
  end
25
29
  @coordinator = coordinator || Report.from_document(document: @document, level: @level)
30
+ @matching_decision_ids = @selection.focus_active? ? @selection.matching_decision_ids(@document) : nil
26
31
  end
27
32
 
28
33
  def render
@@ -45,20 +50,19 @@ module Branchproof
45
50
  end
46
51
  lines << "Empty groups mean no recorded completed observation."
47
52
  lines << ""
53
+ render_focus_notice(lines)
48
54
  lines.concat(@coordinator.coverage_ladder_lines)
55
+ lines.concat(@coordinator.coverage_policy_lines)
49
56
  case @view
50
57
  when :conditions
51
58
  render_conditions(lines)
52
- render_alternatives(lines)
53
59
  when :decision_tables
54
60
  render_decision_tables(lines)
55
61
  else
56
62
  render_tests(lines)
57
63
  end
58
- unless @view == :decision_tables
59
- render_unowned(lines)
60
- render_unsupported(lines)
61
- end
64
+ render_unowned(lines)
65
+ render_unsupported(lines)
62
66
  Array(fetch(@document, :diagnostics)).each do |diagnostic|
63
67
  lines << "Diagnostic: #{@coordinator.diagnostic_message(diagnostic)}"
64
68
  end
@@ -68,8 +72,8 @@ module Branchproof
68
72
  private
69
73
 
70
74
  def render_conditions(lines)
71
- rows = @index.conditions
72
- rows = rows.reject { |row| row[:status].to_s.upcase == "PROVEN" } if @missing_only && @level > 1
75
+ rows, alternatives, hidden = condition_rows_for_display
76
+ append_limit_summary(lines, hidden, "condition/alternative row")
73
77
  rows.each do |row|
74
78
  lines << "Condition: #{row[:expression]}"
75
79
  lines << "Location: #{location(row[:relative_path], row[:line], unavailable: "condition line unavailable")}"
@@ -108,7 +112,8 @@ module Branchproof
108
112
  end
109
113
  lines << ""
110
114
  end
111
- lines << "No missing conditions" if @missing_only && rows.empty?
115
+ lines << "No missing conditions" if @missing_only && rows.empty? && focus_match_exists?
116
+ render_alternatives(lines, alternatives)
112
117
  end
113
118
 
114
119
  def coverage_available?
@@ -133,11 +138,12 @@ module Branchproof
133
138
  # --missing-only only uncovered, non-impossible rules remain: a rule proven
134
139
  # impossible is not a missing obligation.
135
140
  def render_decision_tables(lines)
136
- rows = @index.decision_tables
141
+ all_rows = filtered_decision_tables
142
+ rows, hidden = limit_rows(all_rows)
143
+ append_limit_summary(lines, hidden, "decision")
137
144
  rendered = 0
138
- impossible = 0
145
+ impossible = @index.decision_tables.sum { |row| row[:impossible_rules].to_i }
139
146
  rows.each do |row|
140
- impossible += row[:impossible_rules].to_i
141
147
  rules = @missing_only ? row[:rules].select { |rule| rule[:coverage].to_s == "missing" } : row[:rules]
142
148
  next if @missing_only && rules.empty? && row[:status].to_s == "calculated"
143
149
 
@@ -187,9 +193,8 @@ module Branchproof
187
193
  lines << " Reason: #{Constraints.message(rule[:reachability_reason])}"
188
194
  end
189
195
 
190
- def render_alternatives(lines)
191
- rows = @index.alternatives
192
- rows = rows.select { |row| row[:missing] || row[:status].to_s != "covered" } if @missing_only && @level > 1
196
+ def render_alternatives(lines, rows = nil)
197
+ rows ||= filtered_alternatives
193
198
  rows.each do |row|
194
199
  lines << "Alternative #{row[:index]}: #{row[:expression]}"
195
200
  lines << "Location: #{location(row[:relative_path], row[:line],
@@ -208,7 +213,7 @@ module Branchproof
208
213
  end
209
214
  lines << ""
210
215
  end
211
- lines << "No missing alternatives" if @missing_only && rows.empty?
216
+ lines << "No missing alternatives" if @missing_only && rows.empty? && focus_match_exists?
212
217
  end
213
218
 
214
219
  def render_alternative_group(lines, heading, evidence)
@@ -219,22 +224,22 @@ module Branchproof
219
224
  end
220
225
 
221
226
  def render_tests(lines)
222
- rows = @index.tests
227
+ rows = filtered_tests
223
228
  missing_ids = @index.conditions.reject { |row| row[:status].to_s.upcase == "PROVEN" }.map { |row| row[:id] }
224
229
  missing_alternative_ids = @index.alternatives.select { |row| row[:missing] || row[:status].to_s != "covered" }
225
230
  .map { |row| row[:alternative_id] }
231
+ rows = rows.select do |row|
232
+ !@missing_only || test_observations(row, missing_ids, missing_alternative_ids).any?
233
+ end
234
+ rows, hidden = limit_rows(rows)
235
+ append_limit_summary(lines, hidden, "test")
226
236
  rows.each { |row| render_test_row(lines, row, missing_ids, missing_alternative_ids) }
227
237
  end
228
238
 
229
239
  def render_test_row(lines, row, missing_ids, missing_alternative_ids = [])
230
- observations = row[:observations]
231
- if @missing_only
232
- observations = observations.select do |observation|
233
- missing_ids.include?(observation[:condition_id]) ||
234
- missing_alternative_ids.include?(observation[:alternative_id])
235
- end
236
- return if observations.empty?
237
- end
240
+ observations = test_observations(row, missing_ids, missing_alternative_ids)
241
+ return if @missing_only && observations.empty?
242
+
238
243
  lines << "Test: #{row[:name]}"
239
244
  lines << "Location: #{location(row[:relative_path], row[:line], unavailable: "location unavailable")}"
240
245
  lines << "Status: #{row[:status] || "unknown"}"
@@ -291,6 +296,11 @@ module Branchproof
291
296
  groups.each do |heading, conditions|
292
297
  next if conditions.empty?
293
298
 
299
+ if @selection.active?
300
+ lines << "#{heading}: #{conditions.length} (run-wide)"
301
+ next
302
+ end
303
+
294
304
  lines << "#{heading}:"
295
305
  conditions.each do |row|
296
306
  unavailable = row[:alternative_id] ? "alternative location unavailable" : "condition line unavailable"
@@ -307,6 +317,12 @@ module Branchproof
307
317
  end
308
318
  return if excluded.empty?
309
319
 
320
+ if @selection.active?
321
+ noun = excluded.length == 1 ? "decision" : "decisions"
322
+ lines << "Unsupported conditions (MC/DC unavailable): #{excluded.length} #{noun} (run-wide)"
323
+ return
324
+ end
325
+
310
326
  lines << "Unsupported conditions (MC/DC unavailable):"
311
327
  excluded.each do |decision|
312
328
  source = sources[fetch(decision, :source_id)] || {}
@@ -321,6 +337,107 @@ module Branchproof
321
337
  end
322
338
  end
323
339
 
340
+ def render_focus_notice(lines)
341
+ return unless @selection.focus_active?
342
+
343
+ lines << if @matching_decision_ids.empty?
344
+ "Focus: no matching decisions for #{@selection.focus_label}"
345
+ else
346
+ "Focus: #{@selection.focus_label}"
347
+ end
348
+ end
349
+
350
+ def append_limit_summary(lines, hidden, unit)
351
+ return unless @selection.top
352
+
353
+ noun = hidden == 1 ? unit : "#{unit}s"
354
+ hidden_noun = hidden == 1 ? unit : "#{unit}s"
355
+ lines << "Display limit: top #{@selection.top} #{noun}; hidden #{hidden} #{hidden_noun} " \
356
+ "(not risk-ranked)"
357
+ end
358
+
359
+ def focus_match_exists?
360
+ !@selection.focus_active? || !@matching_decision_ids.empty?
361
+ end
362
+
363
+ def filtered_conditions
364
+ rows = @index.conditions
365
+ rows = rows.select { |row| @matching_decision_ids.include?(row[:decision_id].to_s) } if @selection.focus_active?
366
+ rows = rows.reject { |row| row[:status].to_s.upcase == "PROVEN" } if @missing_only && @level > 1
367
+ rows.sort_by do |row|
368
+ [row[:relative_path].to_s, row[:line].to_i, row[:column].to_i,
369
+ row[:decision_id].to_s, row[:index].to_i]
370
+ end
371
+ end
372
+
373
+ def filtered_alternatives
374
+ rows = @index.alternatives
375
+ rows = rows.select { |row| @matching_decision_ids.include?(row[:decision_id].to_s) } if @selection.focus_active?
376
+ rows = rows.select { |row| row[:missing] || row[:status].to_s != "covered" } if @missing_only && @level > 1
377
+ rows.sort_by do |row|
378
+ [row[:relative_path].to_s, row[:line].to_i, row[:column].to_i,
379
+ row[:decision_id].to_s, row[:index].to_i]
380
+ end
381
+ end
382
+
383
+ def condition_rows_for_display
384
+ conditions = filtered_conditions
385
+ alternatives = filtered_alternatives
386
+ return [conditions, alternatives, 0] unless @selection.top
387
+
388
+ combined = (conditions.map { |row| [:condition, row] } + alternatives.map { |row| [:alternative, row] })
389
+ .sort_by do |kind, row|
390
+ [row[:relative_path].to_s, row[:line].to_i, row[:column].to_i, kind == :condition ? 0 : 1,
391
+ row[:decision_id].to_s, row[:index].to_i]
392
+ end
393
+ visible, hidden = @selection.limit(combined)
394
+ [visible.filter_map { |kind, row| row if kind == :condition },
395
+ visible.filter_map { |kind, row| row if kind == :alternative }, hidden]
396
+ end
397
+
398
+ def filtered_decision_tables
399
+ rows = @index.decision_tables
400
+ rows = rows.select { |row| @matching_decision_ids.include?(row[:decision_id].to_s) } if @selection.focus_active?
401
+ rows = rows.select do |row|
402
+ !@missing_only || row[:status].to_s != "calculated" || row[:rules].any? do |rule|
403
+ rule[:coverage].to_s == "missing"
404
+ end
405
+ end
406
+ rows.sort_by { |row| [row[:relative_path].to_s, row[:line].to_i, row[:decision_id].to_s] }
407
+ end
408
+
409
+ def filtered_tests
410
+ rows = @index.tests
411
+ return rows unless @selection.active?
412
+
413
+ if @selection.focus_active?
414
+ condition_ids = @index.conditions.select { |row| @matching_decision_ids.include?(row[:decision_id].to_s) }
415
+ .map { |row| row[:id].to_s }
416
+ alternative_ids = @index.alternatives.select { |row| @matching_decision_ids.include?(row[:decision_id].to_s) }
417
+ .map { |row| row[:alternative_id].to_s }
418
+ rows = rows.select do |row|
419
+ row[:observations].any? do |observation|
420
+ condition_ids.include?(observation[:condition_id].to_s) ||
421
+ alternative_ids.include?(observation[:alternative_id].to_s)
422
+ end
423
+ end
424
+ end
425
+ rows.sort_by { |row| [row[:relative_path].to_s, row[:line].to_i, row[:name].to_s, row[:id].to_s] }
426
+ end
427
+
428
+ def limit_rows(rows)
429
+ @selection.limit(rows)
430
+ end
431
+
432
+ def test_observations(row, missing_ids, missing_alternative_ids)
433
+ return row[:observations] unless @missing_only
434
+
435
+ row[:observations].select do |observation|
436
+ missing_ids.include?(observation[:condition_id]) ||
437
+ missing_alternative_ids.include?(observation[:alternative_id])
438
+ end
439
+ end
440
+
324
441
  def location(path, line, unavailable: "location unavailable")
325
442
  return "location unavailable" if path.to_s.empty?
326
443
  return "#{path}: #{unavailable}" if line.nil?
@@ -356,4 +473,4 @@ module Branchproof
356
473
  end
357
474
  end
358
475
 
359
- # rubocop:enable Metrics/ClassLength, Metrics/AbcSize, Metrics/BlockLength, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
476
+ # rubocop:enable Metrics/ClassLength, Metrics/AbcSize, Metrics/BlockLength, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/ParameterLists, Metrics/PerceivedComplexity