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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +45 -0
- data/README.md +279 -27
- data/doc/Branchproof/Analyzer.md +2 -2
- data/doc/Branchproof/CLI.md +4 -0
- data/doc/Branchproof/ComparisonReport.md +4 -0
- data/doc/Branchproof/Constraints/Solver.md +21 -0
- data/doc/Branchproof/Constraints.md +98 -0
- data/doc/Branchproof/CoverageIndex.md +6 -0
- data/doc/Branchproof/DecisionSyntax.md +22 -0
- data/doc/Branchproof/DecisionTable.md +153 -0
- data/doc/Branchproof/FlowInstrumentation.md +7 -0
- data/doc/Branchproof/FocusedReport.md +5 -2
- data/doc/Branchproof/Instrumenter.md +1 -0
- data/doc/Branchproof/Loader.md +0 -4
- data/doc/Branchproof/MinitestAdapter.md +0 -3
- data/doc/Branchproof/Report.md +18 -0
- data/doc/Branchproof/Runtime.md +23 -0
- data/doc/Branchproof/RuntimeFlow.md +27 -0
- data/doc/Branchproof/SavedReport.md +21 -0
- data/doc/Branchproof/Source.md +1 -0
- data/doc/Branchproof.md +9 -2
- data/doc/CHANGELOG.md +45 -0
- data/doc/README.md +279 -27
- data/lib/branchproof/analyzer.rb +222 -54
- data/lib/branchproof/cli.rb +28 -19
- data/lib/branchproof/comparison.rb +207 -19
- data/lib/branchproof/comparison_report.rb +49 -1
- data/lib/branchproof/constraints.rb +363 -0
- data/lib/branchproof/coverage_index.rb +120 -3
- data/lib/branchproof/decision_syntax.rb +310 -0
- data/lib/branchproof/decision_table.rb +377 -0
- data/lib/branchproof/evidence.rb +101 -36
- data/lib/branchproof/flow_instrumentation.rb +107 -0
- data/lib/branchproof/focused_report.rb +161 -26
- data/lib/branchproof/instrumenter.rb +65 -39
- data/lib/branchproof/limits.rb +4 -1
- data/lib/branchproof/loader.rb +18 -6
- data/lib/branchproof/minimizer.rb +18 -13
- data/lib/branchproof/minitest_adapter.rb +11 -16
- data/lib/branchproof/records.rb +2 -0
- data/lib/branchproof/report.rb +470 -66
- data/lib/branchproof/runtime.rb +25 -30
- data/lib/branchproof/runtime_flow.rb +58 -0
- data/lib/branchproof/saved_report.rb +438 -13
- data/lib/branchproof/source.rb +239 -41
- data/lib/branchproof/version.rb +1 -1
- data/lib/branchproof/worker.rb +1 -4
- data/lib/branchproof.rb +2 -0
- data/llms.txt +9 -2
- data/sig/branchproof.rbs +54 -1
- 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])
|
|
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]
|