branchproof 0.2.0 → 0.4.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,205 @@
1
+ # frozen_string_literal: true
2
+
3
+ # rubocop:disable Metrics/ClassLength, Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
4
+ require "pathname"
5
+
6
+ module Branchproof
7
+ # Terminal renderings grouped around conditions or tests.
8
+ class FocusedReport
9
+ def initialize(document:, view:, level:, missing_only: false)
10
+ @document = document || {}
11
+ @view = view.to_sym
12
+ raise ArgumentError, "view must be :conditions or :tests" unless %i[conditions tests].include?(@view)
13
+
14
+ @level = level.to_i
15
+ @missing_only = missing_only ? true : false
16
+ @index = CoverageIndex.new(document: @document)
17
+ @tests = @index.tests.to_h { |test| [test[:id].to_s, test] }
18
+ @coordinator = Report.from_document(document: @document, level: @level)
19
+ end
20
+
21
+ def render
22
+ baseline = fetch(@document, :baseline) || {}
23
+ completeness = fetch(@document, :completeness) || {}
24
+ evidence = fetch(@document, :observations) || {}
25
+ lines = ["Branchproof focused view: #{@view}", "Tests: #{fetch(baseline, :status) || "INCOMPLETE"}",
26
+ "Values: T=true, F=false, -=short-circuited"]
27
+ if completeness.values.include?(false) || fetch(baseline, :status).to_s != "PASSED"
28
+ lines << "Warning: failed or incomplete run; observations and proof evidence may be unavailable."
29
+ end
30
+ aborts = fetch(evidence, :abort_counts)
31
+ abort_count = aborts.is_a?(Hash) ? aborts.values.sum(&:to_i) : aborts.to_i
32
+ if abort_count.positive?
33
+ lines << "Warning: #{abort_count} aborted observations; completed vectors do not describe aborted evaluations."
34
+ end
35
+ lines << "Empty groups mean no recorded completed observation."
36
+ lines << ""
37
+ @view == :conditions ? render_conditions(lines) : render_tests(lines)
38
+ render_unowned(lines)
39
+ render_unsupported(lines)
40
+ Array(fetch(@document, :diagnostics)).each do |diagnostic|
41
+ lines << "Diagnostic: #{fetch(diagnostic, :message) || fetch(diagnostic, :code)}"
42
+ end
43
+ lines.join("\n") << "\n"
44
+ end
45
+
46
+ private
47
+
48
+ def render_conditions(lines)
49
+ rows = @index.conditions
50
+ rows = rows.reject { |row| row[:status].to_s.upcase == "PROVEN" } if @missing_only && @level > 1
51
+ rows.each do |row|
52
+ lines << "Condition: #{row[:expression]}"
53
+ lines << "Location: #{location(row[:relative_path], row[:line], unavailable: "condition line unavailable")}"
54
+ lines << "Decision: #{row[:decision_expression]} (condition #{row[:index]})"
55
+ lines << "MC/DC: #{@level == 1 ? "NOT CALCULATED" : (row[:status] || "NOT_PROVEN")}"
56
+ render_group(lines, "Evaluated true by", row[:observed_true], row)
57
+ render_group(lines, "Evaluated false by", row[:observed_false], row)
58
+ render_group(lines, "Short-circuited in", row[:short_circuited], row)
59
+ lines << " Unattributed observations: #{row[:unattributed]}" if row[:unattributed].to_i.positive?
60
+ if @level >= 2 && row[:supporting_set].any?
61
+ lines << " Supporting test set: #{row[:supporting_set].map { |id| test_label(id) }.join(", ")}"
62
+ lines << " Tests outside this MC/DC evidence set may provide other coverage."
63
+ end
64
+ render_constraint(lines, row) if @level >= 3 && row[:constraint_result]
65
+ if @level >= 3 && row[:witness_vector_ids].any?
66
+ lines << " Witness observations (owners of example evidence):"
67
+ row[:witness_vector_ids].each do |id|
68
+ vector = row[:vectors].values.flatten.find { |item| item[:id].to_s == id.to_s }
69
+ next unless vector
70
+
71
+ owners = Array(vector[:test_ids]).map { |test_id| test_label(test_id) }.uniq.sort
72
+ owners << "unattributed" if vector[:unattributed_count].to_i.positive?
73
+ owners = owners.join(", ")
74
+ lines << " [#{values(vector)}] => #{vector[:outcome] ? "T" : "F"} — #{owners}"
75
+ end
76
+ end
77
+ lines << ""
78
+ end
79
+ lines << "No missing conditions" if @missing_only && rows.empty?
80
+ end
81
+
82
+ def render_constraint(lines, row)
83
+ lines << @coordinator.condition_explanation(decision_id: row[:decision_id], condition_id: row[:id])
84
+ end
85
+
86
+ def render_group(lines, heading, ids, _row)
87
+ lines << " #{heading}:"
88
+ if ids.empty?
89
+ lines << " none recorded"
90
+ else
91
+ ids.each { |id| lines << " #{test_label(id)}" }
92
+ end
93
+ end
94
+
95
+ def render_tests(lines)
96
+ rows = @index.tests
97
+ missing_ids = @index.conditions.reject { |row| row[:status].to_s.upcase == "PROVEN" }.map { |row| row[:id] }
98
+ rows.each { |row| render_test_row(lines, row, missing_ids) }
99
+ end
100
+
101
+ def render_test_row(lines, row, missing_ids)
102
+ observations = row[:observations]
103
+ if @missing_only
104
+ observations = observations.select { |observation| missing_ids.include?(observation[:condition_id]) }
105
+ return if observations.empty?
106
+ end
107
+ lines << "Test: #{row[:name]}"
108
+ lines << "Location: #{location(row[:relative_path], row[:line], unavailable: "location unavailable")}"
109
+ lines << "Status: #{row[:status] || "unknown"}"
110
+ lines << "Phases: #{row[:phases].join(", ")}" unless row[:phases].empty?
111
+ render_test_observations(lines, observations)
112
+ lines << ""
113
+ end
114
+
115
+ def render_test_observations(lines, observations)
116
+ if observations.empty?
117
+ lines << " No recorded completed condition observations"
118
+ return
119
+ end
120
+ observations.group_by { |observation| observation[:condition_id] }.each_value do |items|
121
+ observation = items.first
122
+ observed_values = items.map { |item| item[:value] }.uniq
123
+ label = observed_values.all?(&:nil?) ? "short-circuited-only" : "evaluated"
124
+ label += "; owns canonical witness evidence" if @level > 1 && items.any? { |item| item[:owns_witness] }
125
+ phases = items.flat_map { |item| item[:phases] }.uniq.sort.join(", ")
126
+ signs = values(values: observed_values)
127
+ condition_location = location(observation[:relative_path], observation[:line],
128
+ unavailable: "condition line unavailable")
129
+ lines << " #{observation[:expression]} (#{condition_location}): #{signs}; #{label}; " \
130
+ "phases: #{phases}"
131
+ end
132
+ end
133
+
134
+ def render_unowned(lines)
135
+ rows = @index.conditions
136
+ rows = rows.reject { |row| row[:status] == "PROVEN" } if @missing_only
137
+ { "Unexecuted conditions" => rows.select { |row| row[:unexecuted] },
138
+ "Unattributed evidence" => rows.select { |row| row[:unattributed].positive? } }.each do |heading, conditions|
139
+ next if conditions.empty?
140
+
141
+ lines << "#{heading}:"
142
+ conditions.each do |row|
143
+ lines << " #{row[:expression]} (#{location(row[:relative_path], row[:line],
144
+ unavailable: "condition line unavailable")})"
145
+ end
146
+ end
147
+ end
148
+
149
+ def render_unsupported(lines)
150
+ inventory = fetch(@document, :source_inventory) || {}
151
+ sources = Array(fetch(inventory, :source_units)).to_h { |source| [fetch(source, :source_id), source] }
152
+ excluded = Array(fetch(inventory, :decisions)).select do |decision|
153
+ fetch(decision, :support_status) == "UNSUPPORTED"
154
+ end
155
+ return if excluded.empty?
156
+
157
+ lines << "Unsupported conditions (MC/DC unavailable):"
158
+ excluded.each do |decision|
159
+ source = sources[fetch(decision, :source_id)] || {}
160
+ path = fetch(source, :relative_path)
161
+ path = nil if path && Pathname.new(path).absolute?
162
+ conditions = Array(fetch(decision, :conditions))
163
+ conditions = [{ expression: fetch(decision, :expression) }] if conditions.empty?
164
+ conditions.each do |condition|
165
+ label = location(path, fetch(condition, :line), unavailable: "condition line unavailable")
166
+ lines << " #{fetch(condition, :expression)} (#{label})"
167
+ end
168
+ end
169
+ end
170
+
171
+ def location(path, line, unavailable: "location unavailable")
172
+ return "location unavailable" if path.to_s.empty?
173
+ return "#{path}: #{unavailable}" if line.nil?
174
+
175
+ "#{path}:#{line}"
176
+ end
177
+
178
+ def values(vector)
179
+ Array(vector[:values]).map do |item|
180
+ if item.nil?
181
+ "-"
182
+ else
183
+ (item ? "T" : "F")
184
+ end
185
+ end.join
186
+ end
187
+
188
+ def test_label(id)
189
+ test = @tests[id.to_s]
190
+ return id.to_s unless test
191
+
192
+ label = "#{test[:name]} (#{location(test[:relative_path], test[:line])})"
193
+ duplicates = @index.tests.count do |row|
194
+ [row[:name], row[:relative_path], row[:line]] == [test[:name], test[:relative_path], test[:line]]
195
+ end
196
+ duplicates > 1 ? "#{label} [#{id}]" : label
197
+ end
198
+
199
+ def fetch(hash, key)
200
+ hash.key?(key) ? hash[key] : hash[key.to_s]
201
+ end
202
+ end
203
+ end
204
+
205
+ # rubocop:enable Metrics/ClassLength, Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
@@ -1,15 +1,19 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "json"
4
+ require "pathname"
4
5
 
