branchproof 0.5.0 → 0.7.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.
@@ -1,12 +1,14 @@
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.2"
10
12
  CRITERION_VERSION = "masking_occurrence_v1"
11
13
 
12
14
  def initialize(inventory:, evidence:, analysis:, minima:, baseline:, diagnostics:, level: 3, missing_only: false,
@@ -70,6 +72,31 @@ module Branchproof
70
72
  "#{prefix}#{path}: #{message}"
71
73
  end
72
74
 
75
+ # Returns condition-value evidence for focused renderers without exposing
76
+ # the report's internal document traversal or mutating saved records.
77
+ def condition_coverage_evidence(decision_id:, condition_id:)
78
+ decision = inventory_decisions.find { |item| value(item, :id).to_s == decision_id.to_s }
79
+ condition = Array(value(decision, :conditions)).find do |item|
80
+ value(item, :id).to_s == condition_id.to_s
81
+ end
82
+ coverage = value(condition_result(decision, condition), :coverage)
83
+ return [] unless coverage
84
+
85
+ @terminal_ids ||= terminal_ids
86
+ lines = Array(value(coverage, :values)).map do |entry|
87
+ value_label = value(entry, :value) ? "true" : "false"
88
+ owners = Array(value(entry, :test_ids)).map { |id| test_label(id) }
89
+ owners << "unattributed" if value(entry, :unattributed_count).to_i.positive?
90
+ evidence = owners.empty? ? "none recorded" : owners.uniq.join(", ")
91
+ " Value #{value_label}: #{value(entry, :observed) ? "observed" : "missing"}; tests: #{evidence}"
92
+ end
93
+ missing = Array(value(coverage, :missing_values))
94
+ unless missing.empty?
95
+ lines << " Missing values: #{missing.map { |item| item ? "true" : "false" }.join(", ")}"
96
+ end
97
+ lines
98
+ end
99
+
73
100
  def exit_code
74
101
  return 2 unless usage_valid?
75
102
 
@@ -78,7 +105,9 @@ module Branchproof
78
105
  return 1 if status == "FAILED"
79
106
  return 2 unless status == "PASSED" && value(@baseline, :finalized) == true
80
107
  return 2 if @saved_document && (value(@saved_document, :completeness) || {}).values.include?(false)
81
- return 2 unless metrics[:eligible_conditions].positive?
108
+
109
+ eligible = metrics[:eligible_conditions].positive? || metrics[:eligible_alternatives].positive?
110
+ return 2 unless eligible
82
111
  return 2 unless valid_for_requested_level?
83
112
 
84
113
  0
@@ -93,8 +122,8 @@ module Branchproof
93
122
  tool_version: (defined?(Branchproof::VERSION) ? Branchproof::VERSION : "unknown"),
94
123
  criterion_version: CRITERION_VERSION, runtime: RUBY_DESCRIPTION,
95
124
  run_ids: Array(value(@evidence, :run_ids)),
96
- source_inventory: @inventory, baseline: @baseline, observations: @evidence,
97
- analysis: @level == 1 ? nil : @analysis, minima: @minima, metrics: metrics,
125
+ source_inventory: inventory_with_default_kinds, baseline: @baseline, observations: @evidence,
126
+ analysis: @analysis, minima: @minima, metrics: metrics,
98
127
  diagnostics: @diagnostics, completeness: completeness,
99
128
  run_metadata: @run_metadata)
100
129
  end
@@ -112,11 +141,13 @@ module Branchproof
112
141
  "Analysis: #{terminal_analysis_status}",
113
142
  "Decisions: #{metrics[:supported]} supported, #{metrics[:unsupported]} excluded, " \
114
143
  "#{metrics[:unexecuted]} unexecuted (#{metrics[:discovered]} discovered)",
144
+ decision_kind_summary_line,
115
145
  "Observations: #{metrics[:completed]} completed, #{metrics[:aborted]} aborted, " \
116
146
  "#{metrics[:unattributed]} unattributed",
117
- "Values: T=true, F=false, -=short-circuited"]
147
+ values_legend]
148
+ lines.concat(coverage_ladder_lines)
118
149
  lines << missing_summary_line if @missing_only
