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.
@@ -0,0 +1,294 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Branchproof
4
+ # Discovers control-flow expressions whose truth is not represented by an
5
+ # ordinary Prism IfNode. The records intentionally contain byte ranges and
6
+ # scalar metadata only; Prism nodes must not escape the source pass.
7
+ module DecisionSyntax
8
+ OR_WRITE_NODE_NAMES = %w[
9
+ CallOrWriteNode ClassVariableOrWriteNode ConstantOrWriteNode
10
+ ConstantPathOrWriteNode GlobalVariableOrWriteNode IndexOrWriteNode
11
+ InstanceVariableOrWriteNode LocalVariableOrWriteNode
12
+ ].freeze
13
+
14
+ AND_WRITE_NODE_NAMES = %w[
15
+ CallAndWriteNode ClassVariableAndWriteNode ConstantAndWriteNode
16
+ ConstantPathAndWriteNode GlobalVariableAndWriteNode IndexAndWriteNode
17
+ InstanceVariableAndWriteNode LocalVariableAndWriteNode
18
+ ].freeze
19
+
20
+ def flow_decisions_for(program, bytes, source_id, file_reasons = [], encoding = "UTF-8")
21
+ nodes = []
22
+ walk(program) { |node| nodes << node if flow_decision_node?(node) }
23
+ nodes.sort_by { |node| [node.location.start_offset, node.location.length] }.map do |node|
24
+ build_flow_decision(node, bytes, source_id, file_reasons, encoding)
25
+ end
26
+ end
27
+
28
+ private
29
+
30
+ def flow_decision_node?(node)
31
+ return true if node.is_a?(Prism::CaseNode) && node.predicate
32
+ return true if node.is_a?(Prism::CaseMatchNode)
33
+ return true if node.is_a?(Prism::CallNode) && node.safe_navigation?
34
+ return true if assignment_node?(node)
35
+ return true if node.is_a?(Prism::RescueNode) || node.is_a?(Prism::RescueModifierNode)
36
+
37
+ false
38
+ end
39
+
40
+ def assignment_node?(node)
41
+ OR_WRITE_NODE_NAMES.include?(node.class.name.split("::").last) ||
42
+ AND_WRITE_NODE_NAMES.include?(node.class.name.split("::").last)
43
+ end
44
+
45
+ def build_flow_decision(node, bytes, source_id, file_reasons, encoding)
46
+ location = node.location
47
+ kind, context, alternatives, instrumentation, reasons = flow_details(node, bytes, encoding)
48
+ reasons = Array(file_reasons) + Array(reasons)
49
+ decision_id = Records.decision_id(source_id: source_id, context: context,
50
+ byte_start: location.start_offset, byte_length: location.length, tree: nil)
51
+ alternatives = alternatives.each_with_index.map do |alternative, index|
52
+ alternative.merge(index: index, id: Records.condition_id(decision_id, index))
53
+ end
54
+ limit = @limits[:conditions_per_decision] if defined?(@limits) && @limits.respond_to?(:[])
55
+ reasons << "alternative_limit_exceeded" if limit && alternatives.length > limit
56
+
57
+ Records.build(
58
+ id: decision_id,
59
+ source_id: source_id,
60
+ context: context,
61
+ kind: kind,
62
+ byte_start: location.start_offset,
63
+ byte_length: location.length,
64
+ line: location.start_line,
65
+ column: location.start_column,
66
+ expression: text_value(bytes.byteslice(location.start_offset, location.length), encoding),
67
+ tree: nil,
68
+ conditions: [],
69
+ alternatives: alternatives,
70
+ discovered_condition_count: 0,
71
+ support_status: reasons.empty? ? "SUPPORTED" : "UNSUPPORTED",
72
+ support_reasons: reasons.uniq,
73
+ opaque_ranges: [],
74
+ instrumentation: instrumentation
75
+ )
76
+ end
77
+
78
+ def flow_details(node, bytes, encoding)
79
+ case node
80
+ when Prism::CaseNode
81
+ case_details(node, bytes, encoding, kind: "multiway", context: "case")
82
+ when Prism::CaseMatchNode
83
+ case_match_details(node, bytes, encoding)
84
+ when Prism::CallNode
85
+ safe_navigation_details(node, bytes, encoding)
86
+ when Prism::RescueNode, Prism::RescueModifierNode
87
+ rescue_details(node, bytes, encoding)
88
+ else
89
+ assignment_details(node, bytes, encoding)
90
+ end
91
+ end
92
+
93
+ def case_details(node, bytes, encoding, kind:, context:)
94
+ conditions = node.conditions
95
+ candidates = []
96
+ branches = []
97
+ conditions.each_with_index do |branch, branch_index|
98
+ branch.conditions.each do |candidate|
99
+ candidates << {
100
+ expression: text_value(bytes.byteslice(candidate.location.start_offset, candidate.location.length),
101
+ encoding),
102
+ range: byte_range(candidate.location, splat_node?(candidate) ? "splat" : nil),
103
+ byte_start: candidate.location.start_offset,
104
+ byte_length: candidate.location.length
105
+ }
106
+ end
107
+ branches << {
108
+ index: branch_index,
109
+ insert_at: branch_insert_at(branch, conditions[branch_index + 1], node.else_clause, node.end_keyword_loc),
110
+ empty: statements_empty?(branch.statements)
111
+ }
112
+ end
113
+
114
+ else_clause = node.else_clause
115
+ else_index = candidates.length
116
+ candidates << if else_clause
117
+ { expression: "else", range: byte_range(else_clause.else_keyword_loc),
118
+ byte_start: else_clause.else_keyword_loc.start_offset,
119
+ byte_length: else_clause.else_keyword_loc.length }
120
+ else
121
+ { expression: "no_match", range: nil, byte_start: node.end_keyword_loc&.start_offset,
122
+ byte_length: 0 }
123
+ end
124
+ else_metadata = if else_clause
125
+ { insert_at: branch_insert_at(else_clause, nil, nil, node.end_keyword_loc), index: else_index,
126
+ empty: statements_empty?(else_clause.statements) }
127
+ end
128
+ reasons = unsupported_reasons(node, bytes)
129
+ reasons << "unsupported_case_splat" if candidates.any? do |candidate|
130
+ candidate[:range] && candidate[:range][:kind] == "splat"
131
+ end
132
+ instrumentation = {
133
+ type: "case",
134
+ range: byte_range(node.location),
135
+ predicate: node.predicate && byte_range(node.predicate.location),
136
+ candidates: candidates.reject { |candidate| candidate[:expression] == "else" || candidate[:range].nil? }
137
+ .each_with_index.map { |candidate, index| candidate.merge(index: index) },
138
+ branches: branches,
139
+ else: else_metadata,
140
+ end_start: node.end_keyword_loc&.start_offset
141
+ }
142
+ alternatives = candidates.map do |candidate|
143
+ candidate.slice(:expression, :byte_start, :byte_length)
144
+ end
145
+ [kind, context, alternatives, instrumentation, reasons]
146
+ end
147
+
148
+ def case_match_details(node, bytes, encoding)
149
+ conditions = node.conditions
150
+ candidates = []
151
+ branches = []
152
+ reasons = []
153
+ conditions.each_with_index do |branch, branch_index|
154
+ pattern = branch.pattern
155
+ guarded = pattern.is_a?(Prism::IfNode) || pattern.is_a?(Prism::UnlessNode)
156
+ guard = guarded ? pattern.predicate : nil
157
+ pattern_node = guarded ? pattern.statements&.body&.first : pattern
158
+ reasons << "unsupported_pattern_guard" if guard
159
+ candidate_node = pattern_node || pattern
160
+ candidates << {
161
+ expression: text_value(bytes.byteslice(candidate_node.location.start_offset, candidate_node.location.length),
162
+ encoding),
163
+ range: byte_range(candidate_node.location),
164
+ byte_start: candidate_node.location.start_offset,
165
+ byte_length: candidate_node.location.length,
166
+ guard: guard && byte_range(guard.location)
167
+ }
168
+ branches << {
169
+ index: branch_index,
170
+ insert_at: branch_insert_at(branch, conditions[branch_index + 1], node.else_clause, node.end_keyword_loc),
171
+ empty: statements_empty?(branch.statements)
172
+ }
173
+ end
174
+ else_clause = node.else_clause
175
+ else_metadata = if else_clause
176
+ { insert_at: branch_insert_at(else_clause, nil, nil, node.end_keyword_loc), index: nil,
177
+ empty: statements_empty?(else_clause.statements) }
178
+ end
179
+ instrumentation = {
180
+ type: "case_match",
181
+ range: byte_range(node.location),
182
+ predicate: node.predicate && byte_range(node.predicate.location),
183
+ candidates: candidates.reject { |candidate| candidate[:expression] == "else" || candidate[:range].nil? }
184
+ .each_with_index.map { |candidate, index| candidate.merge(index: index) },
185
+ branches: branches,
186
+ else: else_metadata,
187
+ end_start: node.end_keyword_loc&.start_offset
188
+ }
189
+ if node.else_clause
190
+ else_location = node.else_clause.else_keyword_loc
191
+ candidates << { expression: "else", range: byte_range(else_location),
192
+ byte_start: else_location.start_offset, byte_length: else_location.length }
193
+ else_index = candidates.length - 1
194
+ instrumentation[:else] = instrumentation[:else].merge(index: else_index)
195
+ end
196
+ alternatives = candidates.map { |candidate| candidate.slice(:expression, :byte_start, :byte_length) }
197
+ ["pattern", "case_in", alternatives, instrumentation, unsupported_reasons(node, bytes) + reasons]
198
+ end
199
+
200
+ def safe_navigation_details(node, bytes, _encoding)
201
+ receiver = node.receiver
202
+ instrumentation = {
203
+ type: "safe_navigation",
204
+ range: byte_range(node.location),
205
+ receiver: byte_range(receiver.location)
206
+ }
207
+ alternatives = [
208
+ { expression: "receiver nil", byte_start: receiver.location.start_offset,
209
+ byte_length: receiver.location.length },
210
+ { expression: "receiver non-nil", byte_start: receiver.location.start_offset,
211
+ byte_length: receiver.location.length }
212
+ ]
213
+ ["implicit", "safe_navigation", alternatives, instrumentation, unsupported_reasons(node, bytes)]
214
+ end
215
+
216
+ def assignment_details(node, bytes, _encoding)
217
+ rhs = node.value
218
+ operator = bytes.byteslice(node.operator_loc.start_offset, node.operator_loc.length)
219
+ assignment_context = operator == "||=" ? "or_assignment" : "and_assignment"
220
+ reasons = safe_navigation_assignment?(node) ? ["unsupported_assignment_target"] : []
221
+ instrumentation = {
222
+ type: "assignment",
223
+ range: byte_range(node.location),
224
+ rhs: byte_range(rhs.location),
225
+ operator: operator,
226
+ rhs_path: 1,
227
+ skipped_path: 0
228
+ }
229
+ alternatives = if operator == "||="
230
+ [{ expression: "LHS truthy; RHS skipped", byte_start: node.location.start_offset,
231
+ byte_length: node.location.length },
232
+ { expression: "LHS falsey; RHS executed", byte_start: node.location.start_offset,
233
+ byte_length: node.location.length }]
234
+ else
235
+ [{ expression: "LHS falsey; RHS skipped", byte_start: node.location.start_offset,
236
+ byte_length: node.location.length },
237
+ { expression: "LHS truthy; RHS executed", byte_start: node.location.start_offset,
238
+ byte_length: node.location.length }]
239
+ end
240
+ ["implicit", assignment_context, alternatives, instrumentation, unsupported_reasons(node, bytes) + reasons]
241
+ end
242
+
243
+ def rescue_details(node, bytes, encoding)
244
+ alternatives = if node.respond_to?(:exceptions)
245
+ Array(node.exceptions).map do |exception|
246
+ {
247
+ expression: text_value(
248
+ bytes.byteslice(exception.location.start_offset, exception.location.length), encoding
249
+ ),
250
+ byte_start: exception.location.start_offset,
251
+ byte_length: exception.location.length
252
+ }
253
+ end
254
+ else
255
+ []
256
+ end
257
+ instrumentation = { type: "rescue", range: byte_range(node.location) }
258
+ ["exception", "rescue", alternatives, instrumentation, ["unsupported_rescue_control_flow"]]
259
+ end
260
+
261
+ def branch_insert_at(branch, next_branch, else_clause, end_keyword_loc)
262
+ statements = branch.statements
263
+ return statements.location.start_offset unless statements_empty?(statements)
264
+
265
+ next_location = if next_branch
266
+ next_branch.is_a?(Prism::InNode) ? next_branch.in_loc : next_branch.keyword_loc
267
+ end
268
+ next_location&.start_offset || else_clause&.else_keyword_loc&.start_offset ||
269
+ end_keyword_loc&.start_offset || branch.location.end_offset
270
+ end
271
+
272
+ def statements_empty?(statements)
273
+ statements.nil? || Array(statements.body).empty?
274
+ end
275
+
276
+ def safe_navigation_assignment?(node)
277
+ return true if node.respond_to?(:safe_navigation?) && node.safe_navigation?
278
+
279
+ node.respond_to?(:call_operator_loc) && node.call_operator_loc&.slice == "&."
280
+ end
281
+
282
+ def splat_node?(node)
283
+ node.class.name.end_with?("SplatNode")
284
+ end
285
+
286
+ def byte_range(location, kind = nil)
287
+ return nil unless location
288
+
289
+ result = { byte_start: location.start_offset, byte_length: location.length }
290
+ result[:kind] = kind if kind
291
+ result
292
+ end
293
+ end
294
+ end
@@ -208,11 +208,11 @@ module Branchproof
208
208
  decision = decisions.find { |d| d[:id].to_s == value[:decision_id].to_s }
