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
|
@@ -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
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Keep source-boundary and parameter-binding rules together for auditing.
|
|
4
|
+
# rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/ModuleLength, Metrics/PerceivedComplexity
|
|
5
|
+
|
|
6
|
+
module Branchproof
|
|
7
|
+
# Defaults stay inline in their original lexical scope. The owner decision
|
|
8
|
+
# makes body entry edits part of the same original AST edit tree as the
|
|
9
|
+
# parameter expression edits.
|
|
10
|
+
module DefaultInstrumentation
|
|
11
|
+
def rewrite(unit:)
|
|
12
|
+
decisions = Array(unit[:decisions])
|
|
13
|
+
defaults = decisions.select do |decision|
|
|
14
|
+
decision.dig(:instrumentation, :type) == "default" && supported?(decision)
|
|
15
|
+
end
|
|
16
|
+
return super unless defaults.any?
|
|
17
|
+
|
|
18
|
+
prepared = decisions.map do |decision|
|
|
19
|
+
next decision unless defaults.include?(decision)
|
|
20
|
+
|
|
21
|
+
instrumentation = decision[:instrumentation].merge(flag: default_flag(decision, unit[:original_bytes]))
|
|
22
|
+
decision.merge(instrumentation: instrumentation)
|
|
23
|
+
end
|
|
24
|
+
supported_ids = defaults.map { |decision| decision[:id] }
|
|
25
|
+
prepared_defaults = prepared.select do |decision|
|
|
26
|
+
decision.dig(:instrumentation, :type) == "default" && supported_ids.include?(decision[:id])
|
|
27
|
+
end
|
|
28
|
+
super(unit: unit.merge(decisions: owner_decisions(prepared, prepared_defaults)))
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
# An enclosing flow renderer can split a callable range at its own
|
|
34
|
+
# insertion point (for example an iteration callback inside a block). In
|
|
35
|
+
# that case the owner decision cannot be rendered as one child. Preserve
|
|
36
|
+
# the original offset by placing its supplied markers at the chunk that
|
|
37
|
+
# begins at the body entry.
|
|
38
|
+
def render_children(bytes, start, length, nested, encloses)
|
|
39
|
+
output = super
|
|
40
|
+
finish = start + length
|
|
41
|
+
crossing = nested.select do |child|
|
|
42
|
+
next false unless child.dig(:instrumentation, :type) == "default_owner"
|
|
43
|
+
|
|
44
|
+
owner = child.dig(:instrumentation, :owner)
|
|
45
|
+
owner && owner[:body_start] == start &&
|
|
46
|
+
(owner[:byte_start] < start || owner[:byte_start] + owner[:byte_length] > finish)
|
|
47
|
+
end
|
|
48
|
+
return output if crossing.empty?
|
|
49
|
+
|
|
50
|
+
marker = crossing.flat_map { |owner| owner_markers(owner, encloses[owner] || [], bytes) }.join
|
|
51
|
+
marker.empty? ? output : "#{marker}#{output}"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def encloses_decision?(outer, inner)
|
|
55
|
+
same_range = outer[:byte_start] == inner[:byte_start] && outer[:byte_length] == inner[:byte_length]
|
|
56
|
+
if same_range && inner.dig(:instrumentation, :type) == "default_owner"
|
|
57
|
+
return true unless outer.dig(:instrumentation, :type) == "default_owner"
|
|
58
|
+
elsif same_range && outer.dig(:instrumentation, :type) == "default_owner"
|
|
59
|
+
return false
|
|
60
|
+
end
|
|
61
|
+
super
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def render_flow(bytes, decision, nested, encloses)
|
|
65
|
+
if decision.dig(:instrumentation, :type) == "default_owner"
|
|
66
|
+
return render_default_owner(bytes, decision, nested, encloses)
|
|
67
|
+
end
|
|
68
|
+
return super unless decision.dig(:instrumentation, :type) == "default"
|
|
69
|
+
|
|
70
|
+
value = decision.dig(:instrumentation, :value_range)
|
|
71
|
+
flag = decision.dig(:instrumentation, :flag) || default_flag(decision, bytes)
|
|
72
|
+
replacement = flow_replacement(bytes, value, nested, encloses) do |expression|
|
|
73
|
+
"(begin; #{flag} = true; #{self.class::RUNTIME}.default_binding(#{decision[:id].inspect}, 1); " \
|
|
74
|
+
"#{expression}; end)"
|
|
75
|
+
end
|
|
76
|
+
flow_fragments(bytes, decision, nested, [replacement], encloses)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def owner_decisions(decisions, defaults)
|
|
80
|
+
owners = defaults.group_by { |decision| decision.dig(:instrumentation, :owner) }
|
|
81
|
+
additions = owners.filter_map do |owner, grouped|
|
|
82
|
+
next unless owner
|
|
83
|
+
|
|
84
|
+
first = grouped.min_by { |decision| decision[:byte_start] }
|
|
85
|
+
next unless first
|
|
86
|
+
|
|
87
|
+
first.merge(
|
|
88
|
+
byte_start: owner[:byte_start], byte_length: owner[:byte_length],
|
|
89
|
+
expression: nil,
|
|
90
|
+
instrumentation: first[:instrumentation].merge(type: "default_owner")
|
|
91
|
+
)
|
|
92
|
+
end
|
|
93
|
+
(decisions + additions).sort_by { |decision| [decision[:byte_start], decision[:byte_length]] }
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def render_default_owner(bytes, decision, nested, encloses)
|
|
97
|
+
owner = decision.dig(:instrumentation, :owner)
|
|
98
|
+
return super unless owner
|
|
99
|
+
|
|
100
|
+
bindings = nested.filter_map do |child|
|
|
101
|
+
next unless child.dig(:instrumentation, :type) == "default"
|
|
102
|
+
next unless child.dig(:instrumentation, :owner) == owner
|
|
103
|
+
|
|
104
|
+
[child.dig(:instrumentation, :flag) || default_flag(child, bytes), child[:id]]
|
|
105
|
+
end
|
|
106
|
+
marker = bindings.map do |flag, id|
|
|
107
|
+
"#{self.class::RUNTIME}.default_binding(#{id.inspect}, 0) unless #{flag}; "
|
|
108
|
+
end.join
|
|
109
|
+
return flow_fragments(bytes, decision, nested, [], encloses) if marker.empty?
|
|
110
|
+
|
|
111
|
+
start = owner[:body_start] || owner[:closing_start]
|
|
112
|
+
finish = (owner[:body_start] + owner[:body_length] if owner[:equal] && owner[:body_start] && owner[:body_length])
|
|
113
|
+
replacements = if finish
|
|
114
|
+
[{ start: start, length: 0, text: "(begin; #{marker}" },
|
|
115
|
+
{ start: finish, length: 0, text: "; end)" }]
|
|
116
|
+
else
|
|
117
|
+
[{ start: start, length: 0, text: marker }]
|
|
118
|
+
end
|
|
119
|
+
flow_fragments(bytes, decision, nested, replacements, encloses)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def owner_markers(owner, nested, bytes)
|
|
123
|
+
nested.filter_map do |child|
|
|
124
|
+
next unless child.dig(:instrumentation, :type) == "default"
|
|
125
|
+
next unless child.dig(:instrumentation, :owner) == owner.dig(:instrumentation, :owner)
|
|
126
|
+
|
|
127
|
+
flag = child.dig(:instrumentation, :flag) || default_flag(child, bytes)
|
|
128
|
+
"#{self.class::RUNTIME}.default_binding(#{child[:id].inspect}, 0) unless #{flag}; "
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def default_flag(decision, bytes)
|
|
133
|
+
name = "__branchproof_default_#{decision[:id]}"
|
|
134
|
+
name += "_" while bytes.include?(name)
|
|
135
|
+
name
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/ModuleLength, Metrics/PerceivedComplexity
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Branchproof
|
|
4
|
+
# Binding events are immediate; no pending state survives a failed default.
|
|
5
|
+
module DefaultRuntime
|
|
6
|
+
def default_binding(decision_id, index)
|
|
7
|
+
enter(decision_id)
|
|
8
|
+
begin
|
|
9
|
+
flow_path(decision_id, index)
|
|
10
|
+
ensure
|
|
11
|
+
leave(decision_id)
|
|
12
|
+
end
|
|
13
|
+
nil
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Branchproof
|
|
4
|
+
# Each optional parameter has an independent supplied/defaulted obligation.
|
|
5
|
+
module DefaultSyntax
|
|
6
|
+
# rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
7
|
+
|
|
8
|
+
private
|
|
9
|
+
|
|
10
|
+
def flow_decision_node?(node)
|
|
11
|
+
@default_owner_by_node = {}.compare_by_identity if node.is_a?(Prism::ProgramNode)
|
|
12
|
+
register_default_owner(node) if default_owner_node?(node)
|
|
13
|
+
default_parameter_node?(node) || super
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def flow_details(node, bytes, encoding)
|
|
17
|
+
return super unless default_parameter_node?(node)
|
|
18
|
+
|
|
19
|
+
value = node.value
|
|
20
|
+
alternatives = [
|
|
21
|
+
{ expression: "#{node.name} supplied", byte_start: node.location.start_offset,
|
|
22
|
+
byte_length: node.location.length },
|
|
23
|
+
{ expression: "#{node.name} default evaluated", byte_start: value.location.start_offset,
|
|
24
|
+
byte_length: value.location.length }
|
|
25
|
+
]
|
|
26
|
+
owner = @default_owner_by_node && @default_owner_by_node[node]
|
|
27
|
+
metadata = { type: "default", value_range: byte_range(value.location) }
|
|
28
|
+
metadata[:owner] = owner if owner
|
|
29
|
+
["implicit", "default_argument", alternatives, metadata, unsupported_reasons(node, bytes)]
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def register_default_owner(owner)
|
|
33
|
+
@default_owner_by_node ||= {}.compare_by_identity
|
|
34
|
+
parameters = owner.parameters if owner.respond_to?(:parameters)
|
|
35
|
+
parameters = parameters.parameters if parameters.is_a?(Prism::BlockParametersNode)
|
|
36
|
+
return unless parameters.is_a?(Prism::ParametersNode)
|
|
37
|
+
|
|
38
|
+
details = default_owner_details(owner)
|
|
39
|
+
(Array(parameters.optionals) + Array(parameters.keywords)).each do |parameter|
|
|
40
|
+
next unless default_parameter_node?(parameter)
|
|
41
|
+
|
|
42
|
+
@default_owner_by_node[parameter] = details
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def default_parameter_node?(node)
|
|
47
|
+
node.is_a?(Prism::OptionalParameterNode) || node.is_a?(Prism::OptionalKeywordParameterNode)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def default_owner_node?(node)
|
|
51
|
+
node.is_a?(Prism::DefNode) || node.is_a?(Prism::LambdaNode) || node.is_a?(Prism::BlockNode)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def default_owner_details(owner)
|
|
55
|
+
return nil unless owner
|
|
56
|
+
|
|
57
|
+
# Only endless `def name(...) = expr` has a body that can be wrapped as
|
|
58
|
+
# one expression. Lambda/block `closing_loc` values are delimiters, not
|
|
59
|
+
# endless-definition markers, and must stay on the statement path.
|
|
60
|
+
equal_loc = owner.is_a?(Prism::DefNode) && owner.equal_loc
|
|
61
|
+
closing = if owner.is_a?(Prism::DefNode)
|
|
62
|
+
owner.end_keyword_loc
|
|
63
|
+
else
|
|
64
|
+
owner.closing_loc
|
|
65
|
+
end
|
|
66
|
+
body_start, body_length = default_owner_body_span(owner, closing)
|
|
67
|
+
details = { byte_start: owner.location.start_offset, byte_length: owner.location.length,
|
|
68
|
+
equal: equal_loc ? true : false, body_start: body_start,
|
|
69
|
+
body_length: body_length, closing_start: closing&.start_offset }
|
|
70
|
+
details.freeze
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Prism represents a method-level `rescue`/`ensure` as an implicit
|
|
74
|
+
# BeginNode whose location covers the whole def. Its insertion point is
|
|
75
|
+
# the actual statements (or rescue/ensure clause), not the def keyword.
|
|
76
|
+
def default_owner_body_span(owner, closing)
|
|
77
|
+
body = owner.body
|
|
78
|
+
if body.is_a?(Prism::BeginNode) && body.begin_keyword_loc.nil?
|
|
79
|
+
statements = body.statements
|
|
80
|
+
start = implicit_begin_start(statements, body, closing)
|
|
81
|
+
finish = closing&.start_offset || (body.location.start_offset + body.location.length)
|
|
82
|
+
return [start, start && finish ? [finish - start, 0].max : nil]
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
[body&.location&.start_offset, body&.location&.length]
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def implicit_begin_start(statements, body, closing)
|
|
89
|
+
[statements&.location&.start_offset, body.rescue_clause&.location&.start_offset,
|
|
90
|
+
body.ensure_clause&.location&.start_offset, closing&.start_offset].compact.first
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
94
|
+
end
|