119
- lines << "Scope: supported decisions and conditions"
150
+ lines << "Scope: supported decisions, conditions, and alternatives"
120
151
  lines << ""
121
152
  decisions_to_render.each { |decision| render_decision(lines, decision) }
122
153
  render_minima(lines) unless @missing_only
@@ -133,18 +164,25 @@ module Branchproof
133
164
  decision_label = "Decision #{short_id(value(decision, :id))} #{filename}:#{value(decision, :line)}"
134
165
  lines << decision_label
135
166
  lines << " Decision: #{value(decision, :expression)}"
167
+ lines << " Kind: #{decision_kind(decision)}"
168
+ context = value(decision, :context)
169
+ lines << " Context: #{context}" unless context.nil? || context.to_s.empty?
136
170
  lines << " Status: #{value(decision, :support_status) || "SUPPORTED"}"
137
- conditions_to_render(decision).each do |condition|
138
- result = condition_result(decision, condition)
139
- detail = condition_detail(decision, condition, result)
140
- status = if @analysis.nil? || @level == 1
141
- "NOT CALCULATED"
142
- else
143
- value(result, :status) || "NOT_PROVEN"
144
- end
145
- lines << " Condition #{value(condition, :index)}: #{value(condition, :expression)}"
146
- lines << " #{status}#{detail}"
171
+ unless nonboolean_decision?(decision)
172
+ conditions_to_render(decision).each do |condition|
173
+ result = condition_result(decision, condition)
174
+ detail = condition_detail(decision, condition, result)
175
+ status = if @analysis.nil? || (@level == 1 && !coverage_available?)
176
+ "NOT CALCULATED"
177
+ else
178
+ value(result, :status) || "NOT_PROVEN"
179
+ end
180
+ lines << " Condition #{value(condition, :index)}: #{value(condition, :expression)}"
181
+ lines << " #{status}#{detail}"
182
+ render_condition_coverage(lines, decision, condition, result) if @level >= 2 && coverage_available?
183
+ end
147
184
  end
185
+ render_decision_coverage(lines, decision) if coverage_available?
148
186
  vectors_to_render(decision).each do |vector|
149
187
  values = Array(value(vector, :values)).map do |item|
150
188
  if item.nil?
@@ -155,13 +193,136 @@ module Branchproof
155
193
  end.join
156
194
  owners = Array(value(vector, :test_ids)).map { |test_id| test_label(test_id) }
157
195
  owners << "unattributed" if value(vector, :unattributed_count).to_i.positive?
158
- vector_label = " Vector #{short_id(value(vector, :id))} [#{values}] => " \
159
- "#{value(vector, :outcome) ? "T" : "F"}"
196
+ vector_label = if nonboolean_decision?(decision)
197
+ selected = Array(value(vector, :values)).each_with_index.filter_map do |item, index|
198
+ next unless item
199
+
200
+ alternative = Array(value(decision, :alternatives))[index]
201
+ value(alternative, :expression) || "alternative #{index}"
202
+ end
203
+ " Vector #{short_id(value(vector, :id))} [#{values}] => selected: #{selected.join(", ")}"
204
+ else
205
+ " Vector #{short_id(value(vector,
206
+ :id))} [#{values}] => " + (if value(vector,
207
+ :outcome)
208
+ "T"
209
+ else
210
+ "F"
211
+ end).to_s
212
+ end
160
213
  lines << "#{vector_label} owners=#{owners.join(", ")}"
161
214
  end
162
215
  lines << ""
163
216
  end
164
217
 
