branchproof 0.6.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +45 -0
- data/README.md +279 -27
- data/doc/Branchproof/Analyzer.md +2 -2
- data/doc/Branchproof/CLI.md +4 -0
- data/doc/Branchproof/ComparisonReport.md +4 -0
- data/doc/Branchproof/Constraints/Solver.md +21 -0
- data/doc/Branchproof/Constraints.md +98 -0
- data/doc/Branchproof/CoverageIndex.md +6 -0
- data/doc/Branchproof/DecisionSyntax.md +22 -0
- data/doc/Branchproof/DecisionTable.md +153 -0
- data/doc/Branchproof/FlowInstrumentation.md +7 -0
- data/doc/Branchproof/FocusedReport.md +5 -2
- data/doc/Branchproof/Instrumenter.md +1 -0
- data/doc/Branchproof/Loader.md +0 -4
- data/doc/Branchproof/MinitestAdapter.md +0 -3
- data/doc/Branchproof/Report.md +18 -0
- data/doc/Branchproof/Runtime.md +23 -0
- data/doc/Branchproof/RuntimeFlow.md +27 -0
- data/doc/Branchproof/SavedReport.md +21 -0
- data/doc/Branchproof/Source.md +1 -0
- data/doc/Branchproof.md +9 -2
- data/doc/CHANGELOG.md +45 -0
- data/doc/README.md +279 -27
- data/lib/branchproof/analyzer.rb +222 -54
- data/lib/branchproof/cli.rb +28 -19
- data/lib/branchproof/comparison.rb +207 -19
- data/lib/branchproof/comparison_report.rb +49 -1
- data/lib/branchproof/constraints.rb +363 -0
- data/lib/branchproof/coverage_index.rb +120 -3
- data/lib/branchproof/decision_syntax.rb +310 -0
- data/lib/branchproof/decision_table.rb +377 -0
- data/lib/branchproof/evidence.rb +101 -36
- data/lib/branchproof/flow_instrumentation.rb +107 -0
- data/lib/branchproof/focused_report.rb +161 -26
- data/lib/branchproof/instrumenter.rb +65 -39
- data/lib/branchproof/limits.rb +4 -1
- data/lib/branchproof/loader.rb +18 -6
- data/lib/branchproof/minimizer.rb +18 -13
- data/lib/branchproof/minitest_adapter.rb +11 -16
- data/lib/branchproof/records.rb +2 -0
- data/lib/branchproof/report.rb +470 -66
- data/lib/branchproof/runtime.rb +25 -30
- data/lib/branchproof/runtime_flow.rb +58 -0
- data/lib/branchproof/saved_report.rb +438 -13
- data/lib/branchproof/source.rb +239 -41
- data/lib/branchproof/version.rb +1 -1
- data/lib/branchproof/worker.rb +1 -4
- data/lib/branchproof.rb +2 -0
- data/llms.txt +9 -2
- data/sig/branchproof.rbs +54 -1
- metadata +12 -1
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
module Branchproof
|
|
6
|
+
# Discovers control-flow expressions whose truth is not represented by an
|
|
7
|
+
# ordinary Prism IfNode. The records intentionally contain byte ranges and
|
|
8
|
+
# scalar metadata only; Prism nodes must not escape the source pass.
|
|
9
|
+
module DecisionSyntax
|
|
10
|
+
# Some of these node classes were added in later Prism 1.x releases, so
|
|
11
|
+
# look them up by name and skip any that this Prism version lacks.
|
|
12
|
+
def self.node_classes(*names)
|
|
13
|
+
names.filter_map { |name| Prism.const_get(name) if Prism.const_defined?(name) }.to_set.freeze
|
|
14
|
+
end
|
|
15
|
+
private_class_method :node_classes
|
|
16
|
+
|
|
17
|
+
OR_WRITE_NODE_CLASSES = node_classes(
|
|
18
|
+
:CallOrWriteNode, :ClassVariableOrWriteNode, :ConstantOrWriteNode,
|
|
19
|
+
:ConstantPathOrWriteNode, :GlobalVariableOrWriteNode, :IndexOrWriteNode,
|
|
20
|
+
:InstanceVariableOrWriteNode, :LocalVariableOrWriteNode
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
AND_WRITE_NODE_CLASSES = node_classes(
|
|
24
|
+
:CallAndWriteNode, :ClassVariableAndWriteNode, :ConstantAndWriteNode,
|
|
25
|
+
:ConstantPathAndWriteNode, :GlobalVariableAndWriteNode, :IndexAndWriteNode,
|
|
26
|
+
:InstanceVariableAndWriteNode, :LocalVariableAndWriteNode
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
# nodes: flow-decision nodes already collected by a caller's own AST walk
|
|
30
|
+
# (Source merges this discovery into one pass). Falls back to its own
|
|
31
|
+
# walk when nothing is passed in, so this method still works standalone.
|
|
32
|
+
def flow_decisions_for(program, bytes, source_id, file_reasons = [], encoding = "UTF-8", nodes: nil)
|
|
33
|
+
nodes ||= collect_flow_decision_nodes(program)
|
|
34
|
+
nodes.sort_by { |node| [node.location.start_offset, node.location.length] }.map do |node|
|
|
35
|
+
build_flow_decision(node, bytes, source_id, file_reasons, encoding)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def collect_flow_decision_nodes(program)
|
|
42
|
+
nodes = []
|
|
43
|
+
walk(program) { |node| nodes << node if flow_decision_node?(node) }
|
|
44
|
+
nodes
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def flow_decision_node?(node)
|
|
48
|
+
return true if node.is_a?(Prism::CaseNode) && node.predicate
|
|
49
|
+
return true if node.is_a?(Prism::CaseMatchNode)
|
|
50
|
+
return true if node.is_a?(Prism::CallNode) && node.safe_navigation?
|
|
51
|
+
return true if assignment_node?(node)
|
|
52
|
+
return true if node.is_a?(Prism::RescueNode) || node.is_a?(Prism::RescueModifierNode)
|
|
53
|
+
|
|
54
|
+
false
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def assignment_node?(node)
|
|
58
|
+
OR_WRITE_NODE_CLASSES.include?(node.class) || AND_WRITE_NODE_CLASSES.include?(node.class)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def build_flow_decision(node, bytes, source_id, file_reasons, encoding)
|
|
62
|
+
location = node.location
|
|
63
|
+
kind, context, alternatives, instrumentation, reasons = flow_details(node, bytes, encoding)
|
|
64
|
+
reasons = Array(file_reasons) + Array(reasons)
|
|
65
|
+
decision_id = Records.decision_id(source_id: source_id, context: context,
|
|
66
|
+
byte_start: location.start_offset, byte_length: location.length, tree: nil)
|
|
67
|
+
alternatives = alternatives.each_with_index.map do |alternative, index|
|
|
68
|
+
alternative.merge(index: index, id: Records.condition_id(decision_id, index))
|
|
69
|
+
end
|
|
70
|
+
limit = @limits[:conditions_per_decision] if defined?(@limits) && @limits.respond_to?(:[])
|
|
71
|
+
reasons << "alternative_limit_exceeded" if limit && alternatives.length > limit
|
|
72
|
+
|
|
73
|
+
Records.build(
|
|
74
|
+
id: decision_id,
|
|
75
|
+
source_id: source_id,
|
|
76
|
+
context: context,
|
|
77
|
+
kind: kind,
|
|
78
|
+
byte_start: location.start_offset,
|
|
79
|
+
byte_length: location.length,
|
|
80
|
+
line: location.start_line,
|
|
81
|
+
column: location.start_column,
|
|
82
|
+
expression: text_value(bytes.byteslice(location.start_offset, location.length), encoding),
|
|
83
|
+
tree: nil,
|
|
84
|
+
conditions: [],
|
|
85
|
+
alternatives: alternatives,
|
|
86
|
+
discovered_condition_count: 0,
|
|
87
|
+
support_status: reasons.empty? ? "SUPPORTED" : "UNSUPPORTED",
|
|
88
|
+
support_reasons: reasons.uniq,
|
|
89
|
+
opaque_ranges: [],
|
|
90
|
+
instrumentation: instrumentation
|
|
91
|
+
)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def flow_details(node, bytes, encoding)
|
|
95
|
+
case node
|
|
96
|
+
when Prism::CaseNode
|
|
97
|
+
case_details(node, bytes, encoding, kind: "multiway", context: "case")
|
|
98
|
+
when Prism::CaseMatchNode
|
|
99
|
+
case_match_details(node, bytes, encoding)
|
|
100
|
+
when Prism::CallNode
|
|
101
|
+
safe_navigation_details(node, bytes, encoding)
|
|
102
|
+
when Prism::RescueNode, Prism::RescueModifierNode
|
|
103
|
+
rescue_details(node, bytes, encoding)
|
|
104
|
+
else
|
|
105
|
+
assignment_details(node, bytes, encoding)
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def case_details(node, bytes, encoding, kind:, context:)
|
|
110
|
+
conditions = node.conditions
|
|
111
|
+
candidates = []
|
|
112
|
+
branches = []
|
|
113
|
+
conditions.each_with_index do |branch, branch_index|
|
|
114
|
+
branch.conditions.each do |candidate|
|
|
115
|
+
candidates << {
|
|
116
|
+
expression: text_value(bytes.byteslice(candidate.location.start_offset, candidate.location.length),
|
|
117
|
+
encoding),
|
|
118
|
+
range: byte_range(candidate.location, splat_node?(candidate) ? "splat" : nil),
|
|
119
|
+
byte_start: candidate.location.start_offset,
|
|
120
|
+
byte_length: candidate.location.length
|
|
121
|
+
}
|
|
122
|
+
end
|
|
123
|
+
branches << {
|
|
124
|
+
index: branch_index,
|
|
125
|
+
insert_at: branch_insert_at(branch, conditions[branch_index + 1], node.else_clause, node.end_keyword_loc),
|
|
126
|
+
empty: statements_empty?(branch.statements)
|
|
127
|
+
}
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
else_clause = node.else_clause
|
|
131
|
+
else_index = candidates.length
|
|
132
|
+
candidates << if else_clause
|
|
133
|
+
{ expression: "else", range: byte_range(else_clause.else_keyword_loc),
|
|
134
|
+
byte_start: else_clause.else_keyword_loc.start_offset,
|
|
135
|
+
byte_length: else_clause.else_keyword_loc.length }
|
|
136
|
+
else
|
|
137
|
+
{ expression: "no_match", range: nil, byte_start: node.end_keyword_loc&.start_offset,
|
|
138
|
+
byte_length: 0 }
|
|
139
|
+
end
|
|
140
|
+
else_metadata = if else_clause
|
|
141
|
+
{ insert_at: branch_insert_at(else_clause, nil, nil, node.end_keyword_loc), index: else_index,
|
|
142
|
+
empty: statements_empty?(else_clause.statements) }
|
|
143
|
+
end
|
|
144
|
+
reasons = unsupported_reasons(node, bytes)
|
|
145
|
+
reasons << "unsupported_case_splat" if candidates.any? do |candidate|
|
|
146
|
+
candidate[:range] && candidate[:range][:kind] == "splat"
|
|
147
|
+
end
|
|
148
|
+
instrumentation = {
|
|
149
|
+
type: "case",
|
|
150
|
+
range: byte_range(node.location),
|
|
151
|
+
predicate: node.predicate && byte_range(node.predicate.location),
|
|
152
|
+
candidates: candidates.reject { |candidate| candidate[:expression] == "else" || candidate[:range].nil? }
|
|
153
|
+
.each_with_index.map { |candidate, index| candidate.merge(index: index) },
|
|
154
|
+
branches: branches,
|
|
155
|
+
else: else_metadata,
|
|
156
|
+
end_start: node.end_keyword_loc&.start_offset
|
|
157
|
+
}
|
|
158
|
+
alternatives = candidates.map do |candidate|
|
|
159
|
+
candidate.slice(:expression, :byte_start, :byte_length)
|
|
160
|
+
end
|
|
161
|
+
[kind, context, alternatives, instrumentation, reasons]
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def case_match_details(node, bytes, encoding)
|
|
165
|
+
conditions = node.conditions
|
|
166
|
+
candidates = []
|
|
167
|
+
branches = []
|
|
168
|
+
reasons = []
|
|
169
|
+
conditions.each_with_index do |branch, branch_index|
|
|
170
|
+
pattern = branch.pattern
|
|
171
|
+
guarded = pattern.is_a?(Prism::IfNode) || pattern.is_a?(Prism::UnlessNode)
|
|
172
|
+
guard = guarded ? pattern.predicate : nil
|
|
173
|
+
pattern_node = guarded ? pattern.statements&.body&.first : pattern
|
|
174
|
+
reasons << "unsupported_pattern_guard" if guard
|
|
175
|
+
candidate_node = pattern_node || pattern
|
|
176
|
+
candidates << {
|
|
177
|
+
expression: text_value(bytes.byteslice(candidate_node.location.start_offset, candidate_node.location.length),
|
|
178
|
+
encoding),
|
|
179
|
+
range: byte_range(candidate_node.location),
|
|
180
|
+
byte_start: candidate_node.location.start_offset,
|
|
181
|
+
byte_length: candidate_node.location.length,
|
|
182
|
+
guard: guard && byte_range(guard.location)
|
|
183
|
+
}
|
|
184
|
+
branches << {
|
|
185
|
+
index: branch_index,
|
|
186
|
+
insert_at: branch_insert_at(branch, conditions[branch_index + 1], node.else_clause, node.end_keyword_loc),
|
|
187
|
+
empty: statements_empty?(branch.statements)
|
|
188
|
+
}
|
|
189
|
+
end
|
|
190
|
+
else_clause = node.else_clause
|
|
191
|
+
else_metadata = if else_clause
|
|
192
|
+
{ insert_at: branch_insert_at(else_clause, nil, nil, node.end_keyword_loc), index: nil,
|
|
193
|
+
empty: statements_empty?(else_clause.statements) }
|
|
194
|
+
end
|
|
195
|
+
instrumentation = {
|
|
196
|
+
type: "case_match",
|
|
197
|
+
range: byte_range(node.location),
|
|
198
|
+
predicate: node.predicate && byte_range(node.predicate.location),
|
|
199
|
+
candidates: candidates.reject { |candidate| candidate[:expression] == "else" || candidate[:range].nil? }
|
|
200
|
+
.each_with_index.map { |candidate, index| candidate.merge(index: index) },
|
|
201
|
+
branches: branches,
|
|
202
|
+
else: else_metadata,
|
|
203
|
+
end_start: node.end_keyword_loc&.start_offset
|
|
204
|
+
}
|
|
205
|
+
if node.else_clause
|
|
206
|
+
else_location = node.else_clause.else_keyword_loc
|
|
207
|
+
candidates << { expression: "else", range: byte_range(else_location),
|
|
208
|
+
byte_start: else_location.start_offset, byte_length: else_location.length }
|
|
209
|
+
else_index = candidates.length - 1
|
|
210
|
+
instrumentation[:else] = instrumentation[:else].merge(index: else_index)
|
|
211
|
+
end
|
|
212
|
+
alternatives = candidates.map { |candidate| candidate.slice(:expression, :byte_start, :byte_length) }
|
|
213
|
+
["pattern", "case_in", alternatives, instrumentation, unsupported_reasons(node, bytes) + reasons]
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def safe_navigation_details(node, bytes, _encoding)
|
|
217
|
+
receiver = node.receiver
|
|
218
|
+
instrumentation = {
|
|
219
|
+
type: "safe_navigation",
|
|
220
|
+
range: byte_range(node.location),
|
|
221
|
+
receiver: byte_range(receiver.location)
|
|
222
|
+
}
|
|
223
|
+
alternatives = [
|
|
224
|
+
{ expression: "receiver nil", byte_start: receiver.location.start_offset,
|
|
225
|
+
byte_length: receiver.location.length },
|
|
226
|
+
{ expression: "receiver non-nil", byte_start: receiver.location.start_offset,
|
|
227
|
+
byte_length: receiver.location.length }
|
|
228
|
+
]
|
|
229
|
+
["implicit", "safe_navigation", alternatives, instrumentation, unsupported_reasons(node, bytes)]
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def assignment_details(node, bytes, _encoding)
|
|
233
|
+
rhs = node.value
|
|
234
|
+
operator = bytes.byteslice(node.operator_loc.start_offset, node.operator_loc.length)
|
|
235
|
+
assignment_context = operator == "||=" ? "or_assignment" : "and_assignment"
|
|
236
|
+
reasons = safe_navigation_assignment?(node) ? ["unsupported_assignment_target"] : []
|
|
237
|
+
instrumentation = {
|
|
238
|
+
type: "assignment",
|
|
239
|
+
range: byte_range(node.location),
|
|
240
|
+
rhs: byte_range(rhs.location),
|
|
241
|
+
operator: operator,
|
|
242
|
+
rhs_path: 1,
|
|
243
|
+
skipped_path: 0
|
|
244
|
+
}
|
|
245
|
+
alternatives = if operator == "||="
|
|
246
|
+
[{ expression: "LHS truthy; RHS skipped", byte_start: node.location.start_offset,
|
|
247
|
+
byte_length: node.location.length },
|
|
248
|
+
{ expression: "LHS falsey; RHS executed", byte_start: node.location.start_offset,
|
|
249
|
+
byte_length: node.location.length }]
|
|
250
|
+
else
|
|
251
|
+
[{ expression: "LHS falsey; RHS skipped", byte_start: node.location.start_offset,
|
|
252
|
+
byte_length: node.location.length },
|
|
253
|
+
{ expression: "LHS truthy; RHS executed", byte_start: node.location.start_offset,
|
|
254
|
+
byte_length: node.location.length }]
|
|
255
|
+
end
|
|
256
|
+
["implicit", assignment_context, alternatives, instrumentation, unsupported_reasons(node, bytes) + reasons]
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
def rescue_details(node, bytes, encoding)
|
|
260
|
+
alternatives = if node.respond_to?(:exceptions)
|
|
261
|
+
Array(node.exceptions).map do |exception|
|
|
262
|
+
{
|
|
263
|
+
expression: text_value(
|
|
264
|
+
bytes.byteslice(exception.location.start_offset, exception.location.length), encoding
|
|
265
|
+
),
|
|
266
|
+
byte_start: exception.location.start_offset,
|
|
267
|
+
byte_length: exception.location.length
|
|
268
|
+
}
|
|
269
|
+
end
|
|
270
|
+
else
|
|
271
|
+
[]
|
|
272
|
+
end
|
|
273
|
+
instrumentation = { type: "rescue", range: byte_range(node.location) }
|
|
274
|
+
["exception", "rescue", alternatives, instrumentation, ["unsupported_rescue_control_flow"]]
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
def branch_insert_at(branch, next_branch, else_clause, end_keyword_loc)
|
|
278
|
+
statements = branch.statements
|
|
279
|
+
return statements.location.start_offset unless statements_empty?(statements)
|
|
280
|
+
|
|
281
|
+
next_location = if next_branch
|
|
282
|
+
next_branch.is_a?(Prism::InNode) ? next_branch.in_loc : next_branch.keyword_loc
|
|
283
|
+
end
|
|
284
|
+
next_location&.start_offset || else_clause&.else_keyword_loc&.start_offset ||
|
|
285
|
+
end_keyword_loc&.start_offset || branch.location.end_offset
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
def statements_empty?(statements)
|
|
289
|
+
statements.nil? || Array(statements.body).empty?
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
def safe_navigation_assignment?(node)
|
|
293
|
+
return true if node.respond_to?(:safe_navigation?) && node.safe_navigation?
|
|
294
|
+
|
|
295
|
+
node.respond_to?(:call_operator_loc) && node.call_operator_loc&.slice == "&."
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
def splat_node?(node)
|
|
299
|
+
node.class.name.end_with?("SplatNode")
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
def byte_range(location, kind = nil)
|
|
303
|
+
return nil unless location
|
|
304
|
+
|
|
305
|
+
result = { byte_start: location.start_offset, byte_length: location.length }
|
|
306
|
+
result[:kind] = kind if kind
|
|
307
|
+
result
|
|
308
|
+
end
|
|
309
|
+
end
|
|
310
|
+
end
|
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "records"
|
|
4
|
+
require_relative "constraints"
|
|
5
|
+
require_relative "limits"
|
|
6
|
+
|
|
7
|
+
module Branchproof
|
|
8
|
+
# Derives the executable logical rules of a supported Boolean decision and
|
|
9
|
+
# overlays the runtime evidence that the existing Branchproof run captured.
|
|
10
|
+
#
|
|
11
|
+
# Rules come from the Boolean tree itself rather than from an exhaustive
|
|
12
|
+
# Cartesian expansion, so Ruby's short-circuit semantics are preserved: a
|
|
13
|
+
# condition the interpreter would skip becomes an explicit +dont_care+ value
|
|
14
|
+
# instead of two separate rules.
|
|
15
|
+
#
|
|
16
|
+
# Nothing here executes application code, and no additional test run is
|
|
17
|
+
# required: overlay consumes the vectors already recorded for the decision.
|
|
18
|
+
module DecisionTable
|
|
19
|
+
SCHEMA_VERSION = 1
|
|
20
|
+
CONSTRAINT_ANALYSIS_VERSION = Constraints::VERSION
|
|
21
|
+
TRUE_VALUE = "true"
|
|
22
|
+
FALSE_VALUE = "false"
|
|
23
|
+
DONT_CARE = "dont_care"
|
|
24
|
+
VALUE_LABELS = { TRUE_VALUE => "T", FALSE_VALUE => "F", DONT_CARE => "-" }.freeze
|
|
25
|
+
CONDITION_VALUES = [TRUE_VALUE, FALSE_VALUE, DONT_CARE].freeze
|
|
26
|
+
REACHABILITY_STATUSES = %w[observed unknown statically_impossible].freeze
|
|
27
|
+
COVERAGE_STATUSES = %w[covered missing excluded].freeze
|
|
28
|
+
TABLE_STATUSES = %w[calculated not_calculated].freeze
|
|
29
|
+
COVERAGE_SUMMARY_STATUSES = %w[covered partial unexecuted unsupported not_calculated].freeze
|
|
30
|
+
NOT_CALCULATED_REASONS = %w[
|
|
31
|
+
unsupported_decision decision_table_unavailable
|
|
32
|
+
decision_table_condition_limit_exceeded decision_table_rule_limit_exceeded
|
|
33
|
+
].freeze
|
|
34
|
+
DEFAULT_MAX_CONDITIONS = Limits::DEFAULTS[:max_conditions_for_decision_table]
|
|
35
|
+
DEFAULT_MAX_RULES = Limits::DEFAULTS[:decision_table_rules_per_decision]
|
|
36
|
+
|
|
37
|
+
module_function
|
|
38
|
+
|
|
39
|
+
# Builds the reduced, runtime-overlaid table for one Boolean decision.
|
|
40
|
+
def build(decision:, vectors: [], limits: {}, reachability: true)
|
|
41
|
+
raise ArgumentError, "reachability must be a Boolean" unless [true, false].include?(reachability)
|
|
42
|
+
|
|
43
|
+
decision_id = fetch(decision, :id).to_s
|
|
44
|
+
reason = unavailable_reason(decision, limits)
|
|
45
|
+
return not_calculated(decision_id, reason) if reason
|
|
46
|
+
|
|
47
|
+
max_rules = limit(limits, :decision_table_rules_per_decision, DEFAULT_MAX_RULES)
|
|
48
|
+
paths = enumerate(fetch(decision, :tree), false, max_rules + 1)
|
|
49
|
+
return not_calculated(decision_id, "decision_table_rule_limit_exceeded") if paths.nil? || paths.length > max_rules
|
|
50
|
+
|
|
51
|
+
conditions = Array(fetch(decision, :conditions))
|
|
52
|
+
prepared_constraints = conditions.map do |condition|
|
|
53
|
+
raw = fetch(condition, :constraint)
|
|
54
|
+
raw = Constraints.symbolize(raw)
|
|
55
|
+
fetch(condition, :constraint_safe) == true && Constraints.usable?(raw) ? raw : nil
|
|
56
|
+
end
|
|
57
|
+
rules = paths.each_with_index.map do |path, index|
|
|
58
|
+
rule(decision_id, conditions, path, index, reachability: reachability,
|
|
59
|
+
prepared_constraints: prepared_constraints)
|
|
60
|
+
end
|
|
61
|
+
overlay(decision_id: decision_id, rules: rules, vectors: Array(vectors), reachability: reachability,
|
|
62
|
+
indexed: unique_atom_indices?(fetch(decision, :tree)))
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Names why a decision carries no Boolean table, or nil when it carries one.
|
|
66
|
+
def unavailable_reason(decision, limits)
|
|
67
|
+
support = fetch(decision, :support_status).to_s
|
|
68
|
+
return "unsupported_decision" unless support.empty? || support.casecmp("supported").zero?
|
|
69
|
+
|
|
70
|
+
tree = fetch(decision, :tree)
|
|
71
|
+
return "decision_table_unavailable" if tree.nil?
|
|
72
|
+
|
|
73
|
+
conditions = Array(fetch(decision, :conditions)).length
|
|
74
|
+
return "decision_table_condition_limit_exceeded" if conditions >
|
|
75
|
+
limit(limits, :max_conditions_for_decision_table,
|
|
76
|
+
DEFAULT_MAX_CONDITIONS)
|
|
77
|
+
return "decision_table_unavailable" unless representable?(tree, conditions)
|
|
78
|
+
|
|
79
|
+
nil
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Only AND, OR, NOT, and atoms within the decision's own condition range are
|
|
83
|
+
# representable; anything else leaves the table uncalculated rather than
|
|
84
|
+
# producing a table that does not describe the decision.
|
|
85
|
+
def representable?(node, condition_count)
|
|
86
|
+
case fetch(node, :type).to_s
|
|
87
|
+
when "atom" then fetch(node, :index).is_a?(Integer) && (0...condition_count).cover?(fetch(node, :index))
|
|
88
|
+
when "not" then representable?(fetch(node, :child), condition_count)
|
|
89
|
+
when "and", "or"
|
|
90
|
+
representable?(fetch(node, :left), condition_count) &&
|
|
91
|
+
representable?(fetch(node, :right), condition_count)
|
|
92
|
+
else false
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def unique_atom_indices?(node, seen = {})
|
|
97
|
+
case fetch(node, :type).to_s
|
|
98
|
+
when "atom"
|
|
99
|
+
index = fetch(node, :index)
|
|
100
|
+
return false if seen.key?(index)
|
|
101
|
+
|
|
102
|
+
seen[index] = true
|
|
103
|
+
true
|
|
104
|
+
when "not"
|
|
105
|
+
unique_atom_indices?(fetch(node, :child), seen)
|
|
106
|
+
when "and", "or"
|
|
107
|
+
unique_atom_indices?(fetch(node, :left), seen) && unique_atom_indices?(fetch(node, :right), seen)
|
|
108
|
+
else false
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Enumerates every short-circuit evaluation path of the Boolean tree.
|
|
113
|
+
#
|
|
114
|
+
# +prefer+ orders the paths deterministically: a conjunction lists its
|
|
115
|
+
# short-circuiting false path first, a disjunction its true path first, and
|
|
116
|
+
# a negation inverts the preference it inherits. Returns nil once the path
|
|
117
|
+
# count would exceed +budget+.
|
|
118
|
+
def enumerate(node, prefer, budget)
|
|
119
|
+
type = fetch(node, :type).to_s
|
|
120
|
+
case type
|
|
121
|
+
when "atom"
|
|
122
|
+
index = fetch(node, :index).to_i
|
|
123
|
+
(prefer ? [true, false] : [false, true]).map { |value| { assignments: { index => value }, value: value } }
|
|
124
|
+
when "not"
|
|
125
|
+
paths = enumerate(fetch(node, :child), !prefer, budget)
|
|
126
|
+
paths&.map { |path| { assignments: path[:assignments], value: !path[:value] } }
|
|
127
|
+
when "and", "or"
|
|
128
|
+
combine(node, type, budget)
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def combine(node, type, budget)
|
|
133
|
+
short_circuit = type != "and"
|
|
134
|
+
left = enumerate(fetch(node, :left), short_circuit, budget)
|
|
135
|
+
return nil unless left
|
|
136
|
+
|
|
137
|
+
right = nil
|
|
138
|
+
paths = []
|
|
139
|
+
left.each do |path|
|
|
140
|
+
if path[:value] == short_circuit
|
|
141
|
+
paths << path
|
|
142
|
+
next
|
|
143
|
+
end
|
|
144
|
+
right ||= enumerate(fetch(node, :right), short_circuit, budget)
|
|
145
|
+
return nil unless right
|
|
146
|
+
|
|
147
|
+
right.each do |tail|
|
|
148
|
+
paths << { assignments: path[:assignments].merge(tail[:assignments]), value: tail[:value] }
|
|
149
|
+
end
|
|
150
|
+
return nil if paths.length > budget
|
|
151
|
+
end
|
|
152
|
+
paths.length > budget ? nil : paths
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# rubocop:disable-next Metrics/ParameterLists
|
|
156
|
+
def rule(decision_id, conditions, path, index, reachability: true, prepared_constraints: nil)
|
|
157
|
+
values = Array.new(conditions.length, DONT_CARE)
|
|
158
|
+
path[:assignments].each do |condition_index, value|
|
|
159
|
+
values[condition_index] = value ? TRUE_VALUE : FALSE_VALUE if condition_index < values.length
|
|
160
|
+
end
|
|
161
|
+
outcome = path[:value] ? true : false
|
|
162
|
+
reachability_state, reason = if reachability
|
|
163
|
+
static_reachability(conditions, values, prepared_constraints: prepared_constraints)
|
|
164
|
+
else
|
|
165
|
+
["unknown", nil]
|
|
166
|
+
end
|
|
167
|
+
{ id: rule_id(decision_id, values, outcome), label: "R#{index + 1}", index: index,
|
|
168
|
+
conditions: values, outcome: outcome, reachability: reachability_state, reachability_reason: reason }
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Rule identity depends only on the decision, the normalized condition
|
|
172
|
+
# vector, the expected outcome, and the table schema version. It never
|
|
173
|
+
# depends on a test name, a runtime observation, or the Minitest seed.
|
|
174
|
+
def rule_id(decision_id, values, outcome)
|
|
175
|
+
Records.id(schema_version: SCHEMA_VERSION, decision_id: decision_id.to_s,
|
|
176
|
+
conditions: values, outcome: outcome)
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# Conservative static reachability: prove impossibility, or answer unknown.
|
|
180
|
+
def static_reachability(conditions, values, prepared_constraints: nil)
|
|
181
|
+
solver = Constraints::Solver.new
|
|
182
|
+
values.each_with_index do |value, index|
|
|
183
|
+
next if value == DONT_CARE
|
|
184
|
+
|
|
185
|
+
condition = conditions[index] || {}
|
|
186
|
+
truth = value == TRUE_VALUE
|
|
187
|
+
literal = fetch(condition, :literal_truth)
|
|
188
|
+
return %w[statically_impossible boolean_literal_conflict] if !literal.nil? && literal != truth
|
|
189
|
+
|
|
190
|
+
next unless fetch(condition, :constraint_safe) == true
|
|
191
|
+
|
|
192
|
+
prepared = prepared_constraints && prepared_constraints[index]
|
|
193
|
+
reason = if prepared
|
|
194
|
+
solver.add_prepared(prepared, truth)
|
|
195
|
+
else
|
|
196
|
+
solver.add(fetch(condition, :constraint), truth)
|
|
197
|
+
end
|
|
198
|
+
return ["statically_impossible", reason] if reason
|
|
199
|
+
end
|
|
200
|
+
["unknown", nil]
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def overlay(decision_id:, rules:, vectors:, reachability: true, indexed: false)
|
|
204
|
+
diagnostics = []
|
|
205
|
+
matches = indexed ? classify_vectors(rules, vectors) : generic_matches(rules, vectors)
|
|
206
|
+
overlaid = rules.each_with_index.map do |item, index|
|
|
207
|
+
overlay_rule_matches(decision_id, item, matches[index], diagnostics)
|
|
208
|
+
end
|
|
209
|
+
summary(decision_id: decision_id, rules: overlaid, diagnostics: diagnostics,
|
|
210
|
+
reachability_analyzed: reachability)
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# Generated traces have nil in every skipped condition position. Their
|
|
214
|
+
# normalized vector is therefore an exact table key and can be classified
|
|
215
|
+
# without scanning every rule. Malformed or hand-built observations retain
|
|
216
|
+
# the historical matcher as a bounded compatibility fallback.
|
|
217
|
+
def classify_vectors(rules, vectors)
|
|
218
|
+
condition_count = rules.first ? Array(rules.first[:conditions]).length : 0
|
|
219
|
+
by_signature = {}
|
|
220
|
+
rules.each_with_index do |item, position|
|
|
221
|
+
(by_signature[signature(item[:conditions], item[:outcome])] ||= []) << position
|
|
222
|
+
end
|
|
223
|
+
matches = Array.new(rules.length) { [] }
|
|
224
|
+
vectors.each do |vector|
|
|
225
|
+
positions = fast_match_positions(vector, by_signature, condition_count)
|
|
226
|
+
if positions&.length == 1
|
|
227
|
+
matches[positions.first] << vector
|
|
228
|
+
else
|
|
229
|
+
rules.each_with_index do |item, position|
|
|
230
|
+
matches[position] << vector if matches?(item, vector)
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
matches
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def generic_matches(rules, vectors)
|
|
238
|
+
matches = Array.new(rules.length) { [] }
|
|
239
|
+
rules.each_with_index do |rule, position|
|
|
240
|
+
vectors.each { |vector| matches[position] << vector if matches?(rule, vector) }
|
|
241
|
+
end
|
|
242
|
+
matches
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def signature(values, outcome)
|
|
246
|
+
[outcome ? true : false, Array(values).map { |value| value == DONT_CARE ? nil : value == TRUE_VALUE }]
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def fast_match_positions(vector, by_signature, condition_count)
|
|
250
|
+
values = Array(fetch(vector, :values))
|
|
251
|
+
valid_values = values.all? { |value| value.nil? || value == true || value == false }
|
|
252
|
+
return nil unless values.length >= condition_count && valid_values
|
|
253
|
+
|
|
254
|
+
normalized = values.first(condition_count).map do |value|
|
|
255
|
+
if value.nil?
|
|
256
|
+
DONT_CARE
|
|
257
|
+
elsif value
|
|
258
|
+
TRUE_VALUE
|
|
259
|
+
else
|
|
260
|
+
FALSE_VALUE
|
|
261
|
+
end
|
|
262
|
+
end
|
|
263
|
+
by_signature[signature(normalized, fetch(vector, :outcome))]
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
# Public compatibility wrapper: callers historically passed all vectors,
|
|
267
|
+
# so retain matcher filtering for direct calls.
|
|
268
|
+
def overlay_rule(decision_id, rule, vectors, diagnostics)
|
|
269
|
+
matched = Array(vectors).select { |vector| matches?(rule, vector) }
|
|
270
|
+
overlay_rule_matches(decision_id, rule, matched, diagnostics)
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
def overlay_rule_matches(decision_id, rule, matched, diagnostics)
|
|
274
|
+
observed = !matched.empty?
|
|
275
|
+
impossible = rule[:reachability] == "statically_impossible"
|
|
276
|
+
withdrawn = observed && impossible
|
|
277
|
+
if withdrawn
|
|
278
|
+
diagnostics << Records.diagnostic(
|
|
279
|
+
code: "constraint_model_conflict", severity: "warning",
|
|
280
|
+
message: "runtime evidence matched a statically impossible decision-table rule; " \
|
|
281
|
+
"the impossibility claim is withdrawn",
|
|
282
|
+
decision_id: decision_id,
|
|
283
|
+
details: { rule_id: rule[:id], rule_label: rule[:label],
|
|
284
|
+
reachability_reason: rule[:reachability_reason].to_s }
|
|
285
|
+
)
|
|
286
|
+
end
|
|
287
|
+
rule.merge(
|
|
288
|
+
coverage: if observed
|
|
289
|
+
"covered"
|
|
290
|
+
else
|
|
291
|
+
(impossible ? "excluded" : "missing")
|
|
292
|
+
end,
|
|
293
|
+
reachability: observed ? "observed" : rule[:reachability],
|
|
294
|
+
reachability_reason: observed ? nil : rule[:reachability_reason],
|
|
295
|
+
impossible_withdrawn: withdrawn,
|
|
296
|
+
withdrawn_reason: withdrawn ? rule[:reachability_reason] : nil,
|
|
297
|
+
tests: matched.flat_map { |vector| Array(fetch(vector, :test_ids)).map(&:to_s) }.uniq.sort,
|
|
298
|
+
vector_ids: matched.map { |vector| fetch(vector, :id).to_s }.uniq.sort,
|
|
299
|
+
unattributed_count: matched.sum { |vector| fetch(vector, :unattributed_count).to_i }
|
|
300
|
+
)
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
# A runtime observation matches a rule when every required condition value
|
|
304
|
+
# matches and the decision outcome matches. Conditions Ruby skipped may only
|
|
305
|
+
# line up with don't-care positions.
|
|
306
|
+
def matches?(rule, vector)
|
|
307
|
+
return false unless (fetch(vector, :outcome) ? true : false) == rule[:outcome]
|
|
308
|
+
|
|
309
|
+
values = Array(fetch(vector, :values))
|
|
310
|
+
rule[:conditions].each_with_index.all? do |required, index|
|
|
311
|
+
next true if required == DONT_CARE
|
|
312
|
+
|
|
313
|
+
values[index] == (required == TRUE_VALUE)
|
|
314
|
+
end
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
def summary(decision_id:, rules:, diagnostics:, reachability_analyzed: true)
|
|
318
|
+
generated = rules.length
|
|
319
|
+
impossible = rules.count { |rule| rule[:coverage] == "excluded" }
|
|
320
|
+
required = generated - impossible
|
|
321
|
+
covered = rules.count { |rule| rule[:coverage] == "covered" }
|
|
322
|
+
{ status: "calculated", reason: nil, decision_id: decision_id,
|
|
323
|
+
schema_version: SCHEMA_VERSION, constraint_analysis_version: CONSTRAINT_ANALYSIS_VERSION,
|
|
324
|
+
rules: rules, generated_rules: generated, impossible_rules: impossible,
|
|
325
|
+
required_rules: required, covered_rules: covered, missing_rules: required - covered,
|
|
326
|
+
coverage_status: coverage_status(covered, required, generated),
|
|
327
|
+
percentage: percentage(covered, required), reachability_analyzed: reachability_analyzed,
|
|
328
|
+
diagnostics: diagnostics }
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
def coverage_status(covered, required, generated)
|
|
332
|
+
return "unexecuted" if generated.zero?
|
|
333
|
+
return "covered" if required.zero? || covered == required
|
|
334
|
+
return "unexecuted" if covered.zero?
|
|
335
|
+
|
|
336
|
+
"partial"
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
def not_calculated(decision_id, reason)
|
|
340
|
+
{ status: "not_calculated", reason: reason, decision_id: decision_id,
|
|
341
|
+
schema_version: SCHEMA_VERSION, constraint_analysis_version: CONSTRAINT_ANALYSIS_VERSION,
|
|
342
|
+
rules: [], generated_rules: 0, impossible_rules: 0, required_rules: 0, covered_rules: 0,
|
|
343
|
+
missing_rules: 0,
|
|
344
|
+
coverage_status: reason == "unsupported_decision" ? "unsupported" : "not_calculated",
|
|
345
|
+
percentage: nil, reachability_analyzed: false, diagnostics: [] }
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
# The per-decision row the coverage ladder renders.
|
|
349
|
+
def coverage_entry(table)
|
|
350
|
+
{ status: table[:coverage_status], table_status: table[:status], reason: table[:reason],
|
|
351
|
+
covered_rules: table[:covered_rules], required_rules: table[:required_rules],
|
|
352
|
+
generated_rules: table[:generated_rules], impossible_rules: table[:impossible_rules],
|
|
353
|
+
missing_rules: table[:missing_rules], percentage: table[:percentage],
|
|
354
|
+
reachability_analyzed: table[:reachability_analyzed] }
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
def unsupported_coverage_entry
|
|
358
|
+
coverage_entry(not_calculated(nil, "unsupported_decision"))
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
def percentage(covered, required)
|
|
362
|
+
required.zero? ? nil : (covered.to_f / required * 100).round(2)
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
def limit(limits, key, fallback)
|
|
366
|
+
value = fetch(limits, key)
|
|
367
|
+
value.is_a?(Integer) && value.positive? ? value : fallback
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
def fetch(record, key)
|
|
371
|
+
return nil unless record.respond_to?(:key?)
|
|
372
|
+
return record[key] if record.key?(key)
|
|
373
|
+
|
|
374
|
+
record[key.to_s]
|
|
375
|
+
end
|
|
376
|
+
end
|
|
377
|
+
end
|