5
6
  module Branchproof
6
7
  # Renders versioned terminal and JSON analysis reports.
7
8
  class Report
8
- SCHEMA_VERSION = "1.0"
9
+ SCHEMA_VERSION = "1.1"
9
10
  CRITERION_VERSION = "masking_occurrence_v1"
10
11
 
11
- def initialize(inventory:, evidence:, analysis:, minima:, baseline:, diagnostics:, level: 3)
12
+ def initialize(inventory:, evidence:, analysis:, minima:, baseline:, diagnostics:, level: 3, missing_only: false,
13
+ view: :decisions, run_metadata: {}, saved_document: nil)
12
14
  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)
13
17
 
14
18
  @inventory = inventory || {}
15
19
  @evidence = evidence || {}
@@ -18,6 +22,20 @@ module Branchproof
18
22
  @baseline = baseline || {}
19
23
  @diagnostics = Array(diagnostics)
20
24
  @level = level.to_i
25
+ @missing_only = missing_only ? true : false
26
+ @view = view.to_sym
27
+ @run_metadata = run_metadata || {}
28
+ @saved_document = saved_document
29
+ end
30
+
31
+ def self.from_document(document:, level: nil, view: :decisions, missing_only: false)
32
+ 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"],
35
+ analysis: data[:analysis] || data["analysis"], minima: data[:minima] || data["minima"],
36
+ baseline: data[:baseline] || data["baseline"], diagnostics: data[:diagnostics] || data["diagnostics"],
37
+ level: level || (data[:analysis] || data["analysis"] ? 3 : 1), view: view, missing_only: missing_only,
38
+ run_metadata: data[:run_metadata] || data["run_metadata"], saved_document: data)
21
39
  end