218
+ def coverage_status_label(status)
219
+ { "covered" => "PASS", "partial" => "FAIL", "unexecuted" => "UNEXECUTED", "unsupported" => "EXCLUDED" }.fetch(
220
+ status.to_s.downcase, status.to_s.upcase
221
+ )
222
+ end
223
+
224
+ def render_decision_coverage(lines, decision)
225
+ if nonboolean_decision?(decision)
226
+ render_alternative_coverage(lines, decision)
227
+ return
228
+ end
229
+
230
+ decision_coverage = value(value(analysis_for(decision), :coverage), :decision)
231
+ all_coverage = value(analysis_for(decision), :coverage) || {}
232
+ statuses = [["D", :decision], ["C", :condition], ["C/D", :condition_decision],
233
+ ["MC/DC", :mcdc]].filter_map do |label, key|
234
+ row = value(all_coverage, key)
235
+ status = value(row, :status)
236
+ status ? criterion_status_text(label, row) : nil
237
+ end
238
+ lines << " Coverage: #{statuses.join(", ")}" unless statuses.empty?
239
+ coverage = decision_coverage
240
+ return unless coverage
241
+
242
+ return unless @level >= 2
243
+
244
+ Array(value(coverage, :outcomes)).each do |outcome|
245
+ value_label = value(outcome, :value) ? "true" : "false"
246
+ owners = Array(value(outcome, :test_ids)).map { |id| test_label(id) }
247
+ owners << "unattributed" if value(outcome, :unattributed_count).to_i.positive?
248
+ evidence = owners.empty? ? "none recorded" : owners.uniq.join(", ")
249
+ lines << " Outcome #{value_label}: #{value(outcome, :observed) ? "observed" : "missing"}; tests: #{evidence}"
250
+ end
251
+ missing = Array(value(coverage, :missing_outcomes))
252
+ return if missing.empty?
253
+
254
+ lines << " Missing decision outcomes: #{missing.map do |item|
255
+ item ? "true" : "false"
256
+ end.join(", ")}"
257
+ end
258
+
259
+ def render_alternative_coverage(lines, decision)
260
+ coverage = value(value(analysis_for(decision), :coverage), :alternative) || {}
261
+ status = value(coverage, :status)
262
+ required = value(coverage, :required_alternatives) || Array(value(decision, :alternatives)).length
263
+ covered = value(coverage, :covered_alternatives) || 0
264
+ lines << " Alternatives: #{status || "NOT_CALCULATED"} (#{covered}/#{required})"
265
+ lines << " MC/DC: N/A (not applicable)"
266
+ rows = Array(value(coverage, :alternatives))
267
+ rows_by_id = rows.to_h { |row| [value(row, :alternative_id).to_s, row] }
268
+ alternatives_to_render(decision).each do |alternative|
269
+ id = value(alternative, :id)
270
+ row = rows_by_id[id.to_s] || {}
271
+ lines << " Alternative #{value(alternative, :index)}: #{value(alternative, :expression)}"
272
+ render_alternative_state(lines, "Selected", value(row, :selected)) if @level >= 2
273
+ render_alternative_state(lines, "Not selected", value(row, :not_selected)) if @level >= 2
274
+ render_alternative_state(lines, "Skipped", value(row, :skipped)) if @level >= 2
275
+ end
276
+ missing = Array(value(coverage, :missing_alternatives))
277
+ return if missing.empty?
278
+
279
+ labels = missing.map do |id|
280
+ alternative = Array(value(decision, :alternatives)).find { |item| value(item, :id).to_s == id.to_s }
281
+ value(alternative, :expression) || short_id(id)
282
+ end
283
+ label = labels.join(", ")
284
+ lines << " Missing alternatives: #{label}"
285
+ lines << " Need selection of: #{label}"
286
+ end
287
+
288
+ def render_alternative_state(lines, label, evidence)
289
+ evidence ||= {}
290
+ owners = Array(value(evidence, :test_ids)).map { |id| test_label(id) }
291
+ owners << "unattributed" if value(evidence, :unattributed_count).to_i.positive?
292
+ owners = owners.uniq
293
+ status = if value(evidence, :observed)
294
+ "observed"
295
+ elsif label == "Selected"
296
+ "missing"
297
+ else
298
+ "none recorded"
299
+ end
300
+ detail = owners.empty? ? "none recorded" : owners.join(", ")
301
+ lines << " #{label}: #{status}; tests: #{detail}"
302
+ end
303
+
304
+ def render_condition_coverage(lines, _decision, condition, result)
305
+ coverage = value(result, :coverage)
306
+ return unless coverage
307
+
308
+ values = Array(value(coverage, :values))
309
+ return if values.empty?
310
+
311
+ values.each do |entry|
312
+ value_label = value(entry, :value) ? "true" : "false"
313
+ owners = Array(value(entry, :test_ids)).map { |id| test_label(id) }
314
+ owners << "unattributed" if value(entry, :unattributed_count).to_i.positive?
315
+ evidence = owners.empty? ? "none recorded" : owners.uniq.join(", ")
316
+ lines << " Value #{value_label}: #{value(entry, :observed) ? "observed" : "missing"}; tests: #{evidence}"
317
+ end
318
+ missing = Array(value(coverage, :missing_values))
319
+ return if missing.empty?
320
+
321
+ lines << " Missing values for #{value(condition, :expression)}: #{missing.map do |item|
322
+ item ? "true" : "false"
323
+ end.join(", ")}"
324
+ end
325
+
165
326
  def render_minima(lines)
