branchproof 0.6.0 → 0.8.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.
Files changed (52) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +45 -0
  3. data/README.md +279 -27
  4. data/doc/Branchproof/Analyzer.md +2 -2
  5. data/doc/Branchproof/CLI.md +4 -0
  6. data/doc/Branchproof/ComparisonReport.md +4 -0
  7. data/doc/Branchproof/Constraints/Solver.md +21 -0
  8. data/doc/Branchproof/Constraints.md +98 -0
  9. data/doc/Branchproof/CoverageIndex.md +6 -0
  10. data/doc/Branchproof/DecisionSyntax.md +22 -0
  11. data/doc/Branchproof/DecisionTable.md +153 -0
  12. data/doc/Branchproof/FlowInstrumentation.md +7 -0
  13. data/doc/Branchproof/FocusedReport.md +5 -2
  14. data/doc/Branchproof/Instrumenter.md +1 -0
  15. data/doc/Branchproof/Loader.md +0 -4
  16. data/doc/Branchproof/MinitestAdapter.md +0 -3
  17. data/doc/Branchproof/Report.md +18 -0
  18. data/doc/Branchproof/Runtime.md +23 -0
  19. data/doc/Branchproof/RuntimeFlow.md +27 -0
  20. data/doc/Branchproof/SavedReport.md +21 -0
  21. data/doc/Branchproof/Source.md +1 -0
  22. data/doc/Branchproof.md +9 -2
  23. data/doc/CHANGELOG.md +45 -0
  24. data/doc/README.md +279 -27
  25. data/lib/branchproof/analyzer.rb +222 -54
  26. data/lib/branchproof/cli.rb +28 -19
  27. data/lib/branchproof/comparison.rb +207 -19
  28. data/lib/branchproof/comparison_report.rb +49 -1
  29. data/lib/branchproof/constraints.rb +363 -0
  30. data/lib/branchproof/coverage_index.rb +120 -3
  31. data/lib/branchproof/decision_syntax.rb +310 -0
  32. data/lib/branchproof/decision_table.rb +377 -0
  33. data/lib/branchproof/evidence.rb +101 -36
  34. data/lib/branchproof/flow_instrumentation.rb +107 -0
  35. data/lib/branchproof/focused_report.rb +161 -26
  36. data/lib/branchproof/instrumenter.rb +65 -39
  37. data/lib/branchproof/limits.rb +4 -1
  38. data/lib/branchproof/loader.rb +18 -6
  39. data/lib/branchproof/minimizer.rb +18 -13
  40. data/lib/branchproof/minitest_adapter.rb +11 -16
  41. data/lib/branchproof/records.rb +2 -0
  42. data/lib/branchproof/report.rb +470 -66
  43. data/lib/branchproof/runtime.rb +25 -30
  44. data/lib/branchproof/runtime_flow.rb +58 -0
  45. data/lib/branchproof/saved_report.rb +438 -13
  46. data/lib/branchproof/source.rb +239 -41
  47. data/lib/branchproof/version.rb +1 -1
  48. data/lib/branchproof/worker.rb +1 -4
  49. data/lib/branchproof.rb +2 -0
  50. data/llms.txt +9 -2
  51. data/sig/branchproof.rbs +54 -1
  52. metadata +12 -1
