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/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,25 +202,24 @@ 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
|
-
|
|
212
|
-
return "condition count exceeds limit" if
|
|
218
|
+
dimensions = alternative_decision?(decision) ? Array(decision[:alternatives]) : Array(decision[:conditions])
|
|
219
|
+
return "condition count exceeds limit" if !alternative_decision?(decision) && dimensions.length > @limits[:conditions_per_decision]
|
|
213
220
|
return "invalid condition index" unless value[:observations].map(&:first).uniq == value[:observations].map(&:first) && value[:observations].all? do |index, _|
|
|
214
|
-
|
|
215
|
-
|
|
221
|
+
dimensions.any? do |dimension|
|
|
222
|
+
dimension[:index].to_i == index
|
|
216
223
|
end
|
|
217
224
|
end
|
|
218
225
|
return "invalid trace" unless value[:status].to_s != "completed" || valid_trace?(decision, value[:observations],
|
|
@@ -222,7 +229,9 @@ module Branchproof
|
|
|
222
229
|
end
|
|
223
230
|
|
|
224
231
|
def valid_trace?(decision, observations, outcome)
|
|
225
|
-
|
|
232
|
+
return valid_alternative_trace?(decision, observations, outcome) if alternative_decision?(decision)
|
|
233
|
+
|
|
234
|
+
tree = decision[:tree]
|
|
226
235
|
unless tree
|
|
227
236
|
return observations.map(&:first) == observations.map(&:first).sort &&
|
|
228
237
|
(outcome ? true : false) == evaluate_fallback(observations)
|
|
@@ -237,7 +246,6 @@ module Branchproof
|
|
|
237
246
|
end
|
|
238
247
|
|
|
239
248
|
def replay_tree(node, observations, cursor)
|
|
240
|
-
node = symbolize(node)
|
|
241
249
|
type = node[:type].to_s
|
|
242
250
|
if type == "atom"
|
|
243
251
|
pair = observations[cursor]
|
|
@@ -245,6 +253,13 @@ module Branchproof
|
|
|
245
253
|
|
|
246
254
|
return [pair[1], cursor + 1]
|
|
247
255
|
end
|
|
256
|
+
if type == "not"
|
|
257
|
+
child = replay_tree(node.fetch(:child), observations, cursor)
|
|
258
|
+
return nil unless child
|
|
259
|
+
|
|
260
|
+
child_value, next_cursor = child
|
|
261
|
+
return [!child_value, next_cursor]
|
|
262
|
+
end
|
|
248
263
|
left = replay_tree(node.fetch(:left), observations, cursor)
|
|
249
264
|
return nil unless left
|
|
250
265
|
|
|
@@ -265,32 +280,48 @@ module Branchproof
|
|
|
265
280
|
end
|
|
266
281
|
|
|
267
282
|
def condition_values(decision_id, observations)
|
|
268
|
-
count =
|
|
283
|
+
count = decision_dimension_count(decision_id)
|
|
269
284
|
values = Array.new(count)
|
|
270
285
|
observations.each { |index, value| values[index] = value }
|
|
271
286
|
values
|
|
272
287
|
end
|
|
273
288
|
|
|
274
|
-
def vector_for(decision_id, values, outcome)
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
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
|
|
278
298
|
end
|
|
279
299
|
|
|
280
300
|
def merge_vector(raw)
|
|
281
301
|
vector = symbolize(raw)
|
|
282
|
-
|
|
302
|
+
id = vector[:id].to_s
|
|
303
|
+
existing = @vectors[id]
|
|
283
304
|
if existing
|
|
284
|
-
|
|
305
|
+
new_test_ids = Array(vector[:test_ids]).map(&:to_s) - existing[:test_ids]
|
|
306
|
+
existing[:test_ids] |= new_test_ids
|
|
285
307
|
vector.fetch(:phases_by_test, {}).each do |test, phases|
|
|
286
308
|
existing[:phases_by_test][test.to_s] = (existing[:phases_by_test][test.to_s] || []) | phases
|
|
287
309
|
end
|
|
310
|
+
was_unattributed = existing[:unattributed_count].positive?
|
|
288
311
|
existing[:count] += vector[:count].to_i
|
|
289
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?
|
|
290
315
|
else
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
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?
|
|
294
325
|
end
|
|
295
326
|
end
|
|
296
327
|
|
|
@@ -305,10 +336,14 @@ module Branchproof
|
|
|
305
336
|
end
|
|
306
337
|
|
|
307
338
|
def condition_shapes
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
339
|
+
@decisions_by_id.transform_values do |decision|
|
|
340
|
+
shape = { conditions: Array(decision[:conditions]).map { |condition| symbolize(condition) },
|
|
341
|
+
tree: symbolize(decision[:tree]) }
|
|
342
|
+
if alternative_decision?(decision)
|
|
343
|
+
shape[:kind] = decision[:kind].to_s
|
|
344
|
+
shape[:alternatives] = Array(decision[:alternatives]).map { |alternative| symbolize(alternative) }
|
|
345
|
+
end
|
|
346
|
+
shape
|
|
312
347
|
end
|
|
313
348
|
end
|
|
314
349
|
|
|
@@ -335,9 +370,11 @@ module Branchproof
|
|
|
335
370
|
vector = symbolize(raw)
|
|
336
371
|
return "invalid vector" unless vector[:id] && vector[:decision_id] && vector[:values].is_a?(Array)
|
|
337
372
|
|
|
338
|
-
decision =
|
|
373
|
+
decision = @decisions_by_id[vector[:decision_id].to_s]
|
|
339
374
|
return "unknown decision" unless decision
|
|
340
|
-
|
|
375
|
+
|
|
376
|
+
expected_values = decision_dimension_count(decision)
|
|
377
|
+
return "invalid vector shape" unless vector[:values].length == expected_values &&
|
|
341
378
|
vector[:values].all? { |item| item.nil? || item == true || item == false }
|
|
342
379
|
|
|
343
380
|
expected = Branchproof::Records.id([vector[:decision_id].to_s, vector[:values], vector[:outcome] ? true : false])
|
|
@@ -375,17 +412,42 @@ module Branchproof
|
|
|
375
412
|
nil
|
|
376
413
|
end
|
|
377
414
|
|
|
378
|
-
def
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
415
|
+
def alternative_decision?(decision)
|
|
416
|
+
kind = decision[:kind].to_s
|
|
417
|
+
!kind.empty? && kind != "boolean"
|
|
418
|
+
end
|
|
419
|
+
|
|
420
|
+
def decision_dimension_count(decision_or_id)
|
|
421
|
+
decision = decision_or_id.is_a?(Hash) ? decision_or_id : @decisions_by_id[decision_or_id.to_s]
|
|
422
|
+
return 0 unless decision
|
|
382
423
|
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
424
|
+
alternative_decision?(decision) ? Array(decision[:alternatives]).length : Array(decision[:conditions]).length
|
|
425
|
+
end
|
|
426
|
+
|
|
427
|
+
def valid_alternative_trace?(decision, observations, outcome)
|
|
428
|
+
return false unless outcome == true
|
|
429
|
+
|
|
430
|
+
alternatives = Array(decision[:alternatives])
|
|
431
|
+
expected = alternatives.length
|
|
432
|
+
return false unless expected.positive?
|
|
433
|
+
if decision[:kind].to_s == "implicit"
|
|
434
|
+
return expected == 2 && observations.length == 2 &&
|
|
435
|
+
observations.map(&:first) == [0, 1] && observations.map(&:last).count(true) == 1
|
|
436
|
+
end
|
|
437
|
+
|
|
438
|
+
return false unless observations.length.between?(1, expected)
|
|
439
|
+
|
|
440
|
+
observations.each_with_index.all? do |(index, value), position|
|
|
441
|
+
index == position && value == (position == observations.length - 1)
|
|
386
442
|
end
|
|
387
443
|
end
|
|
388
444
|
|
|
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)
|
|
448
|
+
|
|
449
|
+
def owner_associations = @owner_associations_count
|
|
450
|
+
|
|
389
451
|
def incoming_owner_associations(incoming)
|
|
390
452
|
owners = @vectors.each_with_object({}) do |(_id, vector), result|
|
|
391
453
|
vector[:test_ids].each { |test_id| result[[vector[:id], test_id]] = true }
|
|
@@ -455,7 +517,8 @@ module Branchproof
|
|
|
455
517
|
def capture_state
|
|
456
518
|
{ vectors: deep_dup(@vectors), tests: deep_dup(@tests), run_ids: @run_ids.dup,
|
|
457
519
|
run_payloads: @run_payloads.dup, diagnostics: deep_dup(@diagnostics),
|
|
458
|
-
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 }
|
|
459
522
|
end
|
|
460
523
|
|
|
461
524
|
def restore_state(state)
|
|
@@ -465,6 +528,8 @@ module Branchproof
|
|
|
465
528
|
@run_payloads = state[:run_payloads]
|
|
466
529
|
@diagnostics = state[:diagnostics]
|
|
467
530
|
@abort_counts = state[:abort_counts]
|
|
531
|
+
@vector_counts_by_decision = state[:vector_counts_by_decision]
|
|
532
|
+
@owner_associations_count = state[:owner_associations_count]
|
|
468
533
|
@limited = state[:limited]
|
|
469
534
|
@attribution_complete = state[:attribution_complete]
|
|
470
535
|
end
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Keep each bounded source rewrite together so its evaluation order can be audited.
|
|
4
|
+
# rubocop:disable Metrics/AbcSize, Metrics/MethodLength
|
|
5
|
+
|
|
6
|
+
module Branchproof
|
|
7
|
+
# Source-location edits around Ruby's native matching and assignment operations.
|
|
8
|
+
module FlowInstrumentation
|
|
9
|
+
private
|
|
10
|
+
|
|
11
|
+
def render_flow(bytes, decision, nested, encloses)
|
|
12
|
+
metadata = decision.fetch(:instrumentation)
|
|
13
|
+
case metadata.fetch(:type)
|
|
14
|
+
when "safe_navigation"
|
|
15
|
+
receiver = metadata.fetch(:receiver)
|
|
16
|
+
replacements = [flow_replacement(bytes, receiver, nested, encloses) do |expression|
|
|
17
|
+
"#{self.class::RUNTIME}.flow_receiver(#{decision[:id].inspect}, (#{expression}))"
|
|
18
|
+
end]
|
|
19
|
+
flow_fragments(bytes, decision, nested, replacements, encloses)
|
|
20
|
+
when "assignment"
|
|
21
|
+
rhs = metadata.fetch(:rhs)
|
|
22
|
+
replacements = [flow_replacement(bytes, rhs, nested, encloses) do |expression|
|
|
23
|
+
"(begin; #{self.class::RUNTIME}.flow_path(#{decision[:id].inspect}, 1); (#{expression}); end)"
|
|
24
|
+
end]
|
|
25
|
+
expression = flow_fragments(bytes, decision, nested, replacements, encloses)
|
|
26
|
+
flow_frame(decision[:id], expression, default_path: 0)
|
|
27
|
+
when "case"
|
|
28
|
+
render_case_flow(bytes, decision, nested, metadata, encloses)
|
|
29
|
+
when "case_match"
|
|
30
|
+
render_pattern_flow(bytes, decision, nested, metadata, encloses)
|
|
31
|
+
else
|
|
32
|
+
raise ArgumentError, "unknown instrumentation type: #{metadata[:type]}"
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def render_case_flow(bytes, decision, nested, metadata, encloses)
|
|
37
|
+
identifier = decision[:id].inspect
|
|
38
|
+
runtime = self.class::RUNTIME
|
|
39
|
+
replacements = metadata.fetch(:candidates).map do |candidate|
|
|
40
|
+
flow_replacement(bytes, candidate, nested, encloses) do |expression|
|
|
41
|
+
"(begin; #{runtime}.flow_candidate(#{identifier}, #{candidate[:index]}); (#{expression}); end)"
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
metadata.fetch(:branches).each do |branch|
|
|
45
|
+
suffix = branch[:empty] ? "nil; " : ""
|
|
46
|
+
replacements << { start: branch[:insert_at], length: 0,
|
|
47
|
+
text: "; #{runtime}.flow_selected(#{identifier}); #{suffix}" }
|
|
48
|
+
end
|
|
49
|
+
if metadata[:else]
|
|
50
|
+
alternative = metadata[:else]
|
|
51
|
+
replacements << { start: alternative[:insert_at], length: 0,
|
|
52
|
+
text: "; #{runtime}.flow_select(#{identifier}, #{alternative[:index]}); " }
|
|
53
|
+
else
|
|
54
|
+
index = decision.fetch(:alternatives).length - 1
|
|
55
|
+
replacements << { start: metadata.fetch(:end_start), length: 0,
|
|
56
|
+
text: "else; #{runtime}.flow_select(#{identifier}, #{index}); nil; " }
|
|
57
|
+
end
|
|
58
|
+
flow_frame(decision[:id], flow_fragments(bytes, decision, nested, replacements, encloses))
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def render_pattern_flow(bytes, decision, nested, metadata, encloses)
|
|
62
|
+
identifier = decision[:id].inspect
|
|
63
|
+
runtime = self.class::RUNTIME
|
|
64
|
+
replacements = metadata.fetch(:branches).map do |branch|
|
|
65
|
+
suffix = branch[:empty] ? "nil; " : ""
|
|
66
|
+
{ start: branch[:insert_at], length: 0,
|
|
67
|
+
text: "; #{runtime}.flow_select(#{identifier}, #{branch[:index]}); #{suffix}" }
|
|
68
|
+
end
|
|
69
|
+
if metadata[:else]
|
|
70
|
+
alternative = metadata[:else]
|
|
71
|
+
replacements << { start: alternative[:insert_at], length: 0,
|
|
72
|
+
text: "; #{runtime}.flow_select(#{identifier}, #{alternative[:index]}); " }
|
|
73
|
+
end
|
|
74
|
+
flow_frame(decision[:id], flow_fragments(bytes, decision, nested, replacements, encloses))
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def flow_replacement(bytes, location, nested, encloses)
|
|
78
|
+
start = location.fetch(:byte_start)
|
|
79
|
+
length = location.fetch(:byte_length)
|
|
80
|
+
{ start: start, length: length, text: yield(render_children(bytes, start, length, nested, encloses)) }
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def flow_fragments(bytes, decision, nested, replacements, encloses)
|
|
84
|
+
cursor = decision.fetch(:byte_start)
|
|
85
|
+
finish = cursor + decision.fetch(:byte_length)
|
|
86
|
+
chunks = []
|
|
87
|
+
replacements.sort_by { |edit| [edit[:start], edit[:length]] }.each do |edit|
|
|
88
|
+
raise ArgumentError, "overlapping flow edits" if edit[:start] < cursor
|
|
89
|
+
|
|
90
|
+
chunks << render_children(bytes, cursor, edit[:start] - cursor, nested, encloses)
|
|
91
|
+
chunks << edit[:text]
|
|
92
|
+
cursor = edit[:start] + edit[:length]
|
|
93
|
+
end
|
|
94
|
+
chunks << render_children(bytes, cursor, finish - cursor, nested, encloses)
|
|
95
|
+
chunks.join
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def flow_frame(decision_id, expression, default_path: nil)
|
|
99
|
+
runtime = self.class::RUNTIME
|
|
100
|
+
"(begin; #{runtime}.enter(#{decision_id.inspect}); begin; " \
|
|
101
|
+
"#{runtime}.flow_finish(#{decision_id.inspect}, (#{expression}), #{default_path.inspect}); ensure; " \
|
|
102
|
+
"#{runtime}.leave(#{decision_id.inspect}); end; end)"
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# rubocop:enable Metrics/AbcSize, Metrics/MethodLength
|
|
@@ -6,16 +6,23 @@ require "pathname"
|
|
|
6
6
|
module Branchproof
|
|
7
7
|
# Terminal renderings grouped around conditions or tests.
|
|
8
8
|
class FocusedReport
|
|
9
|
-
|
|
9
|
+
DECISION_TABLE_LABELS = { "true" => "T", "false" => "F", "dont_care" => "-" }.freeze
|
|
10
|
+
|
|
11
|
+
def initialize(document:, view:, level:, missing_only: false, coordinator: nil)
|
|
10
12
|
@document = document || {}
|
|
11
13
|
@view = view.to_sym
|
|
12
|
-
|
|
14
|
+
unless (Report::VIEWS - [:decisions]).include?(@view)
|
|
15
|
+
raise ArgumentError, "view must be :conditions, :tests, or :decision_tables"
|
|
16
|
+
end
|
|
13
17
|
|
|
14
18
|
@level = level.to_i
|
|
15
19
|
@missing_only = missing_only ? true : false
|
|
16
20
|
@index = CoverageIndex.new(document: @document)
|
|
17
21
|
@tests = @index.tests.to_h { |test| [test[:id].to_s, test] }
|
|
18
|
-
@
|
|
22
|
+
@test_name_counts = @index.tests.each_with_object(Hash.new(0)) do |test, counts|
|
|
23
|
+
counts[[test[:name], test[:relative_path], test[:line]]] += 1
|
|
24
|
+
end
|
|
25
|
+
@coordinator = coordinator || Report.from_document(document: @document, level: @level)
|
|
19
26
|
end
|
|
20
27
|
|
|
21
28
|
def render
|
|
@@ -23,7 +30,11 @@ module Branchproof
|
|
|
23
30
|
completeness = fetch(@document, :completeness) || {}
|
|
24
31
|
evidence = fetch(@document, :observations) || {}
|
|
25
32
|
lines = ["Branchproof focused view: #{@view}", "Tests: #{fetch(baseline, :status) || "INCOMPLETE"}",
|
|
26
|
-
|
|
33
|
+
(if @index.alternatives.empty?
|
|
34
|
+
"Values: T=true, F=false, -=short-circuited"
|
|
35
|
+
else
|
|
36
|
+
"Values: Boolean T=true/F=false; flow T=selected, F=not-selected, -=skipped"
|
|
37
|
+
end)]
|
|
27
38
|
if completeness.values.include?(false) || fetch(baseline, :status).to_s != "PASSED"
|
|
28
39
|
lines << "Warning: failed or incomplete run; observations and proof evidence may be unavailable."
|
|
29
40
|
end
|
|
@@ -35,9 +46,19 @@ module Branchproof
|
|
|
35
46
|
lines << "Empty groups mean no recorded completed observation."
|
|
36
47
|
lines << ""
|
|
37
48
|
lines.concat(@coordinator.coverage_ladder_lines)
|
|
38
|
-
@view
|
|
39
|
-
|
|
40
|
-
|
|
49
|
+
case @view
|
|
50
|
+
when :conditions
|
|
51
|
+
render_conditions(lines)
|
|
52
|
+
render_alternatives(lines)
|
|
53
|
+
when :decision_tables
|
|
54
|
+
render_decision_tables(lines)
|
|
55
|
+
else
|
|
56
|
+
render_tests(lines)
|
|
57
|
+
end
|
|
58
|
+
unless @view == :decision_tables
|
|
59
|
+
render_unowned(lines)
|
|
60
|
+
render_unsupported(lines)
|
|
61
|
+
end
|
|
41
62
|
Array(fetch(@document, :diagnostics)).each do |diagnostic|
|
|
42
63
|
lines << "Diagnostic: #{@coordinator.diagnostic_message(diagnostic)}"
|
|
43
64
|
end
|
|
@@ -53,6 +74,8 @@ module Branchproof
|
|
|
53
74
|
lines << "Condition: #{row[:expression]}"
|
|
54
75
|
lines << "Location: #{location(row[:relative_path], row[:line], unavailable: "condition line unavailable")}"
|
|
55
76
|
lines << "Decision: #{row[:decision_expression]} (condition #{row[:index]})"
|
|
77
|
+
lines << "Kind: #{row[:kind]}"
|
|
78
|
+
lines << "Context: #{row[:context]}" unless row[:context].to_s.empty?
|
|
56
79
|
status = if @level == 1 && !coverage_available?
|
|
57
80
|
"NOT CALCULATED"
|
|
58
81
|
else
|
|
@@ -106,16 +129,110 @@ module Branchproof
|
|
|
106
129
|
end
|
|
107
130
|
end
|
|
108
131
|
|
|
132
|
+
# One block per Boolean decision that carries a decision table. With
|
|
133
|
+
# --missing-only only uncovered, non-impossible rules remain: a rule proven
|
|
134
|
+
# impossible is not a missing obligation.
|
|
135
|
+
def render_decision_tables(lines)
|
|
136
|
+
rows = @index.decision_tables
|
|
137
|
+
rendered = 0
|
|
138
|
+
impossible = 0
|
|
139
|
+
rows.each do |row|
|
|
140
|
+
impossible += row[:impossible_rules].to_i
|
|
141
|
+
rules = @missing_only ? row[:rules].select { |rule| rule[:coverage].to_s == "missing" } : row[:rules]
|
|
142
|
+
next if @missing_only && rules.empty? && row[:status].to_s == "calculated"
|
|
143
|
+
|
|
144
|
+
rendered += 1
|
|
145
|
+
lines << "Decision: #{row[:decision_expression]}"
|
|
146
|
+
lines << "Location: #{location(row[:relative_path], row[:line], unavailable: "decision line unavailable")}"
|
|
147
|
+
lines << "Context: #{row[:context]}" unless row[:context].to_s.empty?
|
|
148
|
+
if row[:status].to_s != "calculated"
|
|
149
|
+
lines << "Decision Table: NOT CALCULATED"
|
|
150
|
+
lines << "Reason: #{row[:reason]}"
|
|
151
|
+
lines << ""
|
|
152
|
+
next
|
|
153
|
+
end
|
|
154
|
+
lines << "Decision Table: #{row[:covered_rules]}/#{row[:required_rules]} rules covered" \
|
|
155
|
+
"#{" (#{row[:percentage]}%)" unless row[:percentage].nil?}"
|
|
156
|
+
lines << "Reachability: not analyzed" unless row[:reachability_analyzed]
|
|
157
|
+
rules.each { |rule| render_decision_table_rule(lines, row, rule) }
|
|
158
|
+
lines << ""
|
|
159
|
+
end
|
|
160
|
+
lines << "No missing decision-table rules" if @missing_only && rendered.zero?
|
|
161
|
+
return unless impossible.positive?
|
|
162
|
+
|
|
163
|
+
lines << "#{impossible} statically impossible rule#{"s" unless impossible == 1} excluded"
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def render_decision_table_rule(lines, row, rule)
|
|
167
|
+
signature = rule[:conditions].map { |item| DECISION_TABLE_LABELS.fetch(item.to_s, item.to_s) }.join
|
|
168
|
+
status = rule[:coverage].to_s.upcase
|
|
169
|
+
lines << " #{rule[:label]} #{signature} => #{rule[:outcome] ? "T" : "F"} #{status}"
|
|
170
|
+
row[:conditions].each_with_index do |expression, index|
|
|
171
|
+
requirement = @coordinator.decision_table_requirement(rule[:conditions][index])
|
|
172
|
+
next if requirement.nil?
|
|
173
|
+
|
|
174
|
+
lines << " #{expression} = #{requirement}"
|
|
175
|
+
end
|
|
176
|
+
lines << " #{@coordinator.decision_table_expected_heading(row)} #{rule[:outcome] ? "true" : "false"}"
|
|
177
|
+
if rule[:coverage].to_s == "covered"
|
|
178
|
+
owners = Array(rule[:tests]).map { |id| test_label(id) }
|
|
179
|
+
owners << "unattributed" if rule[:unattributed_count].to_i.positive?
|
|
180
|
+
lines << " Tests: #{owners.empty? ? "none recorded" : owners.uniq.join(", ")}"
|
|
181
|
+
else
|
|
182
|
+
lines << " Tests: NOT COVERED"
|
|
183
|
+
end
|
|
184
|
+
lines << " Reachability: #{@coordinator.decision_table_reachability(rule)}"
|
|
185
|
+
return if rule[:reachability_reason].to_s.empty?
|
|
186
|
+
|
|
187
|
+
lines << " Reason: #{Constraints.message(rule[:reachability_reason])}"
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def render_alternatives(lines)
|
|
191
|
+
rows = @index.alternatives
|
|
192
|
+
rows = rows.select { |row| row[:missing] || row[:status].to_s != "covered" } if @missing_only && @level > 1
|
|
193
|
+
rows.each do |row|
|
|
194
|
+
lines << "Alternative #{row[:index]}: #{row[:expression]}"
|
|
195
|
+
lines << "Location: #{location(row[:relative_path], row[:line],
|
|
196
|
+
unavailable: "alternative location unavailable")}"
|
|
197
|
+
lines << "Decision: #{row[:decision_expression]} (alternative #{row[:index]})"
|
|
198
|
+
lines << "Kind: #{row[:kind]}"
|
|
199
|
+
lines << "Context: #{row[:context]}" unless row[:context].to_s.empty?
|
|
200
|
+
lines << "Selection: #{row[:status] || "NOT_CALCULATED"}"
|
|
201
|
+
lines << "MC/DC: N/A (not applicable)"
|
|
202
|
+
render_alternative_group(lines, "Selected by", row[:selected])
|
|
203
|
+
render_alternative_group(lines, "Not selected by", row[:not_selected])
|
|
204
|
+
render_alternative_group(lines, "Skipped in", row[:skipped])
|
|
205
|
+
if row[:missing]
|
|
206
|
+
lines << " Missing alternative: #{row[:expression]}"
|
|
207
|
+
lines << " Need selection of: #{row[:expression]}"
|
|
208
|
+
end
|
|
209
|
+
lines << ""
|
|
210
|
+
end
|
|
211
|
+
lines << "No missing alternatives" if @missing_only && rows.empty?
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def render_alternative_group(lines, heading, evidence)
|
|
215
|
+
evidence ||= {}
|
|
216
|
+
ids = Array(evidence[:test_ids]).map { |id| test_label(id) }
|
|
217
|
+
ids << "unattributed" if evidence[:unattributed_count].to_i.positive?
|
|
218
|
+
lines << " #{heading}: #{ids.empty? ? "none recorded" : ids.uniq.join(", ")}"
|
|
219
|
+
end
|
|
220
|
+
|
|
109
221
|
def render_tests(lines)
|
|
110
222
|
rows = @index.tests
|
|
111
223
|
missing_ids = @index.conditions.reject { |row| row[:status].to_s.upcase == "PROVEN" }.map { |row| row[:id] }
|
|
112
|
-
|
|
224
|
+
missing_alternative_ids = @index.alternatives.select { |row| row[:missing] || row[:status].to_s != "covered" }
|
|
225
|
+
.map { |row| row[:alternative_id] }
|
|
226
|
+
rows.each { |row| render_test_row(lines, row, missing_ids, missing_alternative_ids) }
|
|
113
227
|
end
|
|
114
228
|
|
|
115
|
-
def render_test_row(lines, row, missing_ids)
|
|
229
|
+
def render_test_row(lines, row, missing_ids, missing_alternative_ids = [])
|
|
116
230
|
observations = row[:observations]
|
|
117
231
|
if @missing_only
|
|
118
|
-
observations = observations.select
|
|
232
|
+
observations = observations.select do |observation|
|
|
233
|
+
missing_ids.include?(observation[:condition_id]) ||
|
|
234
|
+
missing_alternative_ids.include?(observation[:alternative_id])
|
|
235
|
+
end
|
|
119
236
|
return if observations.empty?
|
|
120
237
|
end
|
|
121
238
|
lines << "Test: #{row[:name]}"
|
|
@@ -131,31 +248,51 @@ module Branchproof
|
|
|
131
248
|
lines << " No recorded completed condition observations"
|
|
132
249
|
return
|
|
133
250
|
end
|
|
134
|
-
observations.group_by { |observation| observation[:condition_id] }
|
|
251
|
+
grouped = observations.group_by { |observation| observation[:alternative_id] || observation[:condition_id] }
|
|
252
|
+
grouped.each_value do |items|
|
|
135
253
|
observation = items.first
|
|
136
|
-
observed_values = items.map { |item| item[:value] }.uniq
|
|
137
|
-
label = observed_values.all?(&:nil?) ? "short-circuited-only" : "evaluated"
|
|
138
|
-
label += "; owns canonical witness evidence" if @level > 1 && items.any? { |item| item[:owns_witness] }
|
|
139
254
|
phases = items.flat_map { |item| item[:phases] }.uniq.sort.join(", ")
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
255
|
+
if observation[:alternative_id]
|
|
256
|
+
states = items.map { |item| item[:alternative_state] }.uniq.join(", ")
|
|
257
|
+
alternative_location = location(observation[:relative_path], observation[:line],
|
|
258
|
+
unavailable: "alternative location unavailable")
|
|
259
|
+
lines << " Alternative #{observation[:alternative_id]}: #{observation[:expression]} " \
|
|
260
|
+
"(#{alternative_location}): #{states}; phases: #{phases}"
|
|
261
|
+
else
|
|
262
|
+
observed_values = items.map { |item| item[:value] }.uniq
|
|
263
|
+
label = observed_values.all?(&:nil?) ? "short-circuited-only" : "evaluated"
|
|
264
|
+
label += "; owns canonical witness evidence" if @level > 1 && items.any? { |item| item[:owns_witness] }
|
|
265
|
+
signs = values(values: observed_values)
|
|
266
|
+
condition_location = location(observation[:relative_path], observation[:line],
|
|
267
|
+
unavailable: "condition line unavailable")
|
|
268
|
+
lines << " #{observation[:expression]} (#{condition_location}): #{signs}; #{label}; " \
|
|
269
|
+
"phases: #{phases}"
|
|
270
|
+
end
|
|
145
271
|
end
|
|
146
272
|
end
|
|
147
273
|
|
|
148
274
|
def render_unowned(lines)
|
|
149
275
|
rows = @index.conditions
|
|
150
276
|
rows = rows.reject { |row| row[:status] == "PROVEN" } if @missing_only
|
|
151
|
-
{ "Unexecuted conditions" => rows.select { |row| row[:unexecuted] },
|
|
152
|
-
|
|
277
|
+
groups = { "Unexecuted conditions" => rows.select { |row| row[:unexecuted] },
|
|
278
|
+
"Unattributed evidence" => rows.select { |row| row[:unattributed].positive? } }
|
|
279
|
+
alternative_rows = @index.alternatives
|
|
280
|
+
alternative_rows = alternative_rows.reject { |row| row[:status].to_s == "covered" } if @missing_only
|
|
281
|
+
unless alternative_rows.empty?
|
|
282
|
+
groups["Unexecuted alternatives"] = alternative_rows.select do |row|
|
|
283
|
+
row[:selected][:observed] == false && row[:not_selected][:observed] == false
|
|
284
|
+
end
|
|
285
|
+
groups["Unattributed alternative evidence"] = alternative_rows.select do |row|
|
|
286
|
+
%i[selected not_selected skipped].any? { |state| row[state][:unattributed_count].to_i.positive? }
|
|
287
|
+
end
|
|
288
|
+
end
|
|
289
|
+
groups.each do |heading, conditions|
|
|
153
290
|
next if conditions.empty?
|
|
154
291
|
|
|
155
292
|
lines << "#{heading}:"
|
|
156
293
|
conditions.each do |row|
|
|
157
|
-
|
|
158
|
-
|
|
294
|
+
unavailable = row[:alternative_id] ? "alternative location unavailable" : "condition line unavailable"
|
|
295
|
+
lines << " #{row[:expression]} (#{location(row[:relative_path], row[:line], unavailable: unavailable)})"
|
|
159
296
|
end
|
|
160
297
|
end
|
|
161
298
|
end
|
|
@@ -204,9 +341,7 @@ module Branchproof
|
|
|
204
341
|
return id.to_s unless test
|
|
205
342
|
|
|
206
343
|
label = "#{test[:name]} (#{location(test[:relative_path], test[:line])})"
|
|
207
|
-
duplicates = @
|
|
208
|
-
[row[:name], row[:relative_path], row[:line]] == [test[:name], test[:relative_path], test[:line]]
|
|
209
|
-
end
|
|
344
|
+
duplicates = @test_name_counts[[test[:name], test[:relative_path], test[:line]]]
|
|
210
345
|
duplicates > 1 ? "#{label} [#{id}]" : label
|
|
211
346
|
end
|
|
212
347
|
|