166
327
  rows = Array(@minima).map do |minimum|
167
328
  objective = value(minimum, :objective)
@@ -189,19 +350,19 @@ module Branchproof
189
350
  end
190
351
 
191
352
  def terminal_coverage_label
192
- return "not calculated" if @analysis.nil? || @level == 1
353
+ return "not calculated" if @analysis.nil?
193
354
 
194
355
  coverage_label
195
356
  end
196
357
 
197
358
  def terminal_coverage_line
198
- return "MC/DC: not calculated" if @analysis.nil? || @level == 1
359
+ return "MC/DC: not calculated" if @analysis.nil?
199
360
 
200
361
  "MC/DC: #{terminal_coverage_label} (#{metrics[:proven]}/#{metrics[:eligible_conditions]} conditions proven)"
201
362
  end
202
363
 
203
364
  def terminal_analysis_status
204
- return "NOT CALCULATED" if @analysis.nil? || @level == 1
365
+ return "NOT CALCULATED" if @analysis.nil?
205
366
 
206
367
  analysis_status
207
368
  end
@@ -434,23 +595,73 @@ module Branchproof
434
595
  vectors.find { |vector| value(vector, :id).to_s == id }
435
596
  end
436
597
 
598
+ def values_legend
599
+ return "Values: T=true, F=false, -=short-circuited" unless inventory_decisions.any? do |decision|
600
+ nonboolean_decision?(decision)
601
+ end
602
+
603
+ "Values: Boolean T=true/F=false; flow T=selected, F=not-selected, -=skipped"
604
+ end
605
+
606
+ def inventory_with_default_kinds
607
+ decisions = inventory_decisions.map do |decision|
608
+ next decision if value(decision, :kind)
609
+
610
+ decision.respond_to?(:key?) ? decision.merge(kind: "boolean") : decision
611
+ end
612
+ return @inventory unless value(@inventory, :decisions)
613
+
614
+ @inventory.merge(decisions: decisions)
615
+ end
616
+
617
+ def decision_kind(decision)
618
+ kind = value(decision, :kind).to_s
619
+ kind.empty? ? "boolean" : kind
620
+ end
621
+
622
+ def nonboolean_decision?(decision)
623
+ %w[implicit multiway pattern exception].include?(decision_kind(decision))
624
+ end
625
+
626
+ def decision_kind_summary_line
627
+ counts = metrics[:kind_counts]
628
+ return "Decision kinds: none" if counts.empty?
629
+
630
+ contexts = metrics[:context_counts]
631
+ suffix = if contexts.empty?
632
+ ""
633
+ else
634
+ "; contexts: #{contexts.map do |context, count|
635
+ "#{context}=#{count}"
636
+ end.join(", ")}"
637
+ end
638
+ "Decision kinds: #{counts.map { |kind, count| "#{kind}=#{count}" }.join(", ")}#{suffix}"
639
+ end
640
+
437
641
  def metrics
438
642
  decisions = inventory_decisions
439
643
  unsupported, supported = decisions.partition { |decision| unsupported?(decision) }
440
644
  eligible = supported.sum { |decision| Array(value(decision, :conditions)).length }
441
645
  observed = vectors.map { |vector| value(vector, :decision_id).to_s }.uniq
442
646
  proven = @analysis ? value(@analysis, :proven_count).to_i : 0