@@ -0,0 +1,363 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ module Branchproof
6
+ # Normalizes simple atomic conditions into subject/operator/value records and
7
+ # decides, conservatively, whether a set of required condition truths can hold
8
+ # at the same time.
9
+ #
10
+ # The module never evaluates application code. It only inspects the syntax of
11
+ # a condition, and it answers "contradictory" only when the contradiction
12
+ # follows from the normalized constraints alone. Every other shape stays
13
+ # unrepresented, which leaves the owning decision-table rule reachability
14
+ # +unknown+.
15
+ module Constraints
16
+ VERSION = 2
17
+
18
+ NUMERIC_OPERATORS = %w[< <= > >=].freeze
19
+ COMPARISON_OPERATORS = %w[< <= > >= == !=].freeze
20
+ OPERATORS = (COMPARISON_OPERATORS + ["truthy"]).freeze
21
+ FLIPPED = { "<" => ">", "<=" => ">=", ">" => "<", ">=" => "<=", "==" => "==", "!=" => "!=" }.freeze
22
+ NUMERIC_TYPES = %w[integer float].freeze
23
+ FALSEY_TYPES = %w[nil false].freeze
24
+ LITERAL_TYPES = %w[integer float symbol nil true false].freeze
25
+ SUBJECT_KINDS = %w[local instance class global constant].freeze
26
+ REASONS = %w[
27
+ conflicting_numeric_bounds conflicting_equalities equality_outside_numeric_range
28
+ nil_conflict boolean_literal_conflict
29
+ ].freeze
30
+ REASON_MESSAGES = {
31
+ "conflicting_numeric_bounds" => "conflicting numeric bounds",
32
+ "conflicting_equalities" => "conflicting equalities",
33
+ "equality_outside_numeric_range" => "required equality is outside the required numeric range",
34
+ "nil_conflict" => "conflicting nil requirements",
35
+ "boolean_literal_conflict" => "conflicting Boolean literal requirements"
36
+ }.freeze
37
+
38
+ # Accumulates the constraints one decision-table rule requires and reports
39
+ # the first proven contradiction. Subjects never interact with each other.
40
+ class Solver
41
+ def initialize
42
+ @subjects = {}
43
+ end
44
+
45
+ # Returns a reason code when the rule became unsatisfiable, otherwise nil.
46
+ def add(constraint, truth)
47
+ constraint = Constraints.symbolize(constraint)
48
+ return nil unless Constraints.usable?(constraint)
49
+
50
+ add_prepared(constraint, truth)
51
+ end
52
+
53
+ # Adds a constraint that has already been symbolized and validated by
54
+ # +usable?+. Source inventory can use this path after preparing each leaf
55
+ # once instead of repeating normalization for every solver state.
56
+ def add_prepared(constraint, truth)
57
+ state = (@subjects[Constraints.subject_key(constraint[:subject])] ||= new_state)
58
+ apply(state, constraint[:operator].to_s, constraint[:literal], truth ? true : false)
59
+ end
60
+
61
+ private
62
+
63
+ def new_state
64
+ { lower: nil, upper: nil, equality: nil, exclusions: [], nil_required: nil, truthy: nil,
65
+ comparisons: {} }
66
+ end
67
+
68
+ def apply(state, operator, literal, truth)
69
+ if NUMERIC_OPERATORS.include?(operator)
70
+ key = [operator, literal[:type].to_s, literal[:value]]
71
+ opposite = state[:comparisons][key]
72
+ return "conflicting_numeric_bounds" if !opposite.nil? && opposite != truth
73
+
74
+ state[:comparisons][key] = truth
75
+ end
76
+ case operator
77
+ when "truthy" then truth ? require_truthy(state) : require_falsey(state)
78
+ when "==" then truth ? require_equal(state, literal) : exclude(state, literal)
79
+ when "!=" then truth ? exclude(state, literal) : require_equal(state, literal)
80
+ # A false inequality is not a closed numeric bound: Ruby values such as
81
+ # Float::NAN make both sides of a total-order partition false. Keep the
82
+ # exact same-predicate contradiction above, but otherwise leave false
83
+ # inequalities unknown unless another true inequality proves a clash.
84
+ when "<" then truth ? upper(state, literal, false) : nil
85
+ when "<=" then truth ? upper(state, literal, true) : nil
86
+ when ">" then truth ? lower(state, literal, false) : nil
87
+ when ">=" then truth ? lower(state, literal, true) : nil
88
+ end
89
+ end
90
+
91
+ def require_equal(state, literal)
92
+ type = literal[:type].to_s
93
+ # A nil or truthiness clash is reported before a plain equality clash so
94
+ # the reason code names the most specific contradiction.
95
+ reason = equality_truthiness_conflict(state, type)
96
+ return reason if reason
97
+
98
+ if state[:equality] && !Constraints.same_literal?(state[:equality], literal)
99
+ return nil if Constraints.mixed_numeric_literals?(state[:equality], literal)
100
+
101
+ return "conflicting_equalities"
102
+ end
103
+ return "conflicting_equalities" if state[:exclusions].any? { |item| Constraints.same_literal?(item, literal) }
104
+
105
+ state[:equality] = literal
106
+ state[:nil_required] = type == "nil"
107
+ return "equality_outside_numeric_range" if Constraints.numeric?(literal) && !within_bounds?(state,
108
+ literal[:value])
109
+
110
+ nil
111
+ end
112
+
113
+ def equality_truthiness_conflict(state, type)
114
+ if type == "nil"
115
+ return "nil_conflict" if state[:nil_required] == false || state[:truthy] == true
116
+ elsif state[:nil_required] == true
117
+ return "nil_conflict"
118
+ end
119
+ return "boolean_literal_conflict" if state[:truthy] == true && type == "false"
120
+ return "boolean_literal_conflict" if state[:truthy] == false && !FALSEY_TYPES.include?(type)
121
+
122
+ nil
123
+ end
124
+
125
+ def exclude(state, literal)
126
+ if literal[:type].to_s == "nil"
127
+ return "nil_conflict" if state[:nil_required] == true
128
+ return "nil_conflict" if state[:equality] && state[:equality][:type].to_s == "nil"
129
+
130
+ state[:nil_required] = false
131
+ elsif state[:equality] && Constraints.same_literal?(state[:equality], literal)
132
+ return "conflicting_equalities"
133
+ end
134
+ state[:exclusions] << literal unless state[:exclusions].any? { |item| Constraints.same_literal?(item, literal) }
135
+ nil
136
+ end
137
+
138
+ def require_truthy(state)
139
+ return "boolean_literal_conflict" if state[:truthy] == false
140
+ return "nil_conflict" if state[:nil_required] == true
141
+ return "nil_conflict" if state[:equality] && state[:equality][:type].to_s == "nil"
142
+ return "boolean_literal_conflict" if state[:equality] && state[:equality][:type].to_s == "false"
143
+
144
+ state[:truthy] = true
145
+ state[:nil_required] = false
146
+ nil
147
+ end
148
+
149
+ def require_falsey(state)
150
+ return "boolean_literal_conflict" if state[:truthy] == true
151
+ return "boolean_literal_conflict" if state[:equality] && !FALSEY_TYPES.include?(state[:equality][:type].to_s)
152
+
153
+ state[:truthy] = false
154
+ nil
155
+ end
156
+
157
+ def lower(state, literal, inclusive)
158
+ return nil unless Constraints.numeric?(literal)
159
+ return nil unless comparable_bound?(state, literal)
160
+
161
+ current = state[:lower]
162
+ value = literal[:value]
163
+ if current.nil? || value > current[:value] || (value == current[:value] && !inclusive)
164
+ state[:lower] = { type: literal[:type].to_s, value: value, inclusive: inclusive }
165
+ end
166
+ bounds_conflict(state)
167
+ end
168
+
169
+ def upper(state, literal, inclusive)
170
+ return nil unless Constraints.numeric?(literal)
171
+ return nil unless comparable_bound?(state, literal)
172
+
173
+ current = state[:upper]
174
+ value = literal[:value]
175
+ if current.nil? || value < current[:value] || (value == current[:value] && !inclusive)
176
+ state[:upper] = { type: literal[:type].to_s, value: value, inclusive: inclusive }
177
+ end
178
+ bounds_conflict(state)
179
+ end
180
+
181
+ def bounds_conflict(state)
182
+ low = state[:lower]
183
+ high = state[:upper]
184
+ if low && high && (low[:type] == high[:type])
185
+ return "conflicting_numeric_bounds" if low[:value] > high[:value]
186
+ return "conflicting_numeric_bounds" if low[:value] == high[:value] && !(low[:inclusive] && high[:inclusive])
187
+ end
188
+ equality = state[:equality]
189
+ return nil unless equality && Constraints.numeric?(equality) && !within_bounds?(state, equality[:value])
190
+
191
+ "equality_outside_numeric_range"
192
+ end
193
+
194
+ def within_bounds?(state, value)
195
+ low = state[:lower]
196
+ high = state[:upper]
197
+ return false if low && low[:type] == state[:equality][:type] &&
198
+ (low[:inclusive] ? value < low[:value] : value <= low[:value])
199
+ return false if high && high[:type] == state[:equality][:type] &&
200
+ (high[:inclusive] ? value > high[:value] : value >= high[:value])
201
+
202
+ true
203
+ end
204
+
205
+ def comparable_bound?(state, literal)
206
+ [state[:lower], state[:upper]].compact.all? { |bound| bound[:type] == literal[:type] }
207
+ end
208
+ end
209
+
210
+ module_function
211
+
212
+ # Derives the normalized constraint of one atomic condition, or nil when the
213
+ # expression is outside the supported vocabulary.
214
+ def for_node(node)
215
+ return nil unless node.is_a?(Prism::Node)
216
+
217
+ subject = subject_for(node)
218
+ return { subject: subject, operator: "truthy", literal: nil } if subject
219
+ return nil unless simple_call?(node)
220
+
221
+ name = node.name.to_s
222
+ return nil_constraint(node) if name == "nil?"
223
+ return nil unless FLIPPED.key?(name)
224
+
225
+ comparison_constraint(node, name)
226
+ end
227
+
228
+ def comparison_constraint(node, name)
229
+ argument = single_argument(node)
230
+ return nil unless argument
231
+
232
+ subject = subject_for(node.receiver)
233
+ if subject
234
+ literal = literal_for(argument)
235
+ operator = name
236
+ else
237
+ subject = subject_for(argument)
238
+ literal = subject && literal_for(node.receiver)
239
+ operator = FLIPPED.fetch(name)
240
+ end
241
+ return nil unless subject && literal
242
+ return nil if NUMERIC_OPERATORS.include?(operator) && !NUMERIC_TYPES.include?(literal[:type])
243
+
244
+ { subject: subject, operator: operator, literal: literal }
245
+ end
246
+
247
+ def nil_constraint(node)
248
+ return nil unless node.arguments.nil?
249
+
250
+ subject = subject_for(node.receiver)
251
+ return nil unless subject
252
+
253
+ { subject: subject, operator: "==", literal: { type: "nil", value: nil } }
254
+ end
255
+
256
+ def simple_call?(node)
257
+ node.is_a?(Prism::CallNode) && !node.safe_navigation? && node.block.nil?
258
+ end
259
+
260
+ def single_argument(node)
261
+ arguments = node.arguments&.arguments
262
+ return nil unless arguments && arguments.length == 1
263
+
264
+ argument = arguments.first
265
+ return nil if argument.is_a?(Prism::SplatNode) || argument.is_a?(Prism::KeywordHashNode) ||
266
+ argument.is_a?(Prism::BlockArgumentNode)
267
+
268
+ argument
269
+ end
270
+
271
+ # Only unambiguously identifiable storage locations become subjects.
272
+ # Method-call receivers stay unsupported: a repeated call may return a
273
+ # different value or have side effects (see the v1 constraint scope).
274
+ def subject_for(node)
275
+ case node
276
+ when Prism::LocalVariableReadNode then { kind: "local", name: node.name.to_s }
277
+ when Prism::InstanceVariableReadNode then { kind: "instance", name: node.name.to_s }
278
+ when Prism::ClassVariableReadNode then { kind: "class", name: node.name.to_s }
279
+ when Prism::GlobalVariableReadNode then { kind: "global", name: node.name.to_s }
280
+ when Prism::ConstantReadNode then { kind: "constant", name: node.name.to_s }
281
+ end
282
+ end
283
+
284
+ # String equality stays unsupported in v1 so that encoding and mutability
285
+ # questions cannot turn into an impossibility claim.
286
+ def literal_for(node)
287
+ case node
288
+ when Prism::IntegerNode then { type: "integer", value: node.value }
289
+ when Prism::FloatNode
290
+ return nil unless node.value.finite?
291
+
292
+ { type: "float", value: node.value }
293
+ when Prism::SymbolNode then { type: "symbol", value: node.unescaped.to_s }
294
+ when Prism::NilNode then { type: "nil", value: nil }
295
+ when Prism::TrueNode then { type: "true", value: true }
296
+ when Prism::FalseNode then { type: "false", value: false }
297
+ end
298
+ end
299
+
300
+ def usable?(constraint)
301
+ return false unless constraint.is_a?(Hash)
302
+
303
+ subject = symbolize(constraint[:subject])
304
+ operator = constraint[:operator].to_s
305
+ return false unless subject.is_a?(Hash) && SUBJECT_KINDS.include?(subject[:kind].to_s)
306
+ return false if subject[:name].to_s.empty?
307
+ return false unless OPERATORS.include?(operator)
308
+ return true if operator == "truthy"
309
+
310
+ literal = symbolize(constraint[:literal])
311
+ return false unless literal.is_a?(Hash) && LITERAL_TYPES.include?(literal[:type].to_s)
312
+ return false if NUMERIC_OPERATORS.include?(operator) && !NUMERIC_TYPES.include?(literal[:type].to_s)
313
+ return false unless valid_literal_value?(literal)
314
+
315
+ true
316
+ end
317
+
318
+ def valid_literal_value?(literal)
319
+ case literal[:type].to_s
320
+ when "integer" then literal[:value].is_a?(Integer)
321
+ when "float" then literal[:value].is_a?(Float) && literal[:value].finite?
322
+ when "symbol" then literal[:value].is_a?(String)
323
+ when "nil" then literal[:value].nil?
324
+ when "true" then literal[:value] == true
325
+ when "false" then literal[:value] == false
326
+ else false
327
+ end
328
+ end
329
+
330
+ def subject_key(subject)
331
+ subject = symbolize(subject)
332
+ [subject[:kind].to_s, subject[:name].to_s]
333
+ end
334
+
335
+ def numeric?(literal) = NUMERIC_TYPES.include?(literal[:type].to_s)
336
+
337
+ def same_literal?(left, right)
338
+ return left[:value] == right[:value] if left[:type].to_s == right[:type].to_s
339
+ return false unless mixed_numeric_literals?(left, right)
340
+
341
+ integer, float = if left[:type].to_s == "integer"
342
+ [left[:value], right[:value]]
343
+ else
344
+ [right[:value], left[:value]]
345
+ end
346
+ float.finite? && float.to_r == integer
347
+ end
348
+
349
+ def mixed_numeric_literals?(left, right)
350
+ numeric?(left) && numeric?(right) && left[:type].to_s != right[:type].to_s
351
+ end
352
+
353
+ def message(reason) = REASON_MESSAGES.fetch(reason.to_s, reason.to_s.tr("_", " "))
354
+
355
+ def symbolize(value)
356
+ return value unless value.is_a?(Hash)
357
+
358
+ value.each_with_object({}) do |(key, item), result|
359
+ result[key.to_sym] = item.is_a?(Hash) ? symbolize(item) : item
360
+ end
361
+ end
362
+ end
363
+ end
@@ -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,9 +27,14 @@ 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
 
