branchproof 0.3.0 → 0.4.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 +12 -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 +8 -1
- data/doc/Branchproof/SavedReport.md +32 -0
- data/doc/Branchproof.md +6 -1
- data/doc/CHANGELOG.md +12 -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/report.rb +58 -5
- 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 +30 -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: #{fetch(diagnostic, :message) || fetch(diagnostic, :code)}"
|
|
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/report.rb
CHANGED
|
@@ -1,15 +1,19 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "json"
|
|
4
|
+
require "pathname"
|
|
4
5
|
|
|
5
6
|
module Branchproof
|
|
6
7
|
# Renders versioned terminal and JSON analysis reports.
|
|
7
8
|
class Report
|
|
8
|
-
SCHEMA_VERSION = "1.
|
|
9
|
+
SCHEMA_VERSION = "1.1"
|
|
9
10
|
CRITERION_VERSION = "masking_occurrence_v1"
|
|
10
11
|
|
|
11
|
-
def initialize(inventory:, evidence:, analysis:, minima:, baseline:, diagnostics:, level: 3, missing_only: false
|
|
12
|
+
def initialize(inventory:, evidence:, analysis:, minima:, baseline:, diagnostics:, level: 3, missing_only: false,
|
|
13
|
+
view: :decisions, run_metadata: {}, saved_document: nil)
|
|
12
14
|
raise ArgumentError, "level must be 1, 2, or 3" unless [1, 2, 3].include?(level.to_i)
|
|
15
|
+
raise ArgumentError, "view must be :decisions, :conditions, or :tests" unless %i[decisions conditions
|
|
16
|
+
tests].include?(view.to_sym)
|
|
13
17
|
|
|
14
18
|
@inventory = inventory || {}
|
|
15
19
|
@evidence = evidence || {}
|
|
@@ -18,7 +22,20 @@ module Branchproof
|
|
|
18
22
|
@baseline = baseline || {}
|
|
19
23
|
@diagnostics = Array(diagnostics)
|
|
20
24
|
@level = level.to_i
|
|
21
|
-
@missing_only =
|
|
25
|
+
@missing_only = missing_only ? true : false
|
|
26
|
+
@view = view.to_sym
|
|
27
|
+
@run_metadata = run_metadata || {}
|
|
28
|
+
@saved_document = saved_document
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def self.from_document(document:, level: nil, view: :decisions, missing_only: false)
|
|
32
|
+
data = document || {}
|
|
33
|
+
new(inventory: data[:source_inventory] || data["source_inventory"] || data[:inventory] || data["inventory"],
|
|
34
|
+
evidence: data[:observations] || data["observations"] || data[:evidence] || data["evidence"],
|
|
35
|
+
analysis: data[:analysis] || data["analysis"], minima: data[:minima] || data["minima"],
|
|
36
|
+
baseline: data[:baseline] || data["baseline"], diagnostics: data[:diagnostics] || data["diagnostics"],
|
|
37
|
+
level: level || (data[:analysis] || data["analysis"] ? 3 : 1), view: view, missing_only: missing_only,
|
|
38
|
+
run_metadata: data[:run_metadata] || data["run_metadata"], saved_document: data)
|
|
22
39
|
end
|
|
23
40
|
|
|
24
41
|
def write(io:, format:)
|
|
@@ -29,6 +46,18 @@ module Branchproof
|
|
|
29
46
|
nil
|
|
30
47
|
end
|
|
31
48
|
|
|
49
|
+
# Shares the existing missing-case wording with focused terminal views.
|
|
50
|
+
def condition_explanation(decision_id:, condition_id:)
|
|
51
|
+
decision = inventory_decisions.find { |item| value(item, :id).to_s == decision_id.to_s }
|
|
52
|
+
return "" unless decision
|
|
53
|
+
|
|
54
|
+
condition = Array(value(decision, :conditions)).find { |item| value(item, :id).to_s == condition_id.to_s }
|
|
55
|
+
return "" unless condition
|
|
56
|
+
|
|
57
|
+
@terminal_ids ||= terminal_ids
|
|
58
|
+
condition_detail(decision, condition, condition_result(decision, condition))
|
|
59
|
+
end
|
|
60
|
+
|
|
32
61
|
def exit_code
|
|
33
62
|
return 2 unless usage_valid?
|
|
34
63
|
|
|
@@ -36,6 +65,7 @@ module Branchproof
|
|
|
36
65
|
return 2 if %w[ERROR INCOMPLETE].include?(status)
|
|
37
66
|
return 1 if status == "FAILED"
|
|
38
67
|
return 2 unless status == "PASSED" && value(@baseline, :finalized) == true
|
|
68
|
+
return 2 if @saved_document && (value(@saved_document, :completeness) || {}).values.include?(false)
|
|
39
69
|
return 2 unless metrics[:eligible_conditions].positive?
|
|
40
70
|
return 2 unless valid_for_requested_level?
|
|
41
71
|
|
|
@@ -45,16 +75,24 @@ module Branchproof
|
|
|
45
75
|
private
|
|
46
76
|
|
|
47
77
|
def json_document
|
|
78
|
+
return normalize(@saved_document) if @saved_document
|
|
79
|
+
|
|
48
80
|
normalize(schema_version: SCHEMA_VERSION,
|
|
49
81
|
tool_version: (defined?(Branchproof::VERSION) ? Branchproof::VERSION : "unknown"),
|
|
50
82
|
criterion_version: CRITERION_VERSION, runtime: RUBY_DESCRIPTION,
|
|
51
83
|
run_ids: Array(value(@evidence, :run_ids)),
|
|
52
84
|
source_inventory: @inventory, baseline: @baseline, observations: @evidence,
|
|
53
85
|
analysis: @level == 1 ? nil : @analysis, minima: @minima, metrics: metrics,
|
|
54
|
-
diagnostics: @diagnostics, completeness: completeness
|
|
86
|
+
diagnostics: @diagnostics, completeness: completeness,
|
|
87
|
+
run_metadata: @run_metadata)
|
|
55
88
|
end
|
|
56
89
|
|
|
57
90
|
def terminal_document
|
|
91
|
+
unless @view == :decisions
|
|
92
|
+
return FocusedReport.new(document: json_document, view: @view, level: @level,
|
|
93
|
+
missing_only: @missing_only).render
|
|
94
|
+
end
|
|
95
|
+
|
|
58
96
|
@terminal_ids = terminal_ids
|
|
59
97
|
lines = ["Branchproof #{defined?(Branchproof::VERSION) ? Branchproof::VERSION : "unknown"}",
|
|
60
98
|
"Tests: #{baseline_status} (#{baseline_test_counts})",
|
|
@@ -224,17 +262,32 @@ module Branchproof
|
|
|
224
262
|
end
|
|
225
263
|
|
|
226
264
|
def test_location(test)
|
|
265
|
+
saved_location = value(value(@run_metadata, :test_locations), value(test, :id))
|
|
266
|
+
return [relative_test_path(value(saved_location, :relative_path)), value(saved_location, :line)] if saved_location
|
|
267
|
+
|
|
227
268
|
location = value(test, :source)
|
|
228
269
|
source_line = nil
|
|
229
270
|
if location.respond_to?(:key?)
|
|
230
271
|
source_line = value(location, :line)
|
|
231
|
-
location = value(location, :
|
|
272
|
+
location = value(location, :relative_path) || value(location, :path)
|
|
232
273
|
end
|
|
233
274
|
location ||= value(test, :source_path)
|
|
275
|
+
location = relative_test_path(location)
|
|
234
276
|
line = value(test, :line) || value(test, :source_line) || source_line
|
|
235
277
|
[location, line]
|
|
236
278
|
end
|
|
237
279
|
|
|
280
|
+
def relative_test_path(path)
|
|
281
|
+
return path unless path && Pathname.new(path.to_s).absolute?
|
|
282
|
+
|
|
283
|
+
root = value(@inventory, :root) || value(@run_metadata, :project_root)
|
|
284
|
+
return nil unless root && Pathname.new(root.to_s).absolute?
|
|
285
|
+
|
|
286
|
+
Pathname.new(path.to_s).relative_path_from(Pathname.new(root.to_s)).to_s
|
|
287
|
+
rescue ArgumentError
|
|
288
|
+
nil
|
|
289
|
+
end
|
|
290
|
+
|
|
238
291
|
def minimum_member_label(objective, id)
|
|
239
292
|
objective.to_s == "tests" ? test_label(id) : short_id(id)
|
|
240
293
|
end
|