647
+ kind_counts = decisions.group_by { |decision| decision_kind(decision) }.transform_values(&:length)
648
+ context_counts = decisions.group_by { |decision| value(decision, :context).to_s }
649
+ context_counts = context_counts.reject { |context, _| context.empty? }.transform_values(&:length)
443
650
  { discovered: decisions.length, supported: supported.length, unsupported: unsupported.length,
444
651
  unsupported_conditions: unsupported.sum do |decision|
445
652
  discovered_conditions(decision)
446
653
  end, eligible_conditions: eligible,
447
654
  opaque: decisions.sum { |decision| Array(value(decision, :opaque_ranges)).length },
448
655
  unexecuted: supported.count { |decision| !observed.include?(value(decision, :id).to_s) },
656
+ eligible_alternatives: supported.sum do |decision|
657
+ nonboolean_decision?(decision) ? Array(value(decision, :alternatives)).length : 0
658
+ end,
449
659
  completed: vectors.sum do |vector|
450
660
  value(vector, :count).to_i
451
661
  end, aborted: numeric_hash_value(@evidence, :abort_counts),
452
662
  unattributed: vectors.sum { |vector| value(vector, :unattributed_count).to_i }, limited: incomplete? ? 1 : 0,
453
- proven: proven, percentage: percentage(eligible, proven) }
663
+ proven: proven, percentage: percentage(eligible, proven), kind_counts: kind_counts,
664
+ decision_kinds: kind_counts, context_counts: context_counts }
454
665
  end
455
666
 
456
667
  def completeness
@@ -473,7 +684,7 @@ module Branchproof
473
684
  end
474
685
 
475
686
  def valid_for_requested_level?
476
- return completeness[:observation] && completeness[:attribution] if @level == 1
687
+ return completeness[:observation] && completeness[:attribution] if @level == 1 && @analysis.nil?
477
688
 
478
689
  completeness.values.all? { |item| item == true }
479
690
  end
@@ -486,13 +697,14 @@ module Branchproof
486
697
  end
487
698
 
488
699
  def analysis_status
489
- return "NOT_REQUESTED" if @analysis.nil? || @level == 1
700
+ return "NOT_REQUESTED" if @analysis.nil?
490
701
 
491
702
  valid_for_requested_level? ? "COMPLETE" : "PARTIAL"
492
703
  end
493
704
 
494
705
  def incomplete?
495
- !completeness[:observation] || !completeness[:attribution] || (@level > 1 && !completeness[:analysis])
706
+ !completeness[:observation] || !completeness[:attribution] ||
707
+ (analysis_available? && !completeness[:analysis])
496
708
  end
497
709
 
498
710
  def vectors_for(decision)
@@ -507,24 +719,108 @@ module Branchproof
507
719
 
508
720
  inventory_decisions
509
721
  .reject { |decision| unsupported?(decision) }
510
- .select { |decision| conditions_to_render(decision).any? }
722
+ .select do |decision|
723
+ nonboolean_decision?(decision) ? missing_alternatives_for(decision).any? : conditions_to_render(decision).any?
724
+ end
725
+ end
726
+
727
+ def alternatives_to_render(decision)
728
+ alternatives = Array(value(decision, :alternatives))
729
+ return alternatives unless @missing_only && analysis_available?
730
+
731
+ missing_ids = missing_alternatives_for(decision).map(&:to_s)
732
+ alternatives.select { |alternative| missing_ids.include?(value(alternative, :id).to_s) }
733
+ end
734
+
735
+ def missing_alternatives_for(decision)
736
+ coverage = value(value(analysis_for(decision), :coverage), :alternative)
737
+ Array(value(coverage, :missing_alternatives))
511
738
  end
512
739
 
513
740
  def conditions_to_render(decision)
514
741
  conditions = Array(value(decision, :conditions))
515
742
  return conditions unless @missing_only && analysis_available?
516
743
 
517
- conditions.reject { |condition| value(condition_result(decision, condition), :status).to_s.upcase == "PROVEN" }
744
+ conditions.reject do |condition|
745
+ value(condition_result(decision, condition), :status).to_s.upcase == "PROVEN"
746
+ end
518
747
  end