34
+ def decision_tables
35
+ @decision_tables ||= build_decision_tables.freeze
36
+ end
37
+
33
38
  private
34
39
 
35
40
  def build_conditions
@@ -62,7 +67,7 @@ module Branchproof
62
67
  id: condition[:id], decision_id: decision[:id], index: condition[:index],
63
68
  expression: condition[:expression],
64
69
  line: condition[:line], column: condition[:column], relative_path: relative_path(source[:relative_path]),
65
- decision_expression: decision[:expression],
70
+ decision_expression: decision[:expression], kind: decision_kind(decision), context: decision[:context],
66
71
  status: @document[:analysis] ? (result[:status] || "NOT_PROVEN") : "NOT CALCULATED",
67
72
  observed_true: test_ids(observations[true]), observed_false: test_ids(observations[false]),
68
73
  short_circuited: test_ids(observations[:short_circuited]),
@@ -80,12 +85,94 @@ module Branchproof
80
85
  end
81
86
  end
82
87
 
88
+ def build_alternatives
89
+ records(@inventory, :decisions).flat_map do |decision|
90
+ next [] if decision[:support_status].to_s == "UNSUPPORTED"
91
+ next [] if boolean_decision?(decision)
92
+
93
+ source = @source_units_by_id[decision[:source_id].to_s] || {}
94
+ coverage = symbolize(analysis_for(decision)[:coverage] || {})[:alternative] || {}
95
+ coverage_rows = records(coverage, :alternatives).to_h { |row| [row[:alternative_id].to_s, row] }
96
+ vectors = @vectors_by_decision[decision[:id].to_s] || []
97
+ records(decision, :alternatives).map do |alternative|
98
+ alternative = symbolize(alternative)
99
+ evidence = { selected: [], not_selected: [], skipped: [] }
100
+ vectors.each do |vector|
101
+ value = vector_values(vector)[alternative[:index].to_i]
102
+ state = if value.nil?
103
+ :skipped
104
+ else
105
+ (value ? :selected : :not_selected)
106
+ end
107
+ evidence[state] << vector
108
+ end
109
+ row = symbolize(coverage_rows[alternative[:id].to_s] || {})
110
+ {
111
+ id: alternative[:id], alternative_id: alternative[:id], decision_id: decision[:id],
112
+ index: alternative[:index], expression: alternative[:expression],
113
+ line: alternative[:line], column: alternative[:column],
114
+ relative_path: relative_path(source[:relative_path]),
115
+ decision_expression: decision[:expression], kind: decision_kind(decision),
116
+ context: decision[:context],
117
+ status: @document[:analysis] ? alternative_status(row, evidence) : "NOT CALCULATED",
118
+ selected: row[:selected] || evidence_bucket(evidence[:selected]),
119
+ not_selected: row[:not_selected] || evidence_bucket(evidence[:not_selected]),
120
+ skipped: row[:skipped] || evidence_bucket(evidence[:skipped]),
121
+ vectors: evidence, missing: missing_alternative?(coverage, alternative)
122
+ }
123
+ end
124
+ end
125
+ end
126
+
127
+ def build_decision_tables
128
+ rows = records(@inventory, :decisions).filter_map do |decision|
129
+ next unless boolean_decision?(decision)
130
+
131
+ table = analysis_for(decision)[:decision_table] || {}
132
+ next if table.empty?
133
+
134
+ source = @source_units_by_id[decision[:source_id].to_s] || {}
135
+ {
136
+ decision_id: decision[:id], decision_expression: decision[:expression],
137
+ context: decision[:context], kind: decision_kind(decision),
138
+ relative_path: relative_path(source[:relative_path]), line: decision[:line],
139
+ conditions: Array(decision[:conditions]).map { |condition| condition[:expression].to_s },
140
+ status: table[:status], reason: table[:reason],
141
+ schema_version: table[:schema_version], constraint_analysis_version: table[:constraint_analysis_version],
142
+ rules: Array(table[:rules]),
143
+ generated_rules: table[:generated_rules].to_i, impossible_rules: table[:impossible_rules].to_i,
144
+ required_rules: table[:required_rules].to_i, covered_rules: table[:covered_rules].to_i,
145
+ missing_rules: table[:missing_rules].to_i, percentage: table[:percentage],
146
+ coverage_status: table[:coverage_status], reachability_analyzed: table[:reachability_analyzed]
147
+ }
148
+ end
149
+ rows.sort_by { |row| [row[:relative_path].to_s, row[:line].to_i, row[:decision_id].to_s] }
150
+ end
151
+
83
152
  def build_tests
