branchproof 0.3.0 → 0.5.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 +19 -0
- data/README.md +98 -9
- data/doc/Branchproof/Comparison.md +26 -0
- data/doc/Branchproof/ComparisonReport.md +19 -0
- data/doc/Branchproof/CoverageIndex.md +19 -0
- data/doc/Branchproof/FocusedReport.md +16 -0
- data/doc/Branchproof/Report.md +11 -1
- data/doc/Branchproof/SavedReport.md +32 -0
- data/doc/Branchproof.md +6 -1
- data/doc/CHANGELOG.md +19 -0
- data/doc/README.md +98 -9
- data/exe/branchproof +6 -0
- data/lib/branchproof/cli.rb +152 -7
- data/lib/branchproof/comparison.rb +363 -0
- data/lib/branchproof/comparison_report.rb +119 -0
- data/lib/branchproof/coverage_index.rb +187 -0
- data/lib/branchproof/focused_report.rb +205 -0
- data/lib/branchproof/loader.rb +9 -1
- data/lib/branchproof/report.rb +71 -6
- data/lib/branchproof/saved_report.rb +366 -0
- data/lib/branchproof/source.rb +1 -0
- data/lib/branchproof/version.rb +1 -1
- data/lib/branchproof.rb +5 -0
- data/llms.txt +6 -1
- data/sig/branchproof.rbs +31 -1
- metadata +13 -1
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
4
|
+
|
|
5
|
+
require "json"
|
|
6
|
+
|
|
7
|
+
module Branchproof
|
|
8
|
+
# Renders the offline document returned by Comparison.
|
|
9
|
+
class ComparisonReport
|
|
10
|
+
def initialize(document:)
|
|
11
|
+
raise ArgumentError, "comparison document must be a Hash" unless document.is_a?(Hash)
|
|
12
|
+
|
|
13
|
+
@document = document
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def write(io:, format:)
|
|
17
|
+
format = format.to_sym
|
|
18
|
+
raise ArgumentError, "format must be :terminal or :json" unless %i[terminal json].include?(format)
|
|
19
|
+
|
|
20
|
+
io.write(format == :json ? JSON.generate(@document) : terminal_document)
|
|
21
|
+
nil
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def exit_code(fail_on_regression: false)
|
|
25
|
+
return 2 if status != "complete"
|
|
26
|
+
return 1 if fail_on_regression && regression?
|
|
27
|
+
|
|
28
|
+
0
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def terminal_document
|
|
34
|
+
lines = ["Branchproof comparison", "Status: #{status}"]
|
|
35
|
+
reasons.each { |reason| lines << "Reason: #{reason}" }
|
|
36
|
+
Array(value(:context)).each { |item| lines << "Context: #{item}" }
|
|
37
|
+
lines << "Conditions: #{matching.fetch("matched_conditions",
|
|
38
|
+
0)} matched (denominator #{matching.fetch("denominator", 0)})"
|
|
39
|
+
lines << "Before: #{summary_status(before)}; After: #{summary_status(after)}"
|
|
40
|
+
lines << "Full-run totals: before #{totals(before)}; after #{totals(after)}"
|
|
41
|
+
before_total = "#{matching["before_proven"] || 0}/#{matching["denominator"] || 0}"
|
|
42
|
+
after_total = "#{matching["after_proven"] || 0}/#{matching["denominator"] || 0}"
|
|
43
|
+
lines << "Exact-match totals: before #{before_total} proven; after #{after_total} proven"
|
|
44
|
+
lines << "No comparable conditions." if matching.fetch("matched_conditions", 0).zero?
|
|
45
|
+
changed_sources.each { |source| lines << "Source changed: #{source["relative_path"]}" }
|
|
46
|
+
newly.each { |path| lines << "Newly in report: #{path}" }
|
|
47
|
+
removed.each { |path| lines << "No longer in report: #{path}" }
|
|
48
|
+
rendered_changes = changes.reject { |change| change["change"] == "unchanged" }
|
|
49
|
+
if rendered_changes.empty?
|
|
50
|
+
lines << "No comparable condition changes."
|
|
51
|
+
else
|
|
52
|
+
rendered_changes.each do |change|
|
|
53
|
+
location = if change["relative_path"].to_s.empty?
|
|
54
|
+
"location unavailable"
|
|
55
|
+
else
|
|
56
|
+
"#{change["relative_path"]}:#{change["line"] || "condition line unavailable"}"
|
|
57
|
+
end
|
|
58
|
+
lines << "#{change["change"].capitalize}: #{location} (condition #{change["index"]}) #{change["expression"]}"
|
|
59
|
+
next unless change["change"] == "lost proof"
|
|
60
|
+
|
|
61
|
+
owners = Array(change["previous_witness"]).flat_map { |witness| Array(witness["tests"]) }.uniq
|
|
62
|
+
lines << " Previous owners: #{owners.empty? ? "unavailable" : owners.join(", ")}"
|
|
63
|
+
Array(change["previous_witness"]).each do |witness|
|
|
64
|
+
outcome = witness["outcome"] ? "T" : "F"
|
|
65
|
+
owners = Array(witness["tests"]).join(", ")
|
|
66
|
+
lines << " Previous witness: [#{signs(witness["values"])}] => #{outcome} — #{owners}"
|
|
67
|
+
end
|
|
68
|
+
context = Array(change["owner_context"]).map do |item|
|
|
69
|
+
"#{item["label"]}: #{item["test_status"]}; [#{signs(item["values"])}] => " \
|
|
70
|
+
"#{item["outcome"] ? "T" : "F"} #{item["observation_status"]}"
|
|
71
|
+
end
|
|
72
|
+
lines << " #{context.empty? ? "not observed in current run" : context.join(", ")}"
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
lines.join("\n") << "\n"
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def status = value(:status).to_s
|
|
79
|
+
|
|
80
|
+
def signs(values)
|
|
81
|
+
Array(values).map do |item|
|
|
82
|
+
if item.nil?
|
|
83
|
+
"-"
|
|
84
|
+
else
|
|
85
|
+
(item ? "T" : "F")
|
|
86
|
+
end
|
|
87
|
+
end.join
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def totals(summary)
|
|
91
|
+
metrics = value_from(summary, :metrics) || {}
|
|
92
|
+
"#{value_from(metrics,
|
|
93
|
+
:proven) || "unknown"}/#{value_from(metrics, :eligible_conditions) || "unknown"} conditions proven"
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def regression? = value(:regression) == true || value(:regressions).to_i.positive?
|
|
97
|
+
def reasons = Array(value(:reasons))
|
|
98
|
+
def matching = value(:matching) || {}
|
|
99
|
+
def before = value(:before) || {}
|
|
100
|
+
def after = value(:after) || {}
|
|
101
|
+
def changes = Array(value(:changes))
|
|
102
|
+
def changed_sources = Array(value(:changed_sources))
|
|
103
|
+
def newly = Array(value(:newly_in_report))
|
|
104
|
+
def removed = Array(value(:no_longer_in_report))
|
|
105
|
+
|
|
106
|
+
def summary_status(summary)
|
|
107
|
+
value_from(summary, :status).to_s.upcase.then { |item| item.empty? ? "unknown" : item }
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def value(key) = value_from(@document, key)
|
|
111
|
+
|
|
112
|
+
def value_from(hash, key)
|
|
113
|
+
return nil unless hash.respond_to?(:key?)
|
|
114
|
+
|
|
115
|
+
hash.key?(key) ? hash[key] : hash[key.to_s]
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# rubocop:disable Metrics/ClassLength, Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity, Metrics/BlockLength
|
|
4
|
+
|
|
5
|
+
require "pathname"
|
|
6
|
+
|
|
7
|
+
module Branchproof
|
|
8
|
+
# Derives condition- and test-oriented rows from one report document.
|
|
9
|
+
class CoverageIndex
|
|
10
|
+
attr_reader :conditions, :tests
|
|
11
|
+
|
|
12
|
+
def initialize(document:)
|
|
13
|
+
@document = symbolize(document || {})
|
|
14
|
+
@inventory = symbolize(@document[:source_inventory] || @document[:inventory] || {})
|
|
15
|
+
@evidence = symbolize(@document[:observations] || @document[:evidence] || {})
|
|
16
|
+
@analysis = symbolize(@document[:analysis] || {})
|
|
17
|
+
@baseline = symbolize(@document[:baseline] || {})
|
|
18
|
+
@vectors = records(@evidence, :vectors)
|
|
19
|
+
@vectors_by_decision = @vectors.group_by { |vector| vector[:decision_id].to_s }
|
|
20
|
+
@vectors_by_id = @vectors.to_h { |vector| [vector[:id].to_s, vector] }
|
|
21
|
+
@analysis_by_decision = records(@analysis, :decisions).to_h { |decision| [decision[:decision_id].to_s, decision] }
|
|
22
|
+
@source_units_by_id = records(@inventory, :source_units).to_h { |unit| [unit[:source_id].to_s, unit] }
|
|
23
|
+
@test_locations = symbolize(@document[:run_metadata] || {})[:test_locations] || {}
|
|
24
|
+
@tests_by_id = {}
|
|
25
|
+
(records(@evidence, :tests) + records(@baseline, :tests)).each do |test|
|
|
26
|
+
id = test[:id].to_s
|
|
27
|
+
@tests_by_id[id] = (@tests_by_id[id] || {}).merge(test) unless id.empty?
|
|
28
|
+
end
|
|
29
|
+
@conditions = build_conditions.freeze
|
|
30
|
+
@tests = build_tests.freeze
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def build_conditions
|
|
36
|
+
decisions = records(@inventory, :decisions)
|
|
37
|
+
rows = decisions.flat_map do |decision|
|
|
38
|
+
next [] if decision[:support_status].to_s == "UNSUPPORTED"
|
|
39
|
+
|
|
40
|
+
source = @source_units_by_id[decision[:source_id].to_s] || {}
|
|
41
|
+
result_map = records(analysis_for(decision), :condition_results).to_h do |result|
|
|
42
|
+
[result[:condition_id].to_s, result]
|
|
43
|
+
end
|
|
44
|
+
records(decision, :conditions).map do |condition|
|
|
45
|
+
condition = symbolize(condition)
|
|
46
|
+
result = symbolize(result_map[condition[:id].to_s] || {})
|
|
47
|
+
pair = Array(result[:canonical_pair])
|
|
48
|
+
vectors = @vectors_by_decision[decision[:id].to_s] || []
|
|
49
|
+
observations = { true => [], false => [], short_circuited: [], unattributed: [] }
|
|
50
|
+
vectors.each do |vector|
|
|
51
|
+
value = vector_values(vector)[condition[:index].to_i]
|
|
52
|
+
if value.nil?
|
|
53
|
+
observations[:short_circuited] << vector
|
|
54
|
+
elsif value == true
|
|
55
|
+
observations[true] << vector
|
|
56
|
+
elsif value == false
|
|
57
|
+
observations[false] << vector
|
|
58
|
+
end
|
|
59
|
+
observations[:unattributed] << vector if vector[:unattributed_count].to_i.positive?
|
|
60
|
+
end
|
|
61
|
+
{
|
|
62
|
+
id: condition[:id], decision_id: decision[:id], index: condition[:index],
|
|
63
|
+
expression: condition[:expression],
|
|
64
|
+
line: condition[:line], column: condition[:column], relative_path: relative_path(source[:relative_path]),
|
|
65
|
+
decision_expression: decision[:expression],
|
|
66
|
+
status: @document[:analysis] ? (result[:status] || "NOT_PROVEN") : "NOT CALCULATED",
|
|
67
|
+
observed_true: test_ids(observations[true]), observed_false: test_ids(observations[false]),
|
|
68
|
+
short_circuited: test_ids(observations[:short_circuited]),
|
|
69
|
+
unattributed: observations[:unattributed].sum { |vector| vector[:unattributed_count].to_i },
|
|
70
|
+
witness_vector_ids: pair, witness_owners: pair.flat_map { |id| owners_for(id) }.uniq.sort,
|
|
71
|
+
witness_owner_details: pair.flat_map { |id| owner_details_for(id) }.uniq.sort,
|
|
72
|
+
vectors: observations, constraint_result: result[:constraint_result],
|
|
73
|
+
supporting_set: supporting_set_for(decision[:id]), unexecuted: vectors.empty?
|
|
74
|
+
}
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
rows.sort_by do |row|
|
|
78
|
+
[row[:relative_path].to_s, row[:line].to_i, row[:column].to_i,
|
|
79
|
+
row[:decision_id].to_s, row[:index].to_i]
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def build_tests
|
|
84
|
+
by_id = @tests_by_id.transform_values do |test|
|
|
85
|
+
{ id: test[:id], name: display_name(test), relative_path: test_location(test)[0], line: test_location(test)[1],
|
|
86
|
+
status: test[:status], phases: Array(test[:phase_counts]).to_h.keys.sort,
|
|
87
|
+
observations: [], owns_witness: false }
|
|
88
|
+
end
|
|
89
|
+
@conditions.each do |condition|
|
|
90
|
+
condition[:vectors].each do |kind, vectors|
|
|
91
|
+
next if kind == :unattributed
|
|
92
|
+
|
|
93
|
+
vectors.each do |vector|
|
|
94
|
+
Array(vector[:test_ids]).each do |id|
|
|
95
|
+
row = by_id[id.to_s] ||= { id: id, name: id, relative_path: nil, line: nil, status: "unknown",
|
|
96
|
+
phases: [], observations: [], owns_witness: false }
|
|
97
|
+
phase_map = symbolize(vector[:phases_by_test] || {})
|
|
98
|
+
phases = phase_map[id.to_s] || phase_map[id.to_sym] || []
|
|
99
|
+
row[:phases] |= Array(phases).map(&:to_s)
|
|
100
|
+
row[:observations] << { condition_id: condition[:id], expression: condition[:expression],
|
|
101
|
+
relative_path: condition[:relative_path], line: condition[:line],
|
|
102
|
+
value: kind == :short_circuited ? nil : (kind == true), kind: kind,
|
|
103
|
+
phases: phases.map(&:to_s).sort,
|
|
104
|
+
owns_witness: condition[:witness_vector_ids].include?(vector[:id]) }
|
|
105
|
+
row[:owns_witness] = true if condition[:witness_vector_ids].include?(vector[:id])
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
by_id.each_value do |row|
|
|
111
|
+
row[:observations].uniq!
|
|
112
|
+
row[:phases] = row[:phases].map(&:to_s).uniq.sort
|
|
113
|
+
end
|
|
114
|
+
by_id.values.sort_by { |row| [row[:name].to_s, row[:relative_path].to_s, row[:line].to_i, row[:id].to_s] }
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def analysis_for(decision)
|
|
118
|
+
@analysis_by_decision[decision[:id].to_s] || {}
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def supporting_set_for(decision_id)
|
|
122
|
+
Array(@document[:minima] || @document["minima"]).filter_map do |minimum|
|
|
123
|
+
minimum = symbolize(minimum)
|
|
124
|
+
next unless minimum[:objective].to_s == "tests"
|
|
125
|
+
next unless Array(minimum[:scope_decision_ids]).map(&:to_s).include?(decision_id.to_s)
|
|
126
|
+
|
|
127
|
+
Array(minimum[:selected_ids]).map(&:to_s).sort
|
|
128
|
+
end.first || []
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def owners_for(vector_id)
|
|
132
|
+
vector = @vectors_by_id[vector_id.to_s]
|
|
133
|
+
Array(vector && vector[:test_ids]).map { |id| display_name(@tests_by_id[id.to_s] || { id: id }) }
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def owner_details_for(vector_id)
|
|
137
|
+
vector = @vectors_by_id[vector_id.to_s]
|
|
138
|
+
Array(vector && vector[:test_ids]).map do |id|
|
|
139
|
+
test = @tests_by_id[id.to_s] || { id: id }
|
|
140
|
+
name = display_name(test)
|
|
141
|
+
path, line = test_location(test)
|
|
142
|
+
path && line ? "#{name} (#{path}:#{line})" : name
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def test_ids(vectors) = vectors.flat_map { |vector| Array(vector[:test_ids]) }.map(&:to_s).uniq.sort
|
|
147
|
+
def vector_values(vector) = Array(vector[:values])
|
|
148
|
+
def records(hash, key) = Array(hash[key] || hash[key.to_s]).map { |item| symbolize(item) }
|
|
149
|
+
|
|
150
|
+
def display_name(test)
|
|
151
|
+
return "#{test[:class_name]}##{test[:method_name]}" if test[:class_name] && test[:method_name]
|
|
152
|
+
|
|
153
|
+
(test[:name] || test[:id]).to_s
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def test_location(test)
|
|
157
|
+
saved = symbolize(@test_locations[test[:id].to_s] || @test_locations[test[:id].to_sym] || {})
|
|
158
|
+
return [relative_path(saved[:relative_path]), saved[:line]] unless saved.empty?
|
|
159
|
+
|
|
160
|
+
source = symbolize(test[:source] || {})
|
|
161
|
+
path = source[:relative_path] || source[:path] || test[:source_path]
|
|
162
|
+
[relative_path(path), source[:line] || test[:line] || test[:source_line]]
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def relative_path(path)
|
|
166
|
+
return nil if path.nil? || path.to_s.empty?
|
|
167
|
+
return path.to_s unless Pathname.new(path.to_s).absolute?
|
|
168
|
+
|
|
169
|
+
root = @inventory[:root] || symbolize(@document[:run_metadata] || {})[:project_root]
|
|
170
|
+
return nil unless root && Pathname.new(root.to_s).absolute?
|
|
171
|
+
|
|
172
|
+
Pathname.new(path.to_s).relative_path_from(Pathname.new(root.to_s)).to_s
|
|
173
|
+
rescue ArgumentError
|
|
174
|
+
nil
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def symbolize(value)
|
|
178
|
+
case value
|
|
179
|
+
when Hash then value.each_with_object({}) { |(key, item), result| result[key.to_sym] = symbolize(item) }
|
|
180
|
+
when Array then value.map { |item| symbolize(item) }
|
|
181
|
+
else value
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# rubocop:enable Metrics/ClassLength, Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity, Metrics/BlockLength
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# rubocop:disable Metrics/ClassLength, Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
4
|
+
require "pathname"
|
|
5
|
+
|
|
6
|
+
module Branchproof
|
|
7
|
+
# Terminal renderings grouped around conditions or tests.
|
|
8
|
+
class FocusedReport
|
|
9
|
+
def initialize(document:, view:, level:, missing_only: false)
|
|
10
|
+
@document = document || {}
|
|
11
|
+
@view = view.to_sym
|
|
12
|
+
raise ArgumentError, "view must be :conditions or :tests" unless %i[conditions tests].include?(@view)
|
|
13
|
+
|
|
14
|
+
@level = level.to_i
|
|
15
|
+
@missing_only = missing_only ? true : false
|
|
16
|
+
@index = CoverageIndex.new(document: @document)
|
|
17
|
+
@tests = @index.tests.to_h { |test| [test[:id].to_s, test] }
|
|
18
|
+
@coordinator = Report.from_document(document: @document, level: @level)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def render
|
|
22
|
+
baseline = fetch(@document, :baseline) || {}
|
|
23
|
+
completeness = fetch(@document, :completeness) || {}
|
|
24
|
+
evidence = fetch(@document, :observations) || {}
|
|
25
|
+
lines = ["Branchproof focused view: #{@view}", "Tests: #{fetch(baseline, :status) || "INCOMPLETE"}",
|
|
26
|
+
"Values: T=true, F=false, -=short-circuited"]
|
|
27
|
+
if completeness.values.include?(false) || fetch(baseline, :status).to_s != "PASSED"
|
|
28
|
+
lines << "Warning: failed or incomplete run; observations and proof evidence may be unavailable."
|
|
29
|
+
end
|
|
30
|
+
aborts = fetch(evidence, :abort_counts)
|
|
31
|
+
abort_count = aborts.is_a?(Hash) ? aborts.values.sum(&:to_i) : aborts.to_i
|
|
32
|
+
if abort_count.positive?
|
|
33
|
+
lines << "Warning: #{abort_count} aborted observations; completed vectors do not describe aborted evaluations."
|
|
34
|
+
end
|
|
35
|
+
lines << "Empty groups mean no recorded completed observation."
|
|
36
|
+
lines << ""
|
|
37
|
+
@view == :conditions ? render_conditions(lines) : render_tests(lines)
|
|
38
|
+
render_unowned(lines)
|
|
39
|
+
render_unsupported(lines)
|
|
40
|
+
Array(fetch(@document, :diagnostics)).each do |diagnostic|
|
|
41
|
+
lines << "Diagnostic: #{@coordinator.diagnostic_message(diagnostic)}"
|
|
42
|
+
end
|
|
43
|
+
lines.join("\n") << "\n"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def render_conditions(lines)
|
|
49
|
+
rows = @index.conditions
|
|
50
|
+
rows = rows.reject { |row| row[:status].to_s.upcase == "PROVEN" } if @missing_only && @level > 1
|
|
51
|
+
rows.each do |row|
|
|
52
|
+
lines << "Condition: #{row[:expression]}"
|
|
53
|
+
lines << "Location: #{location(row[:relative_path], row[:line], unavailable: "condition line unavailable")}"
|
|
54
|
+
lines << "Decision: #{row[:decision_expression]} (condition #{row[:index]})"
|
|
55
|
+
lines << "MC/DC: #{@level == 1 ? "NOT CALCULATED" : (row[:status] || "NOT_PROVEN")}"
|
|
56
|
+
render_group(lines, "Evaluated true by", row[:observed_true], row)
|
|
57
|
+
render_group(lines, "Evaluated false by", row[:observed_false], row)
|
|
58
|
+
render_group(lines, "Short-circuited in", row[:short_circuited], row)
|
|
59
|
+
lines << " Unattributed observations: #{row[:unattributed]}" if row[:unattributed].to_i.positive?
|
|
60
|
+
if @level >= 2 && row[:supporting_set].any?
|
|
61
|
+
lines << " Supporting test set: #{row[:supporting_set].map { |id| test_label(id) }.join(", ")}"
|
|
62
|
+
lines << " Tests outside this MC/DC evidence set may provide other coverage."
|
|
63
|
+
end
|
|
64
|
+
render_constraint(lines, row) if @level >= 3 && row[:constraint_result]
|
|
65
|
+
if @level >= 3 && row[:witness_vector_ids].any?
|
|
66
|
+
lines << " Witness observations (owners of example evidence):"
|
|
67
|
+
row[:witness_vector_ids].each do |id|
|
|
68
|
+
vector = row[:vectors].values.flatten.find { |item| item[:id].to_s == id.to_s }
|
|
69
|
+
next unless vector
|
|
70
|
+
|
|
71
|
+
owners = Array(vector[:test_ids]).map { |test_id| test_label(test_id) }.uniq.sort
|
|
72
|
+
owners << "unattributed" if vector[:unattributed_count].to_i.positive?
|
|
73
|
+
owners = owners.join(", ")
|
|
74
|
+
lines << " [#{values(vector)}] => #{vector[:outcome] ? "T" : "F"} — #{owners}"
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
lines << ""
|
|
78
|
+
end
|
|
79
|
+
lines << "No missing conditions" if @missing_only && rows.empty?
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def render_constraint(lines, row)
|
|
83
|
+
lines << @coordinator.condition_explanation(decision_id: row[:decision_id], condition_id: row[:id])
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def render_group(lines, heading, ids, _row)
|
|
87
|
+
lines << " #{heading}:"
|
|
88
|
+
if ids.empty?
|
|
89
|
+
lines << " none recorded"
|
|
90
|
+
else
|
|
91
|
+
ids.each { |id| lines << " #{test_label(id)}" }
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def render_tests(lines)
|
|
96
|
+
rows = @index.tests
|
|
97
|
+
missing_ids = @index.conditions.reject { |row| row[:status].to_s.upcase == "PROVEN" }.map { |row| row[:id] }
|
|
98
|
+
rows.each { |row| render_test_row(lines, row, missing_ids) }
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def render_test_row(lines, row, missing_ids)
|
|
102
|
+
observations = row[:observations]
|
|
103
|
+
if @missing_only
|
|
104
|
+
observations = observations.select { |observation| missing_ids.include?(observation[:condition_id]) }
|
|
105
|
+
return if observations.empty?
|
|
106
|
+
end
|
|
107
|
+
lines << "Test: #{row[:name]}"
|
|
108
|
+
lines << "Location: #{location(row[:relative_path], row[:line], unavailable: "location unavailable")}"
|
|
109
|
+
lines << "Status: #{row[:status] || "unknown"}"
|
|
110
|
+
lines << "Phases: #{row[:phases].join(", ")}" unless row[:phases].empty?
|
|
111
|
+
render_test_observations(lines, observations)
|
|
112
|
+
lines << ""
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def render_test_observations(lines, observations)
|
|
116
|
+
if observations.empty?
|
|
117
|
+
lines << " No recorded completed condition observations"
|
|
118
|
+
return
|
|
119
|
+
end
|
|
120
|
+
observations.group_by { |observation| observation[:condition_id] }.each_value do |items|
|
|
121
|
+
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
|
+
phases = items.flat_map { |item| item[:phases] }.uniq.sort.join(", ")
|
|
126
|
+
signs = values(values: observed_values)
|
|
127
|
+
condition_location = location(observation[:relative_path], observation[:line],
|
|
128
|
+
unavailable: "condition line unavailable")
|
|
129
|
+
lines << " #{observation[:expression]} (#{condition_location}): #{signs}; #{label}; " \
|
|
130
|
+
"phases: #{phases}"
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def render_unowned(lines)
|
|
135
|
+
rows = @index.conditions
|
|
136
|
+
rows = rows.reject { |row| row[:status] == "PROVEN" } if @missing_only
|
|
137
|
+
{ "Unexecuted conditions" => rows.select { |row| row[:unexecuted] },
|
|
138
|
+
"Unattributed evidence" => rows.select { |row| row[:unattributed].positive? } }.each do |heading, conditions|
|
|
139
|
+
next if conditions.empty?
|
|
140
|
+
|
|
141
|
+
lines << "#{heading}:"
|
|
142
|
+
conditions.each do |row|
|
|
143
|
+
lines << " #{row[:expression]} (#{location(row[:relative_path], row[:line],
|
|
144
|
+
unavailable: "condition line unavailable")})"
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def render_unsupported(lines)
|
|
150
|
+
inventory = fetch(@document, :source_inventory) || {}
|
|
151
|
+
sources = Array(fetch(inventory, :source_units)).to_h { |source| [fetch(source, :source_id), source] }
|
|
152
|
+
excluded = Array(fetch(inventory, :decisions)).select do |decision|
|
|
153
|
+
fetch(decision, :support_status) == "UNSUPPORTED"
|
|
154
|
+
end
|
|
155
|
+
return if excluded.empty?
|
|
156
|
+
|
|
157
|
+
lines << "Unsupported conditions (MC/DC unavailable):"
|
|
158
|
+
excluded.each do |decision|
|
|
159
|
+
source = sources[fetch(decision, :source_id)] || {}
|
|
160
|
+
path = fetch(source, :relative_path)
|
|
161
|
+
path = nil if path && Pathname.new(path).absolute?
|
|
162
|
+
conditions = Array(fetch(decision, :conditions))
|
|
163
|
+
conditions = [{ expression: fetch(decision, :expression) }] if conditions.empty?
|
|
164
|
+
conditions.each do |condition|
|
|
165
|
+
label = location(path, fetch(condition, :line), unavailable: "condition line unavailable")
|
|
166
|
+
lines << " #{fetch(condition, :expression)} (#{label})"
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def location(path, line, unavailable: "location unavailable")
|
|
172
|
+
return "location unavailable" if path.to_s.empty?
|
|
173
|
+
return "#{path}: #{unavailable}" if line.nil?
|
|
174
|
+
|
|
175
|
+
"#{path}:#{line}"
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def values(vector)
|
|
179
|
+
Array(vector[:values]).map do |item|
|
|
180
|
+
if item.nil?
|
|
181
|
+
"-"
|
|
182
|
+
else
|
|
183
|
+
(item ? "T" : "F")
|
|
184
|
+
end
|
|
185
|
+
end.join
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def test_label(id)
|
|
189
|
+
test = @tests[id.to_s]
|
|
190
|
+
return id.to_s unless test
|
|
191
|
+
|
|
192
|
+
label = "#{test[:name]} (#{location(test[:relative_path], test[:line])})"
|
|
193
|
+
duplicates = @index.tests.count do |row|
|
|
194
|
+
[row[:name], row[:relative_path], row[:line]] == [test[:name], test[:relative_path], test[:line]]
|
|
195
|
+
end
|
|
196
|
+
duplicates > 1 ? "#{label} [#{id}]" : label
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def fetch(hash, key)
|
|
200
|
+
hash.key?(key) ? hash[key] : hash[key.to_s]
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# rubocop:enable Metrics/ClassLength, Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
data/lib/branchproof/loader.rb
CHANGED
|
@@ -55,7 +55,7 @@ module Branchproof
|
|
|
55
55
|
end
|
|
56
56
|
unless rewritten[:changed]
|
|
57
57
|
if Array(rewritten[:diagnostics]).empty?
|
|
58
|
-
add_diagnostic("not_instrumented",
|
|
58
|
+
add_diagnostic("not_instrumented", unchanged_reason(unit), unit[:source_id],
|
|
59
59
|
severity: "info")
|
|
60
60
|
end
|
|
61
61
|
return nil
|
|
@@ -80,6 +80,14 @@ module Branchproof
|
|
|
80
80
|
|
|
81
81
|
private
|
|
82
82
|
|
|
83
|
+
def unchanged_reason(unit)
|
|
84
|
+
decisions = Array(unit[:decisions])
|
|
85
|
+
reasons = decisions.flat_map { |decision| Array(decision[:support_reasons]) }.uniq
|
|
86
|
+
return "conditions cannot be instrumented: #{reasons.join(", ")}" unless reasons.empty?
|
|
87
|
+
|
|
88
|
+
"no supported conditions to instrument"
|
|
89
|
+
end
|
|
90
|
+
|
|
83
91
|
def hook_supported?
|
|
84
92
|
defined?(RubyVM::InstructionSequence) && RubyVM::InstructionSequence.respond_to?(:compile)
|
|
85
93
|
end
|