branchproof 0.5.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 +20 -0
- data/README.md +129 -31
- 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/Report.md +10 -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 +20 -0
- data/doc/README.md +129 -31
- data/lib/branchproof/analyzer.rb +273 -13
- data/lib/branchproof/cli.rb +2 -2
- 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 +102 -21
- data/lib/branchproof/instrumenter.rb +28 -13
- data/lib/branchproof/report.rb +348 -31
- 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
|
@@ -7,7 +7,7 @@ require "pathname"
|
|
|
7
7
|
module Branchproof
|
|
8
8
|
# Derives condition- and test-oriented rows from one report document.
|
|
9
9
|
class CoverageIndex
|
|
10
|
-
attr_reader :conditions, :tests
|
|
10
|
+
attr_reader :conditions, :alternatives, :tests
|
|
11
11
|
|
|
12
12
|
def initialize(document:)
|
|
13
13
|
@document = symbolize(document || {})
|
|
@@ -27,6 +27,7 @@ module Branchproof
|
|
|
27
27
|
@tests_by_id[id] = (@tests_by_id[id] || {}).merge(test) unless id.empty?
|
|
28
28
|
end
|
|
29
29
|
@conditions = build_conditions.freeze
|
|
30
|
+
@alternatives = build_alternatives.freeze
|
|
30
31
|
@tests = build_tests.freeze
|
|
31
32
|
end
|
|
32
33
|
|
|
@@ -62,7 +63,7 @@ module Branchproof
|
|
|
62
63
|
id: condition[:id], decision_id: decision[:id], index: condition[:index],
|
|
63
64
|
expression: condition[:expression],
|
|
64
65
|
line: condition[:line], column: condition[:column], relative_path: relative_path(source[:relative_path]),
|
|
65
|
-
decision_expression: decision[:expression],
|
|
66
|
+
decision_expression: decision[:expression], kind: decision_kind(decision), context: decision[:context],
|
|
66
67
|
status: @document[:analysis] ? (result[:status] || "NOT_PROVEN") : "NOT CALCULATED",
|
|
67
68
|
observed_true: test_ids(observations[true]), observed_false: test_ids(observations[false]),
|
|
68
69
|
short_circuited: test_ids(observations[:short_circuited]),
|
|
@@ -80,12 +81,69 @@ module Branchproof
|
|
|
80
81
|
end
|
|
81
82
|
end
|
|
82
83
|
|
|
84
|
+
def build_alternatives
|
|
85
|
+
records(@inventory, :decisions).flat_map do |decision|
|
|
86
|
+
next [] if decision[:support_status].to_s == "UNSUPPORTED"
|
|
87
|
+
next [] if boolean_decision?(decision)
|
|
88
|
+
|
|
89
|
+
source = @source_units_by_id[decision[:source_id].to_s] || {}
|
|
90
|
+
coverage = symbolize(analysis_for(decision)[:coverage] || {})[:alternative] || {}
|
|
91
|
+
coverage_rows = records(coverage, :alternatives).to_h { |row| [row[:alternative_id].to_s, row] }
|
|
92
|
+
vectors = @vectors_by_decision[decision[:id].to_s] || []
|
|
93
|
+
records(decision, :alternatives).map do |alternative|
|
|
94
|
+
alternative = symbolize(alternative)
|
|
95
|
+
evidence = { selected: [], not_selected: [], skipped: [] }
|
|
96
|
+
vectors.each do |vector|
|
|
97
|
+
value = vector_values(vector)[alternative[:index].to_i]
|
|
98
|
+
state = if value.nil?
|
|
99
|
+
:skipped
|
|
100
|
+
else
|
|
101
|
+
(value ? :selected : :not_selected)
|
|
102
|
+
end
|
|
103
|
+
evidence[state] << vector
|
|
104
|
+
end
|
|
105
|
+
row = symbolize(coverage_rows[alternative[:id].to_s] || {})
|
|
106
|
+
{
|
|
107
|
+
id: alternative[:id], alternative_id: alternative[:id], decision_id: decision[:id],
|
|
108
|
+
index: alternative[:index], expression: alternative[:expression],
|
|
109
|
+
line: alternative[:line], column: alternative[:column],
|
|
110
|
+
relative_path: relative_path(source[:relative_path]),
|
|
111
|
+
decision_expression: decision[:expression], kind: decision_kind(decision),
|
|
112
|
+
context: decision[:context],
|
|
113
|
+
status: @document[:analysis] ? alternative_status(row, evidence) : "NOT CALCULATED",
|
|
114
|
+
selected: row[:selected] || evidence_bucket(evidence[:selected]),
|
|
115
|
+
not_selected: row[:not_selected] || evidence_bucket(evidence[:not_selected]),
|
|
116
|
+
skipped: row[:skipped] || evidence_bucket(evidence[:skipped]),
|
|
117
|
+
vectors: evidence, missing: missing_alternative?(coverage, alternative)
|
|
118
|
+
}
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
83
123
|
def build_tests
|
|
84
124
|
by_id = @tests_by_id.transform_values do |test|
|
|
85
125
|
{ id: test[:id], name: display_name(test), relative_path: test_location(test)[0], line: test_location(test)[1],
|
|
86
126
|
status: test[:status], phases: Array(test[:phase_counts]).to_h.keys.sort,
|
|
87
127
|
observations: [], owns_witness: false }
|
|
88
128
|
end
|
|
129
|
+
@alternatives.each do |alternative|
|
|
130
|
+
alternative[:vectors].each do |kind, vectors|
|
|
131
|
+
vectors.each do |vector|
|
|
132
|
+
Array(vector[:test_ids]).each do |id|
|
|
133
|
+
row = by_id[id.to_s] ||= { id: id, name: id, relative_path: nil, line: nil, status: "unknown",
|
|
134
|
+
phases: [], observations: [], owns_witness: false }
|
|
135
|
+
phase_map = symbolize(vector[:phases_by_test] || {})
|
|
136
|
+
phases = phase_map[id.to_s] || phase_map[id.to_sym] || []
|
|
137
|
+
row[:phases] |= Array(phases).map(&:to_s)
|
|
138
|
+
row[:observations] << { alternative_id: alternative[:id], expression: alternative[:expression],
|
|
139
|
+
relative_path: alternative[:relative_path], line: alternative[:line],
|
|
140
|
+
value: kind == :skipped ? nil : (kind == :selected), kind: :alternative,
|
|
141
|
+
alternative_state: kind.to_s, phases: phases.map(&:to_s).sort,
|
|
142
|
+
owns_witness: false }
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
89
147
|
@conditions.each do |condition|
|
|
90
148
|
condition[:vectors].each do |kind, vectors|
|
|
91
149
|
next if kind == :unattributed
|
|
@@ -118,6 +176,36 @@ module Branchproof
|
|
|
118
176
|
@analysis_by_decision[decision[:id].to_s] || {}
|
|
119
177
|
end
|
|
120
178
|
|
|
179
|
+
def boolean_decision?(decision)
|
|
180
|
+
decision_kind(decision) == "boolean"
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def decision_kind(decision)
|
|
184
|
+
kind = decision[:kind].to_s
|
|
185
|
+
kind.empty? ? "boolean" : kind
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def evidence_bucket(vectors)
|
|
189
|
+
evidence = vectors.map do |vector|
|
|
190
|
+
{ vector_id: vector[:id].to_s, test_ids: Array(vector[:test_ids]).map(&:to_s).uniq.sort,
|
|
191
|
+
unattributed_count: vector[:unattributed_count].to_i }
|
|
192
|
+
end
|
|
193
|
+
{ observed: !vectors.empty?, vector_ids: evidence.map { |item| item[:vector_id] }.uniq.sort,
|
|
194
|
+
test_ids: evidence.flat_map { |item| item[:test_ids] }.uniq.sort,
|
|
195
|
+
unattributed_count: evidence.sum { |item| item[:unattributed_count] } }
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def missing_alternative?(coverage, alternative)
|
|
199
|
+
Array(coverage[:missing_alternatives]).map(&:to_s).include?(alternative[:id].to_s)
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def alternative_status(row, evidence)
|
|
203
|
+
status = row[:status].to_s
|
|
204
|
+
return status unless status.empty?
|
|
205
|
+
|
|
206
|
+
evidence[:selected].empty? ? "unexecuted" : "covered"
|
|
207
|
+
end
|
|
208
|
+
|
|
121
209
|
def supporting_set_for(decision_id)
|
|
122
210
|
Array(@document[:minima] || @document["minima"]).filter_map do |minimum|
|
|
123
211
|
minimum = symbolize(minimum)
|
|
@@ -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
|