84
153
  by_id = @tests_by_id.transform_values do |test|
85
154
  { id: test[:id], name: display_name(test), relative_path: test_location(test)[0], line: test_location(test)[1],
86
155
  status: test[:status], phases: Array(test[:phase_counts]).to_h.keys.sort,
87
156
  observations: [], owns_witness: false }
88
157
  end
158
+ @alternatives.each do |alternative|
159
+ alternative[:vectors].each do |kind, vectors|
160
+ vectors.each do |vector|
161
+ Array(vector[:test_ids]).each do |id|
162
+ row = by_id[id.to_s] ||= { id: id, name: id, relative_path: nil, line: nil, status: "unknown",
163
+ phases: [], observations: [], owns_witness: false }
164
+ phase_map = symbolize(vector[:phases_by_test] || {})
165
+ phases = phase_map[id.to_s] || phase_map[id.to_sym] || []
166
+ row[:phases] |= Array(phases).map(&:to_s)
167
+ row[:observations] << { alternative_id: alternative[:id], expression: alternative[:expression],
168
+ relative_path: alternative[:relative_path], line: alternative[:line],
169
+ value: kind == :skipped ? nil : (kind == :selected), kind: :alternative,
170
+ alternative_state: kind.to_s, phases: phases.map(&:to_s).sort,
171
+ owns_witness: false }
172
+ end
173
+ end
174
+ end
175
+ end
89
176
  @conditions.each do |condition|
