branchproof 0.5.0 → 0.7.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 +20 -0
- data/README.md +129 -31
- data/doc/Branchproof/CoverageIndex.md +3 -0
- data/doc/Branchproof/DecisionSyntax.md +20 -0
- data/doc/Branchproof/FlowInstrumentation.md +7 -0
- data/doc/Branchproof/Instrumenter.md +1 -0
- data/doc/Branchproof/Report.md +10 -0
- data/doc/Branchproof/Runtime.md +19 -0
- data/doc/Branchproof/RuntimeFlow.md +27 -0
- data/doc/Branchproof/SavedReport.md +6 -0
- data/doc/Branchproof/Source.md +1 -0
- data/doc/Branchproof.md +6 -2
- data/doc/CHANGELOG.md +20 -0
- data/doc/README.md +129 -31
- data/lib/branchproof/analyzer.rb +273 -13
- data/lib/branchproof/cli.rb +2 -2
- data/lib/branchproof/comparison.rb +1 -1
- data/lib/branchproof/coverage_index.rb +90 -2
- data/lib/branchproof/decision_syntax.rb +294 -0
- data/lib/branchproof/evidence.rb +54 -9
- data/lib/branchproof/flow_instrumentation.rb +107 -0
- data/lib/branchproof/focused_report.rb +102 -21
- data/lib/branchproof/instrumenter.rb +28 -13
- data/lib/branchproof/report.rb +348 -31
- data/lib/branchproof/runtime.rb +3 -0
- data/lib/branchproof/runtime_flow.rb +58 -0
- data/lib/branchproof/saved_report.rb +245 -6
- data/lib/branchproof/source.rb +191 -35
- data/lib/branchproof/version.rb +1 -1
- data/llms.txt +6 -2
- data/sig/branchproof.rbs +7 -0
- metadata +7 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
# rubocop:disable Metrics/ClassLength, Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
3
|
+
# rubocop:disable Metrics/ClassLength, Metrics/AbcSize, Metrics/BlockLength, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
4
4
|
require "pathname"
|
|
5
5
|
|
|
6
6
|
module Branchproof
|
|
@@ -23,7 +23,11 @@ module Branchproof
|
|
|
23
23
|
completeness = fetch(@document, :completeness) || {}
|
|
24
24
|
evidence = fetch(@document, :observations) || {}
|
|
25
25
|
lines = ["Branchproof focused view: #{@view}", "Tests: #{fetch(baseline, :status) || "INCOMPLETE"}",
|
|
26
|
-
|
|
26
|
+
(if @index.alternatives.empty?
|
|
27
|
+
"Values: T=true, F=false, -=short-circuited"
|
|
28
|
+
else
|
|
29
|
+
"Values: Boolean T=true/F=false; flow T=selected, F=not-selected, -=skipped"
|
|
30
|
+
end)]
|
|
27
31
|
if completeness.values.include?(false) || fetch(baseline, :status).to_s != "PASSED"
|
|
28
32
|
lines << "Warning: failed or incomplete run; observations and proof evidence may be unavailable."
|
|
29
33
|
end
|
|
@@ -34,7 +38,13 @@ module Branchproof
|
|
|
34
38
|
end
|
|
35
39
|
lines << "Empty groups mean no recorded completed observation."
|
|
36
40
|
lines << ""
|
|
37
|
-
|
|
41
|
+
lines.concat(@coordinator.coverage_ladder_lines)
|
|
42
|
+
if @view == :conditions
|
|
43
|
+
render_conditions(lines)
|
|
44
|
+
render_alternatives(lines)
|
|
45
|
+
else
|
|
46
|
+
render_tests(lines)
|
|
47
|
+
end
|
|
38
48
|
render_unowned(lines)
|
|
39
49
|
render_unsupported(lines)
|
|
40
50
|
Array(fetch(@document, :diagnostics)).each do |diagnostic|
|
|
@@ -52,7 +62,17 @@ module Branchproof
|
|
|
52
62
|
lines << "Condition: #{row[:expression]}"
|
|
53
63
|
lines << "Location: #{location(row[:relative_path], row[:line], unavailable: "condition line unavailable")}"
|
|
54
64
|
lines << "Decision: #{row[:decision_expression]} (condition #{row[:index]})"
|
|
55
|
-
lines << "
|
|
65
|
+
lines << "Kind: #{row[:kind]}"
|
|
66
|
+
lines << "Context: #{row[:context]}" unless row[:context].to_s.empty?
|
|
67
|
+
status = if @level == 1 && !coverage_available?
|
|
68
|
+
"NOT CALCULATED"
|
|
69
|
+
else
|
|
70
|
+
row[:status] || "NOT_PROVEN"
|
|
71
|
+
end
|
|
72
|
+
lines << "MC/DC: #{status}"
|
|
73
|
+
if @level >= 2
|
|
74
|
+
lines.concat(@coordinator.condition_coverage_evidence(decision_id: row[:decision_id], condition_id: row[:id]))
|
|
75
|
+
end
|
|
56
76
|
render_group(lines, "Evaluated true by", row[:observed_true], row)
|
|
57
77
|
render_group(lines, "Evaluated false by", row[:observed_false], row)
|
|
58
78
|
render_group(lines, "Short-circuited in", row[:short_circuited], row)
|
|
@@ -79,6 +99,11 @@ module Branchproof
|
|
|
79
99
|
lines << "No missing conditions" if @missing_only && rows.empty?
|
|
80
100
|
end
|
|
81
101
|
|
|
102
|
+
def coverage_available?
|
|
103
|
+
analysis = fetch(@document, :analysis)
|
|
104
|
+
analysis && fetch(analysis, :coverage)
|
|
105
|
+
end
|
|
106
|
+
|
|
82
107
|
def render_constraint(lines, row)
|
|
83
108
|
lines << @coordinator.condition_explanation(decision_id: row[:decision_id], condition_id: row[:id])
|
|
84
109
|
end
|
|
@@ -92,16 +117,52 @@ module Branchproof
|
|
|
92
117
|
end
|
|
93
118
|
end
|
|
94
119
|
|
|
120
|
+
def render_alternatives(lines)
|
|
121
|
+
rows = @index.alternatives
|
|
122
|
+
rows = rows.select { |row| row[:missing] || row[:status].to_s != "covered" } if @missing_only && @level > 1
|
|
123
|
+
rows.each do |row|
|
|
124
|
+
lines << "Alternative #{row[:index]}: #{row[:expression]}"
|
|
125
|
+
lines << "Location: #{location(row[:relative_path], row[:line],
|
|
126
|
+
unavailable: "alternative location unavailable")}"
|
|
127
|
+
lines << "Decision: #{row[:decision_expression]} (alternative #{row[:index]})"
|
|
128
|
+
lines << "Kind: #{row[:kind]}"
|
|
129
|
+
lines << "Context: #{row[:context]}" unless row[:context].to_s.empty?
|
|
130
|
+
lines << "Selection: #{row[:status] || "NOT_CALCULATED"}"
|
|
131
|
+
lines << "MC/DC: N/A (not applicable)"
|
|
132
|
+
render_alternative_group(lines, "Selected by", row[:selected])
|
|
133
|
+
render_alternative_group(lines, "Not selected by", row[:not_selected])
|
|
134
|
+
render_alternative_group(lines, "Skipped in", row[:skipped])
|
|
135
|
+
if row[:missing]
|
|
136
|
+
lines << " Missing alternative: #{row[:expression]}"
|
|
137
|
+
lines << " Need selection of: #{row[:expression]}"
|
|
138
|
+
end
|
|
139
|
+
lines << ""
|
|
140
|
+
end
|
|
141
|
+
lines << "No missing alternatives" if @missing_only && rows.empty?
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def render_alternative_group(lines, heading, evidence)
|
|
145
|
+
evidence ||= {}
|
|
146
|
+
ids = Array(evidence[:test_ids]).map { |id| test_label(id) }
|
|
147
|
+
ids << "unattributed" if evidence[:unattributed_count].to_i.positive?
|
|
148
|
+
lines << " #{heading}: #{ids.empty? ? "none recorded" : ids.uniq.join(", ")}"
|
|
149
|
+
end
|
|
150
|
+
|
|
95
151
|
def render_tests(lines)
|
|
96
152
|
rows = @index.tests
|
|
97
153
|
missing_ids = @index.conditions.reject { |row| row[:status].to_s.upcase == "PROVEN" }.map { |row| row[:id] }
|
|
98
|
-
|
|
154
|
+
missing_alternative_ids = @index.alternatives.select { |row| row[:missing] || row[:status].to_s != "covered" }
|
|
155
|
+
.map { |row| row[:alternative_id] }
|
|
156
|
+
rows.each { |row| render_test_row(lines, row, missing_ids, missing_alternative_ids) }
|
|
99
157
|
end
|
|
100
158
|
|
|
101
|
-
def render_test_row(lines, row, missing_ids)
|
|
159
|
+
def render_test_row(lines, row, missing_ids, missing_alternative_ids = [])
|
|
102
160
|
observations = row[:observations]
|
|
103
161
|
if @missing_only
|
|
104
|
-
observations = observations.select
|
|
162
|
+
observations = observations.select do |observation|
|
|
163
|
+
missing_ids.include?(observation[:condition_id]) ||
|
|
164
|
+
missing_alternative_ids.include?(observation[:alternative_id])
|
|
165
|
+
end
|
|
105
166
|
return if observations.empty?
|
|
106
167
|
end
|
|
107
168
|
lines << "Test: #{row[:name]}"
|
|
@@ -117,31 +178,51 @@ module Branchproof
|
|
|
117
178
|
lines << " No recorded completed condition observations"
|
|
118
179
|
return
|
|
119
180
|
end
|
|
120
|
-
observations.group_by { |observation| observation[:condition_id] }
|
|
181
|
+
grouped = observations.group_by { |observation| observation[:alternative_id] || observation[:condition_id] }
|
|
182
|
+
grouped.each_value do |items|
|
|
121
183
|
observation = items.first
|
|
122
|
-
observed_values = items.map { |item| item[:value] }.uniq
|
|
123
|
-
label = observed_values.all?(&:nil?) ? "short-circuited-only" : "evaluated"
|
|
124
|
-
label += "; owns canonical witness evidence" if @level > 1 && items.any? { |item| item[:owns_witness] }
|
|
125
184
|
phases = items.flat_map { |item| item[:phases] }.uniq.sort.join(", ")
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
185
|
+
if observation[:alternative_id]
|
|
186
|
+
states = items.map { |item| item[:alternative_state] }.uniq.join(", ")
|
|
187
|
+
alternative_location = location(observation[:relative_path], observation[:line],
|
|
188
|
+
unavailable: "alternative location unavailable")
|
|
189
|
+
lines << " Alternative #{observation[:alternative_id]}: #{observation[:expression]} " \
|
|
190
|
+
"(#{alternative_location}): #{states}; phases: #{phases}"
|
|
191
|
+
else
|
|
192
|
+
observed_values = items.map { |item| item[:value] }.uniq
|
|
193
|
+
label = observed_values.all?(&:nil?) ? "short-circuited-only" : "evaluated"
|
|
194
|
+
label += "; owns canonical witness evidence" if @level > 1 && items.any? { |item| item[:owns_witness] }
|
|
195
|
+
signs = values(values: observed_values)
|
|
196
|
+
condition_location = location(observation[:relative_path], observation[:line],
|
|
197
|
+
unavailable: "condition line unavailable")
|
|
198
|
+
lines << " #{observation[:expression]} (#{condition_location}): #{signs}; #{label}; " \
|
|
199
|
+
"phases: #{phases}"
|
|
200
|
+
end
|
|
131
201
|
end
|
|
132
202
|
end
|
|
133
203
|
|
|
134
204
|
def render_unowned(lines)
|
|
135
205
|
rows = @index.conditions
|
|
136
206
|
rows = rows.reject { |row| row[:status] == "PROVEN" } if @missing_only
|
|
137
|
-
{ "Unexecuted conditions" => rows.select { |row| row[:unexecuted] },
|
|
138
|
-
|
|
207
|
+
groups = { "Unexecuted conditions" => rows.select { |row| row[:unexecuted] },
|
|
208
|
+
"Unattributed evidence" => rows.select { |row| row[:unattributed].positive? } }
|
|
209
|
+
alternative_rows = @index.alternatives
|
|
210
|
+
alternative_rows = alternative_rows.reject { |row| row[:status].to_s == "covered" } if @missing_only
|
|
211
|
+
unless alternative_rows.empty?
|
|
212
|
+
groups["Unexecuted alternatives"] = alternative_rows.select do |row|
|
|
213
|
+
row[:selected][:observed] == false && row[:not_selected][:observed] == false
|
|
214
|
+
end
|
|
215
|
+
groups["Unattributed alternative evidence"] = alternative_rows.select do |row|
|
|
216
|
+
%i[selected not_selected skipped].any? { |state| row[state][:unattributed_count].to_i.positive? }
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
groups.each do |heading, conditions|
|
|
139
220
|
next if conditions.empty?
|
|
140
221
|
|
|
141
222
|
lines << "#{heading}:"
|
|
142
223
|
conditions.each do |row|
|
|
143
|
-
|
|
144
|
-
|
|
224
|
+
unavailable = row[:alternative_id] ? "alternative location unavailable" : "condition line unavailable"
|
|
225
|
+
lines << " #{row[:expression]} (#{location(row[:relative_path], row[:line], unavailable: unavailable)})"
|
|
145
226
|
end
|
|
146
227
|
end
|
|
147
228
|
end
|
|
@@ -202,4 +283,4 @@ module Branchproof
|
|
|
202
283
|
end
|
|
203
284
|
end
|
|
204
285
|
|
|
205
|
-
# rubocop:enable Metrics/ClassLength, Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
286
|
+
# rubocop:enable Metrics/ClassLength, Metrics/AbcSize, Metrics/BlockLength, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "flow_instrumentation"
|
|
4
|
+
|
|
3
5
|
module Branchproof
|
|
4
6
|
# Applies the smallest possible source edits around inventoried expressions.
|
|
5
7
|
# The edits are deliberately textual: Prism owns the ranges, while this class
|
|
6
8
|
# never evaluates application code or introduces a Ruby scope.
|
|
7
9
|
class Instrumenter
|
|
10
|
+
include FlowInstrumentation
|
|
11
|
+
|
|
8
12
|
RUNTIME = "::Branchproof::Runtime"
|
|
9
13
|
|
|
10
14
|
def rewrite(unit:)
|
|
@@ -17,8 +21,7 @@ module Branchproof
|
|
|
17
21
|
decisions = Array(unit[:decisions]).select { |decision| supported?(decision) }
|
|
18
22
|
edits = decisions.filter_map do |decision|
|
|
19
23
|
next if decisions.any? do |outer|
|
|
20
|
-
|
|
21
|
-
decision[:byte_length])
|
|
24
|
+
encloses_decision?(outer, decision)
|
|
22
25
|
end
|
|
23
26
|
|
|
24
27
|
decision_edit(bytes, decision, decisions)
|
|
@@ -57,13 +60,13 @@ module Branchproof
|
|
|
57
60
|
return nil unless valid_range?(bytes, start, length)
|
|
58
61
|
|
|
59
62
|
nested = all_decisions.select do |candidate|
|
|
60
|
-
|
|
63
|
+
encloses_decision?(decision, candidate)
|
|
61
64
|
end
|
|
62
|
-
expression =
|
|
65
|
+
expression = render_decision(bytes, decision, nested)
|
|
63
66
|
{
|
|
64
67
|
start: start,
|
|
65
68
|
finish: start + length,
|
|
66
|
-
text:
|
|
69
|
+
text: expression
|
|
67
70
|
}
|
|
68
71
|
end
|
|
69
72
|
|
|
@@ -90,24 +93,36 @@ module Branchproof
|
|
|
90
93
|
contains?(start, length, child[:byte_start], child[:byte_length])
|
|
91
94
|
end
|
|
92
95
|
children = children.reject do |child|
|
|
93
|
-
|
|
94
|
-
candidate != child && contains?(candidate[:byte_start], candidate[:byte_length], child[:byte_start],
|
|
95
|
-
child[:byte_length])
|
|
96
|
-
end
|
|
96
|
+
children.any? { |candidate| encloses_decision?(candidate, child) }
|
|
97
97
|
end
|
|
98
98
|
children.sort_by! { |child| -child[:byte_start] }
|
|
99
99
|
output = bytes.byteslice(start, length)
|
|
100
100
|
children.each do |child|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
end)
|
|
104
|
-
child_text = frame(child[:id], child_text)
|
|
101
|
+
descendants = nested.select { |candidate| encloses_decision?(child, candidate) }
|
|
102
|
+
child_text = render_decision(bytes, child, descendants)
|
|
105
103
|
offset = child[:byte_start] - start
|
|
106
104
|
output[offset, child[:byte_length]] = child_text
|
|
107
105
|
end
|
|
108
106
|
output
|
|
109
107
|
end
|
|
110
108
|
|
|
109
|
+
def encloses_decision?(outer, inner)
|
|
110
|
+
return false if outer.equal?(inner) || outer == inner
|
|
111
|
+
return false unless contains?(outer[:byte_start], outer[:byte_length], inner[:byte_start], inner[:byte_length])
|
|
112
|
+
return true unless outer[:byte_start] == inner[:byte_start] && outer[:byte_length] == inner[:byte_length]
|
|
113
|
+
|
|
114
|
+
(outer[:kind] || "boolean") == "boolean" && inner[:kind] != "boolean" && !inner[:kind].nil?
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def render_decision(bytes, decision, nested)
|
|
118
|
+
if decision[:instrumentation]
|
|
119
|
+
render_flow(bytes, decision, nested)
|
|
120
|
+
else
|
|
121
|
+
expression = render_range(bytes, decision[:byte_start], decision[:byte_length], decision, nested)
|
|
122
|
+
frame(decision[:id], expression)
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
111
126
|
def condition_wrapper(decision_id, index, expression)
|
|
112
127
|
"#{RUNTIME}.condition(#{decision_id.inspect}, #{index}, (#{expression}))"
|
|
113
128
|
end
|