branchproof 0.7.0 → 0.9.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 +53 -0
- data/README.md +255 -21
- 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/DefaultInstrumentation.md +13 -0
- data/doc/Branchproof/DefaultRuntime.md +11 -0
- data/doc/Branchproof/DefaultSyntax.md +7 -0
- data/doc/Branchproof/ExceptionInstrumentation.md +9 -0
- data/doc/Branchproof/ExceptionRuntime.md +27 -0
- data/doc/Branchproof/ExceptionSyntax.md +9 -0
- data/doc/Branchproof/ExtendedAlternativeRuntime.md +19 -0
- data/doc/Branchproof/FocusedReport.md +5 -2
- data/doc/Branchproof/Instrumenter.md +1 -1
- data/doc/Branchproof/IterationInstrumentation.md +7 -0
- data/doc/Branchproof/IterationRuntime.md +21 -0
- data/doc/Branchproof/IterationSyntax.md +11 -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 +60 -1
- data/doc/Branchproof/SavedReport.md +15 -0
- data/doc/Branchproof/Source.md +7 -1
- data/doc/Branchproof/ValueInstrumentation.md +7 -0
- data/doc/Branchproof/ValueRuntime.md +26 -0
- data/doc/Branchproof/ValueSyntax.md +26 -0
- data/doc/Branchproof.md +20 -3
- data/doc/CHANGELOG.md +53 -0
- data/doc/README.md +255 -21
- 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 +50 -24
- data/lib/branchproof/decision_table.rb +377 -0
- data/lib/branchproof/default_instrumentation.rb +140 -0
- data/lib/branchproof/default_runtime.rb +16 -0
- data/lib/branchproof/default_syntax.rb +94 -0
- data/lib/branchproof/evidence.rb +133 -31
- data/lib/branchproof/exception_instrumentation.rb +96 -0
- data/lib/branchproof/exception_runtime.rb +41 -0
- data/lib/branchproof/exception_syntax.rb +157 -0
- data/lib/branchproof/extended_alternative_runtime.rb +23 -0
- data/lib/branchproof/flow_instrumentation.rb +52 -23
- data/lib/branchproof/focused_report.rb +77 -9
- data/lib/branchproof/instrumenter.rb +93 -37
- data/lib/branchproof/iteration_instrumentation.rb +66 -0
- data/lib/branchproof/iteration_runtime.rb +79 -0
- data/lib/branchproof/iteration_syntax.rb +75 -0
- data/lib/branchproof/limits.rb +4 -1
- data/lib/branchproof/loader.rb +24 -7
- 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 +32 -30
- data/lib/branchproof/saved_report.rb +200 -14
- data/lib/branchproof/source.rb +140 -71
- data/lib/branchproof/value_instrumentation.rb +35 -0
- data/lib/branchproof/value_runtime.rb +73 -0
- data/lib/branchproof/value_syntax.rb +116 -0
- data/lib/branchproof/version.rb +1 -1
- data/lib/branchproof/worker.rb +1 -4
- data/lib/branchproof.rb +2 -0
- data/llms.txt +20 -3
- data/sig/branchproof.rbs +64 -1
- metadata +32 -1
data/lib/branchproof/source.rb
CHANGED
|
@@ -4,11 +4,23 @@ require "digest"
|
|
|
4
4
|
require "pathname"
|
|
5
5
|
require "prism"
|
|
6
6
|
require_relative "decision_syntax"
|
|
7
|
+
require_relative "iteration_syntax"
|
|
8
|
+
require_relative "exception_syntax"
|
|
9
|
+
require_relative "default_syntax"
|
|
10
|
+
require_relative "value_syntax"
|
|
11
|
+
require_relative "constraints"
|
|
7
12
|
|
|
8
13
|
module Branchproof
|
|
9
14
|
# Inventories supported condition and decision occurrences from Ruby files.
|
|
10
15
|
class Source
|
|
11
16
|
include DecisionSyntax
|
|
17
|
+
# Contextual syntax modules are layered after the baseline classifier so
|
|
18
|
+
# each can extend discovery without coupling this walker to every syntax
|
|
19
|
+
# family. Additional modules can follow the same seam.
|
|
20
|
+
include IterationSyntax
|
|
21
|
+
include ExceptionSyntax
|
|
22
|
+
prepend DefaultSyntax
|
|
23
|
+
prepend ValueSyntax
|
|
12
24
|
|
|
13
25
|
attr_reader :root, :limits
|
|
14
26
|
|
|
@@ -58,11 +70,13 @@ module Branchproof
|
|
|
58
70
|
end
|
|
59
71
|
|
|
60
72
|
def read_unit(path)
|
|
73
|
+
absolute_path = File.expand_path(path)
|
|
74
|
+
relative_path = relative(path)
|
|
61
75
|
bytes = File.binread(path)
|
|
62
76
|
parsed = Prism.parse(bytes)
|
|
63
77
|
encoding = source_encoding(bytes, parsed)
|
|
64
|
-
|
|
65
|
-
|
|
78
|
+
digest = Digest::SHA256.hexdigest(bytes)
|
|
79
|
+
source_id = Records.source_id(relative_path: relative_path, digest: digest, encoding: encoding)
|
|
66
80
|
diagnostics = parsed.errors.map do |error|
|
|
67
81
|
Records.diagnostic(code: "parse_error", severity: "error", message: error.message, source_id: source_id,
|
|
68
82
|
details: { byte_start: error.location.start_offset, byte_length: error.location.length })
|
|
@@ -78,11 +92,11 @@ module Branchproof
|
|
|
78
92
|
file_reasons << "unsupported_data_section" if parsed.respond_to?(:data_loc) && parsed.data_loc
|
|
79
93
|
file_reasons << "parse_error" unless parsed.errors.empty?
|
|
80
94
|
decisions = parsed.value ? decisions_for(parsed.value, bytes, source_id, file_reasons, encoding) : []
|
|
81
|
-
Records.build(source_id: source_id, relative_path:
|
|
82
|
-
real_path: File.realpath(path), digest:
|
|
95
|
+
Records.build(source_id: source_id, relative_path: relative_path, absolute_path: absolute_path,
|
|
96
|
+
real_path: File.realpath(path), digest: digest, encoding: encoding,
|
|
83
97
|
original_bytes: bytes, decisions: decisions, diagnostics: diagnostics)
|
|
84
98
|
rescue SystemCallError => e
|
|
85
|
-
Records.build(source_id: nil, relative_path:
|
|
99
|
+
Records.build(source_id: nil, relative_path: relative_path, absolute_path: absolute_path,
|
|
86
100
|
real_path: nil, digest: nil, encoding: nil, original_bytes: nil, decisions: [],
|
|
87
101
|
diagnostics: [Records.diagnostic(code: "source_unreadable", severity: "error", message: e.message)])
|
|
88
102
|
end
|
|
@@ -100,25 +114,56 @@ module Branchproof
|
|
|
100
114
|
Encoding::UTF_8.name
|
|
101
115
|
end
|
|
102
116
|
|
|
117
|
+
# Walks the whole program AST exactly once, sorting every node into the
|
|
118
|
+
# buckets the two classification phases below need. Phase one (decision
|
|
119
|
+
# and case-when nodes) must run to completion before phase two (bare
|
|
120
|
+
# boolean/pattern nodes) because phase two skips nodes phase one already
|
|
121
|
+
# inventoried via mark_semantic_boolean_nodes.
|
|
122
|
+
def collect_ast_nodes(program)
|
|
123
|
+
defined_ranges = []
|
|
124
|
+
guard_patterns = {}.compare_by_identity
|
|
125
|
+
phase_one_nodes = []
|
|
126
|
+
phase_two_nodes = []
|
|
127
|
+
flow_nodes = []
|
|
128
|
+
value_nodes = []
|
|
129
|
+
|
|
130
|
+
walk_skipping_defined_operands(program) do |node|
|
|
131
|
+
if node.is_a?(Prism::DefinedNode)
|
|
132
|
+
defined_ranges << node.location
|
|
133
|
+
elsif node.is_a?(Prism::InNode)
|
|
134
|
+
pattern = node.pattern
|
|
135
|
+
guard_patterns[pattern] = true if pattern.is_a?(Prism::IfNode) || pattern.is_a?(Prism::UnlessNode)
|
|
136
|
+
end
|
|
137
|
+
phase_one_nodes << node if decision_node?(node) || subjectless_case?(node)
|
|
138
|
+
phase_two_nodes << node if boolean_node?(node) || node.is_a?(Prism::MatchPredicateNode) ||
|
|
139
|
+
node.is_a?(Prism::DefinedNode)
|
|
140
|
+
flow_nodes << node if flow_decision_node?(node)
|
|
141
|
+
value_nodes << node if respond_to?(:value_candidate_node?, true) && value_candidate_node?(node)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
{ defined_ranges: defined_ranges, guard_patterns: guard_patterns, phase_one_nodes: phase_one_nodes,
|
|
145
|
+
phase_two_nodes: phase_two_nodes, flow_nodes: flow_nodes, value_nodes: value_nodes }
|
|
146
|
+
end
|
|
147
|
+
|
|
103
148
|
def decisions_for(program, bytes, source_id, file_reasons = [], encoding = "UTF-8")
|
|
104
149
|
specs = []
|
|
105
150
|
inventoried_boolean_nodes = {}.compare_by_identity
|
|
106
|
-
|
|
107
|
-
|
|
151
|
+
collected = collect_ast_nodes(program)
|
|
152
|
+
defined_ranges = collected[:defined_ranges]
|
|
153
|
+
guard_patterns = collected[:guard_patterns]
|
|
108
154
|
|
|
109
|
-
|
|
110
|
-
defined_ranges << node.location if node.is_a?(Prism::DefinedNode)
|
|
155
|
+
collected[:phase_one_nodes].each do |node|
|
|
111
156
|
if decision_node?(node)
|
|
112
157
|
predicate = node.predicate
|
|
113
158
|
next unless predicate
|
|
114
159
|
|
|
115
160
|
predicate = unwrap_predicate(predicate)
|
|
116
|
-
context =
|
|
161
|
+
context = guard_patterns[node] ? "pattern_guard" : decision_context(node, bytes)
|
|
117
162
|
specs << { node: node, predicate: predicate, context: context }
|
|
118
163
|
mark_semantic_boolean_nodes(predicate, inventoried_boolean_nodes)
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
164
|
+
else
|
|
165
|
+
conditions_for(node).each do |when_node|
|
|
166
|
+
conditions_for(when_node).each do |predicate|
|
|
122
167
|
predicate = unwrap_predicate(predicate)
|
|
123
168
|
specs << { node: when_node, predicate: predicate, context: "case_when" }
|
|
124
169
|
mark_semantic_boolean_nodes(predicate, inventoried_boolean_nodes)
|
|
@@ -129,18 +174,19 @@ module Branchproof
|
|
|
129
174
|
|
|
130
175
|
# Boolean expressions nested in an atomic expression (for example, a call
|
|
131
176
|
# argument) are separate decisions when tree_for did not decompose them.
|
|
132
|
-
|
|
133
|
-
next unless boolean_node?(node) || node.is_a?(Prism::MatchPredicateNode)
|
|
177
|
+
collected[:phase_two_nodes].each do |node|
|
|
134
178
|
next if inventoried_boolean_nodes[node]
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
179
|
+
next if within_defined_expression?(node, defined_ranges) && !node.is_a?(Prism::DefinedNode)
|
|
180
|
+
|
|
181
|
+
context = if node.is_a?(Prism::MatchPredicateNode)
|
|
182
|
+
"pattern_in"
|
|
183
|
+
elsif node.is_a?(Prism::DefinedNode)
|
|
184
|
+
"defined"
|
|
185
|
+
else
|
|
186
|
+
"short_circuit"
|
|
187
|
+
end
|
|
142
188
|
specs << { node: nil, predicate: node, context: context,
|
|
143
|
-
additional_reasons:
|
|
189
|
+
additional_reasons: [] }
|
|
144
190
|
mark_semantic_boolean_nodes(node, inventoried_boolean_nodes)
|
|
145
191
|
end
|
|
146
192
|
|
|
@@ -158,42 +204,35 @@ module Branchproof
|
|
|
158
204
|
predicate: predicate, context: spec[:context],
|
|
159
205
|
additional_reasons: spec[:additional_reasons] || [])
|
|
160
206
|
end
|
|
161
|
-
boolean_decisions + flow_decisions_for(program, bytes, source_id, file_reasons, encoding
|
|
207
|
+
decisions = boolean_decisions + flow_decisions_for(program, bytes, source_id, file_reasons, encoding,
|
|
208
|
+
defined_ranges: defined_ranges,
|
|
209
|
+
nodes: collected[:flow_nodes])
|
|
210
|
+
decisions + additional_decisions_for(program, bytes, source_id, file_reasons, encoding,
|
|
211
|
+
collected: collected, decisions: decisions)
|
|
162
212
|
end
|
|
163
213
|
|
|
164
|
-
def
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
defined_ranges << node.location if node.is_a?(Prism::DefinedNode)
|
|
168
|
-
end
|
|
214
|
+
def additional_decisions_for(_program, _bytes, _source_id, _file_reasons, _encoding, **_options)
|
|
215
|
+
[]
|
|
216
|
+
end
|
|
169
217
|
|
|
170
|
-
|
|
171
|
-
|
|
218
|
+
def flow_decisions_for(program, bytes, source_id, file_reasons = [], encoding = "UTF-8",
|
|
219
|
+
defined_ranges: nil, nodes: nil)
|
|
220
|
+
defined_ranges ||= collect_defined_ranges(program)
|
|
172
221
|
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
)
|
|
177
|
-
end
|
|
222
|
+
nodes ||= collect_flow_decision_nodes(program)
|
|
223
|
+
nodes = nodes.reject { |node| within_defined_expression?(node, defined_ranges) }
|
|
224
|
+
super(program, bytes, source_id, file_reasons, encoding, nodes: nodes)
|
|
178
225
|
end
|
|
179
226
|
|
|
180
|
-
def
|
|
181
|
-
|
|
182
|
-
walk(program)
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
pattern = node.pattern
|
|
186
|
-
guards[pattern] = true if pattern.is_a?(Prism::IfNode) || pattern.is_a?(Prism::UnlessNode)
|
|
187
|
-
end
|
|
188
|
-
guards
|
|
227
|
+
def collect_defined_ranges(program)
|
|
228
|
+
defined_ranges = []
|
|
229
|
+
walk(program) { |node| defined_ranges << node.location if node.is_a?(Prism::DefinedNode) }
|
|
230
|
+
defined_ranges
|
|
189
231
|
end
|
|
190
232
|
|
|
191
233
|
def range_within_defined_expression?(decision, defined_ranges)
|
|
192
234
|
start_offset = decision[:byte_start]
|
|
193
|
-
|
|
194
|
-
defined_ranges.any? do |defined_location|
|
|
195
|
-
defined_location.start_offset <= start_offset && defined_location.end_offset >= end_offset
|
|
196
|
-
end
|
|
235
|
+
offsets_within_defined_expression?(start_offset, start_offset + decision[:byte_length], defined_ranges)
|
|
197
236
|
end
|
|
198
237
|
|
|
199
238
|
def walk(node, &block)
|
|
@@ -214,16 +253,7 @@ module Branchproof
|
|
|
214
253
|
node.is_a?(Prism::CaseNode) && node.predicate.nil?
|
|
215
254
|
end
|
|
216
255
|
|
|
217
|
-
def
|
|
218
|
-
conditions = node.conditions
|
|
219
|
-
if conditions.is_a?(Array)
|
|
220
|
-
conditions
|
|
221
|
-
else
|
|
222
|
-
(conditions.respond_to?(:body) ? conditions.body : [])
|
|
223
|
-
end
|
|
224
|
-
end
|
|
225
|
-
|
|
226
|
-
def when_predicates(node)
|
|
256
|
+
def conditions_for(node)
|
|
227
257
|
conditions = node.conditions
|
|
228
258
|
if conditions.is_a?(Array)
|
|
229
259
|
conditions
|
|
@@ -249,6 +279,7 @@ module Branchproof
|
|
|
249
279
|
context ||= decision_context(node, bytes)
|
|
250
280
|
leaves = []
|
|
251
281
|
tree = tree_for(predicate, bytes, leaves)
|
|
282
|
+
decision_constraint_safe = constraint_safe_expression?(predicate)
|
|
252
283
|
start_offset = predicate.location.start_offset
|
|
253
284
|
length = predicate.location.length
|
|
254
285
|
opaque_ranges = leaves.filter_map { |leaf| leaf.delete(:_opaque_range) }
|
|
@@ -256,9 +287,16 @@ module Branchproof
|
|
|
256
287
|
expression = text_value(leaf.delete(:_expression), "UTF-8")
|
|
257
288
|
location = leaf.delete(:_location)
|
|
258
289
|
literal_truth = leaf.delete(:_literal_truth)
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
290
|
+
constraint = leaf.delete(:_constraint)
|
|
291
|
+
constraint_safe = leaf.delete(:_constraint_safe)
|
|
292
|
+
contextual = leaf.delete(:_contextual)
|
|
293
|
+
condition = Records.build(id: nil, index: index, byte_start: location.start_offset,
|
|
294
|
+
byte_length: location.length,
|
|
295
|
+
line: location.start_line, column: location.start_column,
|
|
296
|
+
expression: expression, literal_truth: literal_truth, coupling: "unknown",
|
|
297
|
+
constraint: constraint,
|
|
298
|
+
constraint_safe: decision_constraint_safe && constraint_safe == true)
|
|
299
|
+
contextual ? condition.merge(contextual: contextual) : condition
|
|
262
300
|
end
|
|
263
301
|
decision_id = Records.decision_id(source_id: source_id, context: context, byte_start: start_offset,
|
|
264
302
|
byte_length: length, tree: tree)
|
|
@@ -287,9 +325,12 @@ module Branchproof
|
|
|
287
325
|
|
|
288
326
|
def within_defined_expression?(node, defined_ranges)
|
|
289
327
|
location = node.location
|
|
328
|
+
offsets_within_defined_expression?(location.start_offset, location.end_offset, defined_ranges)
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
def offsets_within_defined_expression?(start_offset, end_offset, defined_ranges)
|
|
290
332
|
defined_ranges.any? do |defined_location|
|
|
291
|
-
defined_location.start_offset <=
|
|
292
|
-
defined_location.end_offset >= location.end_offset
|
|
333
|
+
defined_location.start_offset <= start_offset && defined_location.end_offset >= end_offset
|
|
293
334
|
end
|
|
294
335
|
end
|
|
295
336
|
|
|
@@ -300,7 +341,7 @@ module Branchproof
|
|
|
300
341
|
inventoried_boolean_nodes[node] = true
|
|
301
342
|
mark_semantic_boolean_nodes(node.left, inventoried_boolean_nodes)
|
|
302
343
|
mark_semantic_boolean_nodes(node.right, inventoried_boolean_nodes)
|
|
303
|
-
when Prism::MatchPredicateNode
|
|
344
|
+
when Prism::DefinedNode, Prism::MatchPredicateNode
|
|
304
345
|
inventoried_boolean_nodes[node] = true
|
|
305
346
|
when Prism::CallNode
|
|
306
347
|
mark_semantic_boolean_nodes(node.receiver, inventoried_boolean_nodes) if unary_not?(node)
|
|
@@ -330,14 +371,40 @@ module Branchproof
|
|
|
330
371
|
leaf = {
|
|
331
372
|
_expression: bytes.byteslice(location.start_offset, location.length), _location: location,
|
|
332
373
|
_literal_truth: literal_truth(node),
|
|
374
|
+
_constraint: (constraint = Constraints.for_node(node)),
|
|
375
|
+
_constraint_safe: safe_constraint_node?(constraint),
|
|
333
376
|
_opaque_range: if node.is_a?(Prism::CallNode) && node.name == :!
|
|
334
377
|
{ start: location.start_offset, length: location.length }
|
|
335
378
|
end
|
|
336
379
|
}
|
|
380
|
+
leaf[:_contextual] = "implicit_regexp" if node.is_a?(Prism::MatchLastLineNode) ||
|
|
381
|
+
node.is_a?(Prism::InterpolatedMatchLastLineNode)
|
|
382
|
+
leaf[:_contextual] = "flip_flop" if node.is_a?(Prism::FlipFlopNode)
|
|
337
383
|
leaves << leaf
|
|
338
384
|
Records.build(type: :atom, index: leaves.length - 1)
|
|
339
385
|
end
|
|
340
386
|
|
|
387
|
+
# Source-derived comparisons are deliberately untrusted. Ruby permits
|
|
388
|
+
# mutation between leaves and user-defined operators, so only repeated
|
|
389
|
+
# truthiness reads of local variables in an entirely side-effect-free
|
|
390
|
+
# decision may be used by the table solver.
|
|
391
|
+
def constraint_safe_expression?(node)
|
|
392
|
+
node = unwrap_predicate(node)
|
|
393
|
+
case node
|
|
394
|
+
when Prism::AndNode, Prism::OrNode
|
|
395
|
+
constraint_safe_expression?(node.left) && constraint_safe_expression?(node.right)
|
|
396
|
+
when Prism::LocalVariableReadNode, Prism::TrueNode, Prism::FalseNode, Prism::NilNode,
|
|
397
|
+
Prism::IntegerNode, Prism::FloatNode, Prism::SymbolNode, Prism::StringNode
|
|
398
|
+
true
|
|
399
|
+
else
|
|
400
|
+
false
|
|
401
|
+
end
|
|
402
|
+
end
|
|
403
|
+
|
|
404
|
+
def safe_constraint_node?(constraint)
|
|
405
|
+
constraint && constraint[:operator] == "truthy" && constraint[:subject][:kind] == "local"
|
|
406
|
+
end
|
|
407
|
+
|
|
341
408
|
def unary_not?(node)
|
|
342
409
|
node.is_a?(Prism::CallNode) && node.name == :! && node.receiver && node.call_operator_loc.nil?
|
|
343
410
|
end
|
|
@@ -351,12 +418,7 @@ module Branchproof
|
|
|
351
418
|
|
|
352
419
|
def unsupported_reasons(predicate, bytes)
|
|
353
420
|
reasons = []
|
|
354
|
-
|
|
355
|
-
if node.is_a?(Prism::MatchLastLineNode) || node.is_a?(Prism::InterpolatedMatchLastLineNode)
|
|
356
|
-
reasons << "unsupported_implicit_regexp"
|
|
357
|
-
end
|
|
358
|
-
reasons << "unsupported_flip_flop" if node.is_a?(Prism::FlipFlopNode)
|
|
359
|
-
reasons << "unsupported_defined_expression" if node.is_a?(Prism::DefinedNode)
|
|
421
|
+
walk_skipping_defined_operands(predicate) do |node|
|
|
360
422
|
reasons << "unsupported_heredoc" if node.respond_to?(:opening_loc) && node.opening_loc &&
|
|
361
423
|
bytes.byteslice(node.opening_loc.start_offset,
|
|
362
424
|
node.opening_loc.length).start_with?("<<")
|
|
@@ -364,6 +426,13 @@ module Branchproof
|
|
|
364
426
|
reasons
|
|
365
427
|
end
|
|
366
428
|
|
|
429
|
+
def walk_skipping_defined_operands(node, &block)
|
|
430
|
+
yield node
|
|
431
|
+
return if node.is_a?(Prism::DefinedNode)
|
|
432
|
+
|
|
433
|
+
node.child_nodes.each { |child| walk_skipping_defined_operands(child, &block) if child }
|
|
434
|
+
end
|
|
435
|
+
|
|
367
436
|
def unwrap_predicate(node)
|
|
368
437
|
while node.is_a?(Prism::ParenthesesNode)
|
|
369
438
|
statements = node.body
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Branchproof
|
|
4
|
+
# Source rewrite for value observations; it preserves the wrapped value.
|
|
5
|
+
module ValueInstrumentation
|
|
6
|
+
private
|
|
7
|
+
|
|
8
|
+
def render_flow(bytes, decision, nested, encloses)
|
|
9
|
+
return render_value(bytes, decision, nested, encloses) if decision.dig(:instrumentation, :type) == "value"
|
|
10
|
+
|
|
11
|
+
super
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# rubocop:disable-next Metrics/MethodLength -- success and exception traces differ.
|
|
15
|
+
def render_value(bytes, decision, nested, encloses)
|
|
16
|
+
expression = render_children(bytes, decision[:byte_start], decision[:byte_length], nested, encloses)
|
|
17
|
+
metadata = decision.fetch(:instrumentation)
|
|
18
|
+
runtime = self.class::RUNTIME
|
|
19
|
+
id = decision.fetch(:id).inspect
|
|
20
|
+
domain = metadata.fetch(:domain).to_sym.inspect
|
|
21
|
+
count = decision.fetch(:alternatives).length
|
|
22
|
+
enter = "#{runtime}.enter(#{id}); #{runtime}.set_alternative_count(#{id}, #{count})"
|
|
23
|
+
if metadata.fetch(:domain).to_sym == :dispatch
|
|
24
|
+
"(begin; #{enter}; begin; " \
|
|
25
|
+
"(begin; #{runtime}.dispatch_path(#{id}, (#{expression}), false); end); " \
|
|
26
|
+
"rescue ::Exception; #{runtime}.dispatch_path(#{id}, nil, true); raise; " \
|
|
27
|
+
"ensure; #{runtime}.leave(#{id}); end; end)"
|
|
28
|
+
else
|
|
29
|
+
"(begin; #{enter}; begin; " \
|
|
30
|
+
"#{runtime}.value_path(#{id}, (#{expression}), #{domain}); " \
|
|
31
|
+
"ensure; #{runtime}.leave(#{id}); end; end)"
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Branchproof
|
|
4
|
+
# Runtime mapping for value observations.
|
|
5
|
+
module ValueRuntime
|
|
6
|
+
# rubocop:disable Style/CaseEquality, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
7
|
+
def comparison_index(value)
|
|
8
|
+
return 3 if nil.equal?(value)
|
|
9
|
+
return unless Integer === value || Float === value || Rational === value
|
|
10
|
+
return if Float === value && value.nan?
|
|
11
|
+
return 0 if value < 0 # rubocop:disable Style/NumericPredicate
|
|
12
|
+
return 1 if value == 0 # rubocop:disable Style/NumericPredicate
|
|
13
|
+
return 2 if value > 0 # rubocop:disable Style/NumericPredicate
|
|
14
|
+
|
|
15
|
+
nil
|
|
16
|
+
end
|
|
17
|
+
module_function :comparison_index
|
|
18
|
+
# rubocop:enable Style/CaseEquality, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
19
|
+
|
|
20
|
+
def set_alternative_count(decision_id, count)
|
|
21
|
+
frame = current_frame(decision_id)
|
|
22
|
+
frame[:alternative_count] = count if frame
|
|
23
|
+
nil
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# rubocop:disable-next Metrics/MethodLength -- trace state branches are explicit.
|
|
27
|
+
# rubocop:disable-next Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity -- domain dispatch mirrors the observable value contract.
|
|
28
|
+
def value_path(decision_id, value, domain)
|
|
29
|
+
frame = current_frame(decision_id)
|
|
30
|
+
return value unless frame
|
|
31
|
+
return value if frame[:finished]
|
|
32
|
+
|
|
33
|
+
index = case domain.to_sym
|
|
34
|
+
when :truthiness, :defined then value ? 1 : 0
|
|
35
|
+
when :lookup
|
|
36
|
+
if nil.equal?(value)
|
|
37
|
+
2
|
|
38
|
+
elsif value
|
|
39
|
+
0
|
|
40
|
+
else
|
|
41
|
+
1
|
|
42
|
+
end
|
|
43
|
+
when :comparison then comparison_index(value)
|
|
44
|
+
when :dispatch then 0
|
|
45
|
+
end
|
|
46
|
+
if index
|
|
47
|
+
frame[:observations] = if frame.fetch(:alternative_count, index + 1) == 2
|
|
48
|
+
[[0, index.zero?], [1, index == 1]]
|
|
49
|
+
else
|
|
50
|
+
(0..index).map { |position| [position, position == index] }
|
|
51
|
+
end
|
|
52
|
+
frame[:outcome] = true
|
|
53
|
+
frame[:finished] = true
|
|
54
|
+
end
|
|
55
|
+
value
|
|
56
|
+
rescue NoMethodError, ArgumentError
|
|
57
|
+
# A user-defined <=> may return an incomparable object. Preserve it and
|
|
58
|
+
# leave the vector incomplete instead of inventing a domain bucket.
|
|
59
|
+
value
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def dispatch_path(decision_id, value, raised)
|
|
63
|
+
frame = current_frame(decision_id)
|
|
64
|
+
return value unless frame
|
|
65
|
+
|
|
66
|
+
index = raised ? 1 : 0
|
|
67
|
+
frame[:observations] = [[0, index.zero?], [1, index == 1]]
|
|
68
|
+
frame[:outcome] = true
|
|
69
|
+
frame[:finished] = true
|
|
70
|
+
value
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
module Branchproof
|
|
6
|
+
# Inventory for expressions whose result is itself the observable value.
|
|
7
|
+
# This deliberately excludes control-flow predicates already owned by Source.
|
|
8
|
+
module ValueSyntax
|
|
9
|
+
BOOLEAN_OPERATORS = %i[== != < <= > >= === =~ !~].freeze
|
|
10
|
+
DISPATCH_METHODS = %i[public_send send __send__].freeze
|
|
11
|
+
|
|
12
|
+
# rubocop:disable-next Metrics/ParameterLists -- source seam mirrors Source#decisions_for.
|
|
13
|
+
def additional_decisions_for(program, bytes, source_id, file_reasons, encoding, decisions:, collected:)
|
|
14
|
+
occupied = decisions.each_with_object({}) do |decision, ranges|
|
|
15
|
+
ranges[range_key(decision[:byte_start], decision[:byte_length])] = true
|
|
16
|
+
Array(decision[:conditions]).each do |condition|
|
|
17
|
+
ranges[range_key(condition[:byte_start], condition[:byte_length])] = true
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
value_decisions_for(
|
|
21
|
+
program, bytes, source_id, nodes: collected.fetch(:value_nodes), occupied_ranges: occupied,
|
|
22
|
+
file_reasons: file_reasons, encoding: encoding
|
|
23
|
+
)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# `nodes:` is supplied by Source's fused AST walk. It remains optional for
|
|
27
|
+
# the standalone discovery API used by focused syntax tests.
|
|
28
|
+
# rubocop:disable-next Metrics/MethodLength, Metrics/ParameterLists -- source seam mirrors Source#decisions_for.
|
|
29
|
+
def value_decisions_for(program, bytes, source_id, nodes: nil, occupied_ranges: {}, file_reasons: [],
|
|
30
|
+
encoding: "UTF-8")
|
|
31
|
+
occupied = occupied_ranges.transform_keys do |range|
|
|
32
|
+
range.is_a?(Array) ? range_key(*range) : range
|
|
33
|
+
end
|
|
34
|
+
decisions = []
|
|
35
|
+
each_value_node(program, nodes) do |node|
|
|
36
|
+
spec = value_spec(node)
|
|
37
|
+
next unless spec
|
|
38
|
+
|
|
39
|
+
key = range_key(node.location.start_offset, node.location.length)
|
|
40
|
+
next if occupied[key]
|
|
41
|
+
|
|
42
|
+
decisions << build_value_decision(node, spec, bytes, source_id, file_reasons, encoding)
|
|
43
|
+
occupied[key] = true
|
|
44
|
+
end
|
|
45
|
+
decisions
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def value_candidate_node?(node)
|
|
51
|
+
!value_spec(node).nil?
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def each_value_node(program, nodes, &)
|
|
55
|
+
return nodes.each(&) if nodes
|
|
56
|
+
|
|
57
|
+
walker = respond_to?(:walk_skipping_defined_operands, true) ? :walk_skipping_defined_operands : :walk
|
|
58
|
+
send(walker, program, &)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def range_key(start_offset, length)
|
|
62
|
+
(start_offset << 32) | length
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# rubocop:disable-next Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
66
|
+
def value_spec(node)
|
|
67
|
+
if Prism.const_defined?(:MatchWriteNode) && node.is_a?(Prism::MatchWriteNode)
|
|
68
|
+
return { context: "match_capture", domain: :truthiness, alternatives: %w[false true], type: "value" }
|
|
69
|
+
end
|
|
70
|
+
return nil unless node.is_a?(Prism::CallNode)
|
|
71
|
+
|
|
72
|
+
name = node.name.to_sym
|
|
73
|
+
if name == :<=>
|
|
74
|
+
return { context: "comparison", domain: :comparison, alternatives: %w[negative zero positive nil],
|
|
75
|
+
type: "value" }
|
|
76
|
+
end
|
|
77
|
+
return { context: "lookup", domain: :lookup, alternatives: %w[truthy false nil], type: "value" } if name == :[]
|
|
78
|
+
if DISPATCH_METHODS.include?(name)
|
|
79
|
+
return { context: "dispatch", domain: :dispatch, alternatives: %w[success exception],
|
|
80
|
+
type: "value" }
|
|
81
|
+
end
|
|
82
|
+
if BOOLEAN_OPERATORS.include?(name) || name.to_s.end_with?("?")
|
|
83
|
+
return { context: "predicate", domain: :truthiness, alternatives: %w[false true],
|
|
84
|
+
type: "value" }
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
nil
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# rubocop:disable-next Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/ParameterLists, Metrics/PerceivedComplexity -- record assembly is intentionally explicit.
|
|
91
|
+
def build_value_decision(node, spec, bytes, source_id, file_reasons, encoding)
|
|
92
|
+
location = node.location
|
|
93
|
+
expression = text_value(bytes.byteslice(location.start_offset, location.length), encoding)
|
|
94
|
+
alternatives = spec.fetch(:alternatives).each_with_index.map do |label, index|
|
|
95
|
+
{ index: index, id: nil, expression: label, byte_start: location.start_offset, byte_length: location.length }
|
|
96
|
+
end
|
|
97
|
+
id = Records.decision_id(source_id: source_id, context: spec.fetch(:context),
|
|
98
|
+
byte_start: location.start_offset, byte_length: location.length,
|
|
99
|
+
tree: nil)
|
|
100
|
+
alternatives = alternatives.map { |item| item.merge(id: Records.condition_id(id, item.fetch(:index))) }
|
|
101
|
+
kind = alternatives.length == 2 ? "implicit" : "multiway"
|
|
102
|
+
reasons = Array(file_reasons)
|
|
103
|
+
reasons += Array(unsupported_reasons(node, bytes)) if respond_to?(:unsupported_reasons, true)
|
|
104
|
+
limit = @limits[:conditions_per_decision] if defined?(@limits) && @limits.respond_to?(:[])
|
|
105
|
+
reasons << "alternative_limit_exceeded" if limit && alternatives.length > limit
|
|
106
|
+
Records.build(id: id, source_id: source_id, kind: kind, context: spec.fetch(:context),
|
|
107
|
+
byte_start: location.start_offset, byte_length: location.length,
|
|
108
|
+
line: location.start_line, column: location.start_column,
|
|
109
|
+
expression: expression, tree: nil, conditions: [],
|
|
110
|
+
alternatives: alternatives, discovered_condition_count: 0,
|
|
111
|
+
support_status: reasons.empty? ? "SUPPORTED" : "UNSUPPORTED",
|
|
112
|
+
support_reasons: reasons.uniq, opaque_ranges: [],
|
|
113
|
+
instrumentation: { type: "value", domain: spec.fetch(:domain) })
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
data/lib/branchproof/version.rb
CHANGED
data/lib/branchproof/worker.rb
CHANGED
|
@@ -14,12 +14,9 @@ module Branchproof
|
|
|
14
14
|
|
|
15
15
|
def child_process(config_path)
|
|
16
16
|
payload = JSON.parse(File.binread(config_path))
|
|
17
|
-
project = symbolize(payload.fetch("project"
|
|
17
|
+
project = symbolize(payload.fetch("project"))
|
|
18
18
|
prepend_load_paths(project)
|
|
19
19
|
inventory = symbolize(payload.fetch("inventory"))
|
|
20
|
-
Array(inventory[:source_units]).each do |unit|
|
|
21
|
-
unit[:original_bytes] = File.binread(unit[:absolute_path]) if unit[:absolute_path]
|
|
22
|
-
end
|
|
23
20
|
limits = symbolize(payload.fetch("limits"))
|
|
24
21
|
require "minitest"
|
|
25
22
|
require "minitest/test"
|
data/lib/branchproof.rb
CHANGED
|
@@ -16,6 +16,8 @@ module Branchproof
|
|
|
16
16
|
autoload :MinitestAdapter, "branchproof/minitest_adapter"
|
|
17
17
|
autoload :Evidence, "branchproof/evidence"
|
|
18
18
|
autoload :Analyzer, "branchproof/analyzer"
|
|
19
|
+
autoload :Constraints, "branchproof/constraints"
|
|
20
|
+
autoload :DecisionTable, "branchproof/decision_table"
|
|
19
21
|
autoload :Minimizer, "branchproof/minimizer"
|
|
20
22
|
autoload :Report, "branchproof/report"
|
|
21
23
|
autoload :CoverageIndex, "branchproof/coverage_index"
|
data/llms.txt
CHANGED
|
@@ -2,10 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
| | |
|
|
4
4
|
| --- | --- |
|
|
5
|
-
| **Defined in** | lib/branchproof.rb, lib/branchproof/cli.rb, lib/branchproof/limits.rb, lib/branchproof/loader.rb, lib/branchproof/report.rb, lib/branchproof/source.rb, lib/branchproof/worker.rb, lib/branchproof/project.rb, lib/branchproof/records.rb, lib/branchproof/runtime.rb, lib/branchproof/version.rb, lib/branchproof/analyzer.rb, lib/branchproof/evidence.rb, lib/branchproof/minimizer.rb, lib/branchproof/comparison.rb, lib/branchproof/instrumenter.rb, lib/branchproof/runtime_flow.rb, lib/branchproof/saved_report.rb, lib/branchproof/rails_support.rb, lib/branchproof/coverage_index.rb, lib/branchproof/focused_report.rb, lib/branchproof/decision_syntax.rb, lib/branchproof/minitest_adapter.rb, lib/branchproof/comparison_report.rb, lib/branchproof/flow_instrumentation.rb |
|
|
5
|
+
| **Defined in** | lib/branchproof.rb, lib/branchproof/cli.rb, lib/branchproof/limits.rb, lib/branchproof/loader.rb, lib/branchproof/report.rb, lib/branchproof/source.rb, lib/branchproof/worker.rb, lib/branchproof/project.rb, lib/branchproof/records.rb, lib/branchproof/runtime.rb, lib/branchproof/version.rb, lib/branchproof/analyzer.rb, lib/branchproof/evidence.rb, lib/branchproof/minimizer.rb, lib/branchproof/comparison.rb, lib/branchproof/constraints.rb, lib/branchproof/instrumenter.rb, lib/branchproof/runtime_flow.rb, lib/branchproof/saved_report.rb, lib/branchproof/value_syntax.rb, lib/branchproof/rails_support.rb, lib/branchproof/value_runtime.rb, lib/branchproof/coverage_index.rb, lib/branchproof/decision_table.rb, lib/branchproof/default_syntax.rb, lib/branchproof/focused_report.rb, lib/branchproof/decision_syntax.rb, lib/branchproof/default_runtime.rb, lib/branchproof/exception_syntax.rb, lib/branchproof/iteration_syntax.rb, lib/branchproof/minitest_adapter.rb, lib/branchproof/comparison_report.rb, lib/branchproof/exception_runtime.rb, lib/branchproof/iteration_runtime.rb, lib/branchproof/flow_instrumentation.rb, lib/branchproof/value_instrumentation.rb, lib/branchproof/default_instrumentation.rb, lib/branchproof/exception_instrumentation.rb, lib/branchproof/iteration_instrumentation.rb, lib/branchproof/extended_alternative_runtime.rb |
|
|
6
6
|
|
|
7
|
-
Keep
|
|
8
|
-
|
|
7
|
+
Keep source-boundary and parameter-binding rules together for auditing.
|
|
8
|
+
rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity,
|
|
9
|
+
Metrics/MethodLength, Metrics/ModuleLength, Metrics/PerceivedComplexity
|
|
9
10
|
|
|
10
11
|
## Constants
|
|
11
12
|
### `VERSION` <a id="constant-VERSION"></a> <a id="VERSION-constant"></a>
|
|
@@ -17,13 +18,26 @@ Not documented.
|
|
|
17
18
|
- [Branchproof/CLI.md](doc/Branchproof/CLI.md)
|
|
18
19
|
- [Branchproof/Comparison.md](doc/Branchproof/Comparison.md)
|
|
19
20
|
- [Branchproof/ComparisonReport.md](doc/Branchproof/ComparisonReport.md)
|
|
21
|
+
- [Branchproof/Constraints/Solver.md](doc/Branchproof/Constraints/Solver.md)
|
|
22
|
+
- [Branchproof/Constraints.md](doc/Branchproof/Constraints.md)
|
|
20
23
|
- [Branchproof/CoverageIndex.md](doc/Branchproof/CoverageIndex.md)
|
|
21
24
|
- [Branchproof/DecisionSyntax.md](doc/Branchproof/DecisionSyntax.md)
|
|
25
|
+
- [Branchproof/DecisionTable.md](doc/Branchproof/DecisionTable.md)
|
|
26
|
+
- [Branchproof/DefaultInstrumentation.md](doc/Branchproof/DefaultInstrumentation.md)
|
|
27
|
+
- [Branchproof/DefaultRuntime.md](doc/Branchproof/DefaultRuntime.md)
|
|
28
|
+
- [Branchproof/DefaultSyntax.md](doc/Branchproof/DefaultSyntax.md)
|
|
22
29
|
- [Branchproof/Error.md](doc/Branchproof/Error.md)
|
|
23
30
|
- [Branchproof/Evidence.md](doc/Branchproof/Evidence.md)
|
|
31
|
+
- [Branchproof/ExceptionInstrumentation.md](doc/Branchproof/ExceptionInstrumentation.md)
|
|
32
|
+
- [Branchproof/ExceptionRuntime.md](doc/Branchproof/ExceptionRuntime.md)
|
|
33
|
+
- [Branchproof/ExceptionSyntax.md](doc/Branchproof/ExceptionSyntax.md)
|
|
34
|
+
- [Branchproof/ExtendedAlternativeRuntime.md](doc/Branchproof/ExtendedAlternativeRuntime.md)
|
|
24
35
|
- [Branchproof/FlowInstrumentation.md](doc/Branchproof/FlowInstrumentation.md)
|
|
25
36
|
- [Branchproof/FocusedReport.md](doc/Branchproof/FocusedReport.md)
|
|
26
37
|
- [Branchproof/Instrumenter.md](doc/Branchproof/Instrumenter.md)
|
|
38
|
+
- [Branchproof/IterationInstrumentation.md](doc/Branchproof/IterationInstrumentation.md)
|
|
39
|
+
- [Branchproof/IterationRuntime.md](doc/Branchproof/IterationRuntime.md)
|
|
40
|
+
- [Branchproof/IterationSyntax.md](doc/Branchproof/IterationSyntax.md)
|
|
27
41
|
- [Branchproof/Limits.md](doc/Branchproof/Limits.md)
|
|
28
42
|
- [Branchproof/Loader.md](doc/Branchproof/Loader.md)
|
|
29
43
|
- [Branchproof/Minimizer.md](doc/Branchproof/Minimizer.md)
|
|
@@ -37,6 +51,9 @@ Not documented.
|
|
|
37
51
|
- [Branchproof/RuntimeFlow.md](doc/Branchproof/RuntimeFlow.md)
|
|
38
52
|
- [Branchproof/SavedReport.md](doc/Branchproof/SavedReport.md)
|
|
39
53
|
- [Branchproof/Source.md](doc/Branchproof/Source.md)
|
|
54
|
+
- [Branchproof/ValueInstrumentation.md](doc/Branchproof/ValueInstrumentation.md)
|
|
55
|
+
- [Branchproof/ValueRuntime.md](doc/Branchproof/ValueRuntime.md)
|
|
56
|
+
- [Branchproof/ValueSyntax.md](doc/Branchproof/ValueSyntax.md)
|
|
40
57
|
- [Branchproof/Worker.md](doc/Branchproof/Worker.md)
|
|
41
58
|
- [CHANGELOG.md](doc/CHANGELOG.md)
|
|
42
59
|
- [README.md](doc/README.md)
|