90
177
  condition[:vectors].each do |kind, vectors|
91
178
  next if kind == :unattributed
@@ -118,6 +205,36 @@ module Branchproof
118
205
  @analysis_by_decision[decision[:id].to_s] || {}
119
206
  end
120
207
 
208
+ def boolean_decision?(decision)
209
+ decision_kind(decision) == "boolean"
210
+ end
211
+
212
+ def decision_kind(decision)
213
+ kind = decision[:kind].to_s
214
+ kind.empty? ? "boolean" : kind
215
+ end
216
+
217
+ def evidence_bucket(vectors)
218
+ evidence = vectors.map do |vector|
219
+ { vector_id: vector[:id].to_s, test_ids: Array(vector[:test_ids]).map(&:to_s).uniq.sort,
220
+ unattributed_count: vector[:unattributed_count].to_i }
221
+ end
222
+ { observed: !vectors.empty?, vector_ids: evidence.map { |item| item[:vector_id] }.uniq.sort,
223
+ test_ids: evidence.flat_map { |item| item[:test_ids] }.uniq.sort,
224
+ unattributed_count: evidence.sum { |item| item[:unattributed_count] } }
225
+ end
226
+
227
+ def missing_alternative?(coverage, alternative)
228
+ Array(coverage[:missing_alternatives]).map(&:to_s).include?(alternative[:id].to_s)
229
+ end
230
+
231
+ def alternative_status(row, evidence)
232
+ status = row[:status].to_s
233
+ return status unless status.empty?
234
+
235
+ evidence[:selected].empty? ? "unexecuted" : "covered"
236
+ end
237
+
121
238
  def supporting_set_for(decision_id)
122
239
  Array(@document[:minima] || @document["minima"]).filter_map do |minimum|
123
240
  minimum = symbolize(minimum)
@@ -145,7 +262,7 @@ module Branchproof
145
262
 
146
263
  def test_ids(vectors) = vectors.flat_map { |vector| Array(vector[:test_ids]) }.map(&:to_s).uniq.sort
147
264
  def vector_values(vector) = Array(vector[:values])
148
- def records(hash, key) = Array(hash[key] || hash[key.to_s]).map { |item| symbolize(item) }
265
+ def records(hash, key) = Array(hash[key] || hash[key.to_s])
149
266
 
150
267
  def display_name(test)
151
268
  return "#{test[:class_name]}##{test[:method_name]}" if test[:class_name] && test[:method_name]