209
209
  return "unknown decision" unless decision
210
210
 
211
- conditions = Array(decision[:conditions])
212
- return "condition count exceeds limit" if conditions.length > @limits[:conditions_per_decision]
211
+ dimensions = alternative_decision?(decision) ? Array(decision[:alternatives]) : Array(decision[:conditions])
212
+ return "condition count exceeds limit" if !alternative_decision?(decision) && dimensions.length > @limits[:conditions_per_decision]
213
213
  return "invalid condition index" unless value[:observations].map(&:first).uniq == value[:observations].map(&:first) && value[:observations].all? do |index, _|
214
- conditions.any? do |c|
215
- c[:index].to_i == index
214
+ dimensions.any? do |dimension|
215
+ dimension[:index].to_i == index
216
216
  end
217
217
  end
218
218
  return "invalid trace" unless value[:status].to_s != "completed" || valid_trace?(decision, value[:observations],
@@ -222,6 +222,8 @@ module Branchproof
222
222
  end
223
223
 
224
224
  def valid_trace?(decision, observations, outcome)
225
+ return valid_alternative_trace?(decision, observations, outcome) if alternative_decision?(decision)
226
+
225
227
  tree = symbolize(decision[:tree])
226
228
  unless tree
227
229
  return observations.map(&:first) == observations.map(&:first).sort &&
@@ -245,6 +247,13 @@ module Branchproof
245
247
 
246
248
  return [pair[1], cursor + 1]
247
249
  end
250
+ if type == "not"
251
+ child = replay_tree(node.fetch(:child), observations, cursor)
252
+ return nil unless child
253
+
254
+ child_value, next_cursor = child
255
+ return [!child_value, next_cursor]
256
+ end
248
257
  left = replay_tree(node.fetch(:left), observations, cursor)
249
258
  return nil unless left
250
259
 
@@ -265,7 +274,7 @@ module Branchproof
265
274
  end
266
275
 
267
276
  def condition_values(decision_id, observations)
268
- count = decision_conditions(decision_id).length
277
+ count = decision_dimension_count(decision_id)
269
278
  values = Array.new(count)
270
279
  observations.each { |index, value| values[index] = value }
271
280
  values
@@ -306,9 +315,13 @@ module Branchproof
306
315
 
307
316
  def condition_shapes
308
317
  decisions.to_h do |decision|
309
- [decision[:id].to_s, { conditions: Array(decision[:conditions]).map do |condition|
310
- symbolize(condition)
311
- end, tree: symbolize(decision[:tree]) }]
318
+ shape = { conditions: Array(decision[:conditions]).map { |condition| symbolize(condition) },
319
+ tree: symbolize(decision[:tree]) }
320
+ if alternative_decision?(decision)
321
+ shape[:kind] = decision[:kind].to_s
322
+ shape[:alternatives] = Array(decision[:alternatives]).map { |alternative| symbolize(alternative) }
323
+ end
324
+ [decision[:id].to_s, shape]
312
325
  end
313
326
  end
314
327
 
@@ -337,7 +350,9 @@ module Branchproof
337
350
 
338
351
  decision = decisions.find { |item| item[:id].to_s == vector[:decision_id].to_s }
339
352
  return "unknown decision" unless decision
340
- return "invalid vector shape" unless vector[:values].length == Array(decision[:conditions]).length &&
353
+
354
+ expected_values = decision_dimension_count(decision)
355
+ return "invalid vector shape" unless vector[:values].length == expected_values &&
341
356
  vector[:values].all? { |item| item.nil? || item == true || item == false }
342
357
 
343
358
  expected = Branchproof::Records.id([vector[:decision_id].to_s, vector[:values], vector[:outcome] ? true : false])
@@ -375,6 +390,36 @@ module Branchproof
375
390
  nil
376
391
  end
377
392
 
393
+ def alternative_decision?(decision)
394
+ kind = decision[:kind].to_s
395
+ !kind.empty? && kind != "boolean"
396
+ end
397
+
398
+ def decision_dimension_count(decision_or_id)
399
+ decision = decision_or_id.is_a?(Hash) ? decision_or_id : decisions.find { |item| item[:id].to_s == decision_or_id.to_s }
400
+ return 0 unless decision
401
+
402
+ alternative_decision?(decision) ? Array(decision[:alternatives]).length : Array(decision[:conditions]).length
403
+ end
404
+
405
+ def valid_alternative_trace?(decision, observations, outcome)
406
+ return false unless outcome == true
407
+
408
+ alternatives = Array(decision[:alternatives])
409
+ expected = alternatives.length
410
+ return false unless expected.positive?
411
+ if decision[:kind].to_s == "implicit"
412
+ return expected == 2 && observations.length == 2 &&
413
+ observations.map(&:first) == [0, 1] && observations.map(&:last).count(true) == 1
414
+ end
415
+
416
+ return false unless observations.length.between?(1, expected)
417
+
418
+ observations.each_with_index.all? do |(index, value), position|
419
+ index == position && value == (position == observations.length - 1)
420
+ end
421
+ end
422
+
378
423
  def decisions = Array(fetch_value(@inventory, :decisions)).map { symbolize(_1) }
379
424
  def decision_conditions(id) = (decisions.find { |d| d[:id].to_s == id.to_s } || {}).fetch(:conditions, [])
380
425
  def vector_count(id) = @vectors.values.count { |v| v[:decision_id] == id.to_s }
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Keep each bounded source rewrite together so its evaluation order can be audited.
4
+ # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
5
+
6
+ module Branchproof
7
+ # Source-location edits around Ruby's native matching and assignment operations.
8
+ module FlowInstrumentation
9
+ private
10
+
11
+ def render_flow(bytes, decision, nested)
12
+ metadata = decision.fetch(:instrumentation)
13
+ case metadata.fetch(:type)
14
+ when "safe_navigation"
15
+ receiver = metadata.fetch(:receiver)
16
+ replacements = [flow_replacement(bytes, receiver, nested) do |expression|
17
+ "#{self.class::RUNTIME}.flow_receiver(#{decision[:id].inspect}, (#{expression}))"
18
+ end]
19
+ flow_fragments(bytes, decision, nested, replacements)
20
+ when "assignment"
21
+ rhs = metadata.fetch(:rhs)
22
+ replacements = [flow_replacement(bytes, rhs, nested) do |expression|
23
+ "(begin; #{self.class::RUNTIME}.flow_path(#{decision[:id].inspect}, 1); (#{expression}); end)"
24
+ end]
25
+ expression = flow_fragments(bytes, decision, nested, replacements)
26
+ flow_frame(decision[:id], expression, default_path: 0)
27
+ when "case"
28
+ render_case_flow(bytes, decision, nested, metadata)
29
+ when "case_match"
30
+ render_pattern_flow(bytes, decision, nested, metadata)
31
+ else
32
+ raise ArgumentError, "unknown instrumentation type: #{metadata[:type]}"
33
+ end
34
+ end
35
+
36
+ def render_case_flow(bytes, decision, nested, metadata)
37
+ identifier = decision[:id].inspect
38
+ runtime = self.class::RUNTIME
39
+ replacements = metadata.fetch(:candidates).map do |candidate|
40
+ flow_replacement(bytes, candidate, nested) do |expression|
41
+ "(begin; #{runtime}.flow_candidate(#{identifier}, #{candidate[:index]}); (#{expression}); end)"
42
+ end
43
+ end
44
+ metadata.fetch(:branches).each do |branch|
45
+ suffix = branch[:empty] ? "nil; " : ""
46
+ replacements << { start: branch[:insert_at], length: 0,
47
+ text: "; #{runtime}.flow_selected(#{identifier}); #{suffix}" }
48
+ end
49
+ if metadata[:else]
50
+ alternative = metadata[:else]
51
+ replacements << { start: alternative[:insert_at], length: 0,
52
+ text: "; #{runtime}.flow_select(#{identifier}, #{alternative[:index]}); " }
53
+ else
54
+ index = decision.fetch(:alternatives).length - 1
55
+ replacements << { start: metadata.fetch(:end_start), length: 0,
56
+ text: "else; #{runtime}.flow_select(#{identifier}, #{index}); nil; " }
57
+ end
58
+ flow_frame(decision[:id], flow_fragments(bytes, decision, nested, replacements))
59
+ end
60
+
61
+ def render_pattern_flow(bytes, decision, nested, metadata)
62
+ identifier = decision[:id].inspect
63
+ runtime = self.class::RUNTIME
64
+ replacements = metadata.fetch(:branches).map do |branch|
65
+ suffix = branch[:empty] ? "nil; " : ""
66
+ { start: branch[:insert_at], length: 0,
67
+ text: "; #{runtime}.flow_select(#{identifier}, #{branch[:index]}); #{suffix}" }
68
+ end
69
+ if metadata[:else]
70
+ alternative = metadata[:else]
71
+ replacements << { start: alternative[:insert_at], length: 0,
72
+ text: "; #{runtime}.flow_select(#{identifier}, #{alternative[:index]}); " }
73
+ end
74
+ flow_frame(decision[:id], flow_fragments(bytes, decision, nested, replacements))
75
+ end
76
+
77
+ def flow_replacement(bytes, location, nested)
78
+ start = location.fetch(:byte_start)
79
+ length = location.fetch(:byte_length)
80
+ { start: start, length: length, text: yield(render_children(bytes, start, length, nested)) }
81
+ end
82
+
83
+ def flow_fragments(bytes, decision, nested, replacements)
84
+ cursor = decision.fetch(:byte_start)
85
+ finish = cursor + decision.fetch(:byte_length)
86
+ chunks = []
87
+ replacements.sort_by { |edit| [edit[:start], edit[:length]] }.each do |edit|
88
+ raise ArgumentError, "overlapping flow edits" if edit[:start] < cursor
89
+
90
+ chunks << render_children(bytes, cursor, edit[:start] - cursor, nested)
91
+ chunks << edit[:text]
92
+ cursor = edit[:start] + edit[:length]
93
+ end
94
+ chunks << render_children(bytes, cursor, finish - cursor, nested)
95
+ chunks.join
96
+ end
97
+
98
+ def flow_frame(decision_id, expression, default_path: nil)
99
+ runtime = self.class::RUNTIME
100
+ "(begin; #{runtime}.enter(#{decision_id.inspect}); begin; " \
101
+ "#{runtime}.flow_finish(#{decision_id.inspect}, (#{expression}), #{default_path.inspect}); ensure; " \
102
+ "#{runtime}.leave(#{decision_id.inspect}); end; end)"
103
+ end
104
+ end
105
+ end
106
+
107
+ # rubocop:enable Metrics/AbcSize, Metrics/MethodLength
@@ -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
@@ -35,7 +39,12 @@ module Branchproof
35
39
  lines << "Empty groups mean no recorded completed observation."
36
40
  lines << ""
37
41
  lines.concat(@coordinator.coverage_ladder_lines)
38
- @view == :conditions ? render_conditions(lines) : render_tests(lines)
42
+ if @view == :conditions
43
+ render_conditions(lines)
44
+ render_alternatives(lines)
45
+ else
46
+ render_tests(lines)
47
+ end
39
48
  render_unowned(lines)
40
49
  render_unsupported(lines)
41
50
  Array(fetch(@document, :diagnostics)).each do |diagnostic|
@@ -53,6 +62,8 @@ module Branchproof
53
62
  lines << "Condition: #{row[:expression]}"
54
63
  lines << "Location: #{location(row[:relative_path], row[:line], unavailable: "condition line unavailable")}"
55
64
  lines << "Decision: #{row[:decision_expression]} (condition #{row[:index]})"
65
+ lines << "Kind: #{row[:kind]}"
66
+ lines << "Context: #{row[:context]}" unless row[:context].to_s.empty?
56
67
  status = if @level == 1 && !coverage_available?
57
68
  "NOT CALCULATED"
58
69
  else
@@ -106,16 +117,52 @@ module Branchproof
106
117
  end
107
118
  end
108
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
+
109
151
  def render_tests(lines)
110
152
  rows = @index.tests
111
153
  missing_ids = @index.conditions.reject { |row| row[:status].to_s.upcase == "PROVEN" }.map { |row| row[:id] }
112
- 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) }
113
157
  end
