branchproof 0.2.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 +7 -0
- data/CHANGELOG.md +36 -0
- data/CODE_OF_CONDUCT.md +10 -0
- data/LICENSE.txt +202 -0
- data/NOTICE +3 -0
- data/README.md +242 -0
- data/doc/Branchproof/Analyzer.md +26 -0
- data/doc/Branchproof/CLI.md +15 -0
- data/doc/Branchproof/Error.md +6 -0
- data/doc/Branchproof/Evidence.md +46 -0
- data/doc/Branchproof/Instrumenter.md +18 -0
- data/doc/Branchproof/Limits.md +21 -0
- data/doc/Branchproof/Loader.md +25 -0
- data/doc/Branchproof/Minimizer.md +15 -0
- data/doc/Branchproof/MinitestAdapter.md +28 -0
- data/doc/Branchproof/Project.md +23 -0
- data/doc/Branchproof/RailsSupport/Error.md +6 -0
- data/doc/Branchproof/RailsSupport.md +32 -0
- data/doc/Branchproof/Records.md +38 -0
- data/doc/Branchproof/Report.md +26 -0
- data/doc/Branchproof/Runtime.md +37 -0
- data/doc/Branchproof/Source.md +23 -0
- data/doc/Branchproof/Worker.md +45 -0
- data/doc/Branchproof.md +33 -0
- data/doc/CHANGELOG.md +36 -0
- data/doc/README.md +242 -0
- data/exe/mcdc +6 -0
- data/lib/branchproof/analyzer.rb +454 -0
- data/lib/branchproof/cli.rb +266 -0
- data/lib/branchproof/evidence.rb +484 -0
- data/lib/branchproof/instrumenter.rb +150 -0
- data/lib/branchproof/limits.rb +44 -0
- data/lib/branchproof/loader.rb +140 -0
- data/lib/branchproof/minimizer.rb +198 -0
- data/lib/branchproof/minitest_adapter.rb +245 -0
- data/lib/branchproof/project.rb +53 -0
- data/lib/branchproof/rails_support.rb +74 -0
- data/lib/branchproof/records.rb +76 -0
- data/lib/branchproof/report.rb +412 -0
- data/lib/branchproof/runtime.rb +171 -0
- data/lib/branchproof/source.rb +238 -0
- data/lib/branchproof/version.rb +5 -0
- data/lib/branchproof/worker.rb +145 -0
- data/lib/branchproof.rb +24 -0
- data/lib/mcdc.rb +3 -0
- data/llms.txt +33 -0
- data/sig/branchproof.rbs +116 -0
- metadata +132 -0
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Branchproof
|
|
4
|
+
# Boots the selected Rails application after Branchproof's load hook is active.
|
|
5
|
+
module RailsSupport
|
|
6
|
+
class Error < StandardError; end
|
|
7
|
+
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
def boot(project:)
|
|
11
|
+
environment = environment_path(project)
|
|
12
|
+
raise Error, "Rails project is missing #{environment}" unless File.file?(environment)
|
|
13
|
+
|
|
14
|
+
boot_environment(environment)
|
|
15
|
+
metadata
|
|
16
|
+
rescue Error
|
|
17
|
+
raise
|
|
18
|
+
rescue LoadError => e
|
|
19
|
+
raise Error, "Rails boot could not load #{e.path || e.message}: #{e.message}"
|
|
20
|
+
rescue StandardError => e
|
|
21
|
+
raise Error, "Rails boot failed: #{e.class}: #{e.message}"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def environment_path(project)
|
|
25
|
+
root = File.expand_path(project.fetch(:root).to_s)
|
|
26
|
+
File.join(root, "config", "environment.rb")
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def boot_environment(environment)
|
|
30
|
+
require environment
|
|
31
|
+
validate_application!
|
|
32
|
+
require "rails/test_help"
|
|
33
|
+
validate_application!
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def validate_application!
|
|
37
|
+
application = rails_application
|
|
38
|
+
validate_test_environment
|
|
39
|
+
|
|
40
|
+
reloading = reloading_enabled?(application.config)
|
|
41
|
+
raise Error, "Rails reloading is unsupported; set config.enable_reloading = false" if reloading
|
|
42
|
+
|
|
43
|
+
nil
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def rails_application
|
|
47
|
+
if defined?(Rails) && Rails.respond_to?(:application) && Rails.application
|
|
48
|
+
application = Rails.application
|
|
49
|
+
return application unless application.respond_to?(:initialized?) && !application.initialized?
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
raise Error, "Rails boot did not initialize Rails.application"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def validate_test_environment
|
|
56
|
+
return if Rails.respond_to?(:env) && Rails.env.to_s == "test"
|
|
57
|
+
|
|
58
|
+
actual = Rails.respond_to?(:env) ? Rails.env : "unknown"
|
|
59
|
+
raise Error, "Rails boot must use the test environment (got #{actual})"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def reloading_enabled?(config)
|
|
63
|
+
return config.enable_reloading if config.respond_to?(:enable_reloading)
|
|
64
|
+
return !config.cache_classes if config.respond_to?(:cache_classes)
|
|
65
|
+
|
|
66
|
+
false
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def metadata
|
|
70
|
+
{ rails_version: Rails::VERSION::STRING,
|
|
71
|
+
serial_policy: { mode: "single_process", workers: 1, parallel_workers: 1 } }
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module Branchproof
|
|
7
|
+
# Builds immutable records and stable identifiers for analysis artifacts.
|
|
8
|
+
module Records
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def build(value)
|
|
12
|
+
deep_freeze(value)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def id(value)
|
|
16
|
+
Digest::SHA256.hexdigest(canonical(value))
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def canonical(value)
|
|
20
|
+
JSON.generate(normalize(value))
|
|
21
|
+
rescue Encoding::InvalidByteSequenceError, Encoding::UndefinedConversionError, JSON::GeneratorError => e
|
|
22
|
+
raise ArgumentError, "record contains invalid text encoding: #{e.message}"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def diagnostic(code:, message:, severity: "warning", source_id: nil, decision_id: nil,
|
|
26
|
+
execution_id: nil, test_id: nil, details: {})
|
|
27
|
+
build(code: code.to_s, severity: severity.to_s, message: message.to_s,
|
|
28
|
+
source_id: source_id, decision_id: decision_id, execution_id: execution_id,
|
|
29
|
+
test_id: test_id, details: { items: [], **details })
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def source_id(relative_path:, digest:, encoding: "UTF-8")
|
|
33
|
+
id(relative_path: relative_path, digest: digest, encoding: encoding)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def condition_id(decision_id, index)
|
|
37
|
+
id(decision_id: decision_id, index: index)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def decision_id(source_id:, context:, byte_start:, byte_length:, tree:)
|
|
41
|
+
id(source_id: source_id, context: context, byte_start: byte_start,
|
|
42
|
+
byte_length: byte_length, tree: tree)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def deep_freeze(value)
|
|
46
|
+
case value
|
|
47
|
+
when Hash
|
|
48
|
+
value.transform_values { |item| deep_freeze(item) }.freeze
|
|
49
|
+
when Array
|
|
50
|
+
value.map { |item| deep_freeze(item) }.freeze
|
|
51
|
+
when String
|
|
52
|
+
value.dup.freeze
|
|
53
|
+
else
|
|
54
|
+
value.freeze
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def normalize(value)
|
|
59
|
+
case value
|
|
60
|
+
when Hash
|
|
61
|
+
normalized = value.keys.map(&:to_s)
|
|
62
|
+
raise ArgumentError, "record contains colliding hash keys" unless normalized.uniq.length == normalized.length
|
|
63
|
+
|
|
64
|
+
value.keys.sort_by(&:to_s).to_h { |key| [key.to_s, normalize(value[key])] }
|
|
65
|
+
when Array then value.map { |item| normalize(item) }
|
|
66
|
+
when Symbol then value.to_s
|
|
67
|
+
when TrueClass, FalseClass, NilClass, Numeric, String then value
|
|
68
|
+
else normalize_unknown(value)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def normalize_unknown(value)
|
|
73
|
+
value.to_s
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Branchproof
|
|
6
|
+
# Renders versioned terminal and JSON analysis reports.
|
|
7
|
+
class Report
|
|
8
|
+
SCHEMA_VERSION = "1.0"
|
|
9
|
+
CRITERION_VERSION = "masking_occurrence_v1"
|
|
10
|
+
|
|
11
|
+
def initialize(inventory:, evidence:, analysis:, minima:, baseline:, diagnostics:, level: 3)
|
|
12
|
+
raise ArgumentError, "level must be 1, 2, or 3" unless [1, 2, 3].include?(level.to_i)
|
|
13
|
+
|
|
14
|
+
@inventory = inventory || {}
|
|
15
|
+
@evidence = evidence || {}
|
|
16
|
+
@analysis = analysis
|
|
17
|
+
@minima = Array(minima)
|
|
18
|
+
@baseline = baseline || {}
|
|
19
|
+
@diagnostics = Array(diagnostics)
|
|
20
|
+
@level = level.to_i
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def write(io:, format:)
|
|
24
|
+
format = format.to_sym
|
|
25
|
+
raise ArgumentError, "format must be :terminal or :json" unless %i[terminal json].include?(format)
|
|
26
|
+
|
|
27
|
+
io.write(format == :json ? JSON.generate(json_document) : terminal_document)
|
|
28
|
+
nil
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def exit_code
|
|
32
|
+
return 2 unless usage_valid?
|
|
33
|
+
|
|
34
|
+
status = value(@baseline, :status).to_s.upcase
|
|
35
|
+
return 2 if %w[ERROR INCOMPLETE].include?(status)
|
|
36
|
+
return 1 if status == "FAILED"
|
|
37
|
+
return 2 unless status == "PASSED" && value(@baseline, :finalized) == true
|
|
38
|
+
return 2 unless metrics[:eligible_conditions].positive?
|
|
39
|
+
return 2 unless valid_for_requested_level?
|
|
40
|
+
|
|
41
|
+
0
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def json_document
|
|
47
|
+
normalize(schema_version: SCHEMA_VERSION,
|
|
48
|
+
tool_version: (defined?(Branchproof::VERSION) ? Branchproof::VERSION : "unknown"),
|
|
49
|
+
criterion_version: CRITERION_VERSION, runtime: RUBY_DESCRIPTION,
|
|
50
|
+
run_ids: Array(value(@evidence, :run_ids)),
|
|
51
|
+
source_inventory: @inventory, baseline: @baseline, observations: @evidence,
|
|
52
|
+
analysis: @level == 1 ? nil : @analysis, minima: @minima, metrics: metrics,
|
|
53
|
+
diagnostics: @diagnostics, completeness: completeness)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def terminal_document
|
|
57
|
+
@terminal_ids = terminal_ids
|
|
58
|
+
lines = ["Branchproof #{defined?(Branchproof::VERSION) ? Branchproof::VERSION : "unknown"}",
|
|
59
|
+
"Tests: #{baseline_status} (#{baseline_test_counts})",
|
|
60
|
+
terminal_coverage_line,
|
|
61
|
+
"Analysis: #{terminal_analysis_status}",
|
|
62
|
+
"Decisions: #{metrics[:supported]} supported, #{metrics[:unsupported]} excluded, " \
|
|
63
|
+
"#{metrics[:unexecuted]} unexecuted (#{metrics[:discovered]} discovered)",
|
|
64
|
+
"Observations: #{metrics[:completed]} completed, #{metrics[:aborted]} aborted, " \
|
|
65
|
+
"#{metrics[:unattributed]} unattributed",
|
|
66
|
+
"Values: T=true, F=false, -=short-circuited"]
|
|
67
|
+
lines << "Scope: supported decisions and conditions"
|
|
68
|
+
lines << ""
|
|
69
|
+
inventory_decisions.each { |decision| render_decision(lines, decision) }
|
|
70
|
+
render_minima(lines)
|
|
71
|
+
unless @diagnostics.empty?
|
|
72
|
+
lines << "Diagnostics:"
|
|
73
|
+
@diagnostics.each { |diagnostic| lines << " - #{value(diagnostic, :message) || value(diagnostic, :code)}" }
|
|
74
|
+
end
|
|
75
|
+
lines.join("\n") << "\n"
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def render_decision(lines, decision)
|
|
79
|
+
source = source_for(decision)
|
|
80
|
+
filename = value(source, :relative_path) || value(source, :absolute_path) || value(decision, :source_id)
|
|
81
|
+
decision_label = "Decision #{short_id(value(decision, :id))} #{filename}:#{value(decision, :line)}"
|
|
82
|
+
lines << decision_label
|
|
83
|
+
lines << " Decision: #{value(decision, :expression)}"
|
|
84
|
+
lines << " Status: #{value(decision, :support_status) || "SUPPORTED"}"
|
|
85
|
+
Array(value(decision, :conditions)).each do |condition|
|
|
86
|
+
result = condition_result(decision, condition)
|
|
87
|
+
detail = condition_detail(result)
|
|
88
|
+
status = if @analysis.nil? || @level == 1
|
|
89
|
+
"NOT CALCULATED"
|
|
90
|
+
else
|
|
91
|
+
value(result, :status) || "NOT_PROVEN"
|
|
92
|
+
end
|
|
93
|
+
lines << " Condition #{value(condition, :index)}: #{value(condition, :expression)}"
|
|
94
|
+
lines << " #{status}#{detail}"
|
|
95
|
+
end
|
|
96
|
+
vectors_for(decision).each do |vector|
|
|
97
|
+
values = Array(value(vector, :values)).map do |item|
|
|
98
|
+
if item.nil?
|
|
99
|
+
"-"
|
|
100
|
+
else
|
|
101
|
+
item ? "T" : "F"
|
|
102
|
+
end
|
|
103
|
+
end.join
|
|
104
|
+
owners = Array(value(vector, :test_ids)).map { |test_id| test_label(test_id) }
|
|
105
|
+
owners << "unattributed" if value(vector, :unattributed_count).to_i.positive?
|
|
106
|
+
vector_label = " Vector #{short_id(value(vector, :id))} [#{values}] => " \
|
|
107
|
+
"#{value(vector, :outcome) ? "T" : "F"}"
|
|
108
|
+
lines << "#{vector_label} owners=#{owners.join(", ")}"
|
|
109
|
+
end
|
|
110
|
+
lines << ""
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def render_minima(lines)
|
|
114
|
+
rows = Array(@minima).map do |minimum|
|
|
115
|
+
objective = value(minimum, :objective)
|
|
116
|
+
scope = Array(value(minimum, :scope_decision_ids)).map { |id| short_id(id) }.join(", ")
|
|
117
|
+
selected = Array(value(minimum, :selected_ids)).map { |id| minimum_member_label(objective, id) }.join(", ")
|
|
118
|
+
label = objective.to_s == "tests" ? "Tests" : "Vectors"
|
|
119
|
+
" #{label} (#{value(minimum, :status)}, decisions: [#{scope}]): #{selected}"
|
|
120
|
+
end.uniq
|
|
121
|
+
unless rows.empty?
|
|
122
|
+
lines << "Supporting sets:"
|
|
123
|
+
lines.concat(rows)
|
|
124
|
+
end
|
|
125
|
+
lines << "Additional tests outside this MC/DC evidence set may improve coverage." unless @minima.empty?
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def baseline_status
|
|
129
|
+
value(@baseline, :status).to_s.upcase.then { |status| status.empty? ? "INCOMPLETE" : status }
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def baseline_test_counts
|
|
133
|
+
executed = value(@baseline, :executed_tests)
|
|
134
|
+
failed = value(@baseline, :failed_tests) || value(@baseline, :failures) || 0
|
|
135
|
+
skipped = value(@baseline, :skipped_tests) || value(@baseline, :skips) || 0
|
|
136
|
+
"#{executed || 0} tests, #{failed} failed, #{skipped} skipped"
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def terminal_coverage_label
|
|
140
|
+
return "not calculated" if @analysis.nil? || @level == 1
|
|
141
|
+
|
|
142
|
+
coverage_label
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def terminal_coverage_line
|
|
146
|
+
return "MC/DC: not calculated" if @analysis.nil? || @level == 1
|
|
147
|
+
|
|
148
|
+
"MC/DC: #{terminal_coverage_label} (#{metrics[:proven]}/#{metrics[:eligible_conditions]} conditions proven)"
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def terminal_analysis_status
|
|
152
|
+
return "NOT CALCULATED" if @analysis.nil? || @level == 1
|
|
153
|
+
|
|
154
|
+
analysis_status
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def terminal_ids
|
|
158
|
+
ids = []
|
|
159
|
+
ids.concat(inventory_decisions.flat_map { |decision| [value(decision, :id), value(decision, :source_id)] })
|
|
160
|
+
ids.concat(vectors.map { |vector| value(vector, :id) })
|
|
161
|
+
ids.concat(vectors.flat_map { |vector| Array(value(vector, :test_ids)) })
|
|
162
|
+
ids.concat(Array(value(@evidence, :tests)).map { |test| value(test, :id) })
|
|
163
|
+
ids.concat(Array(value(@baseline, :tests)).map { |test| value(test, :id) })
|
|
164
|
+
ids.concat(Array(@minima).flat_map do |minimum|
|
|
165
|
+
Array(value(minimum, :scope_decision_ids)) + Array(value(minimum, :selected_ids))
|
|
166
|
+
end)
|
|
167
|
+
ids.concat(Array(value(@analysis, :decisions)).flat_map do |decision|
|
|
168
|
+
Array(value(decision, :condition_results)).flat_map do |result|
|
|
169
|
+
[value(result, :condition_id), *Array(value(result, :canonical_pair))]
|
|
170
|
+
end
|
|
171
|
+
end)
|
|
172
|
+
ids = ids.compact.map(&:to_s).reject(&:empty?).uniq
|
|
173
|
+
by_prefix = ids.group_by { |id| id[0, 8] }
|
|
174
|
+
ids.to_h do |id|
|
|
175
|
+
prefix_length = 8
|
|
176
|
+
group = by_prefix.fetch(id[0, 8])
|
|
177
|
+
while group.length > 1 && group.map { |item| item[0, prefix_length] }.uniq.length < group.length
|
|
178
|
+
prefix_length += 1
|
|
179
|
+
end
|
|
180
|
+
[id, id.length <= prefix_length ? id : id[0, prefix_length]]
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def short_id(id)
|
|
185
|
+
text = id.to_s
|
|
186
|
+
return text if text.empty? || !@terminal_ids
|
|
187
|
+
|
|
188
|
+
@terminal_ids.fetch(text, text)
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def test_label(test_id)
|
|
192
|
+
id = test_id.to_s
|
|
193
|
+
test = test_records_by_id[id]
|
|
194
|
+
return short_id(id) unless test
|
|
195
|
+
|
|
196
|
+
class_name = value(test, :class_name)
|
|
197
|
+
method_name = value(test, :method_name)
|
|
198
|
+
name = if class_name && method_name
|
|
199
|
+
"#{class_name}##{method_name}"
|
|
200
|
+
else
|
|
201
|
+
value(test, :name).to_s
|
|
202
|
+
end
|
|
203
|
+
return short_id(id) if name.empty? || name == id
|
|
204
|
+
|
|
205
|
+
duplicates = test_records_by_name[name]
|
|
206
|
+
return name if duplicates.length == 1
|
|
207
|
+
|
|
208
|
+
location, line = test_location(test)
|
|
209
|
+
suffix = [location, line].compact.join(":")
|
|
210
|
+
same_location = duplicates.count do |item|
|
|
211
|
+
item_location, item_line = test_location(item)
|
|
212
|
+
[item_location, item_line].compact.join(":") == suffix
|
|
213
|
+
end
|
|
214
|
+
return "#{name} (#{suffix})" if !suffix.empty? && same_location == 1
|
|
215
|
+
|
|
216
|
+
"#{name} (#{suffix.empty? ? short_id(id) : "#{suffix}, #{short_id(id)}"})"
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def test_location(test)
|
|
220
|
+
location = value(test, :source)
|
|
221
|
+
source_line = nil
|
|
222
|
+
if location.respond_to?(:key?)
|
|
223
|
+
source_line = value(location, :line)
|
|
224
|
+
location = value(location, :path) || value(location, :relative_path)
|
|
225
|
+
end
|
|
226
|
+
location ||= value(test, :source_path)
|
|
227
|
+
line = value(test, :line) || value(test, :source_line) || source_line
|
|
228
|
+
[location, line]
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def minimum_member_label(objective, id)
|
|
232
|
+
objective.to_s == "tests" ? test_label(id) : short_id(id)
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def test_display_name(test)
|
|
236
|
+
class_name = value(test, :class_name)
|
|
237
|
+
method_name = value(test, :method_name)
|
|
238
|
+
class_name && method_name ? "#{class_name}##{method_name}" : value(test, :name).to_s
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def test_records
|
|
242
|
+
@test_records ||= begin
|
|
243
|
+
records = {}
|
|
244
|
+
Array(value(@baseline, :tests)).each { |test| records[value(test, :id).to_s] = test }
|
|
245
|
+
Array(value(@evidence, :tests)).each do |test|
|
|
246
|
+
id = value(test, :id).to_s
|
|
247
|
+
records[id] = records.fetch(id, {}).merge(test)
|
|
248
|
+
end
|
|
249
|
+
records.values
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def test_records_by_id
|
|
254
|
+
@test_records_by_id ||= test_records.to_h { |test| [value(test, :id).to_s, test] }
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def test_records_by_name
|
|
258
|
+
@test_records_by_name ||= test_records.group_by { |test| test_display_name(test) }
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def condition_result(decision, condition)
|
|
262
|
+
Array(value(analysis_for(decision), :condition_results)).find do |item|
|
|
263
|
+
value(item, :condition_id).to_s == value(condition, :id).to_s
|
|
264
|
+
end
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def condition_detail(result)
|
|
268
|
+
return "" unless result && @level == 3
|
|
269
|
+
|
|
270
|
+
pair = value(result, :canonical_pair)
|
|
271
|
+
return " (witness #{Array(pair).map { |id| short_id(id) }.join(" + ")})" if pair
|
|
272
|
+
|
|
273
|
+
constraint = value(result, :constraint_result)
|
|
274
|
+
if constraint
|
|
275
|
+
constraints = Array(value(constraint, :constraints)).map { |item| item.is_a?(Hash) ? item.inspect : item.to_s }
|
|
276
|
+
detail = constraints.empty? ? value(constraint, :status) : constraints.join(", ")
|
|
277
|
+
statement = value(constraint, :feasibility_statement)
|
|
278
|
+
detail = [detail, statement].compact.reject(&:empty?).join("; ")
|
|
279
|
+
return " (missing counterpart: #{detail})" unless detail.empty?
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
""
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
def metrics
|
|
286
|
+
decisions = inventory_decisions
|
|
287
|
+
unsupported, supported = decisions.partition { |decision| unsupported?(decision) }
|
|
288
|
+
eligible = supported.sum { |decision| Array(value(decision, :conditions)).length }
|
|
289
|
+
observed = vectors.map { |vector| value(vector, :decision_id).to_s }.uniq
|
|
290
|
+
proven = @analysis ? value(@analysis, :proven_count).to_i : 0
|
|
291
|
+
{ discovered: decisions.length, supported: supported.length, unsupported: unsupported.length,
|
|
292
|
+
unsupported_conditions: unsupported.sum do |decision|
|
|
293
|
+
discovered_conditions(decision)
|
|
294
|
+
end, eligible_conditions: eligible,
|
|
295
|
+
opaque: decisions.sum { |decision| Array(value(decision, :opaque_ranges)).length },
|
|
296
|
+
unexecuted: supported.count { |decision| !observed.include?(value(decision, :id).to_s) },
|
|
297
|
+
completed: vectors.sum do |vector|
|
|
298
|
+
value(vector, :count).to_i
|
|
299
|
+
end, aborted: numeric_hash_value(@evidence, :abort_counts),
|
|
300
|
+
unattributed: vectors.sum { |vector| value(vector, :unattributed_count).to_i }, limited: incomplete? ? 1 : 0,
|
|
301
|
+
proven: proven, percentage: percentage(eligible, proven) }
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
def completeness
|
|
305
|
+
evidence = value(@evidence, :completeness) || {}
|
|
306
|
+
analysis = value(@analysis, :completeness) || {}
|
|
307
|
+
{ observation: completeness_value?(evidence, analysis, :observation),
|
|
308
|
+
attribution: completeness_value?(evidence, analysis, :attribution),
|
|
309
|
+
analysis: @analysis ? value(analysis, :analysis) == true : value(evidence, :analysis) == true }
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
def completeness_value?(evidence, analysis, key)
|
|
313
|
+
if (evidence.key?(key) && evidence[key] == false) || (evidence.key?(key.to_s) && evidence[key.to_s] == false)
|
|
314
|
+
return false
|
|
315
|
+
end
|
|
316
|
+
if (analysis.key?(key) && analysis[key] == false) || (analysis.key?(key.to_s) && analysis[key.to_s] == false)
|
|
317
|
+
return false
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
true
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
def valid_for_requested_level?
|
|
324
|
+
return completeness[:observation] && completeness[:attribution] if @level == 1
|
|
325
|
+
|
|
326
|
+
completeness.values.all? { |item| item == true }
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
def coverage_label
|
|
330
|
+
percentage = metrics[:percentage]
|
|
331
|
+
return "N/A" if percentage.nil?
|
|
332
|
+
|
|
333
|
+
incomplete? ? "#{percentage}% (lower-bound; incomplete evidence)" : "#{percentage}%"
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
def analysis_status
|
|
337
|
+
return "NOT_REQUESTED" if @analysis.nil? || @level == 1
|
|
338
|
+
|
|
339
|
+
valid_for_requested_level? ? "COMPLETE" : "PARTIAL"
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
def incomplete?
|
|
343
|
+
!completeness[:observation] || !completeness[:attribution] || (@level > 1 && !completeness[:analysis])
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
def vectors_for(decision)
|
|
347
|
+
vectors.select do |vector|
|
|
348
|
+
value(vector, :decision_id).to_s == value(decision, :id).to_s
|
|
349
|
+
end
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
def analysis_for(decision)
|
|
353
|
+
Array(value(@analysis, :decisions)).find do |item|
|
|
354
|
+
value(item, :decision_id).to_s == value(decision, :id).to_s
|
|
355
|
+
end
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
def source_for(decision)
|
|
359
|
+
Array(value(@inventory, :source_units)).find do |source|
|
|
360
|
+
value(source, :source_id).to_s == value(decision, :source_id).to_s
|
|
361
|
+
end || decision
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
def inventory_decisions = Array(value(@inventory, :decisions))
|
|
365
|
+
def vectors = Array(value(@evidence, :vectors))
|
|
366
|
+
def unsupported?(decision) = value(decision, :support_status).to_s.upcase == "UNSUPPORTED"
|
|
367
|
+
|
|
368
|
+
def discovered_conditions(decision)
|
|
369
|
+
value(decision,
|
|
370
|
+
:discovered_condition_count) || Array(value(decision,
|
|
371
|
+
:conditions)).length
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
def percentage(denominator, numerator) = denominator.zero? ? nil : (numerator.to_f * 100 / denominator).round(2)
|
|
375
|
+
|
|
376
|
+
def numeric_hash_value(hash, key)
|
|
377
|
+
item = value(hash, key)
|
|
378
|
+
item.is_a?(Hash) ? item.values.sum(&:to_i) : item.to_i
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
def usage_valid?
|
|
382
|
+
@diagnostics.none? do |diagnostic|
|
|
383
|
+
%w[usage invalid_option].include?(value(diagnostic, :code).to_s)
|
|
384
|
+
end
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
def value(hash, key)
|
|
388
|
+
return nil unless hash.respond_to?(:key?)
|
|
389
|
+
return hash[key] if hash.key?(key)
|
|
390
|
+
return hash[key.to_s] if hash.key?(key.to_s)
|
|
391
|
+
|
|
392
|
+
nil
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
def normalize(object)
|
|
396
|
+
case object
|
|
397
|
+
when Hash
|
|
398
|
+
object.each_with_object({}) do |(key, item), result|
|
|
399
|
+
result[key.to_s] = normalize(item) unless key.to_s == "original_bytes"
|
|
400
|
+
end
|
|
401
|
+
when Array then object.map { |item| normalize(item) }
|
|
402
|
+
when Symbol then object.to_s
|
|
403
|
+
when Numeric, String, TrueClass, FalseClass, NilClass then object
|
|
404
|
+
else normalize_unknown(object)
|
|
405
|
+
end
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
def normalize_unknown(object)
|
|
409
|
+
object.to_s
|
|
410
|
+
end
|
|
411
|
+
end
|
|
412
|
+
end
|