branchproof 0.6.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +10 -0
- data/README.md +74 -23
- data/doc/Branchproof/CoverageIndex.md +3 -0
- data/doc/Branchproof/DecisionSyntax.md +20 -0
- data/doc/Branchproof/FlowInstrumentation.md +7 -0
- data/doc/Branchproof/Instrumenter.md +1 -0
- data/doc/Branchproof/Runtime.md +19 -0
- data/doc/Branchproof/RuntimeFlow.md +27 -0
- data/doc/Branchproof/SavedReport.md +6 -0
- data/doc/Branchproof/Source.md +1 -0
- data/doc/Branchproof.md +6 -2
- data/doc/CHANGELOG.md +10 -0
- data/doc/README.md +74 -23
- data/lib/branchproof/analyzer.rb +153 -21
- data/lib/branchproof/comparison.rb +1 -1
- data/lib/branchproof/coverage_index.rb +90 -2
- data/lib/branchproof/decision_syntax.rb +294 -0
- data/lib/branchproof/evidence.rb +54 -9
- data/lib/branchproof/flow_instrumentation.rb +107 -0
- data/lib/branchproof/focused_report.rb +85 -18
- data/lib/branchproof/instrumenter.rb +28 -13
- data/lib/branchproof/report.rb +195 -24
- data/lib/branchproof/runtime.rb +3 -0
- data/lib/branchproof/runtime_flow.rb +58 -0
- data/lib/branchproof/saved_report.rb +245 -6
- data/lib/branchproof/source.rb +191 -35
- data/lib/branchproof/version.rb +1 -1
- data/llms.txt +6 -2
- data/sig/branchproof.rbs +7 -0
- metadata +7 -1
|
@@ -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
|
data/lib/branchproof/evidence.rb
CHANGED
|
@@ -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
|
-
|
|
212
|
-
return "condition count exceeds limit" if
|
|
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
|
-
|
|
215
|
-
|
|
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 =
|
|
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
|
-
|
|
310
|
-
|
|
311
|
-
|
|
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
|
-
|
|
353
|
+
|
|
354
|
+
expected_values = decision_dimension_count(decision)
|
|
355
|
+
return "invalid vector shape" unless vector[:values].length == expected_values &&
|
|
341
356
|
vector[:values].all? { |item| item.nil? || item == true || item == false }
|
|
342
357
|
|
|
343
358
|
expected = Branchproof::Records.id([vector[:decision_id].to_s, vector[:values], vector[:outcome] ? true : false])
|
|
@@ -375,6 +390,36 @@ module Branchproof
|
|
|
375
390
|
nil
|
|
376
391
|
end
|
|
377
392
|
|
|
393
|
+
def alternative_decision?(decision)
|
|
394
|
+
kind = decision[:kind].to_s
|
|
395
|
+
!kind.empty? && kind != "boolean"
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
def decision_dimension_count(decision_or_id)
|
|
399
|
+
decision = decision_or_id.is_a?(Hash) ? decision_or_id : decisions.find { |item| item[:id].to_s == decision_or_id.to_s }
|
|
400
|
+
return 0 unless decision
|
|
401
|
+
|
|
402
|
+
alternative_decision?(decision) ? Array(decision[:alternatives]).length : Array(decision[:conditions]).length
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
def valid_alternative_trace?(decision, observations, outcome)
|
|
406
|
+
return false unless outcome == true
|
|
407
|
+
|
|
408
|
+
alternatives = Array(decision[:alternatives])
|
|
409
|
+
expected = alternatives.length
|
|
410
|
+
return false unless expected.positive?
|
|
411
|
+
if decision[:kind].to_s == "implicit"
|
|
412
|
+
return expected == 2 && observations.length == 2 &&
|
|
413
|
+
observations.map(&:first) == [0, 1] && observations.map(&:last).count(true) == 1
|
|
414
|
+
end
|
|
415
|
+
|
|
416
|
+
return false unless observations.length.between?(1, expected)
|
|
417
|
+
|
|
418
|
+
observations.each_with_index.all? do |(index, value), position|
|
|
419
|
+
index == position && value == (position == observations.length - 1)
|
|
420
|
+
end
|
|
421
|
+
end
|
|
422
|
+
|
|
378
423
|
def decisions = Array(fetch_value(@inventory, :decisions)).map { symbolize(_1) }
|
|
379
424
|
def decision_conditions(id) = (decisions.find { |d| d[:id].to_s == id.to_s } || {}).fetch(:conditions, [])
|
|
380
425
|
def vector_count(id) = @vectors.values.count { |v| v[:decision_id] == id.to_s }
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Keep each bounded source rewrite together so its evaluation order can be audited.
|
|
4
|
+
# rubocop:disable Metrics/AbcSize, Metrics/MethodLength
|
|
5
|
+
|
|
6
|
+
module Branchproof
|
|
7
|
+
# Source-location edits around Ruby's native matching and assignment operations.
|
|
8
|
+
module FlowInstrumentation
|
|
9
|
+
private
|
|
10
|
+
|
|
11
|
+
def render_flow(bytes, decision, nested)
|
|
12
|
+
metadata = decision.fetch(:instrumentation)
|
|
13
|
+
case metadata.fetch(:type)
|
|
14
|
+
when "safe_navigation"
|
|
15
|
+
receiver = metadata.fetch(:receiver)
|
|
16
|
+
replacements = [flow_replacement(bytes, receiver, nested) do |expression|
|
|
17
|
+
"#{self.class::RUNTIME}.flow_receiver(#{decision[:id].inspect}, (#{expression}))"
|
|
18
|
+
end]
|
|
19
|
+
flow_fragments(bytes, decision, nested, replacements)
|
|
20
|
+
when "assignment"
|
|
21
|
+
rhs = metadata.fetch(:rhs)
|
|
22
|
+
replacements = [flow_replacement(bytes, rhs, nested) do |expression|
|
|
23
|
+
"(begin; #{self.class::RUNTIME}.flow_path(#{decision[:id].inspect}, 1); (#{expression}); end)"
|
|
24
|
+
end]
|
|
25
|
+
expression = flow_fragments(bytes, decision, nested, replacements)
|
|
26
|
+
flow_frame(decision[:id], expression, default_path: 0)
|
|
27
|
+
when "case"
|
|
28
|
+
render_case_flow(bytes, decision, nested, metadata)
|
|
29
|
+
when "case_match"
|
|
30
|
+
render_pattern_flow(bytes, decision, nested, metadata)
|
|
31
|
+
else
|
|
32
|
+
raise ArgumentError, "unknown instrumentation type: #{metadata[:type]}"
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def render_case_flow(bytes, decision, nested, metadata)
|
|
37
|
+
identifier = decision[:id].inspect
|
|
38
|
+
runtime = self.class::RUNTIME
|
|
39
|
+
replacements = metadata.fetch(:candidates).map do |candidate|
|
|
40
|
+
flow_replacement(bytes, candidate, nested) do |expression|
|
|
41
|
+
"(begin; #{runtime}.flow_candidate(#{identifier}, #{candidate[:index]}); (#{expression}); end)"
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
metadata.fetch(:branches).each do |branch|
|
|
45
|
+
suffix = branch[:empty] ? "nil; " : ""
|
|
46
|
+
replacements << { start: branch[:insert_at], length: 0,
|
|
47
|
+
text: "; #{runtime}.flow_selected(#{identifier}); #{suffix}" }
|
|
48
|
+
end
|
|
49
|
+
if metadata[:else]
|
|
50
|
+
alternative = metadata[:else]
|
|
51
|
+
replacements << { start: alternative[:insert_at], length: 0,
|
|
52
|
+
text: "; #{runtime}.flow_select(#{identifier}, #{alternative[:index]}); " }
|
|
53
|
+
else
|
|
54
|
+
index = decision.fetch(:alternatives).length - 1
|
|
55
|
+
replacements << { start: metadata.fetch(:end_start), length: 0,
|
|
56
|
+
text: "else; #{runtime}.flow_select(#{identifier}, #{index}); nil; " }
|
|
57
|
+
end
|
|
58
|
+
flow_frame(decision[:id], flow_fragments(bytes, decision, nested, replacements))
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def render_pattern_flow(bytes, decision, nested, metadata)
|
|
62
|
+
identifier = decision[:id].inspect
|
|
63
|
+
runtime = self.class::RUNTIME
|
|
64
|
+
replacements = metadata.fetch(:branches).map do |branch|
|
|
65
|
+
suffix = branch[:empty] ? "nil; " : ""
|
|
66
|
+
{ start: branch[:insert_at], length: 0,
|
|
67
|
+
text: "; #{runtime}.flow_select(#{identifier}, #{branch[:index]}); #{suffix}" }
|
|
68
|
+
end
|
|
69
|
+
if metadata[:else]
|
|
70
|
+
alternative = metadata[:else]
|
|
71
|
+
replacements << { start: alternative[:insert_at], length: 0,
|
|
72
|
+
text: "; #{runtime}.flow_select(#{identifier}, #{alternative[:index]}); " }
|
|
73
|
+
end
|
|
74
|
+
flow_frame(decision[:id], flow_fragments(bytes, decision, nested, replacements))
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def flow_replacement(bytes, location, nested)
|
|
78
|
+
start = location.fetch(:byte_start)
|
|
79
|
+
length = location.fetch(:byte_length)
|
|
80
|
+
{ start: start, length: length, text: yield(render_children(bytes, start, length, nested)) }
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def flow_fragments(bytes, decision, nested, replacements)
|
|
84
|
+
cursor = decision.fetch(:byte_start)
|
|
85
|
+
finish = cursor + decision.fetch(:byte_length)
|
|
86
|
+
chunks = []
|
|
87
|
+
replacements.sort_by { |edit| [edit[:start], edit[:length]] }.each do |edit|
|
|
88
|
+
raise ArgumentError, "overlapping flow edits" if edit[:start] < cursor
|
|
89
|
+
|
|
90
|
+
chunks << render_children(bytes, cursor, edit[:start] - cursor, nested)
|
|
91
|
+
chunks << edit[:text]
|
|
92
|
+
cursor = edit[:start] + edit[:length]
|
|
93
|
+
end
|
|
94
|
+
chunks << render_children(bytes, cursor, finish - cursor, nested)
|
|
95
|
+
chunks.join
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def flow_frame(decision_id, expression, default_path: nil)
|
|
99
|
+
runtime = self.class::RUNTIME
|
|
100
|
+
"(begin; #{runtime}.enter(#{decision_id.inspect}); begin; " \
|
|
101
|
+
"#{runtime}.flow_finish(#{decision_id.inspect}, (#{expression}), #{default_path.inspect}); ensure; " \
|
|
102
|
+
"#{runtime}.leave(#{decision_id.inspect}); end; end)"
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# rubocop:enable Metrics/AbcSize, Metrics/MethodLength
|
|
@@ -23,7 +23,11 @@ module Branchproof
|
|
|
23
23
|
completeness = fetch(@document, :completeness) || {}
|
|
24
24
|
evidence = fetch(@document, :observations) || {}
|
|
25
25
|
lines = ["Branchproof focused view: #{@view}", "Tests: #{fetch(baseline, :status) || "INCOMPLETE"}",
|
|
26
|
-
|
|
26
|
+
(if @index.alternatives.empty?
|
|
27
|
+
"Values: T=true, F=false, -=short-circuited"
|
|
28
|
+
else
|
|
29
|
+
"Values: Boolean T=true/F=false; flow T=selected, F=not-selected, -=skipped"
|
|
30
|
+
end)]
|
|
27
31
|
if completeness.values.include?(false) || fetch(baseline, :status).to_s != "PASSED"
|
|
28
32
|
lines << "Warning: failed or incomplete run; observations and proof evidence may be unavailable."
|
|
29
33
|
end
|
|
@@ -35,7 +39,12 @@ module Branchproof
|
|
|
35
39
|
lines << "Empty groups mean no recorded completed observation."
|
|
36
40
|
lines << ""
|
|
37
41
|
lines.concat(@coordinator.coverage_ladder_lines)
|
|
38
|
-
@view == :conditions
|
|
42
|
+
if @view == :conditions
|
|
43
|
+
render_conditions(lines)
|
|
44
|
+
render_alternatives(lines)
|
|
45
|
+
else
|
|
46
|
+
render_tests(lines)
|
|
47
|
+
end
|
|
39
48
|
render_unowned(lines)
|
|
40
49
|
render_unsupported(lines)
|
|
41
50
|
Array(fetch(@document, :diagnostics)).each do |diagnostic|
|
|
@@ -53,6 +62,8 @@ module Branchproof
|
|
|
53
62
|
lines << "Condition: #{row[:expression]}"
|
|
54
63
|
lines << "Location: #{location(row[:relative_path], row[:line], unavailable: "condition line unavailable")}"
|
|
55
64
|
lines << "Decision: #{row[:decision_expression]} (condition #{row[:index]})"
|
|
65
|
+
lines << "Kind: #{row[:kind]}"
|
|
66
|
+
lines << "Context: #{row[:context]}" unless row[:context].to_s.empty?
|
|
56
67
|
status = if @level == 1 && !coverage_available?
|
|
57
68
|
"NOT CALCULATED"
|
|
58
69
|
else
|
|
@@ -106,16 +117,52 @@ module Branchproof
|
|
|
106
117
|
end
|
|
107
118
|
end
|
|
108
119
|
|
|
120
|
+
def render_alternatives(lines)
|
|
121
|
+
rows = @index.alternatives
|
|
122
|
+
rows = rows.select { |row| row[:missing] || row[:status].to_s != "covered" } if @missing_only && @level > 1
|
|
123
|
+
rows.each do |row|
|
|
124
|
+
lines << "Alternative #{row[:index]}: #{row[:expression]}"
|
|
125
|
+
lines << "Location: #{location(row[:relative_path], row[:line],
|
|
126
|
+
unavailable: "alternative location unavailable")}"
|
|
127
|
+
lines << "Decision: #{row[:decision_expression]} (alternative #{row[:index]})"
|
|
128
|
+
lines << "Kind: #{row[:kind]}"
|
|
129
|
+
lines << "Context: #{row[:context]}" unless row[:context].to_s.empty?
|
|
130
|
+
lines << "Selection: #{row[:status] || "NOT_CALCULATED"}"
|
|
131
|
+
lines << "MC/DC: N/A (not applicable)"
|
|
132
|
+
render_alternative_group(lines, "Selected by", row[:selected])
|
|
133
|
+
render_alternative_group(lines, "Not selected by", row[:not_selected])
|
|
134
|
+
render_alternative_group(lines, "Skipped in", row[:skipped])
|
|
135
|
+
if row[:missing]
|
|
136
|
+
lines << " Missing alternative: #{row[:expression]}"
|
|
137
|
+
lines << " Need selection of: #{row[:expression]}"
|
|
138
|
+
end
|
|
139
|
+
lines << ""
|
|
140
|
+
end
|
|
141
|
+
lines << "No missing alternatives" if @missing_only && rows.empty?
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def render_alternative_group(lines, heading, evidence)
|
|
145
|
+
evidence ||= {}
|
|
146
|
+
ids = Array(evidence[:test_ids]).map { |id| test_label(id) }
|
|
147
|
+
ids << "unattributed" if evidence[:unattributed_count].to_i.positive?
|
|
148
|
+
lines << " #{heading}: #{ids.empty? ? "none recorded" : ids.uniq.join(", ")}"
|
|
149
|
+
end
|
|
150
|
+
|
|
109
151
|
def render_tests(lines)
|
|
110
152
|
rows = @index.tests
|
|
111
153
|
missing_ids = @index.conditions.reject { |row| row[:status].to_s.upcase == "PROVEN" }.map { |row| row[:id] }
|
|
112
|
-
|
|
154
|
+
missing_alternative_ids = @index.alternatives.select { |row| row[:missing] || row[:status].to_s != "covered" }
|
|
155
|
+
.map { |row| row[:alternative_id] }
|
|
156
|
+
rows.each { |row| render_test_row(lines, row, missing_ids, missing_alternative_ids) }
|
|
113
157
|
end
|
|
114
158
|
|
|
115
|
-
def render_test_row(lines, row, missing_ids)
|
|
159
|
+
def render_test_row(lines, row, missing_ids, missing_alternative_ids = [])
|
|
116
160
|
observations = row[:observations]
|
|
117
161
|
if @missing_only
|
|
118
|
-
observations = observations.select
|
|
162
|
+
observations = observations.select do |observation|
|
|
163
|
+
missing_ids.include?(observation[:condition_id]) ||
|
|
164
|
+
missing_alternative_ids.include?(observation[:alternative_id])
|
|
165
|
+
end
|
|
119
166
|
return if observations.empty?
|
|
120
167
|
end
|
|
121
168
|
lines << "Test: #{row[:name]}"
|
|
@@ -131,31 +178,51 @@ module Branchproof
|
|
|
131
178
|
lines << " No recorded completed condition observations"
|
|
132
179
|
return
|
|
133
180
|
end
|
|
134
|
-
observations.group_by { |observation| observation[:condition_id] }
|
|
181
|
+
grouped = observations.group_by { |observation| observation[:alternative_id] || observation[:condition_id] }
|
|
182
|
+
grouped.each_value do |items|
|
|
135
183
|
observation = items.first
|
|
136
|
-
observed_values = items.map { |item| item[:value] }.uniq
|
|
137
|
-
label = observed_values.all?(&:nil?) ? "short-circuited-only" : "evaluated"
|
|
138
|
-
label += "; owns canonical witness evidence" if @level > 1 && items.any? { |item| item[:owns_witness] }
|
|
139
184
|
phases = items.flat_map { |item| item[:phases] }.uniq.sort.join(", ")
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
185
|
+
if observation[:alternative_id]
|
|
186
|
+
states = items.map { |item| item[:alternative_state] }.uniq.join(", ")
|
|
187
|
+
alternative_location = location(observation[:relative_path], observation[:line],
|
|
188
|
+
unavailable: "alternative location unavailable")
|
|
189
|
+
lines << " Alternative #{observation[:alternative_id]}: #{observation[:expression]} " \
|
|
190
|
+
"(#{alternative_location}): #{states}; phases: #{phases}"
|
|
191
|
+
else
|
|
192
|
+
observed_values = items.map { |item| item[:value] }.uniq
|
|
193
|
+
label = observed_values.all?(&:nil?) ? "short-circuited-only" : "evaluated"
|
|
194
|
+
label += "; owns canonical witness evidence" if @level > 1 && items.any? { |item| item[:owns_witness] }
|
|
195
|
+
signs = values(values: observed_values)
|
|
196
|
+
condition_location = location(observation[:relative_path], observation[:line],
|
|
197
|
+
unavailable: "condition line unavailable")
|
|
198
|
+
lines << " #{observation[:expression]} (#{condition_location}): #{signs}; #{label}; " \
|
|
199
|
+
"phases: #{phases}"
|
|
200
|
+
end
|
|
145
201
|
end
|
|
146
202
|
end
|
|
147
203
|
|
|
148
204
|
def render_unowned(lines)
|
|
149
205
|
rows = @index.conditions
|
|
150
206
|
rows = rows.reject { |row| row[:status] == "PROVEN" } if @missing_only
|
|
151
|
-
{ "Unexecuted conditions" => rows.select { |row| row[:unexecuted] },
|
|
152
|
-
|
|
207
|
+
groups = { "Unexecuted conditions" => rows.select { |row| row[:unexecuted] },
|
|
208
|
+
"Unattributed evidence" => rows.select { |row| row[:unattributed].positive? } }
|
|
209
|
+
alternative_rows = @index.alternatives
|
|
210
|
+
alternative_rows = alternative_rows.reject { |row| row[:status].to_s == "covered" } if @missing_only
|
|
211
|
+
unless alternative_rows.empty?
|
|
212
|
+
groups["Unexecuted alternatives"] = alternative_rows.select do |row|
|
|
213
|
+
row[:selected][:observed] == false && row[:not_selected][:observed] == false
|
|
214
|
+
end
|
|
215
|
+
groups["Unattributed alternative evidence"] = alternative_rows.select do |row|
|
|
216
|
+
%i[selected not_selected skipped].any? { |state| row[state][:unattributed_count].to_i.positive? }
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
groups.each do |heading, conditions|
|
|
153
220
|
next if conditions.empty?
|
|
154
221
|
|
|
155
222
|
lines << "#{heading}:"
|
|
156
223
|
conditions.each do |row|
|
|
157
|
-
|
|
158
|
-
|
|
224
|
+
unavailable = row[:alternative_id] ? "alternative location unavailable" : "condition line unavailable"
|
|
225
|
+
lines << " #{row[:expression]} (#{location(row[:relative_path], row[:line], unavailable: unavailable)})"
|
|
159
226
|
end
|
|
160
227
|
end
|
|
161
228
|
end
|