114
158
 
115
- def render_test_row(lines, row, missing_ids)
159
+ def render_test_row(lines, row, missing_ids, missing_alternative_ids = [])
116
160
  observations = row[:observations]
117
161
  if @missing_only
118
- 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
119
166
  return if observations.empty?
120
167
  end
121
168
  lines << "Test: #{row[:name]}"
@@ -131,31 +178,51 @@ module Branchproof
131
178
  lines << " No recorded completed condition observations"
132
179
  return
133
180
  end
134
- 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|
135
183
  observation = items.first
136
- observed_values = items.map { |item| item[:value] }.uniq
137
- label = observed_values.all?(&:nil?) ? "short-circuited-only" : "evaluated"
138
- label += "; owns canonical witness evidence" if @level > 1 && items.any? { |item| item[:owns_witness] }
139
184
  phases = items.flat_map { |item| item[:phases] }.uniq.sort.join(", ")
140
- signs = values(values: observed_values)
141
- condition_location = location(observation[:relative_path], observation[:line],
142
- unavailable: "condition line unavailable")
143
- lines << " #{observation[:expression]} (#{condition_location}): #{signs}; #{label}; " \
144
- "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
145
201
  end
146
202
  end
147
203
 
148
204
  def render_unowned(lines)
149
205
  rows = @index.conditions
150
206
  rows = rows.reject { |row| row[:status] == "PROVEN" } if @missing_only
151
- { "Unexecuted conditions" => rows.select { |row| row[:unexecuted] },
152
- "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|
153
220
  next if conditions.empty?
154
221
 
155
222
  lines << "#{heading}:"
156
223
  conditions.each do |row|
157
- lines << " #{row[:expression]} (#{location(row[:relative_path], row[:line],
158
- 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)})"
159
226
  end
160
227
  end
161
228
  end