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,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # rubocop:disable Metrics/ClassLength, Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
3
+ # rubocop:disable Metrics/ClassLength, Metrics/AbcSize, Metrics/BlockLength, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
4
4
  require "pathname"
5
5
 
6
6
  module Branchproof
@@ -23,7 +23,11 @@ module Branchproof
23
23
  completeness = fetch(@document, :completeness) || {}
24
24
  evidence = fetch(@document, :observations) || {}
25
25
  lines = ["Branchproof focused view: #{@view}", "Tests: #{fetch(baseline, :status) || "INCOMPLETE"}",
26
- "Values: T=true, F=false, -=short-circuited"]
26
+ (if @index.alternatives.empty?
27
+ "Values: T=true, F=false, -=short-circuited"
28
+ else
29
+ "Values: Boolean T=true/F=false; flow T=selected, F=not-selected, -=skipped"
30
+ end)]
27
31
  if completeness.values.include?(false) || fetch(baseline, :status).to_s != "PASSED"
28
32
  lines << "Warning: failed or incomplete run; observations and proof evidence may be unavailable."
29
33
  end
@@ -34,7 +38,13 @@ module Branchproof
34
38
  end
35
39
  lines << "Empty groups mean no recorded completed observation."
36
40
  lines << ""
37
- @view == :conditions ? render_conditions(lines) : render_tests(lines)
41
+ lines.concat(@coordinator.coverage_ladder_lines)
42
+ if @view == :conditions
43
+ render_conditions(lines)
44
+ render_alternatives(lines)
45
+ else
46
+ render_tests(lines)
47
+ end
38
48
  render_unowned(lines)
39
49
  render_unsupported(lines)
40
50
  Array(fetch(@document, :diagnostics)).each do |diagnostic|
@@ -52,7 +62,17 @@ module Branchproof
52
62
  lines << "Condition: #{row[:expression]}"
53
63
  lines << "Location: #{location(row[:relative_path], row[:line], unavailable: "condition line unavailable")}"
54
64
  lines << "Decision: #{row[:decision_expression]} (condition #{row[:index]})"
55
- lines << "MC/DC: #{@level == 1 ? "NOT CALCULATED" : (row[:status] || "NOT_PROVEN")}"
65
+ lines << "Kind: #{row[:kind]}"
66
+ lines << "Context: #{row[:context]}" unless row[:context].to_s.empty?
67
+ status = if @level == 1 && !coverage_available?
68
+ "NOT CALCULATED"
69
+ else
70
+ row[:status] || "NOT_PROVEN"
71
+ end
72
+ lines << "MC/DC: #{status}"
73
+ if @level >= 2
74
+ lines.concat(@coordinator.condition_coverage_evidence(decision_id: row[:decision_id], condition_id: row[:id]))
75
+ end
56
76
  render_group(lines, "Evaluated true by", row[:observed_true], row)
57
77
  render_group(lines, "Evaluated false by", row[:observed_false], row)
58
78
  render_group(lines, "Short-circuited in", row[:short_circuited], row)
@@ -79,6 +99,11 @@ module Branchproof
79
99
  lines << "No missing conditions" if @missing_only && rows.empty?
80
100
  end
81
101
 
102
+ def coverage_available?
103
+ analysis = fetch(@document, :analysis)
104
+ analysis && fetch(analysis, :coverage)
105
+ end
106
+
82
107
  def render_constraint(lines, row)
83
108
  lines << @coordinator.condition_explanation(decision_id: row[:decision_id], condition_id: row[:id])
84
109
  end
@@ -92,16 +117,52 @@ module Branchproof
92
117
  end
93
118
  end
94
119
 
120
+ def render_alternatives(lines)
121
+ rows = @index.alternatives
122
+ rows = rows.select { |row| row[:missing] || row[:status].to_s != "covered" } if @missing_only && @level > 1
123
+ rows.each do |row|
124
+ lines << "Alternative #{row[:index]}: #{row[:expression]}"
125
+ lines << "Location: #{location(row[:relative_path], row[:line],
126
+ unavailable: "alternative location unavailable")}"
127
+ lines << "Decision: #{row[:decision_expression]} (alternative #{row[:index]})"
128
+ lines << "Kind: #{row[:kind]}"
129
+ lines << "Context: #{row[:context]}" unless row[:context].to_s.empty?
130
+ lines << "Selection: #{row[:status] || "NOT_CALCULATED"}"
131
+ lines << "MC/DC: N/A (not applicable)"
132
+ render_alternative_group(lines, "Selected by", row[:selected])
133
+ render_alternative_group(lines, "Not selected by", row[:not_selected])
134
+ render_alternative_group(lines, "Skipped in", row[:skipped])
135
+ if row[:missing]
136
+ lines << " Missing alternative: #{row[:expression]}"
137
+ lines << " Need selection of: #{row[:expression]}"
138
+ end
139
+ lines << ""
140
+ end
141
+ lines << "No missing alternatives" if @missing_only && rows.empty?
142
+ end
143
+
144
+ def render_alternative_group(lines, heading, evidence)
145
+ evidence ||= {}
146
+ ids = Array(evidence[:test_ids]).map { |id| test_label(id) }
147
+ ids << "unattributed" if evidence[:unattributed_count].to_i.positive?
148
+ lines << " #{heading}: #{ids.empty? ? "none recorded" : ids.uniq.join(", ")}"
149
+ end
150
+
95
151
  def render_tests(lines)