22
40
 
23
41
  def write(io:, format:)
@@ -28,6 +46,18 @@ module Branchproof
28
46
  nil
29
47
  end
30
48
 
49
+ # Shares the existing missing-case wording with focused terminal views.
50
+ def condition_explanation(decision_id:, condition_id:)
51
+ decision = inventory_decisions.find { |item| value(item, :id).to_s == decision_id.to_s }
52
+ return "" unless decision
53
+
54
+ condition = Array(value(decision, :conditions)).find { |item| value(item, :id).to_s == condition_id.to_s }
55
+ return "" unless condition
56
+
57
+ @terminal_ids ||= terminal_ids
58
+ condition_detail(decision, condition, condition_result(decision, condition))
59
+ end
60
+
31
61
  def exit_code
32
62
  return 2 unless usage_valid?
33
63
 
@@ -35,6 +65,7 @@ module Branchproof
35
65
  return 2 if %w[ERROR INCOMPLETE].include?(status)
36
66
  return 1 if status == "FAILED"
37
67
  return 2 unless status == "PASSED" && value(@baseline, :finalized) == true
68
+ return 2 if @saved_document && (value(@saved_document, :completeness) || {}).values.include?(false)
38
69
  return 2 unless metrics[:eligible_conditions].positive?
39
70
  return 2 unless valid_for_requested_level?
