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
data/lib/branchproof/analyzer.rb
CHANGED
|
@@ -8,20 +8,32 @@ module Branchproof
|
|
|
8
8
|
class Analyzer
|
|
9
9
|
CRITERION = "masking_occurrence_v1"
|
|
10
10
|
|
|
11
|
-
def initialize(inventory:, evidence:, limits:)
|
|
11
|
+
def initialize(inventory:, evidence:, limits:, reachability: true)
|
|
12
12
|
@inventory = inventory || {}
|
|
13
13
|
@evidence = evidence || {}
|
|
14
14
|
@limits = limits || {}
|
|
15
|
+
@reachability = reachability
|
|
15
16
|
@vectors = records(@evidence, :vectors)
|
|
17
|
+
@vectors_by_decision = @vectors.each_with_object(Hash.new { |hash, key| hash[key] = [] }) do |vector, hash|
|
|
18
|
+
hash[id(vector, :decision_id)] << vector
|
|
19
|
+
end
|
|
20
|
+
@decisions = records(@inventory, :decisions)
|
|
21
|
+
@decisions_by_id = @decisions.each_with_object({}) do |decision, hash|
|
|
22
|
+
key = id(decision, :id)
|
|
23
|
+
hash[key] = decision unless hash.key?(key)
|
|
24
|
+
end
|
|
16
25
|
@constraint_states = Hash.new(0)
|
|
17
26
|
@missing_cache = {}
|
|
18
27
|
@invalid_diagnostics = []
|
|
19
28
|
@analysis_invalid = false
|
|
20
29
|
@diagnostic_keys = Set.new
|
|
30
|
+
# Keyed by vector object identity (not content), so a validated vector's
|
|
31
|
+
# values are checked once and reused on every later read.
|
|
32
|
+
@values_cache = {}.compare_by_identity
|
|
21
33
|
end
|
|
22
34
|
|
|
23
35
|
def call
|
|
24
|
-
decisions =
|
|
36
|
+
decisions = @decisions.map { analyze_decision(_1) }
|
|
25
37
|
proven = decisions.sum { |decision| decision[:condition_results].count { |result| result[:status] == "PROVEN" } }
|
|
26
38
|
eligible = decisions.sum { |decision| decision[:unsupported] ? 0 : decision[:conditions].length }
|
|
27
39
|
{
|
|
@@ -35,27 +47,29 @@ module Branchproof
|
|
|
35
47
|
}.freeze
|
|
36
48
|
end
|
|
37
49
|
|
|
38
|
-
def pair?(decision_id:, condition_index:, left:, right:)
|
|
50
|
+
def pair?(decision_id:, condition_index:, left:, right:, masks: nil)
|
|
51
|
+
return false if alternative_decision_for_id?(decision_id)
|
|
39
52
|
return false unless compatible_vectors?(left, right, decision_id)
|
|
40
53
|
return false unless observed?(left, condition_index) && observed?(right, condition_index)
|
|
41
54
|
return false if value(left, condition_index) == value(right, condition_index)
|
|
42
55
|
return false if outcome(left) == outcome(right)
|
|
43
56
|
|
|
44
|
-
|
|
45
|
-
masks.all? { |mask| mask&.anybits?(1 << condition_index) }
|
|
57
|
+
[left, right].all? { |vector| masked_bits(vector, decision_id, masks)&.anybits?(1 << condition_index) }
|
|
46
58
|
end
|
|
47
59
|
|
|
48
60
|
def missing(decision_id:, condition_index:)
|
|
49
61
|
cache_key = [decision_id, condition_index]
|
|
50
62
|
return @missing_cache[cache_key] if @missing_cache.key?(cache_key)
|
|
51
63
|
|
|
52
|
-
decision =
|
|
64
|
+
decision = @decisions_by_id[decision_id]
|
|
53
65
|
return @missing_cache[cache_key] = nil unless decision
|
|
54
66
|
|
|
67
|
+
return @missing_cache[cache_key] = nil if alternative_decision?(decision)
|
|
68
|
+
|
|
55
69
|
support_status = id(decision, :support_status).to_s
|
|
56
70
|
return @missing_cache[cache_key] = nil unless support_status.empty? || support_status.upcase == "SUPPORTED"
|
|
57
71
|
|
|
58
|
-
relevant = @
|
|
72
|
+
relevant = @vectors_by_decision[decision_id]
|
|
59
73
|
valid = relevant.filter_map { valid_vector(_1, decision) }
|
|
60
74
|
if valid.empty?
|
|
61
75
|
return @missing_cache[cache_key] = {
|
|
@@ -64,14 +78,15 @@ module Branchproof
|
|
|
64
78
|
feasibility_statement: "No completed structural observation exists."
|
|
65
79
|
}
|
|
66
80
|
end
|
|
67
|
-
|
|
81
|
+
masks = valid.to_h { |vector| [id(vector, :id), effective_mask(vector, decision_id)] }
|
|
82
|
+
return @missing_cache[cache_key] = nil if first_pair(valid, condition_index, decision_id, masks)
|
|
68
83
|
|
|
69
|
-
existing = valid.find { |vector|
|
|
84
|
+
existing = valid.find { |vector| masks[id(vector, :id)].anybits?(1 << condition_index) }
|
|
70
85
|
signs = existing ? [!value(existing, condition_index)] : [true, false]
|
|
71
86
|
candidates = signs.filter_map { counterpart(decision, condition_index, _1) }
|
|
72
87
|
candidates.select! do |candidate|
|
|
73
88
|
!existing || pair?(decision_id: decision_id, condition_index: condition_index, left: existing,
|
|
74
|
-
right: candidate[:vector])
|
|
89
|
+
right: candidate[:vector], masks: masks)
|
|
75
90
|
end
|
|
76
91
|
limited = @constraint_states[id(decision, :id)] > constraint_limit
|
|
77
92
|
if limited
|
|
@@ -94,17 +109,21 @@ module Branchproof
|
|
|
94
109
|
private
|
|
95
110
|
|
|
96
111
|
def analyze_decision(decision)
|
|
112
|
+
return analyze_alternative_decision(decision) if alternative_decision?(decision)
|
|
113
|
+
|
|
97
114
|
decision_id = id(decision, :id)
|
|
98
115
|
unless id(decision, :support_status).to_s.empty? || id(decision, :support_status).to_s.upcase == "SUPPORTED"
|
|
99
116
|
return {
|
|
100
|
-
decision_id: decision_id, effective_masks_by_vector: {}, condition_results: [],
|
|
117
|
+
decision_id: decision_id, effective_masks_by_vector: {}, condition_results: [],
|
|
101
118
|
conditions: [], unsupported: true, coverage: unsupported_coverage,
|
|
119
|
+
decision_table: DecisionTable.build(decision: decision, vectors: [], limits: @limits,
|
|
120
|
+
reachability: @reachability),
|
|
102
121
|
completeness: { observation: true, attribution: true, analysis: true },
|
|
103
122
|
diagnostics: [diagnostic("unsupported_decision", "warning", decision_id, nil)]
|
|
104
123
|
}
|
|
105
124
|
end
|
|
106
125
|
|
|
107
|
-
vectors = @
|
|
126
|
+
vectors = @vectors_by_decision[decision_id].filter_map { |vector| valid_vector(vector, decision) }
|
|
108
127
|
masks = vectors.to_h { |vector| [id(vector, :id), effective_mask(vector, decision_id)] }
|
|
109
128
|
buckets = {}
|
|
110
129
|
conditions(decision).each do |condition|
|
|
@@ -127,7 +146,7 @@ module Branchproof
|
|
|
127
146
|
true_ids = buckets[index][:true]
|
|
128
147
|
false_ids = buckets[index][:false]
|
|
129
148
|
# rubocop:enable Lint/BooleanSymbol
|
|
130
|
-
pair = first_pair(vectors, index, decision_id)
|
|
149
|
+
pair = first_pair(vectors, index, decision_id, masks)
|
|
131
150
|
{
|
|
132
151
|
condition_id: id(condition, :id),
|
|
133
152
|
status: pair ? "PROVEN" : "NOT_PROVEN",
|
|
@@ -144,28 +163,98 @@ module Branchproof
|
|
|
144
163
|
decision_coverage = decision_coverage(vectors)
|
|
145
164
|
condition_coverage_summary = condition_summary(results)
|
|
146
165
|
mcdc = mcdc_coverage(results)
|
|
166
|
+
table = DecisionTable.build(decision: decision, vectors: vectors, limits: @limits,
|
|
167
|
+
reachability: @reachability)
|
|
147
168
|
{
|
|
148
169
|
decision_id: decision_id,
|
|
149
170
|
effective_masks_by_vector: masks,
|
|
150
171
|
condition_results: results,
|
|
151
|
-
witness_buckets: buckets,
|
|
152
172
|
edge_table: edge_table(decision[:tree]),
|
|
153
173
|
conditions: conditions(decision),
|
|
154
174
|
coverage: { decision: decision_coverage, condition: condition_coverage_summary,
|
|
155
175
|
condition_decision: conjunction_coverage(decision_coverage, condition_coverage_summary),
|
|
156
|
-
mcdc: mcdc },
|
|
176
|
+
mcdc: mcdc, decision_table: DecisionTable.coverage_entry(table) },
|
|
177
|
+
decision_table: table,
|
|
157
178
|
completeness: { observation: true, attribution: true, analysis: !@analysis_invalid },
|
|
158
|
-
diagnostics: []
|
|
179
|
+
diagnostics: table[:diagnostics]
|
|
159
180
|
}
|
|
160
181
|
end
|
|
161
182
|
|
|
183
|
+
def analyze_alternative_decision(decision)
|
|
184
|
+
decision_id = id(decision, :id)
|
|
185
|
+
unless id(decision, :support_status).to_s.empty? || id(decision, :support_status).to_s.upcase == "SUPPORTED"
|
|
186
|
+
return {
|
|
187
|
+
decision_id: decision_id, kind: id(decision, :kind), effective_masks_by_vector: {},
|
|
188
|
+
condition_results: [],
|
|
189
|
+
conditions: [], alternatives: alternatives(decision), unsupported: true,
|
|
190
|
+
coverage: unsupported_alternative_coverage,
|
|
191
|
+
completeness: { observation: true, attribution: true, analysis: true },
|
|
192
|
+
diagnostics: [diagnostic("unsupported_decision", "warning", decision_id, nil)]
|
|
193
|
+
}
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
vectors = @vectors_by_decision[decision_id].filter_map { |vector| valid_vector(vector, decision) }
|
|
197
|
+
rows = alternatives(decision).map do |alternative|
|
|
198
|
+
index = id(alternative, :index).to_i
|
|
199
|
+
selected = vectors.select { |vector| value(vector, index) == true }
|
|
200
|
+
not_selected = vectors.select { |vector| value(vector, index) == false }
|
|
201
|
+
skipped = vectors.select { |vector| value(vector, index).nil? }
|
|
202
|
+
{
|
|
203
|
+
alternative_id: id(alternative, :id), index: id(alternative, :index),
|
|
204
|
+
expression: id(alternative, :expression),
|
|
205
|
+
selected: alternative_evidence_bucket(selected),
|
|
206
|
+
not_selected: alternative_evidence_bucket(not_selected),
|
|
207
|
+
skipped: alternative_evidence_bucket(skipped)
|
|
208
|
+
}
|
|
209
|
+
end
|
|
210
|
+
missing = rows.filter_map { |row| row[:alternative_id] unless row[:selected][:observed] }
|
|
211
|
+
covered = rows.count { |row| row[:selected][:observed] }
|
|
212
|
+
{
|
|
213
|
+
decision_id: decision_id, kind: id(decision, :kind), effective_masks_by_vector: {},
|
|
214
|
+
condition_results: [],
|
|
215
|
+
conditions: [], alternatives: alternatives(decision), unsupported: false,
|
|
216
|
+
coverage: { alternative: { status: coverage_status(covered, rows.length), covered_alternatives: covered,
|
|
217
|
+
required_alternatives: rows.length, alternatives: rows,
|
|
218
|
+
missing_alternatives: missing },
|
|
219
|
+
mcdc: { status: "not_applicable" } },
|
|
220
|
+
completeness: { observation: true, attribution: true, analysis: !@analysis_invalid }, diagnostics: []
|
|
221
|
+
}
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def unsupported_alternative_coverage
|
|
225
|
+
{ alternative: { status: "unsupported", covered_alternatives: 0, required_alternatives: 0,
|
|
226
|
+
alternatives: [], missing_alternatives: [] }, mcdc: { status: "unsupported" } }
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def alternative_evidence_bucket(vectors)
|
|
230
|
+
evidence = vectors.map { |vector| provenance(vector) }
|
|
231
|
+
{ observed: !vectors.empty?, vector_ids: evidence.map { |item| item[:vector_id] }.uniq.sort,
|
|
232
|
+
test_ids: evidence.flat_map { |item| item[:test_ids] }.uniq.sort,
|
|
233
|
+
unattributed_count: evidence.sum { |item| item[:unattributed_count] } }
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def alternative_decision?(decision)
|
|
237
|
+
kind = id(decision, :kind).to_s
|
|
238
|
+
!kind.empty? && kind != "boolean"
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def alternative_decision_for_id?(decision_id)
|
|
242
|
+
decision = @decisions_by_id[decision_id]
|
|
243
|
+
decision && alternative_decision?(decision)
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def alternatives(decision)
|
|
247
|
+
records(decision, :alternatives)
|
|
248
|
+
end
|
|
249
|
+
|
|
162
250
|
def unsupported_coverage
|
|
163
251
|
{ decision: { status: "unsupported", true_observed: false, false_observed: false,
|
|
164
252
|
covered_outcomes: 0, required_outcomes: 2, outcomes: [], missing_outcomes: [] },
|
|
165
253
|
condition: { status: "unsupported", covered_values: 0, required_values: 0,
|
|
166
254
|
covered_conditions: 0, condition_count: 0 },
|
|
167
255
|
condition_decision: { status: "unsupported" },
|
|
168
|
-
mcdc: { status: "unsupported", proven_conditions: 0, condition_count: 0 }
|
|
256
|
+
mcdc: { status: "unsupported", proven_conditions: 0, condition_count: 0 },
|
|
257
|
+
decision_table: DecisionTable.unsupported_coverage_entry }
|
|
169
258
|
end
|
|
170
259
|
|
|
171
260
|
def provenance(vector)
|
|
@@ -254,29 +343,57 @@ module Branchproof
|
|
|
254
343
|
|
|
255
344
|
def aggregate_coverage(decisions)
|
|
256
345
|
supported = decisions.reject { |decision| decision[:unsupported] }
|
|
257
|
-
|
|
346
|
+
boolean_supported = supported.reject { |decision| alternative_decision?(decision) }
|
|
347
|
+
decision_covered = boolean_supported.count do |decision|
|
|
258
348
|
decision[:coverage][:decision][:status] == "covered"
|
|
259
349
|
end
|
|
260
|
-
condition_decision_covered =
|
|
350
|
+
condition_decision_covered = boolean_supported.count do |decision|
|
|
261
351
|
decision[:coverage][:condition_decision][:status] == "covered"
|
|
262
352
|
end
|
|
263
|
-
condition_count =
|
|
264
|
-
condition_values =
|
|
265
|
-
covered_conditions =
|
|
353
|
+
condition_count = boolean_supported.sum { |decision| decision[:coverage][:condition][:condition_count] }
|
|
354
|
+
condition_values = boolean_supported.sum { |decision| decision[:coverage][:condition][:covered_values] }
|
|
355
|
+
covered_conditions = boolean_supported.sum do |decision|
|
|
266
356
|
decision[:coverage][:condition][:covered_conditions]
|
|
267
357
|
end
|
|
268
|
-
proven =
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
percentage: percentage.call(decision_covered, supported.length) },
|
|
358
|
+
proven = boolean_supported.sum { |decision| decision[:coverage][:mcdc][:proven_conditions] }
|
|
359
|
+
{ decision: { covered_decisions: decision_covered, supported_decisions: boolean_supported.length,
|
|
360
|
+
percentage: DecisionTable.percentage(decision_covered, boolean_supported.length) },
|
|
272
361
|
condition: { covered_values: condition_values, required_values: condition_count * 2,
|
|
273
362
|
covered_conditions: covered_conditions, condition_count: condition_count,
|
|
274
|
-
percentage: percentage
|
|
363
|
+
percentage: DecisionTable.percentage(condition_values, condition_count * 2) },
|
|
275
364
|
condition_decision: { covered_decisions: condition_decision_covered,
|
|
276
|
-
supported_decisions:
|
|
277
|
-
percentage: percentage
|
|
365
|
+
supported_decisions: boolean_supported.length,
|
|
366
|
+
percentage: DecisionTable.percentage(condition_decision_covered,
|
|
367
|
+
boolean_supported.length) },
|
|
278
368
|
mcdc: { proven_conditions: proven, supported_conditions: condition_count,
|
|
279
|
-
percentage: percentage
|
|
369
|
+
percentage: DecisionTable.percentage(proven, condition_count) },
|
|
370
|
+
decision_table: decision_table_aggregate(boolean_supported),
|
|
371
|
+
alternative: alternative_aggregate(decisions) }
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
# Rule coverage and fully covered decisions stay separate metrics: a
|
|
375
|
+
# decision can hold most of its rules and still fail the criterion.
|
|
376
|
+
def decision_table_aggregate(boolean_supported)
|
|
377
|
+
analyzed = boolean_supported.select { |decision| decision.dig(:decision_table, :status) == "calculated" }
|
|
378
|
+
not_calculated = boolean_supported.length - analyzed.length
|
|
379
|
+
covered = analyzed.sum { |decision| decision.dig(:decision_table, :covered_rules).to_i }
|
|
380
|
+
required = analyzed.sum { |decision| decision.dig(:decision_table, :required_rules).to_i }
|
|
381
|
+
generated = analyzed.sum { |decision| decision.dig(:decision_table, :generated_rules).to_i }
|
|
382
|
+
impossible = analyzed.sum { |decision| decision.dig(:decision_table, :impossible_rules).to_i }
|
|
383
|
+
fully_covered = analyzed.count { |decision| decision.dig(:decision_table, :coverage_status) == "covered" }
|
|
384
|
+
{ decisions_analyzed: analyzed.length, not_calculated_decisions: not_calculated,
|
|
385
|
+
fully_covered_decisions: fully_covered, covered_rules: covered, required_rules: required,
|
|
386
|
+
generated_rules: generated, impossible_rules: impossible,
|
|
387
|
+
percentage: DecisionTable.percentage(covered, required),
|
|
388
|
+
decision_percentage: DecisionTable.percentage(fully_covered, analyzed.length) }
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
def alternative_aggregate(decisions)
|
|
392
|
+
flow = decisions.select { |decision| !decision[:unsupported] && alternative_decision?(decision) }
|
|
393
|
+
required = flow.sum { |decision| decision.dig(:coverage, :alternative, :required_alternatives).to_i }
|
|
394
|
+
covered = flow.sum { |decision| decision.dig(:coverage, :alternative, :covered_alternatives).to_i }
|
|
395
|
+
{ covered_alternatives: covered, required_alternatives: required,
|
|
396
|
+
supported_decisions: flow.length, percentage: DecisionTable.percentage(covered, required) }
|
|
280
397
|
end
|
|
281
398
|
|
|
282
399
|
def valid_vector(vector, decision)
|
|
@@ -308,7 +425,11 @@ module Branchproof
|
|
|
308
425
|
return nil
|
|
309
426
|
end
|
|
310
427
|
|
|
311
|
-
|
|
428
|
+
if alternative_decision?(decision)
|
|
429
|
+
handle_alternative_vector(vector, decision)
|
|
430
|
+
else
|
|
431
|
+
effective_mask(vector, id(decision, :id), decision[:tree])
|
|
432
|
+
end
|
|
312
433
|
decision_source.nil? ? vector : vector.merge(source_id: decision_source)
|
|
313
434
|
rescue ArgumentError
|
|
314
435
|
@analysis_invalid = true
|
|
@@ -316,8 +437,38 @@ module Branchproof
|
|
|
316
437
|
nil
|
|
317
438
|
end
|
|
318
439
|
|
|
440
|
+
# rubocop:disable-next Naming/PredicateMethod -- raises on malformed flow vectors
|
|
441
|
+
def handle_alternative_vector(vector, decision)
|
|
442
|
+
values = values_for(vector)
|
|
443
|
+
expected = alternatives(decision).length
|
|
444
|
+
raise ArgumentError, "invalid alternative vector shape" unless values.length == expected
|
|
445
|
+
|
|
446
|
+
observations = values.each_with_index.filter_map { |item, index| [index, item] unless item.nil? }
|
|
447
|
+
raise ArgumentError, "invalid alternative trace" unless valid_alternative_trace?(decision, observations,
|
|
448
|
+
outcome(vector))
|
|
449
|
+
|
|
450
|
+
true
|
|
451
|
+
end
|
|
452
|
+
|
|
453
|
+
def valid_alternative_trace?(decision, observations, result)
|
|
454
|
+
return false unless result == true
|
|
455
|
+
|
|
456
|
+
expected = alternatives(decision).length
|
|
457
|
+
return false unless expected.positive?
|
|
458
|
+
if id(decision, :kind).to_s == "implicit"
|
|
459
|
+
return expected == 2 && observations.length == 2 &&
|
|
460
|
+
observations.map(&:first) == [0, 1] && observations.map(&:last).count(true) == 1
|
|
461
|
+
end
|
|
462
|
+
|
|
463
|
+
return false unless observations.length.between?(1, expected)
|
|
464
|
+
|
|
465
|
+
observations.each_with_index.all? do |(index, value), position|
|
|
466
|
+
index == position && value == (position == observations.length - 1)
|
|
467
|
+
end
|
|
468
|
+
end
|
|
469
|
+
|
|
319
470
|
def effective_mask(vector, decision_id, tree = nil)
|
|
320
|
-
decision =
|
|
471
|
+
decision = @decisions_by_id[decision_id] unless tree
|
|
321
472
|
tree ||= decision && decision[:tree]
|
|
322
473
|
raise ArgumentError, "unknown decision" unless tree
|
|
323
474
|
|
|
@@ -332,6 +483,15 @@ module Branchproof
|
|
|
332
483
|
mask
|
|
333
484
|
end
|
|
334
485
|
|
|
486
|
+
# Returns the effective mask for a vector, reusing a precomputed
|
|
487
|
+
# vector-id => mask hash when the caller has one, instead of
|
|
488
|
+
# replaying the whole Boolean tree again.
|
|
489
|
+
def masked_bits(vector, decision_id, masks)
|
|
490
|
+
return effective_mask(vector, decision_id) unless masks
|
|
491
|
+
|
|
492
|
+
masks[id(vector, :id)] || effective_mask(vector, decision_id)
|
|
493
|
+
end
|
|
494
|
+
|
|
335
495
|
def replay(node, values, offset)
|
|
336
496
|
type = id(node, :type).to_sym
|
|
337
497
|
if type == :atom
|
|
@@ -341,6 +501,10 @@ module Branchproof
|
|
|
341
501
|
|
|
342
502
|
return [value, 1 << index, offset + 1]
|
|
343
503
|
end
|
|
504
|
+
if type == :not
|
|
505
|
+
child_result, child_mask, consumed = replay(node.fetch(:child), values, offset)
|
|
506
|
+
return [!child_result, child_mask, consumed]
|
|
507
|
+
end
|
|
344
508
|
left_result, left_mask, consumed = replay(node.fetch(:left), values, offset)
|
|
345
509
|
return [left_result, left_mask, consumed] if (type == :and && !left_result) || (type == :or && left_result)
|
|
346
510
|
|
|
@@ -352,9 +516,9 @@ module Branchproof
|
|
|
352
516
|
end
|
|
353
517
|
end
|
|
354
518
|
|
|
355
|
-
def first_pair(vectors, index, decision_id)
|
|
519
|
+
def first_pair(vectors, index, decision_id, masks = nil)
|
|
356
520
|
observed_vectors = vectors.select do |vector|
|
|
357
|
-
observed?(vector, index) &&
|
|
521
|
+
observed?(vector, index) && masked_bits(vector, decision_id, masks)&.anybits?(1 << index)
|
|
358
522
|
end
|
|
359
523
|
observed_vectors.sort_by! { |vector| id(vector, :id).to_s }
|
|
360
524
|
grouped = observed_vectors.group_by do |vector|
|
|
@@ -371,7 +535,9 @@ module Branchproof
|
|
|
371
535
|
|
|
372
536
|
left = left_vectors.first
|
|
373
537
|
right = right_vectors.first
|
|
374
|
-
|
|
538
|
+
if pair?(decision_id: decision_id, condition_index: index, left: left, right: right, masks: masks)
|
|
539
|
+
return [left, right]
|
|
540
|
+
end
|
|
375
541
|
end
|
|
376
542
|
nil
|
|
377
543
|
end
|
|
@@ -440,6 +606,8 @@ module Branchproof
|
|
|
440
606
|
return { entry: entry, true_exits: [edges[0]], false_exits: [edges[1]], all_bits: 1 << index, edges: edges }
|
|
441
607
|
end
|
|
442
608
|
type = id(node, :type).to_sym
|
|
609
|
+
return build_graph(node.fetch(:child), false_destination, true_destination) if type == :not
|
|
610
|
+
|
|
443
611
|
right = build_graph(node[:right], true_destination, false_destination)
|
|
444
612
|
if type == :and
|
|
445
613
|
left = build_graph(node[:left], right[:entry], false_destination)
|
|
@@ -455,7 +623,9 @@ module Branchproof
|
|
|
455
623
|
end
|
|
456
624
|
|
|
457
625
|
def impose_path(node, target, target_value, constraints)
|
|
458
|
-
|
|
626
|
+
type = id(node, :type).to_sym
|
|
627
|
+
return true if type == :atom && id(node, :index) == target
|
|
628
|
+
return impose_path(node.fetch(:child), target, target_value, constraints) if type == :not
|
|
459
629
|
|
|
460
630
|
left = node[:left]
|
|
461
631
|
right = node[:right]
|
|
@@ -480,6 +650,8 @@ module Branchproof
|
|
|
480
650
|
|
|
481
651
|
constraints << { condition_index: id(node, :index), value: desired }
|
|
482
652
|
true
|
|
653
|
+
elsif id(node, :type).to_sym == :not
|
|
654
|
+
impose_subtree(node.fetch(:child), !desired, constraints)
|
|
483
655
|
elsif desired == (id(node, :type).to_sym == :and)
|
|
484
656
|
impose_subtree(node[:left], desired, constraints) && impose_subtree(node[:right], desired, constraints)
|
|
485
657
|
else
|
|
@@ -487,22 +659,17 @@ module Branchproof
|
|
|
487
659
|
end
|
|
488
660
|
end
|
|
489
661
|
|
|
490
|
-
def evaluate(node, values)
|
|
491
|
-
return values[id(node, :index)] if id(node, :type).to_sym == :atom
|
|
492
|
-
|
|
493
|
-
left = evaluate(node[:left], values)
|
|
494
|
-
return left if id(node, :type).to_sym == :and && !left
|
|
495
|
-
return left if id(node, :type).to_sym == :or && left
|
|
496
|
-
|
|
497
|
-
evaluate(node[:right], values)
|
|
498
|
-
end
|
|
499
|
-
|
|
500
662
|
def evaluate_with_trace(node, values, trace = [])
|
|
501
|
-
|
|
663
|
+
type = id(node, :type).to_sym
|
|
664
|
+
if type == :atom
|
|
502
665
|
value = !values[id(node, :index)].nil? && values[id(node, :index)] != false
|
|
503
666
|
trace << [id(node, :index), value]
|
|
504
667
|
return [value, trace]
|
|
505
668
|
end
|
|
669
|
+
if type == :not
|
|
670
|
+
child, = evaluate_with_trace(node.fetch(:child), values, trace)
|
|
671
|
+
return [!child, trace]
|
|
672
|
+
end
|
|
506
673
|
left, = evaluate_with_trace(node[:left], values, trace)
|
|
507
674
|
return [left, trace] if id(node, :type).to_sym == :and && !left
|
|
508
675
|
return [left, trace] if id(node, :type).to_sym == :or && left
|
|
@@ -511,24 +678,25 @@ module Branchproof
|
|
|
511
678
|
end
|
|
512
679
|
|
|
513
680
|
def contains?(node, target)
|
|
514
|
-
if id(node,
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
681
|
+
if id(node, :type).to_sym == :atom
|
|
682
|
+
id(node, :index) == target
|
|
683
|
+
elsif id(node, :type).to_sym == :not
|
|
684
|
+
contains?(node.fetch(:child), target)
|
|
518
685
|
else
|
|
519
|
-
contains?(node[:left],
|
|
520
|
-
target) || contains?(node[:right], target)
|
|
686
|
+
contains?(node[:left], target) || contains?(node[:right], target)
|
|
521
687
|
end
|
|
522
688
|
end
|
|
523
689
|
|
|
524
690
|
def values_for(vector)
|
|
691
|
+
return @values_cache[vector] if @values_cache.key?(vector)
|
|
692
|
+
|
|
525
693
|
raw = vector[:values] || vector["values"]
|
|
526
694
|
raise ArgumentError, "observation must contain booleans or nil" unless raw.is_a?(Array)
|
|
527
695
|
unless raw.all? { |value| value.nil? || value == true || value == false }
|
|
528
696
|
raise ArgumentError, "observation must contain booleans or nil"
|
|
529
697
|
end
|
|
530
698
|
|
|
531
|
-
raw
|
|
699
|
+
@values_cache[vector] = raw
|
|
532
700
|
end
|
|
533
701
|
|
|
534
702
|
def outcome(vector) = vector[:outcome].nil? ? vector["outcome"] : vector[:outcome]
|
data/lib/branchproof/cli.rb
CHANGED
|
@@ -15,6 +15,9 @@ require "time"
|
|
|
15
15
|
module Branchproof
|
|
16
16
|
# Coordinates source inventory, isolated test execution, and report output.
|
|
17
17
|
class CLI
|
|
18
|
+
VIEWS = Report::VIEWS.to_h { |view| [view.to_s.tr("_", "-"), view] }
|
|
19
|
+
.merge("decision_tables" => :decision_tables).freeze
|
|
20
|
+
|
|
18
21
|
def initialize(stdout:, stderr:)
|
|
19
22
|
@stdout = stdout
|
|
20
23
|
@stderr = stderr
|
|
@@ -25,7 +28,7 @@ module Branchproof
|
|
|
25
28
|
return help if [["--help"], ["help"], ["analyze", "--help"]].include?(argv)
|
|
26
29
|
return offline(argv) if %w[report compare].include?(argv.first)
|
|
27
30
|
|
|
28
|
-
options = parse(
|
|
31
|
+
options = parse(argv)
|
|
29
32
|
return usage_error("expected analyze, report, or compare; use branchproof --help") unless options
|
|
30
33
|
|
|
31
34
|
inventory = build_inventory(options)
|
|
@@ -49,30 +52,29 @@ module Branchproof
|
|
|
49
52
|
message: merge_status[:reason].to_s }]
|
|
50
53
|
end
|
|
51
54
|
end
|
|
55
|
+
snapshot = evidence.snapshot
|
|
52
56
|
if value(baseline, :status).to_s == "PASSED"
|
|
53
|
-
analysis = Analyzer.new(inventory: inventory, evidence:
|
|
57
|
+
analysis = Analyzer.new(inventory: inventory, evidence: snapshot, limits: options[:limits],
|
|
58
|
+
reachability: options[:reachability]).call
|
|
54
59
|
baseline[:analysis] = analysis
|
|
55
60
|
if options[:level] >= 2
|
|
56
61
|
ids = Array(value(inventory, :decisions)).map { |decision| value(decision, :id) }
|
|
62
|
+
minimizer = Minimizer.new(analysis: analysis, evidence: snapshot, limits: options[:limits])
|
|
57
63
|
baseline[:minima] = ids.filter_map do |decision_id|
|
|
58
|
-
|
|
64
|
+
minimizer.call(objective: :vectors, decision_ids: [decision_id])
|
|
59
65
|
end
|
|
60
66
|
baseline[:minima] += ids.filter_map do |decision_id|
|
|
61
|
-
|
|
62
|
-
objective: :tests, decision_ids: [decision_id]
|
|
63
|
-
)
|
|
67
|
+
minimizer.call(objective: :tests, decision_ids: [decision_id])
|
|
64
68
|
end
|
|
65
|
-
baseline[:minima] <<
|
|
66
|
-
objective: :tests, decision_ids: ids
|
|
67
|
-
)
|
|
69
|
+
baseline[:minima] << minimizer.call(objective: :tests, decision_ids: ids)
|
|
68
70
|
end
|
|
69
71
|
end
|
|
70
72
|
diagnostics = Array(value(inventory, :diagnostics)) + Array(value(baseline, :diagnostics)) +
|
|
71
|
-
Array(value(
|
|
73
|
+
Array(value(snapshot,
|
|
72
74
|
:diagnostics)) + Array(value(value(baseline, :analysis), :diagnostics))
|
|
73
75
|
analysis = value(baseline, :analysis)
|
|
74
76
|
minima = options[:level] == 1 ? [] : Array(value(baseline, :minima))
|
|
75
|
-
report = Report.new(inventory: inventory, evidence: value(baseline, :evidence) ||
|
|
77
|
+
report = Report.new(inventory: inventory, evidence: value(baseline, :evidence) || snapshot,
|
|
76
78
|
analysis: analysis, minima: minima, baseline: baseline, diagnostics: diagnostics,
|
|
77
79
|
level: options[:level], missing_only: options[:missing_only], view: options[:view],
|
|
78
80
|
run_metadata: run_metadata(options, baseline))
|
|
@@ -92,21 +94,24 @@ module Branchproof
|
|
|
92
94
|
@stdout.write(<<~HELP)
|
|
93
95
|
Usage:
|
|
94
96
|
branchproof analyze [SOURCE_GLOB ...] [--test TEST_GLOB] [--project auto|ruby|rails]
|
|
95
|
-
[--view decisions|conditions|tests] [--level 1|2|3] [--missing-only]
|
|
96
|
-
[--format terminal|json] [--output PATH] [--limits PATH] [-- RUNNER_ARGS]
|
|
97
|
-
branchproof report SNAPSHOT [--view decisions|conditions|tests
|
|
98
|
-
[--missing-only] [--format terminal|json] [--output PATH]
|
|
97
|
+
[--view decisions|conditions|tests|decision-tables] [--level 1|2|3] [--missing-only]
|
|
98
|
+
[--format terminal|json] [--output PATH] [--limits PATH] [--no-reachability] [-- RUNNER_ARGS]
|
|
99
|
+
branchproof report SNAPSHOT [--view decisions|conditions|tests|decision-tables]
|
|
100
|
+
[--level 1|2|3] [--missing-only] [--format terminal|json] [--output PATH]
|
|
99
101
|
branchproof compare BEFORE AFTER [--format terminal|json] [--output PATH] [--fail-on-regression]
|
|
100
102
|
mcdc accepts the same commands as a compatibility alias.
|
|
101
103
|
JSON always contains full evidence; --view requires terminal output.
|
|
104
|
+
decision_tables is also accepted as an alias for the decision-tables view.
|
|
105
|
+
--no-reachability keeps every generated decision-table rule as a coverage obligation.
|
|
102
106
|
HELP
|
|
103
107
|
0
|
|
104
108
|
end
|
|
105
109
|
|
|
106
110
|
def parse_view(view)
|
|
107
|
-
|
|
111
|
+
resolved = VIEWS[view.to_s]
|
|
112
|
+
raise ArgumentError, "view must be decisions, conditions, tests, or decision-tables" unless resolved
|
|
108
113
|
|
|
109
|
-
|
|
114
|
+
resolved
|
|
110
115
|
end
|
|
111
116
|
|
|
112
117
|
def validate_view!(options)
|
|
@@ -204,7 +209,8 @@ module Branchproof
|
|
|
204
209
|
project_root: root, source_patterns: options[:source_patterns].map { |path| relative_path(path, root) },
|
|
205
210
|
test_patterns: options[:test_patterns].map { |path| relative_path(path, root) },
|
|
206
211
|
test_files: options[:tests].map { |path| relative_path(path, root) }, runner_args: options[:runner_args],
|
|
207
|
-
seed: value(baseline, :seed), limits: options[:limits], test_locations: locations
|
|
212
|
+
seed: value(baseline, :seed), limits: options[:limits], test_locations: locations,
|
|
213
|
+
reachability: options[:reachability] }
|
|
208
214
|
end
|
|
209
215
|
|
|
210
216
|
def relative_path(path, root)
|
|
@@ -221,7 +227,8 @@ module Branchproof
|
|
|
221
227
|
runner_args = delimiter ? args[(delimiter + 1)..] : []
|
|
222
228
|
args = args[0...delimiter] if delimiter
|
|
223
229
|
options = { level: 3, format: :terminal, output: nil, tests: [], source_paths: [], limits: Limits.default,
|
|
224
|
-
runner_args: runner_args, project: nil, missing_only: false, view: :decisions
|
|
230
|
+
runner_args: runner_args, project: nil, missing_only: false, view: :decisions,
|
|
231
|
+
reachability: true }
|
|
225
232
|
until args.empty?
|
|
226
233
|
token = args.shift
|
|
227
234
|
case token
|
|
@@ -230,6 +237,8 @@ module Branchproof
|
|
|
230
237
|
options[:explicit_view] = true
|
|
231
238
|
when "--missing-only"
|
|
232
239
|
options[:missing_only] = true
|
|
240
|
+
when "--no-reachability"
|
|
241
|
+
options[:reachability] = false
|
|
233
242
|
when "--level"
|
|
234
243
|
level = Integer(args.shift.to_s, 10)
|
|
235
244
|
raise ArgumentError, "level must be 1, 2, or 3" unless (1..3).cover?(level)
|