branchproof 0.6.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,10 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "flow_instrumentation"
4
+
3
5
  module Branchproof
4
6
  # Applies the smallest possible source edits around inventoried expressions.
5
7
  # The edits are deliberately textual: Prism owns the ranges, while this class
6
8
  # never evaluates application code or introduces a Ruby scope.
7
9
  class Instrumenter
10
+ include FlowInstrumentation
11
+
8
12
  RUNTIME = "::Branchproof::Runtime"
9
13
 
10
14
  def rewrite(unit:)
@@ -17,8 +21,7 @@ module Branchproof
17
21
  decisions = Array(unit[:decisions]).select { |decision| supported?(decision) }
18
22
  edits = decisions.filter_map do |decision|
19
23
  next if decisions.any? do |outer|
20
- outer != decision && contains?(outer[:byte_start], outer[:byte_length], decision[:byte_start],
21
- decision[:byte_length])
24
+ encloses_decision?(outer, decision)
22
25
  end
23
26
 
24
27
  decision_edit(bytes, decision, decisions)
@@ -57,13 +60,13 @@ module Branchproof
57
60
  return nil unless valid_range?(bytes, start, length)
58
61
 
59
62
  nested = all_decisions.select do |candidate|
60
- candidate != decision && contains?(start, length, candidate[:byte_start], candidate[:byte_length])
63
+ encloses_decision?(decision, candidate)
61
64
  end
62
- expression = render_range(bytes, start, length, decision, nested)
65
+ expression = render_decision(bytes, decision, nested)
63
66
  {
64
67
  start: start,
65
68
  finish: start + length,
66
- text: frame(decision[:id], expression)
69
+ text: expression
67
70
  }
68
71
  end
69
72
 
@@ -90,24 +93,36 @@ module Branchproof
90
93
  contains?(start, length, child[:byte_start], child[:byte_length])
91
94
  end
92
95
  children = children.reject do |child|
93
- nested.any? do |candidate|
94
- candidate != child && contains?(candidate[:byte_start], candidate[:byte_length], child[:byte_start],
95
- child[:byte_length])
96
- end
96
+ children.any? { |candidate| encloses_decision?(candidate, child) }
97
97
  end
98
98
  children.sort_by! { |child| -child[:byte_start] }
99
99
  output = bytes.byteslice(start, length)
100
100
  children.each do |child|
101
- child_text = render_range(bytes, child[:byte_start], child[:byte_length], child, nested.reject do |item|
102
- item.equal?(child)
103
- end)
104
- child_text = frame(child[:id], child_text)
101
+ descendants = nested.select { |candidate| encloses_decision?(child, candidate) }
102
+ child_text = render_decision(bytes, child, descendants)
105
103
  offset = child[:byte_start] - start
106
104
  output[offset, child[:byte_length]] = child_text
107
105
  end
108
106
  output
109
107
  end
110
108
 
109
+ def encloses_decision?(outer, inner)
110
+ return false if outer.equal?(inner) || outer == inner
111
+ return false unless contains?(outer[:byte_start], outer[:byte_length], inner[:byte_start], inner[:byte_length])
112
+ return true unless outer[:byte_start] == inner[:byte_start] && outer[:byte_length] == inner[:byte_length]
113
+
114
+ (outer[:kind] || "boolean") == "boolean" && inner[:kind] != "boolean" && !inner[:kind].nil?
115
+ end
116
+
117
+ def render_decision(bytes, decision, nested)
118
+ if decision[:instrumentation]
119
+ render_flow(bytes, decision, nested)
120
+ else
121
+ expression = render_range(bytes, decision[:byte_start], decision[:byte_length], decision, nested)
122
+ frame(decision[:id], expression)
123
+ end
124
+ end
125
+
111
126
  def condition_wrapper(decision_id, index, expression)
112
127
  "#{RUNTIME}.condition(#{decision_id.inspect}, #{index}, (#{expression}))"
113
128
  end
@@ -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,
@@ -103,7 +105,9 @@ module Branchproof
103
105
  return 1 if status == "FAILED"
104
106
  return 2 unless status == "PASSED" && value(@baseline, :finalized) == true
105
107
  return 2 if @saved_document && (value(@saved_document, :completeness) || {}).values.include?(false)
106
- return 2 unless metrics[:eligible_conditions].positive?
108
+
109
+ eligible = metrics[:eligible_conditions].positive? || metrics[:eligible_alternatives].positive?
110
+ return 2 unless eligible
107
111
  return 2 unless valid_for_requested_level?
108
112
 
109
113
  0
@@ -118,7 +122,7 @@ module Branchproof
118
122
  tool_version: (defined?(Branchproof::VERSION) ? Branchproof::VERSION : "unknown"),
119
123
  criterion_version: CRITERION_VERSION, runtime: RUBY_DESCRIPTION,
120
124
  run_ids: Array(value(@evidence, :run_ids)),
121
- source_inventory: @inventory, baseline: @baseline, observations: @evidence,
125
+ source_inventory: inventory_with_default_kinds, baseline: @baseline, observations: @evidence,
122
126
  analysis: @analysis, minima: @minima, metrics: metrics,
123
127
  diagnostics: @diagnostics, completeness: completeness,
124
128
  run_metadata: @run_metadata)
@@ -137,12 +141,13 @@ module Branchproof
137
141
  "Analysis: #{terminal_analysis_status}",