40
71
 
@@ -44,16 +75,24 @@ module Branchproof
44
75
  private
45
76
 
46
77
  def json_document
78
+ return normalize(@saved_document) if @saved_document
79
+
47
80
  normalize(schema_version: SCHEMA_VERSION,
48
81
  tool_version: (defined?(Branchproof::VERSION) ? Branchproof::VERSION : "unknown"),
49
82
  criterion_version: CRITERION_VERSION, runtime: RUBY_DESCRIPTION,
50
83
  run_ids: Array(value(@evidence, :run_ids)),
51
84
  source_inventory: @inventory, baseline: @baseline, observations: @evidence,
52
85
  analysis: @level == 1 ? nil : @analysis, minima: @minima, metrics: metrics,
53
- diagnostics: @diagnostics, completeness: completeness)
86
+ diagnostics: @diagnostics, completeness: completeness,
87
+ run_metadata: @run_metadata)
54
88
  end
55
89
 
56
90
  def terminal_document
91
+ unless @view == :decisions
92
+ return FocusedReport.new(document: json_document, view: @view, level: @level,
93
+ missing_only: @missing_only).render
94
+ end
95
+
57
96
  @terminal_ids = terminal_ids
58
97
  lines = ["Branchproof #{defined?(Branchproof::VERSION) ? Branchproof::VERSION : "unknown"}",
59
98
  "Tests: #{baseline_status} (#{baseline_test_counts})",
@@ -64,10 +103,11 @@ module Branchproof
64
103
  "Observations: #{metrics[:completed]} completed, #{metrics[:aborted]} aborted, " \
65
104
  "#{metrics[:unattributed]} unattributed",
66
105
  "Values: T=true, F=false, -=short-circuited"]
106
+ lines << missing_summary_line if @missing_only
67
107
  lines << "Scope: supported decisions and conditions"
68
108
  lines << ""
69
- inventory_decisions.each { |decision| render_decision(lines, decision) }
70
- render_minima(lines)
109
+ decisions_to_render.each { |decision| render_decision(lines, decision) }
110
+ render_minima(lines) unless @missing_only
71
111
  unless @diagnostics.empty?
72
112
  lines << "Diagnostics:"
73
113
  @diagnostics.each { |diagnostic| lines << " - #{value(diagnostic, :message) || value(diagnostic, :code)}" }
@@ -82,9 +122,9 @@ module Branchproof
82
122
  lines << decision_label
83
123
  lines << " Decision: #{value(decision, :expression)}"
84
124
  lines << " Status: #{value(decision, :support_status) || "SUPPORTED"}"
85
- Array(value(decision, :conditions)).each do |condition|
125
+ conditions_to_render(decision).each do |condition|
86
126
  result = condition_result(decision, condition)
87
- detail = condition_detail(result)
127
+ detail = condition_detail(decision, condition, result)
88
128
  status = if @analysis.nil? || @level == 1
89
129
  "NOT CALCULATED"
90
130
  else
@@ -93,7 +133,7 @@ module Branchproof
93
133
  lines << " Condition #{value(condition, :index)}: #{value(condition, :expression)}"
94
134
  lines << " #{status}#{detail}"
95
135
  end
96
- vectors_for(decision).each do |vector|
136
+ vectors_to_render(decision).each do |vector|
97
137
  values = Array(value(vector, :values)).map do |item|
98
138
  if item.nil?
99
139
  "-"
@@ -169,6 +209,11 @@ module Branchproof
169
209
  [value(result, :condition_id), *Array(value(result, :canonical_pair))]
170
210
  end
171
211
  end)
212
+ ids.concat(Array(value(@analysis, :decisions)).flat_map do |decision|
213
+ Array(value(decision, :condition_results)).flat_map do |result|
214
+ Array(value(value(result, :constraint_result), :candidate_vectors)).map { |vector| value(vector, :id) }
215
+ end
216
+ end)
172
217
  ids = ids.compact.map(&:to_s).reject(&:empty?).uniq
