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.
@@ -7,7 +7,7 @@ require "pathname"
7
7
  module Branchproof
8
8
  # Derives condition- and test-oriented rows from one report document.
9
9
  class CoverageIndex
10
- attr_reader :conditions, :tests
10
+ attr_reader :conditions, :alternatives, :tests
11
11
 
12
12
  def initialize(document:)
13
13
  @document = symbolize(document || {})
@@ -27,6 +27,7 @@ module Branchproof
27
27
  @tests_by_id[id] = (@tests_by_id[id] || {}).merge(test) unless id.empty?
28
28
  end
29
29
  @conditions = build_conditions.freeze
30
+ @alternatives = build_alternatives.freeze
30
31
  @tests = build_tests.freeze
31
32
  end
32
33
 
@@ -62,7 +63,7 @@ module Branchproof
62
63
  id: condition[:id], decision_id: decision[:id], index: condition[:index],
63
64
  expression: condition[:expression],
64
65
  line: condition[:line], column: condition[:column], relative_path: relative_path(source[:relative_path]),
65
- decision_expression: decision[:expression],
66
+ decision_expression: decision[:expression], kind: decision_kind(decision), context: decision[:context],
66
67
  status: @document[:analysis] ? (result[:status] || "NOT_PROVEN") : "NOT CALCULATED",
67
68
  observed_true: test_ids(observations[true]), observed_false: test_ids(observations[false]),
68
69
  short_circuited: test_ids(observations[:short_circuited]),
@@ -80,12 +81,69 @@ module Branchproof
80
81
  end
81
82
  end
82
83
 
84
+ def build_alternatives
85
+ records(@inventory, :decisions).flat_map do |decision|
86
+ next [] if decision[:support_status].to_s == "UNSUPPORTED"
87
+ next [] if boolean_decision?(decision)
88
+
89
+ source = @source_units_by_id[decision[:source_id].to_s] || {}
90
+ coverage = symbolize(analysis_for(decision)[:coverage] || {})[:alternative] || {}
91
+ coverage_rows = records(coverage, :alternatives).to_h { |row| [row[:alternative_id].to_s, row] }
92
+ vectors = @vectors_by_decision[decision[:id].to_s] || []
93
+ records(decision, :alternatives).map do |alternative|
94
+ alternative = symbolize(alternative)
95
+ evidence = { selected: [], not_selected: [], skipped: [] }
96
+ vectors.each do |vector|
97
+ value = vector_values(vector)[alternative[:index].to_i]
98
+ state = if value.nil?
99
+ :skipped
100
+ else
101
+ (value ? :selected : :not_selected)
102
+ end
103
+ evidence[state] << vector
104
+ end
105
+ row = symbolize(coverage_rows[alternative[:id].to_s] || {})
106
+ {
107
+ id: alternative[:id], alternative_id: alternative[:id], decision_id: decision[:id],
108
+ index: alternative[:index], expression: alternative[:expression],
109
+ line: alternative[:line], column: alternative[:column],
110
+ relative_path: relative_path(source[:relative_path]),
111
+ decision_expression: decision[:expression], kind: decision_kind(decision),
112
+ context: decision[:context],
113
+ status: @document[:analysis] ? alternative_status(row, evidence) : "NOT CALCULATED",
114
+ selected: row[:selected] || evidence_bucket(evidence[:selected]),
115
+ not_selected: row[:not_selected] || evidence_bucket(evidence[:not_selected]),
116
+ skipped: row[:skipped] || evidence_bucket(evidence[:skipped]),
117
+ vectors: evidence, missing: missing_alternative?(coverage, alternative)
118
+ }
119
+ end
120
+ end
121
+ end
122
+
83
123
  def build_tests
84
124
  by_id = @tests_by_id.transform_values do |test|
85
125
  { id: test[:id], name: display_name(test), relative_path: test_location(test)[0], line: test_location(test)[1],
86
126
  status: test[:status], phases: Array(test[:phase_counts]).to_h.keys.sort,
87
127
  observations: [], owns_witness: false }
88
128
  end
129
+ @alternatives.each do |alternative|
130
+ alternative[:vectors].each do |kind, vectors|
131
+ vectors.each do |vector|
132
+ Array(vector[:test_ids]).each do |id|
133
+ row = by_id[id.to_s] ||= { id: id, name: id, relative_path: nil, line: nil, status: "unknown",
134
+ phases: [], observations: [], owns_witness: false }
135
+ phase_map = symbolize(vector[:phases_by_test] || {})
136
+ phases = phase_map[id.to_s] || phase_map[id.to_sym] || []
137
+ row[:phases] |= Array(phases).map(&:to_s)
138
+ row[:observations] << { alternative_id: alternative[:id], expression: alternative[:expression],
139
+ relative_path: alternative[:relative_path], line: alternative[:line],
140
+ value: kind == :skipped ? nil : (kind == :selected), kind: :alternative,
141
+ alternative_state: kind.to_s, phases: phases.map(&:to_s).sort,
142
+ owns_witness: false }
143
+ end
144
+ end
145
+ end
146
+ end
89
147
  @conditions.each do |condition|
90
148
  condition[:vectors].each do |kind, vectors|
91
149
  next if kind == :unattributed
@@ -118,6 +176,36 @@ module Branchproof
118
176
  @analysis_by_decision[decision[:id].to_s] || {}
119
177
  end
120
178
 
179
+ def boolean_decision?(decision)
180
+ decision_kind(decision) == "boolean"
181
+ end
182
+
183
+ def decision_kind(decision)
184
+ kind = decision[:kind].to_s
185
+ kind.empty? ? "boolean" : kind
186
+ end
187
+
188
+ def evidence_bucket(vectors)
189
+ evidence = vectors.map do |vector|
190
+ { vector_id: vector[:id].to_s, test_ids: Array(vector[:test_ids]).map(&:to_s).uniq.sort,
191
+ unattributed_count: vector[:unattributed_count].to_i }
192
+ end
193
+ { observed: !vectors.empty?, vector_ids: evidence.map { |item| item[:vector_id] }.uniq.sort,
194
+ test_ids: evidence.flat_map { |item| item[:test_ids] }.uniq.sort,
195
+ unattributed_count: evidence.sum { |item| item[:unattributed_count] } }
196
+ end
197
+
198
+ def missing_alternative?(coverage, alternative)
199
+ Array(coverage[:missing_alternatives]).map(&:to_s).include?(alternative[:id].to_s)
200
+ end
201
+
202
+ def alternative_status(row, evidence)
203
+ status = row[:status].to_s
204
+ return status unless status.empty?
205
+
206
+ evidence[:selected].empty? ? "unexecuted" : "covered"
207
+ end
208
+
121
209
  def supporting_set_for(decision_id)
122
210
  Array(@document[:minima] || @document["minima"]).filter_map do |minimum|
123
211
  minimum = symbolize(minimum)
@@ -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