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
data/lib/branchproof/cli.rb
CHANGED
|
@@ -10,6 +10,7 @@ require "rbconfig"
|
|
|
10
10
|
require "stringio"
|
|
11
11
|
require "fileutils"
|
|
12
12
|
require "securerandom"
|
|
13
|
+
require "time"
|
|
13
14
|
|
|
14
15
|
module Branchproof
|
|
15
16
|
# Coordinates source inventory, isolated test execution, and report output.
|
|
@@ -20,8 +21,12 @@ module Branchproof
|
|
|
20
21
|
end
|
|
21
22
|
|
|
22
23
|
def call(argv)
|
|
24
|
+
argv = Array(argv)
|
|
25
|
+
return help if [["--help"], ["help"], ["analyze", "--help"]].include?(argv)
|
|
26
|
+
return offline(argv) if %w[report compare].include?(argv.first)
|
|
27
|
+
|
|
23
28
|
options = parse(Array(argv))
|
|
24
|
-
return usage_error("analyze
|
|
29
|
+
return usage_error("expected analyze, report, or compare; use branchproof --help") unless options
|
|
25
30
|
|
|
26
31
|
inventory = build_inventory(options)
|
|
27
32
|
evidence = empty_evidence(inventory, options)
|
|
@@ -69,17 +74,145 @@ module Branchproof
|
|
|
69
74
|
minima = options[:level] == 1 ? [] : Array(value(baseline, :minima))
|
|
70
75
|
report = Report.new(inventory: inventory, evidence: value(baseline, :evidence) || evidence.snapshot,
|
|
71
76
|
analysis: analysis, minima: minima, baseline: baseline, diagnostics: diagnostics,
|
|
72
|
-
level: options[:level], missing_only: options[:missing_only]
|
|
77
|
+
level: options[:level], missing_only: options[:missing_only], view: options[:view],
|
|
78
|
+
run_metadata: run_metadata(options, baseline))
|
|
73
79
|
output_report(report, options)
|
|
74
80
|
report.exit_code
|
|
75
81
|
rescue ArgumentError => e
|
|
76
82
|
usage_error(e.message)
|
|
77
83
|
rescue JSON::ParserError => e
|
|
78
84
|
usage_error("invalid JSON limits: #{e.message}")
|
|
85
|
+
rescue SystemCallError, IOError => e
|
|
86
|
+
usage_error("report IO failed: #{e.message}")
|
|
79
87
|
end
|
|
80
88
|
|
|
81
89
|
private
|
|
82
90
|
|
|
91
|
+
def help
|
|
92
|
+
@stdout.write(<<~HELP)
|
|
93
|
+
Usage:
|
|
94
|
+
branchproof analyze [SOURCE_GLOB ...] [--test TEST_GLOB] [--project auto|ruby|rails]
|
|
95
|
+
[--view decisions|conditions|tests] [--level 1|2|3] [--missing-only]
|
|
96
|
+
[--format terminal|json] [--output PATH] [--limits PATH] [-- RUNNER_ARGS]
|
|
97
|
+
branchproof report SNAPSHOT [--view decisions|conditions|tests] [--level 1|2|3]
|
|
98
|
+
[--missing-only] [--format terminal|json] [--output PATH]
|
|
99
|
+
branchproof compare BEFORE AFTER [--format terminal|json] [--output PATH] [--fail-on-regression]
|
|
100
|
+
mcdc accepts the same commands as a compatibility alias.
|
|
101
|
+
JSON always contains full evidence; --view requires terminal output.
|
|
102
|
+
HELP
|
|
103
|
+
0
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def parse_view(view)
|
|
107
|
+
raise ArgumentError, "view must be decisions, conditions, or tests" unless %w[decisions conditions tests].include?(view)
|
|
108
|
+
|
|
109
|
+
view.to_sym
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def validate_view!(options)
|
|
113
|
+
return unless options[:explicit_view] && options[:format] == :json
|
|
114
|
+
|
|
115
|
+
raise ArgumentError, "--view requires terminal format; JSON contains full evidence"
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def offline(argv)
|
|
119
|
+
command = argv.first
|
|
120
|
+
return help if argv.drop(1) == ["--help"]
|
|
121
|
+
|
|
122
|
+
options, paths = parse_offline(command, argv.drop(1))
|
|
123
|
+
reject_input_output_collision!(paths, options[:output]) if options[:output]
|
|
124
|
+
documents = paths.map { |path| SavedReport.read(path) }
|
|
125
|
+
if command == "compare"
|
|
126
|
+
report = ComparisonReport.new(document: Comparison.new(before: documents[0], after: documents[1]).call)
|
|
127
|
+
output_report(report, options)
|
|
128
|
+
report.exit_code(fail_on_regression: options[:fail_on_regression])
|
|
129
|
+
else
|
|
130
|
+
document = documents.first
|
|
131
|
+
options[:level] ||= document["analysis"] ? 3 : 1
|
|
132
|
+
if options[:level] > 1 && !document["analysis"]
|
|
133
|
+
raise ArgumentError, "saved report has no analysis; use --level 1"
|
|
134
|
+
end
|
|
135
|
+
if options[:missing_only] && (options[:format] != :terminal || options[:level] == 1)
|
|
136
|
+
raise ArgumentError, "--missing-only requires terminal format and level 2 or 3"
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
report = Report.from_document(document: document, level: options[:level], view: options[:view],
|
|
140
|
+
missing_only: options[:missing_only])
|
|
141
|
+
output_report(report, options)
|
|
142
|
+
report.exit_code
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def parse_offline(command, args)
|
|
147
|
+
options = { format: :terminal, view: :decisions, missing_only: false }
|
|
148
|
+
paths = []
|
|
149
|
+
until args.empty?
|
|
150
|
+
token = args.shift
|
|
151
|
+
case token
|
|
152
|
+
when "--format"
|
|
153
|
+
format = args.shift
|
|
154
|
+
raise ArgumentError, "format must be terminal or json" unless %w[terminal json].include?(format)
|
|
155
|
+
|
|
156
|
+
options[:format] = format.to_sym
|
|
157
|
+
when "--output"
|
|
158
|
+
options[:output] = args.shift
|
|
159
|
+
raise ArgumentError, "--output requires a path" if options[:output].to_s.empty?
|
|
160
|
+
when "--fail-on-regression"
|
|
161
|
+
raise ArgumentError, "--fail-on-regression requires compare" unless command == "compare"
|
|
162
|
+
|
|
163
|
+
options[:fail_on_regression] = true
|
|
164
|
+
when "--view", "--level", "--missing-only"
|
|
165
|
+
raise ArgumentError, "#{token} requires report" unless command == "report"
|
|
166
|
+
|
|
167
|
+
case token
|
|
168
|
+
when "--view"
|
|
169
|
+
options[:view] = parse_view(args.shift)
|
|
170
|
+
options[:explicit_view] = true
|
|
171
|
+
when "--level"
|
|
172
|
+
options[:level] = Integer(args.shift.to_s, 10)
|
|
173
|
+
raise ArgumentError, "level must be 1, 2, or 3" unless (1..3).cover?(options[:level])
|
|
174
|
+
else options[:missing_only] = true
|
|
175
|
+
end
|
|
176
|
+
else
|
|
177
|
+
raise ArgumentError, "unknown option: #{token}" if token.start_with?("-")
|
|
178
|
+
|
|
179
|
+
paths << token
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
expected = command == "compare" ? 2 : 1
|
|
183
|
+
raise ArgumentError, "#{command} requires #{expected} saved report #{expected == 1 ? "path" : "paths"}" unless paths.length == expected
|
|
184
|
+
|
|
185
|
+
validate_view!(options)
|
|
186
|
+
[options, paths]
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def reject_input_output_collision!(paths, output)
|
|
190
|
+
collision = paths.any? do |input|
|
|
191
|
+
File.expand_path(input) == File.expand_path(output) ||
|
|
192
|
+
(File.exist?(input) && File.exist?(output) && File.identical?(input, output))
|
|
193
|
+
end
|
|
194
|
+
raise ArgumentError, "output must not overwrite an input report" if collision
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def run_metadata(options, baseline)
|
|
198
|
+
root = options[:project][:root]
|
|
199
|
+
locations = Array(value(baseline, :tests)).to_h do |test|
|
|
200
|
+
source = value(test, :source) || {}
|
|
201
|
+
[value(test, :id), { relative_path: relative_path(value(source, :path), root), line: value(source, :line) }]
|
|
202
|
+
end
|
|
203
|
+
{ captured_at: Time.now.utc.iso8601, requested_level: options[:level], project_kind: options[:project][:kind],
|
|
204
|
+
project_root: root, source_patterns: options[:source_patterns].map { |path| relative_path(path, root) },
|
|
205
|
+
test_patterns: options[:test_patterns].map { |path| relative_path(path, root) },
|
|
206
|
+
test_files: options[:tests].map { |path| relative_path(path, root) }, runner_args: options[:runner_args],
|
|
207
|
+
seed: value(baseline, :seed), limits: options[:limits], test_locations: locations }
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def relative_path(path, root)
|
|
211
|
+
return nil if path.to_s.empty?
|
|
212
|
+
|
|
213
|
+
Pathname.new(File.expand_path(path, root)).relative_path_from(Pathname.new(root)).to_s
|
|
214
|
+
end
|
|
215
|
+
|
|
83
216
|
def parse(argv)
|
|
84
217
|
return nil if argv.empty? || argv.first != "analyze"
|
|
85
218
|
|
|
@@ -88,10 +221,13 @@ module Branchproof
|
|
|
88
221
|
runner_args = delimiter ? args[(delimiter + 1)..] : []
|
|
89
222
|
args = args[0...delimiter] if delimiter
|
|
90
223
|
options = { level: 3, format: :terminal, output: nil, tests: [], source_paths: [], limits: Limits.default,
|
|
91
|
-
runner_args: runner_args, project: nil, missing_only: false }
|
|
224
|
+
runner_args: runner_args, project: nil, missing_only: false, view: :decisions }
|
|
92
225
|
until args.empty?
|
|
93
226
|
token = args.shift
|
|
94
227
|
case token
|
|
228
|
+
when "--view"
|
|
229
|
+
options[:view] = parse_view(args.shift)
|
|
230
|
+
options[:explicit_view] = true
|
|
95
231
|
when "--missing-only"
|
|
96
232
|
options[:missing_only] = true
|
|
97
233
|
when "--level"
|
|
@@ -128,6 +264,7 @@ module Branchproof
|
|
|
128
264
|
options[:source_paths] << token
|
|
129
265
|
end
|
|
130
266
|
end
|
|
267
|
+
validate_view!(options)
|
|
131
268
|
if options[:missing_only] && (options[:format] != :terminal || options[:level] == 1)
|
|
132
269
|
raise ArgumentError, "--missing-only requires terminal format and level 2 or 3"
|
|
133
270
|
end
|
|
@@ -135,6 +272,8 @@ module Branchproof
|
|
|
135
272
|
options[:project] ||= Project.new(root: Dir.pwd, mode: "auto").to_h
|
|
136
273
|
options[:source_paths] = default_sources if options[:source_paths].empty?
|
|
137
274
|
options[:tests] = default_tests if options[:tests].empty?
|
|
275
|
+
options[:source_patterns] = options[:source_paths].dup
|
|
276
|
+
options[:test_patterns] = argv.include?("--test") ? options[:tests].dup : %w[test/**/*_test.rb test/**/test_*.rb]
|
|
138
277
|
options[:tests] = expand_paths(options[:tests], root: options[:project][:root])
|
|
139
278
|
options
|
|
140
279
|
end
|
|
@@ -199,13 +338,19 @@ module Branchproof
|
|
|
199
338
|
end
|
|
200
339
|
|
|
201
340
|
def output_report(report, options)
|
|
341
|
+
created = false
|
|
202
342
|
if options[:output]
|
|
203
|
-
temporary = "#{options[:output]}.tmp-#{
|
|
204
|
-
File.
|
|
343
|
+
temporary = "#{options[:output]}.tmp-#{SecureRandom.hex(12)}"
|
|
344
|
+
File.open(temporary, "wx") do |file|
|
|
345
|
+
created = true
|
|
346
|
+
file.write(report_string(report, options[:format]))
|
|
347
|
+
end
|
|
205
348
|
File.rename(temporary, options[:output])
|
|
206
349
|
else
|
|
207
350
|
@stdout.write(report_string(report, options[:format]))
|
|
208
351
|
end
|
|
352
|
+
ensure
|
|
353
|
+
File.unlink(temporary) if created && temporary && File.file?(temporary)
|
|
209
354
|
end
|
|
210
355
|
|
|
211
356
|
def report_string(report, format)
|
|
@@ -215,7 +360,7 @@ module Branchproof
|
|
|
215
360
|
end
|
|
216
361
|
|
|
217
362
|
def usage_error(message)
|
|
218
|
-
@stderr.write("
|
|
363
|
+
@stderr.write("branchproof: #{message}\n")
|
|
219
364
|
2
|
|
220
365
|
end
|
|
221
366
|
|
|
@@ -245,7 +390,7 @@ module Branchproof
|
|
|
245
390
|
def value(hash, key)
|
|
246
391
|
return nil unless hash.respond_to?(:key?)
|
|
247
392
|
|
|
248
|
-
hash[key]
|
|
393
|
+
hash.key?(key) ? hash[key] : hash[key.to_s]
|
|
249
394
|
end
|
|
250
395
|
|
|
251
396
|
def normalize(value)
|
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# rubocop:disable Metrics/ClassLength, Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module Branchproof
|
|
7
|
+
# Compares two complete report documents without loading or executing the project.
|
|
8
|
+
class Comparison
|
|
9
|
+
SCHEMA_VERSION = "1.0"
|
|
10
|
+
SUPPORTED_REPORT_SCHEMAS = %w[1.0 1.1].freeze
|
|
11
|
+
CRITERION_VERSION = "masking_occurrence_v1"
|
|
12
|
+
|
|
13
|
+
def initialize(before:, after:)
|
|
14
|
+
@before = before
|
|
15
|
+
@after = after
|
|
16
|
+
@contexts = {}.compare_by_identity
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def call
|
|
20
|
+
errors = validate_documents
|
|
21
|
+
return invalid_result(errors) unless errors.empty?
|
|
22
|
+
|
|
23
|
+
before_sources = sources(@before)
|
|
24
|
+
after_sources = sources(@after)
|
|
25
|
+
changed_paths = before_sources.keys & after_sources.keys
|
|
26
|
+
changed_paths.reject! { |path| digest(before_sources[path]) == digest(after_sources[path]) }
|
|
27
|
+
before_conditions = condition_map(@before)
|
|
28
|
+
after_conditions = condition_map(@after)
|
|
29
|
+
matched_ids = before_conditions.keys & after_conditions.keys
|
|
30
|
+
matched_ids.select! { |id| compatible_condition?(before_conditions[id], after_conditions[id], changed_paths) }
|
|
31
|
+
changes = matched_ids.filter_map { |id| condition_change(id, before_conditions[id], after_conditions[id]) }
|
|
32
|
+
reasons = comparability_reasons(changed_paths)
|
|
33
|
+
reasons.concat(metadata_requirements)
|
|
34
|
+
reasons << "legacy report is missing comparison context" if [@before, @after].any? do |document|
|
|
35
|
+
value(document, :schema_version).to_s == "1.0" && value(document, :run_metadata).nil?
|
|
36
|
+
end
|
|
37
|
+
complete = complete_run?(@before) && complete_run?(@after)
|
|
38
|
+
reasons << "run baselines are not successful finalized complete reports" unless complete
|
|
39
|
+
reasons << "no comparable conditions" if matched_ids.empty?
|
|
40
|
+
if changes.any? { |change| [change["status_before"], change["status_after"]].include?("UNKNOWN") }
|
|
41
|
+
reasons << "condition proof status unavailable"
|
|
42
|
+
end
|
|
43
|
+
if before_sources.keys.sort != after_sources.keys.sort
|
|
44
|
+
reasons << "source population differs; whole-source comparison is partial"
|
|
45
|
+
end
|
|
46
|
+
if matched_ids.length != before_conditions.length || matched_ids.length != after_conditions.length
|
|
47
|
+
reasons << "unmatched conditions; exact-match scope is partial"
|
|
48
|
+
end
|
|
49
|
+
status = reasons.empty? ? "complete" : "comparison incomplete"
|
|
50
|
+
lost = changes.count { |change| change["change"] == "lost proof" }
|
|
51
|
+
|
|
52
|
+
{
|
|
53
|
+
"schema_version" => SCHEMA_VERSION,
|
|
54
|
+
"criterion_version" => CRITERION_VERSION,
|
|
55
|
+
"status" => status,
|
|
56
|
+
"comparison_status" => status,
|
|
57
|
+
"reasons" => reasons,
|
|
58
|
+
"before" => summary(@before),
|
|
59
|
+
"after" => summary(@after),
|
|
60
|
+
"context" => context_changes,
|
|
61
|
+
"matching" => { "before_conditions" => before_conditions.length,
|
|
62
|
+
"after_conditions" => after_conditions.length,
|
|
63
|
+
"matched_conditions" => matched_ids.length,
|
|
64
|
+
"denominator" => matched_ids.length,
|
|
65
|
+
"before_proven" => changes.count { |change| change["status_before"] == "PROVEN" },
|
|
66
|
+
"after_proven" => changes.count { |change| change["status_after"] == "PROVEN" } },
|
|
67
|
+
"changed_sources" => changed_paths.sort.map do |path|
|
|
68
|
+
{ "relative_path" => path, "reason" => "source changed / condition comparison unavailable" }
|
|
69
|
+
end,
|
|
70
|
+
"newly_in_report" => (after_sources.keys - before_sources.keys).sort,
|
|
71
|
+
"no_longer_in_report" => (before_sources.keys - after_sources.keys).sort,
|
|
72
|
+
"changes" => changes.sort_by do |change|
|
|
73
|
+
[change["relative_path"].to_s, change["line"].to_i, change["condition_id"]]
|
|
74
|
+
end,
|
|
75
|
+
"regressions" => lost,
|
|
76
|
+
"regression" => lost.positive? && status == "complete"
|
|
77
|
+
}
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
def validate_documents
|
|
83
|
+
errors = []
|
|
84
|
+
{ "before" => @before, "after" => @after }.each do |label, document|
|
|
85
|
+
errors << "#{label} report must be an object" unless document.is_a?(Hash)
|
|
86
|
+
next unless document.is_a?(Hash)
|
|
87
|
+
|
|
88
|
+
schema = value(document, :schema_version).to_s
|
|
89
|
+
criterion = value(document, :criterion_version).to_s
|
|
90
|
+
errors << "#{label} report has unsupported schema" unless SUPPORTED_REPORT_SCHEMAS.include?(schema)
|
|
91
|
+
errors << "#{label} report has incompatible criterion" unless criterion == CRITERION_VERSION
|
|
92
|
+
errors << "#{label} source_inventory must be an object" unless value(document, :source_inventory).is_a?(Hash)
|
|
93
|
+
end
|
|
94
|
+
errors.uniq
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def invalid_result(errors)
|
|
98
|
+
{ "schema_version" => SCHEMA_VERSION, "status" => "comparison incomplete",
|
|
99
|
+
"comparison_status" => "comparison incomplete", "reasons" => errors,
|
|
100
|
+
"before" => summary(@before), "after" => summary(@after),
|
|
101
|
+
"matching" => { "before_conditions" => 0, "after_conditions" => 0,
|
|
102
|
+
"matched_conditions" => 0, "denominator" => 0 },
|
|
103
|
+
"changed_sources" => [], "newly_in_report" => [], "no_longer_in_report" => [],
|
|
104
|
+
"changes" => [], "regressions" => 0, "regression" => false }
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def sources(document)
|
|
108
|
+
units = value(value(document, :source_inventory), :source_units)
|
|
109
|
+
Array(units).each_with_object({}) do |source, result|
|
|
110
|
+
path = value(source, :relative_path).to_s
|
|
111
|
+
result[path] = source unless path.empty?
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def condition_map(document)
|
|
116
|
+
data = context(document)
|
|
117
|
+
data[:decisions].values.each_with_object({}) do |decision, result|
|
|
118
|
+
next if value(decision, :support_status).to_s == "UNSUPPORTED"
|
|
119
|
+
|
|
120
|
+
Array(value(decision, :conditions)).each do |condition|
|
|
121
|
+
id = value(condition, :id).to_s
|
|
122
|
+
unless id.empty?
|
|
123
|
+
result[id] = { condition: condition, row: data[:conditions][id], decision: decision,
|
|
124
|
+
result: data[:results][id] }
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def compatible_condition?(before, after, changed_paths)
|
|
131
|
+
before_path = source_path(@before, before[:decision])
|
|
132
|
+
after_path = source_path(@after, after[:decision])
|
|
133
|
+
return false if before_path.nil? || changed_paths.include?(before_path) || before_path != after_path
|
|
134
|
+
|
|
135
|
+
before_digest = digest(sources(@before)[before_path])
|
|
136
|
+
after_digest = digest(sources(@after)[after_path])
|
|
137
|
+
return false unless before_digest && before_digest == after_digest
|
|
138
|
+
|
|
139
|
+
%i[index expression byte_start byte_length].all? do |key|
|
|
140
|
+
value(before[:condition], key) == value(after[:condition], key)
|
|
141
|
+
end && %i[id source_id expression context byte_start byte_length tree].all? do |key|
|
|
142
|
+
Records.normalize(value(before[:decision], key)) == Records.normalize(value(after[:decision], key))
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def condition_change(id, before, after)
|
|
147
|
+
old_status = status(before[:result], @before)
|
|
148
|
+
new_status = status(after[:result], @after)
|
|
149
|
+
if new_status == "NOT_PROVEN" && !complete_run?(@after) &&
|
|
150
|
+
context(@after)[:vectors].values.none? { |vector| value(vector, :decision_id) == value(after[:decision], :id) }
|
|
151
|
+
new_status = "UNKNOWN"
|
|
152
|
+
end
|
|
153
|
+
kind = if old_status == "PROVEN" && new_status == "NOT_PROVEN"
|
|
154
|
+
"lost proof"
|
|
155
|
+
elsif old_status == "NOT_PROVEN" && new_status == "PROVEN"
|
|
156
|
+
"gained proof"
|
|
157
|
+
elsif old_status == new_status
|
|
158
|
+
"unchanged"
|
|
159
|
+
else
|
|
160
|
+
"unknown"
|
|
161
|
+
end
|
|
162
|
+
return if kind.nil?
|
|
163
|
+
|
|
164
|
+
decision = after[:decision]
|
|
165
|
+
source_path = source_path(@after, decision)
|
|
166
|
+
row = { "condition_id" => id, "change" => kind, "status_before" => old_status,
|
|
167
|
+
"status_after" => new_status, "expression" => value(after[:condition], :expression),
|
|
168
|
+
"relative_path" => source_path, "line" => value(after[:condition], :line),
|
|
169
|
+
"index" => value(after[:condition], :index),
|
|
170
|
+
"previous_witness" => witness(@before, before[:result], before[:decision]),
|
|
171
|
+
"current_witness" => witness(@after, after[:result], after[:decision]) }
|
|
172
|
+
row["previous_witness_owners"] = row["previous_witness"].to_a.flat_map { |item| item["tests"] }.uniq
|
|
173
|
+
row["current_witness_owners"] = row["current_witness"].to_a.flat_map { |item| item["tests"] }.uniq
|
|
174
|
+
row["owner_context"] = owner_context(before[:result], before[:decision])
|
|
175
|
+
row
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def context(document)
|
|
179
|
+
@contexts[document] ||= begin
|
|
180
|
+
index = CoverageIndex.new(document: document)
|
|
181
|
+
tests = Array(value(value(document, :observations), :tests)).to_h { |test| [value(test, :id).to_s, test] }
|
|
182
|
+
rows = index.tests.to_h { |test| [test[:id].to_s, test] }
|
|
183
|
+
keys = tests.to_h do |id, test|
|
|
184
|
+
parts = %i[adapter class_name method_name].map { |key| value(test, key) }
|
|
185
|
+
parts << rows.dig(id, :relative_path)
|
|
186
|
+
[id, parts.all? { |part| part.is_a?(String) && !part.empty? } ? parts : nil]
|
|
187
|
+
end
|
|
188
|
+
results = Array(value(value(document, :analysis), :decisions)).flat_map do |decision|
|
|
189
|
+
Array(value(decision, :condition_results))
|
|
190
|
+
end
|
|
191
|
+
{ conditions: index.conditions.to_h { |row| [row[:id].to_s, row] }, tests: tests, test_rows: rows,
|
|
192
|
+
test_keys: keys, key_owners: keys.keys.group_by { |id| keys[id] },
|
|
193
|
+
vectors: Array(value(value(document, :observations), :vectors)).to_h do |vector|
|
|
194
|
+
[value(vector, :id).to_s, vector]
|
|
195
|
+
end,
|
|
196
|
+
decisions: Array(value(value(document, :source_inventory), :decisions)).to_h do |decision|
|
|
197
|
+
[value(decision, :id).to_s, decision]
|
|
198
|
+
end,
|
|
199
|
+
results: results.to_h { |result| [value(result, :condition_id).to_s, result] } }
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def owner_context(result, decision)
|
|
204
|
+
before = context(@before)
|
|
205
|
+
after = context(@after)
|
|
206
|
+
Array(value(result, :canonical_pair)).flat_map do |vector_id|
|
|
207
|
+
vector = before[:vectors][vector_id.to_s]
|
|
208
|
+
next [] unless vector
|
|
209
|
+
|
|
210
|
+
Array(value(vector, :test_ids)).sort.map do |id|
|
|
211
|
+
key = before[:test_keys][id.to_s]
|
|
212
|
+
unique = key && before[:key_owners][key]&.length == 1 && after[:key_owners][key]&.length == 1
|
|
213
|
+
current_id = unique ? after[:key_owners][key].first : nil
|
|
214
|
+
observed = current_id && after[:vectors].values.any? do |current|
|
|
215
|
+
value(current, :decision_id) == value(decision, :id) &&
|
|
216
|
+
value(current,
|
|
217
|
+
:values) == value(vector, :values) && value(current, :outcome) == value(vector, :outcome) &&
|
|
218
|
+
Array(value(current, :test_ids)).include?(current_id)
|
|
219
|
+
end
|
|
220
|
+
test_status = current_id ? "present in current run" : "not observed in current run (no unique test match)"
|
|
221
|
+
{ "label" => test_display(@before, id), "current_label" => current_id && test_display(@after, current_id),
|
|
222
|
+
"test_status" => test_status,
|
|
223
|
+
"observation_status" => observed ? "observed in current run" : "not observed in current run",
|
|
224
|
+
"values" => value(vector, :values), "outcome" => value(vector, :outcome) }
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def witness(document, result, _decision)
|
|
230
|
+
pair = value(result, :canonical_pair)
|
|
231
|
+
return nil unless pair.is_a?(Array)
|
|
232
|
+
|
|
233
|
+
pair.map do |id|
|
|
234
|
+
vector = context(document)[:vectors][id.to_s]
|
|
235
|
+
{ "vector_id" => id, "values" => value(vector, :values), "outcome" => value(vector, :outcome),
|
|
236
|
+
"tests" => Array(value(vector, :test_ids)).map { |test_id| test_display(document, test_id) }.uniq.sort }
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def test_display(document, id)
|
|
241
|
+
row = context(document)[:test_rows][id.to_s]
|
|
242
|
+
return "unknown test (location unavailable)" unless row
|
|
243
|
+
|
|
244
|
+
path = row[:relative_path]
|
|
245
|
+
location = path && row[:line] ? "#{path}:#{row[:line]}" : "location unavailable"
|
|
246
|
+
"#{row[:name]} (#{location})"
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def context_changes
|
|
250
|
+
changes = []
|
|
251
|
+
if metadata(@before, :seed) != metadata(@after, :seed)
|
|
252
|
+
changes << "seed differs: #{metadata(@before, :seed).inspect} -> #{metadata(@after, :seed).inspect}"
|
|
253
|
+
end
|
|
254
|
+
if metadata(@before, :test_files) != metadata(@after, :test_files)
|
|
255
|
+
changes << "expanded test files differ under the recorded selection patterns"
|
|
256
|
+
end
|
|
257
|
+
if context(@before)[:test_keys].values.sort_by(&:to_s) != context(@after)[:test_keys].values.sort_by(&:to_s)
|
|
258
|
+
changes << "test population differs"
|
|
259
|
+
end
|
|
260
|
+
changes << "Deltas describe observed runs; nondeterminism and external environment changes are not ruled out."
|
|
261
|
+
changes
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
def comparability_reasons(changed_paths)
|
|
265
|
+
reasons = []
|
|
266
|
+
reasons << "source files changed: #{changed_paths.sort.join(", ")}" unless changed_paths.empty?
|
|
267
|
+
%i[criterion_version project_kind runner_args source_patterns test_patterns limits runtime].each do |key|
|
|
268
|
+
next if key == :criterion_version
|
|
269
|
+
|
|
270
|
+
left = metadata(@before, key)
|
|
271
|
+
right = metadata(@after, key)
|
|
272
|
+
if key == :runner_args
|
|
273
|
+
left = comparable_runner_args(left)
|
|
274
|
+
right = comparable_runner_args(right)
|
|
275
|
+
end
|
|
276
|
+
reasons << "#{key} differs" if !left.nil? && !right.nil? && left != right
|
|
277
|
+
end
|
|
278
|
+
reasons << "source selection differs" if source_selection(@before) != source_selection(@after)
|
|
279
|
+
reasons
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
def source_selection(document)
|
|
283
|
+
value(value(document, :run_metadata), :source_patterns)
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
def metadata_requirements
|
|
287
|
+
[@before, @after].each_with_index.filter_map do |document, index|
|
|
288
|
+
metadata = value(document, :run_metadata)
|
|
289
|
+
required = %i[project_kind source_patterns test_patterns test_files runner_args limits]
|
|
290
|
+
missing = required.reject { |key| metadata.is_a?(Hash) && !value(metadata, key).nil? }
|
|
291
|
+
missing << :runtime unless value(document, :runtime).is_a?(String) && !value(document, :runtime).empty?
|
|
292
|
+
if missing.empty?
|
|
293
|
+
nil
|
|
294
|
+
else
|
|
295
|
+
"#{index.zero? ? "before" : "after"} report missing run metadata: #{missing.join(", ")}"
|
|
296
|
+
end
|
|
297
|
+
end
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
def metadata(document, key)
|
|
301
|
+
value(value(document, :run_metadata), key) || value(document, key)
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
def complete_run?(document)
|
|
305
|
+
baseline = value(document, :baseline) || {}
|
|
306
|
+
completeness = value(document, :completeness) || {}
|
|
307
|
+
analysis = value(document, :analysis)
|
|
308
|
+
return false unless value(baseline, :status).to_s.upcase == "PASSED" && value(baseline, :finalized) == true
|
|
309
|
+
return false unless analysis.is_a?(Hash)
|
|
310
|
+
|
|
311
|
+
[completeness, value(analysis, :completeness),
|
|
312
|
+
value(value(document, :observations), :completeness)].all? do |section|
|
|
313
|
+
%i[observation attribution analysis].all? { |key| value(section, key) == true }
|
|
314
|
+
end
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
def summary(document)
|
|
318
|
+
{ "run_id" => Array(value(value(document, :observations), :run_ids)).first,
|
|
319
|
+
"status" => value(value(document, :baseline), :status), "metrics" => value(document, :metrics),
|
|
320
|
+
"completeness" => value(document, :completeness), "run_metadata" => value(document, :run_metadata) }
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
def source_path(document, decision)
|
|
324
|
+
units = value(value(document, :source_inventory), :source_units)
|
|
325
|
+
source = Array(units).find { |item| value(item, :source_id).to_s == value(decision, :source_id).to_s }
|
|
326
|
+
value(source, :relative_path) || value(decision, :relative_path)
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
def digest(source) = value(source, :digest) || value(source, :content_digest)
|
|
330
|
+
|
|
331
|
+
def comparable_runner_args(args)
|
|
332
|
+
skip_next = false
|
|
333
|
+
Array(args).each_with_object([]) do |arg, result|
|
|
334
|
+
text = arg.to_s
|
|
335
|
+
if skip_next
|
|
336
|
+
skip_next = false
|
|
337
|
+
next
|
|
338
|
+
end
|
|
339
|
+
if ["--seed", "-s"].include?(text)
|
|
340
|
+
skip_next = true
|
|
341
|
+
next
|
|
342
|
+
end
|
|
343
|
+
next if text.start_with?("--seed=")
|
|
344
|
+
|
|
345
|
+
result << arg
|
|
346
|
+
end
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
def status(result, _document = nil)
|
|
350
|
+
text = value(result, :status).to_s.upcase
|
|
351
|
+
return text unless text.empty?
|
|
352
|
+
|
|
353
|
+
"UNKNOWN"
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
def value(hash, key)
|
|
357
|
+
return nil unless hash.respond_to?(:key?)
|
|
358
|
+
|
|
359
|
+
hash.key?(key) ? hash[key] : hash[key.to_s]
|
|
360
|
+
end
|
|
361
|
+
end
|
|
362
|
+
end
|
|
363
|
+
# rubocop:enable Metrics/ClassLength, Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|