96
152
  rows = @index.tests
97
153
  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) }
154
+ missing_alternative_ids = @index.alternatives.select { |row| row[:missing] || row[:status].to_s != "covered" }
155
+ .map { |row| row[:alternative_id] }
156
+ rows.each { |row| render_test_row(lines, row, missing_ids, missing_alternative_ids) }
99
157
  end
100
158
 
101
- def render_test_row(lines, row, missing_ids)
159
+ def render_test_row(lines, row, missing_ids, missing_alternative_ids = [])
102
160
  observations = row[:observations]
103
161
  if @missing_only
104
- observations = observations.select { |observation| missing_ids.include?(observation[:condition_id]) }
162
+ observations = observations.select do |observation|
163
+ missing_ids.include?(observation[:condition_id]) ||
164
+ missing_alternative_ids.include?(observation[:alternative_id])
165
+ end
105
166
  return if observations.empty?
106
167
  end
107
168
  lines << "Test: #{row[:name]}"
@@ -117,31 +178,51 @@ module Branchproof
117
178
  lines << " No recorded completed condition observations"
118
179
  return
119
180
  end
120
- observations.group_by { |observation| observation[:condition_id] }.each_value do |items|
181
+ grouped = observations.group_by { |observation| observation[:alternative_id] || observation[:condition_id] }
182
+ grouped.each_value do |items|
121
183
  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
184
  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}"
185
+ if observation[:alternative_id]
186
+ states = items.map { |item| item[:alternative_state] }.uniq.join(", ")
187
+ alternative_location = location(observation[:relative_path], observation[:line],
188
+ unavailable: "alternative location unavailable")
189
+ lines << " Alternative #{observation[:alternative_id]}: #{observation[:expression]} " \
190
+ "(#{alternative_location}): #{states}; phases: #{phases}"
191
+ else
192
+ observed_values = items.map { |item| item[:value] }.uniq
193
+ label = observed_values.all?(&:nil?) ? "short-circuited-only" : "evaluated"
194
+ label += "; owns canonical witness evidence" if @level > 1 && items.any? { |item| item[:owns_witness] }
195
+ signs = values(values: observed_values)
196
+ condition_location = location(observation[:relative_path], observation[:line],
197
+ unavailable: "condition line unavailable")
198
+ lines << " #{observation[:expression]} (#{condition_location}): #{signs}; #{label}; " \
199
+ "phases: #{phases}"
200
+ end
131
201
  end
132
202
  end
133
203
 
134
204
  def render_unowned(lines)
135
205
  rows = @index.conditions
136
206
  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|
207
+ groups = { "Unexecuted conditions" => rows.select { |row| row[:unexecuted] },
208
+ "Unattributed evidence" => rows.select { |row| row[:unattributed].positive? } }
209
+ alternative_rows = @index.alternatives
210
+ alternative_rows = alternative_rows.reject { |row| row[:status].to_s == "covered" } if @missing_only
211
+ unless alternative_rows.empty?
212
+ groups["Unexecuted alternatives"] = alternative_rows.select do |row|
213
+ row[:selected][:observed] == false && row[:not_selected][:observed] == false
214
+ end
215
+ groups["Unattributed alternative evidence"] = alternative_rows.select do |row|
216
+ %i[selected not_selected skipped].any? { |state| row[state][:unattributed_count].to_i.positive? }
217
+ end
218
+ end
219
+ groups.each do |heading, conditions|
139
220
  next if conditions.empty?
140
221
 
141
222
  lines << "#{heading}:"
142
223
  conditions.each do |row|
143
- lines << " #{row[:expression]} (#{location(row[:relative_path], row[:line],
144
- unavailable: "condition line unavailable")})"
224
+ unavailable = row[:alternative_id] ? "alternative location unavailable" : "condition line unavailable"
225
+ lines << " #{row[:expression]} (#{location(row[:relative_path], row[:line], unavailable: unavailable)})"
145
226
  end
146
227
  end
147
228
  end
@@ -202,4 +283,4 @@ module Branchproof
202
283
  end
203
284
  end
204
285
 
205
- # rubocop:enable Metrics/ClassLength, Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
286
+ # rubocop:enable Metrics/ClassLength, Metrics/AbcSize, Metrics/BlockLength, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
@@ -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