138
142
  "Decisions: #{metrics[:supported]} supported, #{metrics[:unsupported]} excluded, " \
139
143
  "#{metrics[:unexecuted]} unexecuted (#{metrics[:discovered]} discovered)",
144
+ decision_kind_summary_line,
140
145
  "Observations: #{metrics[:completed]} completed, #{metrics[:aborted]} aborted, " \
141
146
  "#{metrics[:unattributed]} unattributed",
142
- "Values: T=true, F=false, -=short-circuited"]
147
+ values_legend]
143
148
  lines.concat(coverage_ladder_lines)
144
149
  lines << missing_summary_line if @missing_only
145
- lines << "Scope: supported decisions and conditions"
150
+ lines << "Scope: supported decisions, conditions, and alternatives"
146
151
  lines << ""
147
152
  decisions_to_render.each { |decision| render_decision(lines, decision) }
148
153
  render_minima(lines) unless @missing_only
@@ -159,18 +164,23 @@ module Branchproof
159
164
  decision_label = "Decision #{short_id(value(decision, :id))} #{filename}:#{value(decision, :line)}"
160
165
  lines << decision_label
161
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?
162
170
  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?
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
174
184
  end
175
185
  render_decision_coverage(lines, decision) if coverage_available?
176
186
  vectors_to_render(decision).each do |vector|
@@ -183,8 +193,23 @@ module Branchproof
183
193
  end.join
184
194
  owners = Array(value(vector, :test_ids)).map { |test_id| test_label(test_id) }
185
195
  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"}"
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
188
213
  lines << "#{vector_label} owners=#{owners.join(", ")}"
189
214
  end
190
215
  lines << ""
@@ -197,6 +222,11 @@ module Branchproof
197
222
  end
198
223
 
199
224
  def render_decision_coverage(lines, decision)
225
+ if nonboolean_decision?(decision)
226
+ render_alternative_coverage(lines, decision)
227
+ return
228
+ end
229
+
200
230
  decision_coverage = value(value(analysis_for(decision), :coverage), :decision)
201
231
  all_coverage = value(analysis_for(decision), :coverage) || {}
202
232
  statuses = [["D", :decision], ["C", :condition], ["C/D", :condition_decision],
@@ -226,6 +256,51 @@ module Branchproof
226
256
  end.join(", ")}"
227
257
  end
228
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
+
229
304
  def render_condition_coverage(lines, _decision, condition, result)
230
305
  coverage = value(result, :coverage)
231
306
  return unless coverage
@@ -520,23 +595,73 @@ module Branchproof
520
595
  vectors.find { |vector| value(vector, :id).to_s == id }
521
596
  end
522
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
+
523
641
  def metrics
524
642
  decisions = inventory_decisions
525
643
  unsupported, supported = decisions.partition { |decision| unsupported?(decision) }
526
644
  eligible = supported.sum { |decision| Array(value(decision, :conditions)).length }
527
645
  observed = vectors.map { |vector| value(vector, :decision_id).to_s }.uniq
528
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)
529
650
  { discovered: decisions.length, supported: supported.length, unsupported: unsupported.length,
530
651
  unsupported_conditions: unsupported.sum do |decision|
531
652
  discovered_conditions(decision)
532
653
  end, eligible_conditions: eligible,
533
654
  opaque: decisions.sum { |decision| Array(value(decision, :opaque_ranges)).length },
534
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,
535
659
  completed: vectors.sum do |vector|
536
660
  value(vector, :count).to_i
537
661
  end, aborted: numeric_hash_value(@evidence, :abort_counts),
538
662
  unattributed: vectors.sum { |vector| value(vector, :unattributed_count).to_i }, limited: incomplete? ? 1 : 0,
539
- 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 }
540
665
  end
541
666
 
542
667
  def completeness
@@ -594,18 +719,36 @@ module Branchproof
594
719
 
595
720
  inventory_decisions
596
721
  .reject { |decision| unsupported?(decision) }
597
- .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))
598
738
  end
599
739
 
600
740
  def conditions_to_render(decision)
601
741
  conditions = Array(value(decision, :conditions))
602
742
  return conditions unless @missing_only && analysis_available?
603
743
 
604
- 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
605
747
  end
606
748
 
607
749
  def vectors_to_render(decision)
608
750
  return vectors_for(decision) unless @missing_only
751
+ return [] if nonboolean_decision?(decision)
609
752
 
610
753
  []
611
754
  end
@@ -650,6 +793,14 @@ module Branchproof
650
793
  end
651
794
  lines << " #{label} (#{description}): #{shown} (#{numerator}/#{denominator} #{denominator_label})#{qualifier}"
652
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
653
804
  lines << ""
654
805
  lines
655
806
  end
@@ -683,11 +834,29 @@ module Branchproof
683
834
  def missing_summary_line
684
835
  return "Missing conditions: Cannot identify missing conditions (analysis unavailable)" unless analysis_available?
685
836
  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?
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?
688
840
 
689
841
  count = decisions_to_render.length
690
- "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 }
691
860
  end
692
861
 
693
862
  def analysis_for(decision)
@@ -752,3 +921,5 @@ module Branchproof
752
921
  public :condition_coverage_evidence, :coverage_ladder_lines, :coverage_status_label
753
922
  end
754
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