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.
@@ -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
@@ -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.0"
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 = !!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, :path) || value(location, :relative_path)
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