173
218
  by_prefix = ids.group_by { |id| id[0, 8] }
174
219
  ids.to_h do |id|
@@ -217,17 +262,32 @@ module Branchproof
217
262
  end
218
263
 
219
264
  def test_location(test)
265
+ saved_location = value(value(@run_metadata, :test_locations), value(test, :id))
266
+ return [relative_test_path(value(saved_location, :relative_path)), value(saved_location, :line)] if saved_location
267
+
220
268
  location = value(test, :source)
221
269
  source_line = nil
222
270
  if location.respond_to?(:key?)
223
271
  source_line = value(location, :line)
224
- location = value(location, :path) || value(location, :relative_path)
272
+ location = value(location, :relative_path) || value(location, :path)
225
273
  end
226
274
  location ||= value(test, :source_path)
275
+ location = relative_test_path(location)
227
276
  line = value(test, :line) || value(test, :source_line) || source_line
228
277
  [location, line]
229
278
  end
230
279
 
280
+ def relative_test_path(path)
281
+ return path unless path && Pathname.new(path.to_s).absolute?
282
+
283
+ root = value(@inventory, :root) || value(@run_metadata, :project_root)
284
+ return nil unless root && Pathname.new(root.to_s).absolute?
285
+
286
+ Pathname.new(path.to_s).relative_path_from(Pathname.new(root.to_s)).to_s
287
+ rescue ArgumentError
288
+ nil
289
+ end
290
+
231
291
  def minimum_member_label(objective, id)
232
292
  objective.to_s == "tests" ? test_label(id) : short_id(id)
233
293
  end
@@ -264,24 +324,104 @@ module Branchproof
264
324
  end
265
325
  end
266
326
 
267
- def condition_detail(result)
268
- return "" unless result && @level == 3
327
+ def condition_detail(decision, _condition, result)
328
+ return "" unless result && (@level == 3 || @missing_only)
269
329
 
270
330
  pair = value(result, :canonical_pair)
271
331
  return " (witness #{Array(pair).map { |id| short_id(id) }.join(" + ")})" if pair
272
332
 
273
333
  constraint = value(result, :constraint_result)
274
334
  if constraint
275
- constraints = Array(value(constraint, :constraints)).map { |item| item.is_a?(Hash) ? item.inspect : item.to_s }
335
+ constraints = readable_constraints(decision, constraint)
276
336
  detail = constraints.empty? ? value(constraint, :status) : constraints.join(", ")
277
337
  statement = value(constraint, :feasibility_statement)
278
338
  detail = [detail, statement].compact.reject(&:empty?).join("; ")
339
+ candidates = Array(value(constraint, :candidate_vectors))
340
+ existing = existing_vector_for(constraint)
341
+ unless candidates.empty?
342
+ detail_lines = [" — missing observation"]
343
+ candidates.each do |candidate|
344
+ detail_lines << " Need an observation where:"
345
+ detail_lines.concat(candidate_requirements(decision, candidate).map { |item| " #{item}" })
346
+ outcome = value(candidate, :outcome) ? "true" : "false"
347
+ detail_lines << " Expected decision: #{outcome} [#{vector_values(candidate)}]"
348
+ end
349
+ detail_lines << " #{statement}" unless statement.to_s.empty?
350
+ detail_lines << " Compare with: #{existing_vector_context(existing)}"
351
+ return detail_lines.join("\n")
352
+ end
353
+ unless existing.nil?
354
+ owner_ids = Array(value(existing, :test_ids))
355
+ owners = owner_ids.first(2).map { |id| test_label(id) }
356
+ owners << "#{owner_ids.length - 2} more" if owner_ids.length > 2
357
+ owner_detail = owners.empty? ? nil : " (#{owners.join(", ")})"
358
+ detail = [detail, "existing observation: #{short_id(value(existing, :id))}#{owner_detail}"].compact.join("; ")
359
+ end
279
360
  return " (missing counterpart: #{detail})" unless detail.empty?
280
361
  end
281
362
 
282
363
  ""
283
364
  end
284
365
 