519
748
 
520
749
  def vectors_to_render(decision)
521
750
  return vectors_for(decision) unless @missing_only
751
+ return [] if nonboolean_decision?(decision)
522
752
 
523
753
  []
524
754
  end
525
755
 
526
756
  def analysis_available?
527
- !@analysis.nil? && @level > 1
757
+ !@analysis.nil?
758
+ end
759
+
760
+ def coverage_available?
761
+ analysis_available? && !value(@analysis, :coverage).nil?
762
+ end
763
+
764
+ # Render the shared ladder in every terminal view.
765
+ def coverage_ladder_lines
766
+ return [] unless coverage_available?
767
+
768
+ aggregate = value(@analysis, :coverage) || {}
769
+ rows = [["D", :decision, :covered_decisions, :supported_decisions],
770
+ ["C", :condition, :covered_values, :required_values],
771
+ ["C/D", :condition_decision, :covered_decisions, :supported_decisions],
772
+ ["MC/DC", :mcdc, :proven_conditions, :supported_conditions]]
773
+ lines = ["Coverage ladder:"]
774
+ rows.each do |label, key, numerator_key, denominator_key|
775
+ row = value(aggregate, key)
776
+ next unless row
777
+
778
+ percentage = value(row, :percentage)
779
+ numerator = value(row, numerator_key) || 0
780
+ denominator = value(row, denominator_key) || 0
781
+ shown = percentage.nil? ? "N/A" : "#{percentage}%"
782
+ denominator_label = if ["D", "C/D"].include?(label)
783
+ "decisions"
784
+ else
785
+ label == "C" ? "truth values" : "conditions"
786
+ end
787
+ qualifier = incomplete? ? " (lower bound)" : ""
788
+ description = case label
789
+ when "D" then "Decision coverage"
790
+ when "C" then "Condition coverage"
791
+ when "C/D" then "Condition/decision coverage"
792
+ else "MC/DC coverage"
793
+ end
794
+ lines << " #{label} (#{description}): #{shown} (#{numerator}/#{denominator} #{denominator_label})#{qualifier}"
795
+ end
796
+ alternative = value(aggregate, :alternative)
797
+ if alternative
798
+ covered = value(alternative, :covered_alternatives) || 0
799
+ required = value(alternative, :required_alternatives) || 0
800
+ percentage = value(alternative, :percentage)
801
+ shown = percentage.nil? ? "N/A" : "#{percentage}%"
802
+ lines << " Alternative coverage: #{covered}/#{required} alternatives (#{shown})"
803
+ end
804
+ lines << ""
805
+ lines
806
+ end
807
+
808
+ def criterion_status_text(label, row)
809
+ raw_status = value(row, :status).to_s.downcase
810
+ status = coverage_status_label(raw_status)
811
+ return "#{label}=#{status}" if raw_status == "unsupported"
812
+
813
+ case label
814
+ when "D"
815
+ "#{label}=#{status} (#{value(row, :covered_outcomes) || 0}/#{value(row, :required_outcomes) || 0} outcomes)"
816
+ when "C"
817
+ "#{label}=#{status} (#{value(row, :covered_values) || 0}/#{value(row, :required_values) || 0} values; " \
818
+ "#{value(row, :covered_conditions) || 0}/#{value(row, :condition_count) || 0} conditions fully covered)"
819
+ when "MC/DC"
820
+ "#{label}=#{status} (#{value(row, :proven_conditions) || 0}/#{value(row, :condition_count) || 0} conditions)"
821
+ else
822
+ "#{label}=#{status}"
823
+ end
528
824
  end
529
825
 
530
826
  def analysis_complete?
@@ -538,11 +834,29 @@ module Branchproof
538
834
  def missing_summary_line
539
835
  return "Missing conditions: Cannot identify missing conditions (analysis unavailable)" unless analysis_available?
540
836
  return "Missing conditions: Cannot identify missing conditions (analysis incomplete)" unless analysis_complete?
