branchproof 0.7.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 +35 -0
- data/README.md +212 -11
- 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/FocusedReport.md +5 -2
- 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 +4 -0
- data/doc/Branchproof/SavedReport.md +15 -0
- data/doc/Branchproof.md +4 -1
- data/doc/CHANGELOG.md +35 -0
- data/doc/README.md +212 -11
- 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 +31 -15
- data/lib/branchproof/decision_table.rb +377 -0
- data/lib/branchproof/evidence.rb +51 -31
- data/lib/branchproof/flow_instrumentation.rb +17 -17
- data/lib/branchproof/focused_report.rb +77 -9
- data/lib/branchproof/instrumenter.rb +45 -34
- 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 +301 -68
- data/lib/branchproof/runtime.rb +22 -30
- data/lib/branchproof/saved_report.rb +200 -14
- data/lib/branchproof/source.rb +90 -48
- data/lib/branchproof/version.rb +1 -1
- data/lib/branchproof/worker.rb +1 -4
- data/lib/branchproof.rb +2 -0
- data/llms.txt +4 -1
- data/sig/branchproof.rbs +47 -1
- metadata +6 -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
|
data/lib/branchproof/evidence.rb
CHANGED
|
@@ -20,10 +20,16 @@ module Branchproof
|
|
|
20
20
|
raise ArgumentError, "run_id is required" if run_id.nil? || run_id.to_s.empty?
|
|
21
21
|
|
|
22
22
|
@inventory = inventory
|
|
23
|
+
@decisions_by_id = Array(fetch_value(@inventory, :decisions)).each_with_object({}) do |raw, index|
|
|
24
|
+
decision = symbolize(raw)
|
|
25
|
+
index[decision[:id].to_s] = decision
|
|
26
|
+
end
|
|
23
27
|
@limits = normalize_limits(limits)
|
|
24
28
|
@run_id = run_id.to_s
|
|
25
29
|
@run_ids = [@run_id]
|
|
26
30
|
@vectors = {}
|
|
31
|
+
@vector_counts_by_decision = Hash.new(0)
|
|
32
|
+
@owner_associations_count = 0
|
|
27
33
|
@tests = {}
|
|
28
34
|
@run_payloads = {}
|
|
29
35
|
@abort_counts = Hash.new(0)
|
|
@@ -68,12 +74,13 @@ module Branchproof
|
|
|
68
74
|
end
|
|
69
75
|
decision_id = value[:decision_id].to_s
|
|
70
76
|
vector_values = condition_values(decision_id, value[:observations])
|
|
71
|
-
|
|
72
|
-
|
|
77
|
+
outcome = value[:outcome] ? true : false
|
|
78
|
+
vector_id = Branchproof::Records.id([decision_id, vector_values, outcome])
|
|
79
|
+
if new_vector?(vector_id) && vector_count(decision_id) >= @limits[:vectors_per_decision]
|
|
73
80
|
@limited = true
|
|
74
81
|
return status("limited", "vectors_per_decision reached")
|
|
75
82
|
end
|
|
76
|
-
vector = vector_for(decision_id, vector_values,
|
|
83
|
+
vector = vector_for(decision_id, vector_values, outcome, vector_id)
|
|
77
84
|
new_owner = if value[:test_id]
|
|
78
85
|
!vector[:test_ids].include?(value[:test_id].to_s)
|
|
79
86
|
else
|
|
@@ -89,6 +96,7 @@ module Branchproof
|
|
|
89
96
|
phase_map << value[:phase].to_s if phase_map && !phase_map.include?(value[:phase].to_s)
|
|
90
97
|
vector[:unattributed_count] += 1 unless value[:test_id]
|
|
91
98
|
vector[:count] += 1
|
|
99
|
+
@owner_associations_count += 1 if new_owner
|
|
92
100
|
if value[:test_id] && @tests.key?(value[:test_id].to_s)
|
|
93
101
|
test = @tests[value[:test_id].to_s]
|
|
94
102
|
phase = value[:phase].to_s
|
|
@@ -194,18 +202,17 @@ module Branchproof
|
|
|
194
202
|
def validate_execution(value)
|
|
195
203
|
return "execution must be a hash" unless value.is_a?(Hash)
|
|
196
204
|
|
|
197
|
-
%i[run_id
|
|
205
|
+
%i[run_id decision_id test_id phase observations outcome status].each do |key|
|
|
198
206
|
return "missing #{key}" unless value.key?(key)
|
|
199
207
|
end
|
|
200
208
|
return "run mismatch" unless value[:run_id].to_s == @run_id
|
|
201
|
-
return "invalid owner" unless value[:owner].is_a?(Hash)
|
|
202
209
|
return "invalid status" unless %w[completed aborted invalid].include?(value[:status].to_s)
|
|
203
210
|
return "invalid phase" unless %w[setup body teardown suite unattributed].include?(value[:phase].to_s)
|
|
204
211
|
return "invalid observations" unless value[:observations].is_a?(Array) && value[:observations].all? do |pair|
|
|
205
212
|
pair.is_a?(Array) && pair.length == 2 && pair[0].is_a?(Integer) && [true, false].include?(pair[1])
|
|
206
213
|
end
|
|
207
214
|
|
|
208
|
-
decision =
|
|
215
|
+
decision = @decisions_by_id[value[:decision_id].to_s]
|
|
209
216
|
return "unknown decision" unless decision
|
|
210
217
|
|
|
211
218
|
dimensions = alternative_decision?(decision) ? Array(decision[:alternatives]) : Array(decision[:conditions])
|
|
@@ -224,7 +231,7 @@ module Branchproof
|
|
|
224
231
|
def valid_trace?(decision, observations, outcome)
|
|
225
232
|
return valid_alternative_trace?(decision, observations, outcome) if alternative_decision?(decision)
|
|
226
233
|
|
|
227
|
-
tree =
|
|
234
|
+
tree = decision[:tree]
|
|
228
235
|
unless tree
|
|
229
236
|
return observations.map(&:first) == observations.map(&:first).sort &&
|
|
230
237
|
(outcome ? true : false) == evaluate_fallback(observations)
|
|
@@ -239,7 +246,6 @@ module Branchproof
|
|
|
239
246
|
end
|
|
240
247
|
|
|
241
248
|
def replay_tree(node, observations, cursor)
|
|
242
|
-
node = symbolize(node)
|
|
243
249
|
type = node[:type].to_s
|
|
244
250
|
if type == "atom"
|
|
245
251
|
pair = observations[cursor]
|
|
@@ -280,26 +286,42 @@ module Branchproof
|
|
|
280
286
|
values
|
|
281
287
|
end
|
|
282
288
|
|
|
283
|
-
def vector_for(decision_id, values, outcome)
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
289
|
+
def vector_for(decision_id, values, outcome, id)
|
|
290
|
+
existing = @vectors[id]
|
|
291
|
+
return existing if existing
|
|
292
|
+
|
|
293
|
+
vector = { id: id, decision_id: decision_id, values: values.dup, outcome: outcome,
|
|
294
|
+
test_ids: [], phases_by_test: {}, unattributed_count: 0, count: 0 }
|
|
295
|
+
@vectors[id] = vector
|
|
296
|
+
@vector_counts_by_decision[decision_id] += 1
|
|
297
|
+
vector
|
|
287
298
|
end
|
|
288
299
|
|
|
289
300
|
def merge_vector(raw)
|
|
290
301
|
vector = symbolize(raw)
|
|
291
|
-
|
|
302
|
+
id = vector[:id].to_s
|
|
303
|
+
existing = @vectors[id]
|
|
292
304
|
if existing
|
|
293
|
-
|
|
305
|
+
new_test_ids = Array(vector[:test_ids]).map(&:to_s) - existing[:test_ids]
|
|
306
|
+
existing[:test_ids] |= new_test_ids
|
|
294
307
|
vector.fetch(:phases_by_test, {}).each do |test, phases|
|
|
295
308
|
existing[:phases_by_test][test.to_s] = (existing[:phases_by_test][test.to_s] || []) | phases
|
|
296
309
|
end
|
|
310
|
+
was_unattributed = existing[:unattributed_count].positive?
|
|
297
311
|
existing[:count] += vector[:count].to_i
|
|
298
312
|
existing[:unattributed_count] += vector[:unattributed_count].to_i
|
|
313
|
+
@owner_associations_count += new_test_ids.length
|
|
314
|
+
@owner_associations_count += 1 if !was_unattributed && existing[:unattributed_count].positive?
|
|
299
315
|
else
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
316
|
+
decision_id = vector[:decision_id].to_s
|
|
317
|
+
test_ids = Array(vector[:test_ids]).map(&:to_s)
|
|
318
|
+
unattributed_count = vector[:unattributed_count].to_i
|
|
319
|
+
@vectors[id] = { id: id, decision_id: decision_id,
|
|
320
|
+
values: Array(vector[:values]).dup, outcome: !!vector[:outcome], test_ids: test_ids,
|
|
321
|
+
phases_by_test: vector.fetch(:phases_by_test, {}).transform_keys(&:to_s), unattributed_count: unattributed_count, count: vector[:count].to_i }
|
|
322
|
+
@vector_counts_by_decision[decision_id] += 1
|
|
323
|
+
@owner_associations_count += test_ids.length
|
|
324
|
+
@owner_associations_count += 1 if unattributed_count.positive?
|
|
303
325
|
end
|
|
304
326
|
end
|
|
305
327
|
|
|
@@ -314,14 +336,14 @@ module Branchproof
|
|
|
314
336
|
end
|
|
315
337
|
|
|
316
338
|
def condition_shapes
|
|
317
|
-
|
|
339
|
+
@decisions_by_id.transform_values do |decision|
|
|
318
340
|
shape = { conditions: Array(decision[:conditions]).map { |condition| symbolize(condition) },
|
|
319
341
|
tree: symbolize(decision[:tree]) }
|
|
320
342
|
if alternative_decision?(decision)
|
|
321
343
|
shape[:kind] = decision[:kind].to_s
|
|
322
344
|
shape[:alternatives] = Array(decision[:alternatives]).map { |alternative| symbolize(alternative) }
|
|
323
345
|
end
|
|
324
|
-
|
|
346
|
+
shape
|
|
325
347
|
end
|
|
326
348
|
end
|
|
327
349
|
|
|
@@ -348,7 +370,7 @@ module Branchproof
|
|
|
348
370
|
vector = symbolize(raw)
|
|
349
371
|
return "invalid vector" unless vector[:id] && vector[:decision_id] && vector[:values].is_a?(Array)
|
|
350
372
|
|
|
351
|
-
decision =
|
|
373
|
+
decision = @decisions_by_id[vector[:decision_id].to_s]
|
|
352
374
|
return "unknown decision" unless decision
|
|
353
375
|
|
|
354
376
|
expected_values = decision_dimension_count(decision)
|
|
@@ -396,7 +418,7 @@ module Branchproof
|
|
|
396
418
|
end
|
|
397
419
|
|
|
398
420
|
def decision_dimension_count(decision_or_id)
|
|
399
|
-
decision = decision_or_id.is_a?(Hash) ? decision_or_id :
|
|
421
|
+
decision = decision_or_id.is_a?(Hash) ? decision_or_id : @decisions_by_id[decision_or_id.to_s]
|
|
400
422
|
return 0 unless decision
|
|
401
423
|
|
|
402
424
|
alternative_decision?(decision) ? Array(decision[:alternatives]).length : Array(decision[:conditions]).length
|
|
@@ -420,16 +442,11 @@ module Branchproof
|
|
|
420
442
|
end
|
|
421
443
|
end
|
|
422
444
|
|
|
423
|
-
def
|
|
424
|
-
def
|
|
425
|
-
def
|
|
426
|
-
def new_vector?(id, values, outcome) = !@vectors.key?(Branchproof::Records.id([id, values, outcome]))
|
|
445
|
+
def decision_conditions(id) = (@decisions_by_id[id.to_s] || {}).fetch(:conditions, [])
|
|
446
|
+
def vector_count(id) = @vector_counts_by_decision[id.to_s]
|
|
447
|
+
def new_vector?(id) = !@vectors.key?(id)
|
|
427
448
|
|
|
428
|
-
def owner_associations
|
|
429
|
-
@vectors.values.sum do |v|
|
|
430
|
-
v[:test_ids].length + (v[:unattributed_count].positive? ? 1 : 0)
|
|
431
|
-
end
|
|
432
|
-
end
|
|
449
|
+
def owner_associations = @owner_associations_count
|
|
433
450
|
|
|
434
451
|
def incoming_owner_associations(incoming)
|
|
435
452
|
owners = @vectors.each_with_object({}) do |(_id, vector), result|
|
|
@@ -500,7 +517,8 @@ module Branchproof
|
|
|
500
517
|
def capture_state
|
|
501
518
|
{ vectors: deep_dup(@vectors), tests: deep_dup(@tests), run_ids: @run_ids.dup,
|
|
502
519
|
run_payloads: @run_payloads.dup, diagnostics: deep_dup(@diagnostics),
|
|
503
|
-
abort_counts: @abort_counts.dup, limited: @limited, attribution_complete: @attribution_complete
|
|
520
|
+
abort_counts: @abort_counts.dup, limited: @limited, attribution_complete: @attribution_complete,
|
|
521
|
+
vector_counts_by_decision: @vector_counts_by_decision.dup, owner_associations_count: @owner_associations_count }
|
|
504
522
|
end
|
|
505
523
|
|
|
506
524
|
def restore_state(state)
|
|
@@ -510,6 +528,8 @@ module Branchproof
|
|
|
510
528
|
@run_payloads = state[:run_payloads]
|
|
511
529
|
@diagnostics = state[:diagnostics]
|
|
512
530
|
@abort_counts = state[:abort_counts]
|
|
531
|
+
@vector_counts_by_decision = state[:vector_counts_by_decision]
|
|
532
|
+
@owner_associations_count = state[:owner_associations_count]
|
|
513
533
|
@limited = state[:limited]
|
|
514
534
|
@attribution_complete = state[:attribution_complete]
|
|
515
535
|
end
|
|
@@ -8,36 +8,36 @@ module Branchproof
|
|
|
8
8
|
module FlowInstrumentation
|
|
9
9
|
private
|
|
10
10
|
|
|
11
|
-
def render_flow(bytes, decision, nested)
|
|
11
|
+
def render_flow(bytes, decision, nested, encloses)
|
|
12
12
|
metadata = decision.fetch(:instrumentation)
|
|
13
13
|
case metadata.fetch(:type)
|
|
14
14
|
when "safe_navigation"
|
|
15
15
|
receiver = metadata.fetch(:receiver)
|
|
16
|
-
replacements = [flow_replacement(bytes, receiver, nested) do |expression|
|
|
16
|
+
replacements = [flow_replacement(bytes, receiver, nested, encloses) do |expression|
|
|
17
17
|
"#{self.class::RUNTIME}.flow_receiver(#{decision[:id].inspect}, (#{expression}))"
|
|
18
18
|
end]
|
|
19
|
-
flow_fragments(bytes, decision, nested, replacements)
|
|
19
|
+
flow_fragments(bytes, decision, nested, replacements, encloses)
|
|
20
20
|
when "assignment"
|
|
21
21
|
rhs = metadata.fetch(:rhs)
|
|
22
|
-
replacements = [flow_replacement(bytes, rhs, nested) do |expression|
|
|
22
|
+
replacements = [flow_replacement(bytes, rhs, nested, encloses) do |expression|
|
|
23
23
|
"(begin; #{self.class::RUNTIME}.flow_path(#{decision[:id].inspect}, 1); (#{expression}); end)"
|
|
24
24
|
end]
|
|
25
|
-
expression = flow_fragments(bytes, decision, nested, replacements)
|
|
25
|
+
expression = flow_fragments(bytes, decision, nested, replacements, encloses)
|
|
26
26
|
flow_frame(decision[:id], expression, default_path: 0)
|
|
27
27
|
when "case"
|
|
28
|
-
render_case_flow(bytes, decision, nested, metadata)
|
|
28
|
+
render_case_flow(bytes, decision, nested, metadata, encloses)
|
|
29
29
|
when "case_match"
|
|
30
|
-
render_pattern_flow(bytes, decision, nested, metadata)
|
|
30
|
+
render_pattern_flow(bytes, decision, nested, metadata, encloses)
|
|
31
31
|
else
|
|
32
32
|
raise ArgumentError, "unknown instrumentation type: #{metadata[:type]}"
|
|
33
33
|
end
|
|
34
34
|
end
|
|
35
35
|
|
|
36
|
-
def render_case_flow(bytes, decision, nested, metadata)
|
|
36
|
+
def render_case_flow(bytes, decision, nested, metadata, encloses)
|
|
37
37
|
identifier = decision[:id].inspect
|
|
38
38
|
runtime = self.class::RUNTIME
|
|
39
39
|
replacements = metadata.fetch(:candidates).map do |candidate|
|
|
40
|
-
flow_replacement(bytes, candidate, nested) do |expression|
|
|
40
|
+
flow_replacement(bytes, candidate, nested, encloses) do |expression|
|
|
41
41
|
"(begin; #{runtime}.flow_candidate(#{identifier}, #{candidate[:index]}); (#{expression}); end)"
|
|
42
42
|
end
|
|
43
43
|
end
|
|
@@ -55,10 +55,10 @@ module Branchproof
|
|
|
55
55
|
replacements << { start: metadata.fetch(:end_start), length: 0,
|
|
56
56
|
text: "else; #{runtime}.flow_select(#{identifier}, #{index}); nil; " }
|
|
57
57
|
end
|
|
58
|
-
flow_frame(decision[:id], flow_fragments(bytes, decision, nested, replacements))
|
|
58
|
+
flow_frame(decision[:id], flow_fragments(bytes, decision, nested, replacements, encloses))
|
|
59
59
|
end
|
|
60
60
|
|
|
61
|
-
def render_pattern_flow(bytes, decision, nested, metadata)
|
|
61
|
+
def render_pattern_flow(bytes, decision, nested, metadata, encloses)
|
|
62
62
|
identifier = decision[:id].inspect
|
|
63
63
|
runtime = self.class::RUNTIME
|
|
64
64
|
replacements = metadata.fetch(:branches).map do |branch|
|
|
@@ -71,27 +71,27 @@ module Branchproof
|
|
|
71
71
|
replacements << { start: alternative[:insert_at], length: 0,
|
|
72
72
|
text: "; #{runtime}.flow_select(#{identifier}, #{alternative[:index]}); " }
|
|
73
73
|
end
|
|
74
|
-
flow_frame(decision[:id], flow_fragments(bytes, decision, nested, replacements))
|
|
74
|
+
flow_frame(decision[:id], flow_fragments(bytes, decision, nested, replacements, encloses))
|
|
75
75
|
end
|
|
76
76
|
|
|
77
|
-
def flow_replacement(bytes, location, nested)
|
|
77
|
+
def flow_replacement(bytes, location, nested, encloses)
|
|
78
78
|
start = location.fetch(:byte_start)
|
|
79
79
|
length = location.fetch(:byte_length)
|
|
80
|
-
{ start: start, length: length, text: yield(render_children(bytes, start, length, nested)) }
|
|
80
|
+
{ start: start, length: length, text: yield(render_children(bytes, start, length, nested, encloses)) }
|
|
81
81
|
end
|
|
82
82
|
|
|
83
|
-
def flow_fragments(bytes, decision, nested, replacements)
|
|
83
|
+
def flow_fragments(bytes, decision, nested, replacements, encloses)
|
|
84
84
|
cursor = decision.fetch(:byte_start)
|
|
85
85
|
finish = cursor + decision.fetch(:byte_length)
|
|
86
86
|
chunks = []
|
|
87
87
|
replacements.sort_by { |edit| [edit[:start], edit[:length]] }.each do |edit|
|
|
88
88
|
raise ArgumentError, "overlapping flow edits" if edit[:start] < cursor
|
|
89
89
|
|
|
90
|
-
chunks << render_children(bytes, cursor, edit[:start] - cursor, nested)
|
|
90
|
+
chunks << render_children(bytes, cursor, edit[:start] - cursor, nested, encloses)
|
|
91
91
|
chunks << edit[:text]
|
|
92
92
|
cursor = edit[:start] + edit[:length]
|
|
93
93
|
end
|
|
94
|
-
chunks << render_children(bytes, cursor, finish - cursor, nested)
|
|
94
|
+
chunks << render_children(bytes, cursor, finish - cursor, nested, encloses)
|
|
95
95
|
chunks.join
|
|
96
96
|
end
|
|
97
97
|
|