366
+ def readable_constraints(decision, constraint)
367
+ Array(value(constraint, :constraints)).filter_map do |item|
368
+ if item.respond_to?(:key?)
369
+ index = value(item, :condition_index)
370
+ expression = condition_expression(decision, index) || "condition #{index}"
371
+ required = value(item, :required)
372
+ required = value(item, :value) if required.nil?
373
+ "requires #{expression}=#{required ? "true" : "false"}"
374
+ else
375
+ item.to_s
376
+ end
377
+ end
378
+ end
379
+
380
+ def condition_expression(decision, index)
381
+ condition = Array(value(decision, :conditions)).find { |item| value(item, :index).to_i == index.to_i }
382
+ value(condition, :expression)
383
+ end
384
+
385
+ def candidate_requirements(decision, candidate)
386
+ Array(value(candidate, :values)).each_with_index.map do |required, index|
387
+ expression = condition_expression(decision, index) || "condition #{index}"
388
+ if required.nil?
389
+ "#{expression} is not evaluated (short-circuited)"
390
+ else
391
+ "#{expression} is #{required ? "truthy" : "falsey"}"
392
+ end
393
+ end
394
+ end
395
+
396
+ def vector_values(vector)
397
+ Array(value(vector, :values)).map do |item|
398
+ if item.nil?
399
+ "-"
400
+ elsif item
401
+ "T"
402
+ else
403
+ "F"
404
+ end
405
+ end.join
406
+ end
407
+
408
+ def existing_vector_context(existing)
409
+ return "no existing effective observation" unless existing
410
+
411
+ owner_ids = Array(value(existing, :test_ids))
412
+ owners = owner_ids.first(2).map { |id| test_label(id) }
413
+ owners << "#{owner_ids.length - 2} more" if owner_ids.length > 2
414
+ context = "[#{vector_values(existing)}] => #{value(existing, :outcome) ? "true" : "false"}"
415
+ owners.empty? ? context : "#{context} (#{owners.join(", ")})"
416
+ end
417
+
418
+ def existing_vector_for(constraint)
419
+ id = value(constraint, :existing_vector_id).to_s
420
+ return if id.empty?
421
+
422
+ vectors.find { |vector| value(vector, :id).to_s == id }
423
+ end
424
+
285
425
  def metrics
286
426
  decisions = inventory_decisions
287
427
  unsupported, supported = decisions.partition { |decision| unsupported?(decision) }
@@ -349,6 +489,50 @@ module Branchproof
349
489
  end
350
490
  end
351
491
 
492
+ def decisions_to_render
493
+ return inventory_decisions unless @missing_only
494
+ return [] unless analysis_available?
495
+
496
+ inventory_decisions
497
+ .reject { |decision| unsupported?(decision) }
498
+ .select { |decision| conditions_to_render(decision).any? }
499
+ end
500
+
501
+ def conditions_to_render(decision)
502
+ conditions = Array(value(decision, :conditions))
503
+ return conditions unless @missing_only && analysis_available?
504
+
505
+ conditions.reject { |condition| value(condition_result(decision, condition), :status).to_s.upcase == "PROVEN" }
506
+ end
507
+
508
+ def vectors_to_render(decision)
509
+ return vectors_for(decision) unless @missing_only
510
+
511
+ []
512
+ end
513
+
514
+ def analysis_available?
515
+ !@analysis.nil? && @level > 1
516
+ end
517
+
518
+ def analysis_complete?
519
+ analysis_available? && baseline_status == "PASSED" && completeness.values.all?
520
+ end
521
+
522
+ def missing_condition_count
523
+ decisions_to_render.sum { |decision| conditions_to_render(decision).length }
524
+ end
525
+
526
+ def missing_summary_line
527
+ return "Missing conditions: Cannot identify missing conditions (analysis unavailable)" unless analysis_available?
528
+ return "Missing conditions: Cannot identify missing conditions (analysis incomplete)" unless analysis_complete?
529
+ return "No eligible conditions" if metrics[:eligible_conditions].zero?
530
+ return "No missing conditions" if analysis_complete? && missing_condition_count.zero?
531
+
532
+ count = decisions_to_render.length
533
+ "Missing conditions: #{missing_condition_count} across #{count} #{count == 1 ? "decision" : "decisions"}"
534
+ end
535
+
352
536
  def analysis_for(decision)
353
537
  Array(value(@analysis, :decisions)).find do |item|
354
538
  value(item, :decision_id).to_s == value(decision, :id).to_s