541
- return "No eligible conditions" if metrics[:eligible_conditions].zero?
542
- return "No missing conditions" if analysis_complete? && missing_condition_count.zero?
837
+ return "No missing conditions or alternatives" if analysis_complete? && missing_condition_count.zero? &&
838
+ missing_alternatives_count.zero?
839
+ return "No eligible conditions" if metrics[:eligible_conditions].zero? && metrics[:eligible_alternatives].zero?
543
840
 
544
841
  count = decisions_to_render.length
545
- "Missing conditions: #{missing_condition_count} across #{count} #{count == 1 ? "decision" : "decisions"}"
842
+ if missing_alternatives_count.zero?
843
+ label = count == 1 ? "decision" : "decisions"
844
+ return "Missing conditions: #{missing_condition_count} across #{count} #{label}"
845
+ end
846
+ if missing_condition_count.zero?
847
+ label = count == 1 ? "decision" : "decisions"
848
+ return "Missing alternatives: #{missing_alternatives_count} across #{count} #{label}"
849
+ end
850
+
851
+ label = count == 1 ? "decision" : "decisions"
852
+ conditions = missing_condition_count
853
+ alternatives = missing_alternatives_count
854
+ "Missing conditions: #{conditions}; alternatives: #{alternatives} " \
855
+ "across #{count} #{label}"
856
+ end
857
+
858
+ def missing_alternatives_count
859
+ decisions_to_render.sum { |decision| missing_alternatives_for(decision).length }
546
860
  end
547
861
 
548
862
  def analysis_for(decision)
@@ -604,5 +918,8 @@ module Branchproof
604
918
  def normalize_unknown(object)
605
919
  object.to_s
606
920
  end
921
+ public :condition_coverage_evidence, :coverage_ladder_lines, :coverage_status_label
607
922
  end
608
923
  end
924
+
925
+ # rubocop:enable Metrics/BlockLength
@@ -1,12 +1,15 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "securerandom"
4
+ require_relative "runtime_flow"
4
5
 
5
6
  module Branchproof
6
7
  # Process-local execution recorder. It deliberately never coerces or stores
7
8
  # application values: Ruby's conditional expression is used for truthiness.
8
9
  # Captures condition evaluations while preserving application values.
9
10
  module Runtime
11
+ extend RuntimeFlow
12
+
10
13
  class << self
11
14
  def boot(evidence:)
12
15
  return nil if defined?(@booted) && @booted && @evidence.equal?(evidence) && Process.pid == @process_id
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Branchproof
4
+ # Records native Ruby path selection without evaluating application values twice.
5
+ module RuntimeFlow
6
+ def flow_candidate(decision_id, index)
7
+ frame = current_frame(decision_id)
8
+ frame[:observations] << [index, false] if frame
9
+ nil
10
+ end
11
+
12
+ def flow_selected(decision_id)
13
+ frame = current_frame(decision_id)
14
+ if frame && !frame[:observations].empty?
15
+ frame[:observations].last[1] = true
16
+ frame[:outcome] = true
17
+ frame[:finished] = true
18
+ end
19
+ nil
20
+ end
21
+
22
+ def flow_select(decision_id, index)
23
+ frame = current_frame(decision_id)
24
+ if frame
25
+ frame[:observations] = (0..index).map { |position| [position, position == index] }
26
+ frame[:outcome] = true
27
+ frame[:finished] = true
28
+ end
29
+ nil
30
+ end
31
+
32
+ def flow_path(decision_id, index)
33
+ frame = current_frame(decision_id)
34
+ if frame
35
+ frame[:observations] = [[0, index.zero?], [1, index == 1]]
36
+ frame[:outcome] = true
37
+ frame[:finished] = true
38
+ end
39
+ nil
40
+ end
41
+
42
+ def flow_finish(decision_id, value, default_path = nil)
43
+ frame = current_frame(decision_id)
44
+ flow_path(decision_id, default_path) if frame && !frame[:finished] && !default_path.nil?
45
+ value
46
+ end
47
+
48
+ def flow_receiver(decision_id, receiver)
49
+ enter(decision_id)
50
+ begin
51
+ flow_path(decision_id, nil.equal?(receiver) ? 0 : 1)
52
+ ensure
53
+ leave(decision_id)
54
+ end
55
+ receiver
56
+ end
57
+ end
58
+ end