branchproof 0.7.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 +35 -0
- data/README.md +212 -11
- 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 +3 -0
- data/doc/Branchproof/DecisionSyntax.md +6 -4
- data/doc/Branchproof/DecisionTable.md +153 -0
- data/doc/Branchproof/FocusedReport.md +5 -2
- 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 +4 -0
- data/doc/Branchproof/SavedReport.md +15 -0
- data/doc/Branchproof.md +4 -1
- data/doc/CHANGELOG.md +35 -0
- data/doc/README.md +212 -11
- data/lib/branchproof/analyzer.rb +80 -44
- 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 +30 -1
- data/lib/branchproof/decision_syntax.rb +31 -15
- data/lib/branchproof/decision_table.rb +377 -0
- data/lib/branchproof/evidence.rb +51 -31
- data/lib/branchproof/flow_instrumentation.rb +17 -17
- data/lib/branchproof/focused_report.rb +77 -9
- data/lib/branchproof/instrumenter.rb +45 -34
- 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 +301 -68
- data/lib/branchproof/runtime.rb +22 -30
- data/lib/branchproof/saved_report.rb +200 -14
- data/lib/branchproof/source.rb +90 -48
- data/lib/branchproof/version.rb +1 -1
- data/lib/branchproof/worker.rb +1 -4
- data/lib/branchproof.rb +2 -0
- data/llms.txt +4 -1
- data/sig/branchproof.rbs +47 -1
- metadata +6 -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
|
|
@@ -31,6 +31,10 @@ module Branchproof
|
|
|
31
31
|
@tests = build_tests.freeze
|
|
32
32
|
end
|
|
33
33
|
|
|
34
|
+
def decision_tables
|
|
35
|
+
@decision_tables ||= build_decision_tables.freeze
|
|
36
|
+
end
|
|
37
|
+
|
|
34
38
|
private
|
|
35
39
|
|
|
36
40
|
def build_conditions
|
|
@@ -120,6 +124,31 @@ module Branchproof
|
|
|
120
124
|
end
|
|
121
125
|
end
|
|
122
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
|
+
|
|
123
152
|
def build_tests
|
|
124
153
|
by_id = @tests_by_id.transform_values do |test|
|
|
125
154
|
{ id: test[:id], name: display_name(test), relative_path: test_location(test)[0], line: test_location(test)[1],
|
|
@@ -233,7 +262,7 @@ module Branchproof
|
|
|
233
262
|
|
|
234
263
|
def test_ids(vectors) = vectors.flat_map { |vector| Array(vector[:test_ids]) }.map(&:to_s).uniq.sort
|
|
235
264
|
def vector_values(vector) = Array(vector[:values])
|
|
236
|
-
def records(hash, key) = Array(hash[key] || hash[key.to_s])
|
|
265
|
+
def records(hash, key) = Array(hash[key] || hash[key.to_s])
|
|
237
266
|
|
|
238
267
|
def display_name(test)
|
|
239
268
|
return "#{test[:class_name]}##{test[:method_name]}" if test[:class_name] && test[:method_name]
|
|
@@ -1,25 +1,36 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
3
5
|
module Branchproof
|
|
4
6
|
# Discovers control-flow expressions whose truth is not represented by an
|
|
5
7
|
# ordinary Prism IfNode. The records intentionally contain byte ranges and
|
|
6
8
|
# scalar metadata only; Prism nodes must not escape the source pass.
|
|
7
9
|
module DecisionSyntax
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
10
|
+
# Some of these node classes were added in later Prism 1.x releases, so
|
|
11
|
+
# look them up by name and skip any that this Prism version lacks.
|
|
12
|
+
def self.node_classes(*names)
|
|
13
|
+
names.filter_map { |name| Prism.const_get(name) if Prism.const_defined?(name) }.to_set.freeze
|
|
14
|
+
end
|
|
15
|
+
private_class_method :node_classes
|
|
13
16
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
17
|
+
OR_WRITE_NODE_CLASSES = node_classes(
|
|
18
|
+
:CallOrWriteNode, :ClassVariableOrWriteNode, :ConstantOrWriteNode,
|
|
19
|
+
:ConstantPathOrWriteNode, :GlobalVariableOrWriteNode, :IndexOrWriteNode,
|
|
20
|
+
:InstanceVariableOrWriteNode, :LocalVariableOrWriteNode
|
|
21
|
+
)
|
|
19
22
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
+
AND_WRITE_NODE_CLASSES = node_classes(
|
|
24
|
+
:CallAndWriteNode, :ClassVariableAndWriteNode, :ConstantAndWriteNode,
|
|
25
|
+
:ConstantPathAndWriteNode, :GlobalVariableAndWriteNode, :IndexAndWriteNode,
|
|
26
|
+
:InstanceVariableAndWriteNode, :LocalVariableAndWriteNode
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
# nodes: flow-decision nodes already collected by a caller's own AST walk
|
|
30
|
+
# (Source merges this discovery into one pass). Falls back to its own
|
|
31
|
+
# walk when nothing is passed in, so this method still works standalone.
|
|
32
|
+
def flow_decisions_for(program, bytes, source_id, file_reasons = [], encoding = "UTF-8", nodes: nil)
|
|
33
|
+
nodes ||= collect_flow_decision_nodes(program)
|
|
23
34
|
nodes.sort_by { |node| [node.location.start_offset, node.location.length] }.map do |node|
|
|
24
35
|
build_flow_decision(node, bytes, source_id, file_reasons, encoding)
|
|
25
36
|
end
|
|
@@ -27,6 +38,12 @@ module Branchproof
|
|
|
27
38
|
|
|
28
39
|
private
|
|
29
40
|
|
|
41
|
+
def collect_flow_decision_nodes(program)
|
|
42
|
+
nodes = []
|
|
43
|
+
walk(program) { |node| nodes << node if flow_decision_node?(node) }
|
|
44
|
+
nodes
|
|
45
|
+
end
|
|
46
|
+
|
|
30
47
|
def flow_decision_node?(node)
|
|
31
48
|
return true if node.is_a?(Prism::CaseNode) && node.predicate
|
|
32
49
|
return true if node.is_a?(Prism::CaseMatchNode)
|
|
@@ -38,8 +55,7 @@ module Branchproof
|
|
|
38
55
|
end
|
|
39
56
|
|
|
40
57
|
def assignment_node?(node)
|
|
41
|
-
|
|
42
|
-
AND_WRITE_NODE_NAMES.include?(node.class.name.split("::").last)
|
|
58
|
+
OR_WRITE_NODE_CLASSES.include?(node.class) || AND_WRITE_NODE_CLASSES.include?(node.class)
|
|
43
59
|
end
|
|
44
60
|
|
|
45
61
|
def build_flow_decision(node, bytes, source_id, file_reasons, encoding)
|