branchproof 0.3.0 → 0.5.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.
@@ -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 is the only supported command") unless options
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-#{Process.pid}"
204
- File.binwrite(temporary, report_string(report, options[:format]))
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("mcdc: #{message}\n")
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] || hash[key.to_s]
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