branchproof 0.2.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 +18 -0
- data/LICENSE +201 -0
- data/README.md +144 -8
- 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 +18 -0
- data/doc/README.md +144 -8
- data/exe/branchproof +6 -0
- data/lib/branchproof/analyzer.rb +1 -1
- data/lib/branchproof/cli.rb +158 -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 +196 -12
- 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 +14 -1
|
@@ -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
|
|
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,6 +22,20 @@ module Branchproof
|
|
|
18
22
|
@baseline = baseline || {}
|
|
19
23
|
@diagnostics = Array(diagnostics)
|
|
20
24
|
@level = level.to_i
|
|
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)
|
|
21
39
|
end
|
|
22
40
|
|
|
23
41
|
def write(io:, format:)
|
|
@@ -28,6 +46,18 @@ module Branchproof
|
|
|
28
46
|
nil
|
|
29
47
|
end
|
|
30
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
|
+
|
|
31
61
|
def exit_code
|
|
32
62
|
return 2 unless usage_valid?
|
|
33
63
|
|
|
@@ -35,6 +65,7 @@ module Branchproof
|
|
|
35
65
|
return 2 if %w[ERROR INCOMPLETE].include?(status)
|
|
36
66
|
return 1 if status == "FAILED"
|
|
37
67
|
return 2 unless status == "PASSED" && value(@baseline, :finalized) == true
|
|
68
|
+
return 2 if @saved_document && (value(@saved_document, :completeness) || {}).values.include?(false)
|
|
38
69
|
return 2 unless metrics[:eligible_conditions].positive?
|
|
39
70
|
return 2 unless valid_for_requested_level?
|
|
40
71
|
|
|
@@ -44,16 +75,24 @@ module Branchproof
|
|
|
44
75
|
private
|
|
45
76
|
|
|
46
77
|
def json_document
|
|
78
|
+
return normalize(@saved_document) if @saved_document
|
|
79
|
+
|
|
47
80
|
normalize(schema_version: SCHEMA_VERSION,
|
|
48
81
|
tool_version: (defined?(Branchproof::VERSION) ? Branchproof::VERSION : "unknown"),
|
|
49
82
|
criterion_version: CRITERION_VERSION, runtime: RUBY_DESCRIPTION,
|
|
50
83
|
run_ids: Array(value(@evidence, :run_ids)),
|
|
51
84
|
source_inventory: @inventory, baseline: @baseline, observations: @evidence,
|
|
52
85
|
analysis: @level == 1 ? nil : @analysis, minima: @minima, metrics: metrics,
|
|
53
|
-
diagnostics: @diagnostics, completeness: completeness
|
|
86
|
+
diagnostics: @diagnostics, completeness: completeness,
|
|
87
|
+
run_metadata: @run_metadata)
|
|
54
88
|
end
|
|
55
89
|
|
|
56
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
|
+
|
|
57
96
|
@terminal_ids = terminal_ids
|
|
58
97
|
lines = ["Branchproof #{defined?(Branchproof::VERSION) ? Branchproof::VERSION : "unknown"}",
|
|
59
98
|
"Tests: #{baseline_status} (#{baseline_test_counts})",
|
|
@@ -64,10 +103,11 @@ module Branchproof
|
|
|
64
103
|
"Observations: #{metrics[:completed]} completed, #{metrics[:aborted]} aborted, " \
|
|
65
104
|
"#{metrics[:unattributed]} unattributed",
|
|
66
105
|
"Values: T=true, F=false, -=short-circuited"]
|
|
106
|
+
lines << missing_summary_line if @missing_only
|
|
67
107
|
lines << "Scope: supported decisions and conditions"
|
|
68
108
|
lines << ""
|
|
69
|
-
|
|
70
|
-
render_minima(lines)
|
|
109
|
+
decisions_to_render.each { |decision| render_decision(lines, decision) }
|
|
110
|
+
render_minima(lines) unless @missing_only
|
|
71
111
|
unless @diagnostics.empty?
|
|
72
112
|
lines << "Diagnostics:"
|
|
73
113
|
@diagnostics.each { |diagnostic| lines << " - #{value(diagnostic, :message) || value(diagnostic, :code)}" }
|
|
@@ -82,9 +122,9 @@ module Branchproof
|
|
|
82
122
|
lines << decision_label
|
|
83
123
|
lines << " Decision: #{value(decision, :expression)}"
|
|
84
124
|
lines << " Status: #{value(decision, :support_status) || "SUPPORTED"}"
|
|
85
|
-
|
|
125
|
+
conditions_to_render(decision).each do |condition|
|
|
86
126
|
result = condition_result(decision, condition)
|
|
87
|
-
detail = condition_detail(result)
|
|
127
|
+
detail = condition_detail(decision, condition, result)
|
|
88
128
|
status = if @analysis.nil? || @level == 1
|
|
89
129
|
"NOT CALCULATED"
|
|
90
130
|
else
|
|
@@ -93,7 +133,7 @@ module Branchproof
|
|
|
93
133
|
lines << " Condition #{value(condition, :index)}: #{value(condition, :expression)}"
|
|
94
134
|
lines << " #{status}#{detail}"
|
|
95
135
|
end
|
|
96
|
-
|
|
136
|
+
vectors_to_render(decision).each do |vector|
|
|
97
137
|
values = Array(value(vector, :values)).map do |item|
|
|
98
138
|
if item.nil?
|
|
99
139
|
"-"
|
|
@@ -169,6 +209,11 @@ module Branchproof
|
|
|
169
209
|
[value(result, :condition_id), *Array(value(result, :canonical_pair))]
|
|
170
210
|
end
|
|
171
211
|
end)
|
|
212
|
+
ids.concat(Array(value(@analysis, :decisions)).flat_map do |decision|
|
|
213
|
+
Array(value(decision, :condition_results)).flat_map do |result|
|
|
214
|
+
Array(value(value(result, :constraint_result), :candidate_vectors)).map { |vector| value(vector, :id) }
|
|
215
|
+
end
|
|
216
|
+
end)
|
|
172
217
|
ids = ids.compact.map(&:to_s).reject(&:empty?).uniq
|
|
173
218
|
by_prefix = ids.group_by { |id| id[0, 8] }
|
|
174
219
|
ids.to_h do |id|
|
|
@@ -217,17 +262,32 @@ module Branchproof
|
|
|
217
262
|
end
|
|
218
263
|
|
|
219
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
|
+
|
|
220
268
|
location = value(test, :source)
|
|
221
269
|
source_line = nil
|
|
222
270
|
if location.respond_to?(:key?)
|
|
223
271
|
source_line = value(location, :line)
|
|
224
|
-
location = value(location, :
|
|
272
|
+
location = value(location, :relative_path) || value(location, :path)
|
|
225
273
|
end
|
|
226
274
|
location ||= value(test, :source_path)
|
|
275
|
+
location = relative_test_path(location)
|
|
227
276
|
line = value(test, :line) || value(test, :source_line) || source_line
|
|
228
277
|
[location, line]
|
|
229
278
|
end
|
|
230
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
|
+
|
|
231
291
|
def minimum_member_label(objective, id)
|
|
232
292
|
objective.to_s == "tests" ? test_label(id) : short_id(id)
|
|
233
293
|
end
|
|
@@ -264,24 +324,104 @@ module Branchproof
|
|
|
264
324
|
end
|
|
265
325
|
end
|
|
266
326
|
|
|
267
|
-
def condition_detail(result)
|
|
268
|
-
return "" unless result && @level == 3
|
|
327
|
+
def condition_detail(decision, _condition, result)
|
|
328
|
+
return "" unless result && (@level == 3 || @missing_only)
|
|
269
329
|
|
|
270
330
|
pair = value(result, :canonical_pair)
|
|
271
331
|
return " (witness #{Array(pair).map { |id| short_id(id) }.join(" + ")})" if pair
|
|
272
332
|
|
|
273
333
|
constraint = value(result, :constraint_result)
|
|
274
334
|
if constraint
|
|
275
|
-
constraints =
|
|
335
|
+
constraints = readable_constraints(decision, constraint)
|
|
276
336
|
detail = constraints.empty? ? value(constraint, :status) : constraints.join(", ")
|
|
277
337
|
statement = value(constraint, :feasibility_statement)
|
|
278
338
|
detail = [detail, statement].compact.reject(&:empty?).join("; ")
|
|
339
|
+
candidates = Array(value(constraint, :candidate_vectors))
|
|
340
|
+
existing = existing_vector_for(constraint)
|
|
341
|
+
unless candidates.empty?
|
|
342
|
+
detail_lines = [" — missing observation"]
|
|
343
|
+
candidates.each do |candidate|
|
|
344
|
+
detail_lines << " Need an observation where:"
|
|
345
|
+
detail_lines.concat(candidate_requirements(decision, candidate).map { |item| " #{item}" })
|
|
346
|
+
outcome = value(candidate, :outcome) ? "true" : "false"
|
|
347
|
+
detail_lines << " Expected decision: #{outcome} [#{vector_values(candidate)}]"
|
|
348
|
+
end
|
|
349
|
+
detail_lines << " #{statement}" unless statement.to_s.empty?
|
|
350
|
+
detail_lines << " Compare with: #{existing_vector_context(existing)}"
|
|
351
|
+
return detail_lines.join("\n")
|
|
352
|
+
end
|
|
353
|
+
unless existing.nil?
|
|
354
|
+
owner_ids = Array(value(existing, :test_ids))
|
|
355
|
+
owners = owner_ids.first(2).map { |id| test_label(id) }
|
|
356
|
+
owners << "#{owner_ids.length - 2} more" if owner_ids.length > 2
|
|
357
|
+
owner_detail = owners.empty? ? nil : " (#{owners.join(", ")})"
|
|
358
|
+
detail = [detail, "existing observation: #{short_id(value(existing, :id))}#{owner_detail}"].compact.join("; ")
|
|
359
|
+
end
|
|
279
360
|
return " (missing counterpart: #{detail})" unless detail.empty?
|
|
280
361
|
end
|
|
281
362
|
|
|
282
363
|
""
|
|
283
364
|
end
|
|
284
365
|
|
|
366
|
+
def readable_constraints(decision, constraint)
|
|
367
|
+
Array(value(constraint, :constraints)).filter_map do |item|
|
|
368
|
+
if item.respond_to?(:key?)
|
|
369
|
+
index = value(item, :condition_index)
|
|
370
|
+
expression = condition_expression(decision, index) || "condition #{index}"
|
|
371
|
+
required = value(item, :required)
|
|
372
|
+
required = value(item, :value) if required.nil?
|
|
373
|
+
"requires #{expression}=#{required ? "true" : "false"}"
|
|
374
|
+
else
|
|
375
|
+
item.to_s
|
|
376
|
+
end
|
|
377
|
+
end
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
def condition_expression(decision, index)
|
|
381
|
+
condition = Array(value(decision, :conditions)).find { |item| value(item, :index).to_i == index.to_i }
|
|
382
|
+
value(condition, :expression)
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
def candidate_requirements(decision, candidate)
|
|
386
|
+
Array(value(candidate, :values)).each_with_index.map do |required, index|
|
|
387
|
+
expression = condition_expression(decision, index) || "condition #{index}"
|
|
388
|
+
if required.nil?
|
|
389
|
+
"#{expression} is not evaluated (short-circuited)"
|
|
390
|
+
else
|
|
391
|
+
"#{expression} is #{required ? "truthy" : "falsey"}"
|
|
392
|
+
end
|
|
393
|
+
end
|
|
394
|
+
end
|
|
395
|
+
|
|
396
|
+
def vector_values(vector)
|
|
397
|
+
Array(value(vector, :values)).map do |item|
|
|
398
|
+
if item.nil?
|
|
399
|
+
"-"
|
|
400
|
+
elsif item
|
|
401
|
+
"T"
|
|
402
|
+
else
|
|
403
|
+
"F"
|
|
404
|
+
end
|
|
405
|
+
end.join
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
def existing_vector_context(existing)
|
|
409
|
+
return "no existing effective observation" unless existing
|
|
410
|
+
|
|
411
|
+
owner_ids = Array(value(existing, :test_ids))
|
|
412
|
+
owners = owner_ids.first(2).map { |id| test_label(id) }
|
|
413
|
+
owners << "#{owner_ids.length - 2} more" if owner_ids.length > 2
|
|
414
|
+
context = "[#{vector_values(existing)}] => #{value(existing, :outcome) ? "true" : "false"}"
|
|
415
|
+
owners.empty? ? context : "#{context} (#{owners.join(", ")})"
|
|
416
|
+
end
|
|
417
|
+
|
|
418
|
+
def existing_vector_for(constraint)
|
|
419
|
+
id = value(constraint, :existing_vector_id).to_s
|
|
420
|
+
return if id.empty?
|
|
421
|
+
|
|
422
|
+
vectors.find { |vector| value(vector, :id).to_s == id }
|
|
423
|
+
end
|
|
424
|
+
|
|
285
425
|
def metrics
|
|
286
426
|
decisions = inventory_decisions
|
|
287
427
|
unsupported, supported = decisions.partition { |decision| unsupported?(decision) }
|
|
@@ -349,6 +489,50 @@ module Branchproof
|
|
|
349
489
|
end
|
|
350
490
|
end
|
|
351
491
|
|
|
492
|
+
def decisions_to_render
|
|
493
|
+
return inventory_decisions unless @missing_only
|
|
494
|
+
return [] unless analysis_available?
|
|
495
|
+
|
|
496
|
+
inventory_decisions
|
|
497
|
+
.reject { |decision| unsupported?(decision) }
|
|
498
|
+
.select { |decision| conditions_to_render(decision).any? }
|
|
499
|
+
end
|
|
500
|
+
|
|
501
|
+
def conditions_to_render(decision)
|
|
502
|
+
conditions = Array(value(decision, :conditions))
|
|
503
|
+
return conditions unless @missing_only && analysis_available?
|
|
504
|
+
|
|
505
|
+
conditions.reject { |condition| value(condition_result(decision, condition), :status).to_s.upcase == "PROVEN" }
|
|
506
|
+
end
|
|
507
|
+
|
|
508
|
+
def vectors_to_render(decision)
|
|
509
|
+
return vectors_for(decision) unless @missing_only
|
|
510
|
+
|
|
511
|
+
[]
|
|
512
|
+
end
|
|
513
|
+
|
|
514
|
+
def analysis_available?
|
|
515
|
+
!@analysis.nil? && @level > 1
|
|
516
|
+
end
|
|
517
|
+
|
|
518
|
+
def analysis_complete?
|
|
519
|
+
analysis_available? && baseline_status == "PASSED" && completeness.values.all?
|
|
520
|
+
end
|
|
521
|
+
|
|
522
|
+
def missing_condition_count
|
|
523
|
+
decisions_to_render.sum { |decision| conditions_to_render(decision).length }
|
|
524
|
+
end
|
|
525
|
+
|
|
526
|
+
def missing_summary_line
|
|
527
|
+
return "Missing conditions: Cannot identify missing conditions (analysis unavailable)" unless analysis_available?
|
|
528
|
+
return "Missing conditions: Cannot identify missing conditions (analysis incomplete)" unless analysis_complete?
|
|
529
|
+
return "No eligible conditions" if metrics[:eligible_conditions].zero?
|
|
530
|
+
return "No missing conditions" if analysis_complete? && missing_condition_count.zero?
|
|
531
|
+
|
|
532
|
+
count = decisions_to_render.length
|
|
533
|
+
"Missing conditions: #{missing_condition_count} across #{count} #{count == 1 ? "decision" : "decisions"}"
|
|
534
|
+
end
|
|
535
|
+
|
|
352
536
|
def analysis_for(decision)
|
|
353
537
|
Array(value(@analysis, :decisions)).find do |item|
|
|
354
538
|
value(item, :decision_id).to_s == value(decision, :id).to_s
|