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.
@@ -1,15 +1,19 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "json"
4
+ require "pathname"
4
5
 
5
6
  module Branchproof
6
7
  # Renders versioned terminal and JSON analysis reports.
7
8
  class Report
8
- SCHEMA_VERSION = "1.0"
9
+ SCHEMA_VERSION = "1.1"
9
10
  CRITERION_VERSION = "masking_occurrence_v1"
10
11
 
11
- def initialize(inventory:, evidence:, analysis:, minima:, baseline:, diagnostics:, level: 3, missing_only: false)
12
+ def initialize(inventory:, evidence:, analysis:, minima:, baseline:, diagnostics:, level: 3, missing_only: false,
13
+ view: :decisions, run_metadata: {}, saved_document: nil)
12
14
  raise ArgumentError, "level must be 1, 2, or 3" unless [1, 2, 3].include?(level.to_i)
15
+ raise ArgumentError, "view must be :decisions, :conditions, or :tests" unless %i[decisions conditions
16
+ tests].include?(view.to_sym)
13
17
 
14
18
  @inventory = inventory || {}
15
19
  @evidence = evidence || {}
@@ -18,7 +22,20 @@ module Branchproof
18
22
  @baseline = baseline || {}
19
23
  @diagnostics = Array(diagnostics)
20
24
  @level = level.to_i
21
- @missing_only = !!missing_only
25
+ @missing_only = missing_only ? true : false
26
+ @view = view.to_sym
27
+ @run_metadata = run_metadata || {}
28
+ @saved_document = saved_document
29
+ end
30
+
31
+ def self.from_document(document:, level: nil, view: :decisions, missing_only: false)
32
+ data = document || {}
33
+ new(inventory: data[:source_inventory] || data["source_inventory"] || data[:inventory] || data["inventory"],
34
+ evidence: data[:observations] || data["observations"] || data[:evidence] || data["evidence"],
35
+ analysis: data[:analysis] || data["analysis"], minima: data[:minima] || data["minima"],
36
+ baseline: data[:baseline] || data["baseline"], diagnostics: data[:diagnostics] || data["diagnostics"],
37
+ level: level || (data[:analysis] || data["analysis"] ? 3 : 1), view: view, missing_only: missing_only,
38
+ run_metadata: data[:run_metadata] || data["run_metadata"], saved_document: data)
22
39
  end
23
40
 
24
41
  def write(io:, format:)
@@ -29,6 +46,30 @@ module Branchproof
29
46
  nil
30
47
  end
31
48
 
49
+ # Shares the existing missing-case wording with focused terminal views.
50
+ def condition_explanation(decision_id:, condition_id:)
51
+ decision = inventory_decisions.find { |item| value(item, :id).to_s == decision_id.to_s }
52
+ return "" unless decision
53
+
54
+ condition = Array(value(decision, :conditions)).find { |item| value(item, :id).to_s == condition_id.to_s }
55
+ return "" unless condition
56
+
57
+ @terminal_ids ||= terminal_ids
58
+ condition_detail(decision, condition, condition_result(decision, condition))
59
+ end
60
+
61
+ # Formats source context consistently in live and saved terminal views.
62
+ def diagnostic_message(diagnostic)
63
+ message = value(diagnostic, :message) || value(diagnostic, :code)
64
+ source_id = value(diagnostic, :source_id)
65
+ return message unless source_id
66
+
67
+ source = source_for(diagnostic)
68
+ path = value(source, :relative_path) || source_id
69
+ prefix = %w[not_instrumented unsupported_source].include?(value(diagnostic, :code).to_s) ? "Skipped " : ""
70
+ "#{prefix}#{path}: #{message}"
71
+ end
72
+
32
73
  def exit_code
33
74
  return 2 unless usage_valid?
34
75
 
@@ -36,6 +77,7 @@ module Branchproof
36
77
  return 2 if %w[ERROR INCOMPLETE].include?(status)
37
78
  return 1 if status == "FAILED"
38
79
  return 2 unless status == "PASSED" && value(@baseline, :finalized) == true
80
+ return 2 if @saved_document && (value(@saved_document, :completeness) || {}).values.include?(false)
39
81
  return 2 unless metrics[:eligible_conditions].positive?
40
82
  return 2 unless valid_for_requested_level?
41
83
 
@@ -45,16 +87,24 @@ module Branchproof
45
87
  private
46
88
 
47
89
  def json_document
90
+ return normalize(@saved_document) if @saved_document
91
+
48
92
  normalize(schema_version: SCHEMA_VERSION,
49
93
  tool_version: (defined?(Branchproof::VERSION) ? Branchproof::VERSION : "unknown"),
50
94
  criterion_version: CRITERION_VERSION, runtime: RUBY_DESCRIPTION,
51
95
  run_ids: Array(value(@evidence, :run_ids)),
52
96
  source_inventory: @inventory, baseline: @baseline, observations: @evidence,
53
97
  analysis: @level == 1 ? nil : @analysis, minima: @minima, metrics: metrics,
54
- diagnostics: @diagnostics, completeness: completeness)
98
+ diagnostics: @diagnostics, completeness: completeness,
99
+ run_metadata: @run_metadata)
55
100
  end
56
101
 
57
102
  def terminal_document
103
+ unless @view == :decisions
104
+ return FocusedReport.new(document: json_document, view: @view, level: @level,
105
+ missing_only: @missing_only).render
106
+ end
107
+
58
108
  @terminal_ids = terminal_ids
59
109
  lines = ["Branchproof #{defined?(Branchproof::VERSION) ? Branchproof::VERSION : "unknown"}",
60
110
  "Tests: #{baseline_status} (#{baseline_test_counts})",
@@ -72,7 +122,7 @@ module Branchproof
72
122
  render_minima(lines) unless @missing_only
73
123
  unless @diagnostics.empty?
74
124
  lines << "Diagnostics:"
75
- @diagnostics.each { |diagnostic| lines << " - #{value(diagnostic, :message) || value(diagnostic, :code)}" }
125
+ @diagnostics.each { |diagnostic| lines << " - #{diagnostic_message(diagnostic)}" }
76
126
  end
77
127
  lines.join("\n") << "\n"
78
128
  end
@@ -224,17 +274,32 @@ module Branchproof
224
274
  end
225
275
 
226
276
  def test_location(test)
277
+ saved_location = value(value(@run_metadata, :test_locations), value(test, :id))
278
+ return [relative_test_path(value(saved_location, :relative_path)), value(saved_location, :line)] if saved_location
279
+
227
280
  location = value(test, :source)
228
281
  source_line = nil
229
282
  if location.respond_to?(:key?)
230
283
  source_line = value(location, :line)
231
- location = value(location, :path) || value(location, :relative_path)
284
+ location = value(location, :relative_path) || value(location, :path)
232
285
  end
233
286
  location ||= value(test, :source_path)
287
+ location = relative_test_path(location)
234
288
  line = value(test, :line) || value(test, :source_line) || source_line
235
289
  [location, line]
236
290
  end
237
291
 
292
+ def relative_test_path(path)
293
+ return path unless path && Pathname.new(path.to_s).absolute?
294
+
295
+ root = value(@inventory, :root) || value(@run_metadata, :project_root)
296
+ return nil unless root && Pathname.new(root.to_s).absolute?
297
+
298
+ Pathname.new(path.to_s).relative_path_from(Pathname.new(root.to_s)).to_s
299
+ rescue ArgumentError
300
+ nil
301
+ end
302
+
238
303
  def minimum_member_label(objective, id)
239
304
  objective.to_s == "tests" ? test_label(id) : short_id(id)
240
305
  end
@@ -0,0 +1,366 @@
1
+ # frozen_string_literal: true
2
+
3
+ # rubocop:disable Metrics/ClassLength, Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
4
+
5
+ require "json"
6
+
7
+ module Branchproof
8
+ # Reads and validates a persisted JSON report without loading the project.
9
+ class SavedReport
10
+ SUPPORTED_SCHEMAS = %w[1.0 1.1].freeze
11
+ CRITERION_VERSION = "masking_occurrence_v1"
12
+ REQUIRED_FIELDS = %w[schema_version tool_version criterion_version runtime run_ids source_inventory baseline
13
+ observations analysis minima metrics diagnostics completeness].freeze
14
+ COMPLETENESS_FIELDS = %w[observation attribution analysis].freeze
15
+
16
+ class << self
17
+ def read(path)
18
+ raise ArgumentError, "report path must be a string" unless path.is_a?(String)
19
+
20
+ json = File.binread(path)
21
+ document = JSON.parse(json)
22
+ new(document).validate!
23
+ rescue IOError, SystemCallError => e
24
+ raise ArgumentError, "cannot read report: #{e.message}"
25
+ rescue JSON::ParserError
26
+ raise ArgumentError, "invalid report JSON"
27
+ end
28
+ end
29
+
30
+ def initialize(document)
31
+ @document = document
32
+ end
33
+
34
+ def validate!
35
+ fail_with("document must be an object") unless hash_with_string_keys?(@document)
36
+ schema = @document["schema_version"]
37
+ fail_with("unsupported schema version") unless SUPPORTED_SCHEMAS.include?(schema)
38
+ missing = REQUIRED_FIELDS.reject { |field| @document.key?(field) }
39
+ fail_with("missing field: #{missing.first}") unless missing.empty?
40
+ fail_with("criterion version mismatch") unless @document["criterion_version"] == CRITERION_VERSION
41
+ fail_with("run_ids must be an array of strings") unless strings?(@document["run_ids"])
42
+
43
+ validate_inventory(@document["source_inventory"])
44
+ validate_observations(@document["observations"])
45
+ validate_baseline(@document["baseline"])
46
+ validate_analysis(@document["analysis"])
47
+ validate_completeness(@document["completeness"])
48
+ validate_completeness(@document.dig("observations", "completeness"))
49
+ validate_completeness(@document.dig("analysis", "completeness")) if @document["analysis"]
50
+ validate_optional_sections
51
+ validate_analysis_coverage
52
+ @document
53
+ end
54
+
55
+ private
56
+
57
+ def validate_inventory(inventory)
58
+ fail_with("source_inventory must be an object") unless hash_with_string_keys?(inventory)
59
+ validate_string_field(inventory, "root", nullable: true)
60
+ sources = inventory["source_units"]
61
+ decisions = inventory["decisions"]
62
+ fail_with("source_units must be an array") unless sources.is_a?(Array)
63
+ fail_with("decisions must be an array") unless decisions.is_a?(Array)
64
+ source_ids = unique_ids(sources, "source_id", "source")
65
+ decision_ids = unique_ids(decisions, "id", "decision")
66
+ decisions.each do |decision|
67
+ fail_with("invalid decision") unless hash_with_string_keys?(decision)
68
+ fail_with("unknown decision source") unless source_ids.include?(decision["source_id"])
69
+ validate_string_field(decision, "expression")
70
+ validate_integer_field(decision, "line")
71
+ conditions = decision["conditions"]
72
+ fail_with("conditions must be an array") unless conditions.is_a?(Array)
73
+ unique_ids(conditions, "id", "condition")
74
+ conditions.each do |condition|
75
+ fail_with("invalid condition") unless hash_with_string_keys?(condition) && condition["index"].is_a?(Integer)
76
+ validate_string_field(condition, "expression")
77
+ %w[line column byte_start byte_length].each do |field|
78
+ validate_integer_field(condition, field, nullable: true)
79
+ end
80
+ end
81
+ fail_with("condition indexes must be consecutive") unless conditions.map do |condition|
82
+ condition["index"]
83
+ end.sort == (0...conditions.length).to_a
84
+ end
85
+ @source_ids = source_ids
86
+ @decision_ids = decision_ids
87
+ @condition_ids = decisions.flat_map do |decision|
88
+ Array(decision["conditions"]).map do |condition|
89
+ condition["id"]
90
+ end
91
+ end
92
+ fail_with("duplicate condition id") unless @condition_ids.uniq.length == @condition_ids.length
93
+ @conditions_by_decision = decisions.to_h do |decision|
94
+ [decision["id"], Array(decision["conditions"]).map { |condition| condition["id"] }]
95
+ end
96
+ sources.each do |source|
97
+ validate_string_field(source, "relative_path", nullable: true)
98
+ validate_string_field(source, "absolute_path", nullable: true)
99
+ digest = source["digest"] || source["content_digest"]
100
+ fail_with("source digest must be a nonempty string") unless digest.is_a?(String) && !digest.empty?
101
+ end
102
+ paths = sources.map { |source| source["relative_path"] }.compact
103
+ fail_with("duplicate source relative path") unless paths.uniq.length == paths.length
104
+ @condition_counts = decisions.to_h { |decision| [decision["id"], decision["conditions"].length] }
105
+ @supported_ids = decisions.filter_map do |decision|
106
+ decision["id"] unless decision["support_status"] == "UNSUPPORTED"
107
+ end
108
+ end
109
+
110
+ def validate_observations(observations)
111
+ fail_with("observations must be an object") unless hash_with_string_keys?(observations)
112
+ tests = observations["tests"]
113
+ vectors = observations["vectors"]
114
+ fail_with("tests must be an array") unless tests.is_a?(Array)
115
+ fail_with("vectors must be an array") unless vectors.is_a?(Array)
116
+ @test_ids = unique_ids(tests, "id", "test")
117
+ tests.each { |test| validate_test(test) }
118
+ vector_ids = unique_ids(vectors, "id", "vector")
119
+ vectors.each do |vector|
120
+ fail_with("invalid vector") unless hash_with_string_keys?(vector)
121
+ fail_with("unknown vector decision") unless @decision_ids.include?(vector["decision_id"])
122
+ values = vector["values"]
123
+ valid_values = values.is_a?(Array) && values.all? do |value|
124
+ value.nil? || value == true || value == false
125
+ end
126
+ fail_with("vector values must be an array of booleans or null") unless valid_values
127
+ expected_length = @condition_counts.fetch(vector["decision_id"])
128
+ fail_with("vector values do not match decision conditions") unless values.length == expected_length
129
+ fail_with("vector outcome must be boolean") unless [true, false].include?(vector["outcome"])
130
+ fail_with("unknown vector test") unless strings?(vector["test_ids"]) && vector["test_ids"].all? do |id|
131
+ @test_ids.include?(id)
132
+ end
133
+ validate_phases(vector)
134
+ validate_integer_field(vector, "count")
135
+ validate_integer_field(vector, "unattributed_count")
136
+ end
137
+ @vector_ids = vector_ids
138
+ @vector_decision_by_id = vectors.to_h { |vector| [vector["id"], vector["decision_id"]] }
139
+ aborts = observations["abort_counts"]
140
+ return if aborts.nil? || aborts.is_a?(Integer) || (aborts.is_a?(Hash) && aborts.values.all?(Integer))
141
+
142
+ fail_with("invalid abort_counts")
143
+ end
144
+
145
+ def validate_baseline(baseline)
146
+ fail_with("baseline must be an object") unless hash_with_string_keys?(baseline)
147
+ fail_with("baseline status must be a string") unless baseline["status"].is_a?(String)
148
+ fail_with("baseline finalized must be boolean") unless [true, false].include?(baseline["finalized"])
149
+ return unless baseline.key?("tests")
150
+
151
+ fail_with("baseline tests must be an array") unless baseline["tests"].is_a?(Array)
152
+ unique_ids(baseline["tests"], "id", "baseline test")
153
+ baseline["tests"].each { |test| validate_test(test) }
154
+ end
155
+
156
+ def validate_analysis(analysis)
157
+ return if analysis.nil?
158
+
159
+ fail_with("analysis must be an object") unless hash_with_string_keys?(analysis)
160
+ %w[proven_count eligible_count].each { |field| validate_integer_field(analysis, field) }
161
+ decisions = analysis["decisions"]
162
+ fail_with("analysis decisions must be an array") unless decisions.is_a?(Array)
163
+ seen = {}
164
+ decisions.each do |decision|
165
+ fail_with("invalid analysis decision") unless hash_with_string_keys?(decision)
166
+ id = decision["decision_id"]
167
+ fail_with("unknown analysis decision") unless @decision_ids.include?(id) && !seen.key?(id)
168
+ seen[id] = true
169
+ results = decision["condition_results"]
170
+ fail_with("condition results must be an array") unless results.is_a?(Array)
171
+ seen_conditions = {}
172
+ results.each do |result|
173
+ condition_id = result["condition_id"] if hash_with_string_keys?(result)
174
+ fail_with("invalid condition result") unless @conditions_by_decision.fetch(id).include?(condition_id)
175
+ fail_with("duplicate condition result") if seen_conditions.key?(condition_id)
176
+ seen_conditions[condition_id] = true
177
+ validate_constraint(result["constraint_result"], id) if result["constraint_result"]
178
+ next unless result.key?("canonical_pair")
179
+
180
+ next if result["canonical_pair"].nil?
181
+
182
+ pair = result["canonical_pair"]
183
+ valid_pair = pair.is_a?(Array) && pair.length == 2 && pair.uniq.length == 2 && pair.all? do |id|
184
+ @vector_ids.include?(id) && @vector_decision_by_id[id] == decision["decision_id"]
185
+ end
186
+ fail_with("canonical pair must reference vectors") unless valid_pair
187
+ end
188
+ end
189
+ end
190
+
191
+ def validate_completeness(completeness)
192
+ fail_with("completeness must be an object") unless hash_with_string_keys?(completeness)
193
+ COMPLETENESS_FIELDS.each do |field|
194
+ valid = completeness.key?(field) && [true, false].include?(completeness[field])
195
+ fail_with("completeness #{field} must be boolean") unless valid
196
+ end
197
+ end
198
+
199
+ def validate_optional_sections
200
+ fail_with("minima must be an array") unless @document["minima"].is_a?(Array)
201
+ fail_with("metrics must be an object") unless hash_with_string_keys?(@document["metrics"])
202
+ fail_with("diagnostics must be an array") unless @document["diagnostics"].is_a?(Array)
203
+ @document["diagnostics"].each do |diagnostic|
204
+ fail_with("invalid diagnostic") unless hash_with_string_keys?(diagnostic)
205
+ end
206
+ @document["minima"].each { |minimum| validate_minimum(minimum) }
207
+ if @document.key?("run_metadata") && !hash_with_string_keys?(@document["run_metadata"])
208
+ fail_with("run_metadata must be an object")
209
+ end
210
+ validate_metadata(@document["run_metadata"]) if @document["run_metadata"]
211
+ fail_with("document contains non-string object keys") unless all_hashes_have_string_keys?(@document)
212
+ end
213
+
214
+ def validate_phases(vector)
215
+ phases = vector["phases_by_test"]
216
+ return if phases.nil?
217
+
218
+ valid = hash_with_string_keys?(phases) && phases.all? do |test_id, values|
219
+ valid_phases = %w[setup body teardown suite unattributed]
220
+ @test_ids.include?(test_id) && strings?(values) && values.all? { |phase| valid_phases.include?(phase) }
221
+ end
222
+ fail_with("invalid phases_by_test") unless valid
223
+ end
224
+
225
+ def validate_test(test)
226
+ %w[name class_name method_name source_path].each { |field| validate_string_field(test, field, nullable: true) }
227
+ validate_integer_field(test, "line", nullable: true)
228
+ phase_counts = test["phase_counts"]
229
+ if phase_counts
230
+ valid = hash_with_string_keys?(phase_counts) && phase_counts.values.all?(Integer)
231
+ fail_with("invalid test phase_counts") unless valid
232
+ end
233
+ source = test["source"]
234
+ return if source.nil?
235
+
236
+ fail_with("test source must be an object") unless hash_with_string_keys?(source)
237
+ validate_string_field(source, "path", nullable: true)
238
+ validate_string_field(source, "relative_path", nullable: true)
239
+ validate_integer_field(source, "line", nullable: true)
240
+ end
241
+
242
+ # Keep each record's shape and reference checks together.
243
+ def validate_minimum(minimum)
244
+ fail_with("invalid minimum") unless hash_with_string_keys?(minimum)
245
+ %w[scope_decision_ids selected_ids].each do |field|
246
+ fail_with("minimum #{field} must be an array of strings") if minimum.key?(field) && !strings?(minimum[field])
247
+ end
248
+ validate_string_field(minimum, "objective", nullable: true)
249
+ validate_string_field(minimum, "status", nullable: true)
250
+ scope = minimum["scope_decision_ids"] || []
251
+ fail_with("unknown minimum decision") unless (scope - @decision_ids).empty?
252
+ ids = minimum["objective"] == "tests" ? @test_ids : @vector_ids
253
+ fail_with("unknown minimum member") unless ((minimum["selected_ids"] || []) - ids).empty?
254
+ end
255
+
256
+ def validate_constraint(constraint, decision_id)
257
+ fail_with("constraint_result must be an object") unless hash_with_string_keys?(constraint)
258
+ %w[status feasibility_statement].each { |field| validate_string_field(constraint, field, nullable: true) }
259
+ %w[constraints candidate_vectors].each do |field|
260
+ fail_with("#{field} must be an array") if constraint.key?(field) && !constraint[field].is_a?(Array)
261
+ end
262
+ Array(constraint["candidate_vectors"]).each do |candidate|
263
+ fail_with("invalid candidate vector") unless hash_with_string_keys?(candidate)
264
+ values = candidate["values"]
265
+ unless values.is_a?(Array) && values.length == @condition_counts[decision_id] && values.all? do |item|
266
+ [true, false, nil].include?(item)
267
+ end
268
+ fail_with("invalid candidate values")
269
+ end
270
+ fail_with("invalid candidate outcome") unless [true, false].include?(candidate["outcome"])
271
+ end
272
+ existing = constraint["existing_vector_id"]
273
+ fail_with("unknown constraint vector") if existing && @vector_decision_by_id[existing] != decision_id
274
+ end
275
+
276
+ def validate_analysis_coverage
277
+ analysis = @document["analysis"]
278
+ return unless analysis && analysis.dig("completeness", "analysis") == true
279
+
280
+ results = analysis["decisions"].to_h { |decision| [decision["decision_id"], decision["condition_results"]] }
281
+ @supported_ids.each do |id|
282
+ actual = Array(results[id]).map { |result| result["condition_id"] }.sort
283
+ fail_with("complete analysis is missing condition results") unless actual == @conditions_by_decision[id].sort
284
+ end
285
+ end
286
+
287
+ def validate_string_field(record, field, nullable: false)
288
+ return unless record.key?(field)
289
+ return if nullable && record[field].nil?
290
+
291
+ fail_with("#{field} must be a string") unless record[field].is_a?(String)
292
+ end
293
+
294
+ def validate_integer_field(record, field, nullable: false)
295
+ return unless record.key?(field)
296
+ return if nullable && record[field].nil?
297
+
298
+ fail_with("#{field} must be an integer") unless record[field].is_a?(Integer)
299
+ end
300
+
301
+ def validate_metadata(metadata)
302
+ string_fields = %w[captured_at project_kind project_root]
303
+ string_fields.each do |field|
304
+ fail_with("run_metadata #{field} must be a string") if metadata.key?(field) && !metadata[field].is_a?(String)
305
+ end
306
+ array_fields = %w[source_patterns test_patterns test_files runner_args]
307
+ array_fields.each do |field|
308
+ if metadata.key?(field) && !strings?(metadata[field])
309
+ fail_with("run_metadata #{field} must be an array of strings")
310
+ end
311
+ end
312
+ if metadata.key?("requested_level") && !metadata["requested_level"].is_a?(Integer)
313
+ fail_with("run_metadata requested_level must be an integer")
314
+ end
315
+ if metadata.key?("limits") && !hash_with_string_keys?(metadata["limits"])
316
+ fail_with("run_metadata limits must be an object")
317
+ end
318
+ locations = metadata["test_locations"]
319
+ return unless locations
320
+
321
+ valid = hash_with_string_keys?(locations) && locations.all? do |test_id, location|
322
+ @test_ids.include?(test_id) && hash_with_string_keys?(location) &&
323
+ (!location.key?("relative_path") || location["relative_path"].nil? ||
324
+ location["relative_path"].is_a?(String)) &&
325
+ (!location.key?("line") || location["line"].nil? || location["line"].is_a?(Integer))
326
+ end
327
+ fail_with("invalid run_metadata test_locations") unless valid
328
+ end
329
+
330
+ def unique_ids(items, field, label)
331
+ ids = items.map do |item|
332
+ unless hash_with_string_keys?(item) && item[field].is_a?(String) && !item[field].empty?
333
+ fail_with("invalid #{label} record")
334
+ end
335
+ item[field]
336
+ end
337
+ fail_with("duplicate #{label} id") unless ids.uniq.length == ids.length
338
+ ids
339
+ end
340
+
341
+ def strings?(value)
342
+ value.is_a?(Array) && value.all?(String)
343
+ end
344
+
345
+ def hash_with_string_keys?(value)
346
+ value.is_a?(Hash) && value.keys.all?(String)
347
+ end
348
+
349
+ def all_hashes_have_string_keys?(value)
350
+ return true unless value.is_a?(Hash)
351
+ return false unless hash_with_string_keys?(value)
352
+
353
+ value.values.all? do |item|
354
+ all_hashes_have_string_keys?(item) || (item.is_a?(Array) && item.all? do |entry|
355
+ all_hashes_have_string_keys?(entry)
356
+ end)
357
+ end
358
+ end
359
+
360
+ def fail_with(message)
361
+ raise ArgumentError, "invalid saved report: #{message}"
362
+ end
363
+ end
364
+ end
365
+
366
+ # rubocop:enable Metrics/ClassLength, Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
@@ -134,6 +134,7 @@ module Branchproof
134
134
  location = leaf.delete(:_location)
135
135
  literal_truth = leaf.delete(:_literal_truth)
136
136
  Records.build(id: nil, index: index, byte_start: location.start_offset, byte_length: location.length,
137
+ line: location.start_line, column: location.start_column,
137
138
  expression: expression, literal_truth: literal_truth, coupling: "unknown")
138
139
  end
139
140
  decision_id = Records.decision_id(source_id: source_id, context: context, byte_start: start_offset,
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Branchproof
4
- VERSION = "0.3.0"
4
+ VERSION = "0.5.0"
5
5
  end
data/lib/branchproof.rb CHANGED
@@ -18,6 +18,11 @@ module Branchproof
18
18
  autoload :Analyzer, "branchproof/analyzer"
19
19
  autoload :Minimizer, "branchproof/minimizer"
20
20
  autoload :Report, "branchproof/report"
21
+ autoload :CoverageIndex, "branchproof/coverage_index"
22
+ autoload :FocusedReport, "branchproof/focused_report"
23
+ autoload :SavedReport, "branchproof/saved_report"
24
+ autoload :Comparison, "branchproof/comparison"
25
+ autoload :ComparisonReport, "branchproof/comparison_report"
21
26
  autoload :Worker, "branchproof/worker"
22
27
  end
23
28
 
data/llms.txt CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  | | |
4
4
  | --- | --- |
5
- | **Defined in** | lib/branchproof.rb, lib/branchproof/cli.rb, lib/branchproof/limits.rb, lib/branchproof/loader.rb, lib/branchproof/report.rb, lib/branchproof/source.rb, lib/branchproof/worker.rb, lib/branchproof/project.rb, lib/branchproof/records.rb, lib/branchproof/runtime.rb, lib/branchproof/version.rb, lib/branchproof/analyzer.rb, lib/branchproof/evidence.rb, lib/branchproof/minimizer.rb, lib/branchproof/instrumenter.rb, lib/branchproof/rails_support.rb, lib/branchproof/minitest_adapter.rb |
5
+ | **Defined in** | lib/branchproof.rb, lib/branchproof/cli.rb, lib/branchproof/limits.rb, lib/branchproof/loader.rb, lib/branchproof/report.rb, lib/branchproof/source.rb, lib/branchproof/worker.rb, lib/branchproof/project.rb, lib/branchproof/records.rb, lib/branchproof/runtime.rb, lib/branchproof/version.rb, lib/branchproof/analyzer.rb, lib/branchproof/evidence.rb, lib/branchproof/minimizer.rb, lib/branchproof/comparison.rb, lib/branchproof/instrumenter.rb, lib/branchproof/saved_report.rb, lib/branchproof/rails_support.rb, lib/branchproof/coverage_index.rb, lib/branchproof/focused_report.rb, lib/branchproof/minitest_adapter.rb, lib/branchproof/comparison_report.rb |
6
6
 
7
7
  Public namespace for source inventory and one-run MC/DC reporting.
8
8
 
@@ -14,8 +14,12 @@ Not documented.
14
14
 
15
15
  - [Branchproof/Analyzer.md](doc/Branchproof/Analyzer.md)
16
16
  - [Branchproof/CLI.md](doc/Branchproof/CLI.md)
17
+ - [Branchproof/Comparison.md](doc/Branchproof/Comparison.md)
18
+ - [Branchproof/ComparisonReport.md](doc/Branchproof/ComparisonReport.md)
19
+ - [Branchproof/CoverageIndex.md](doc/Branchproof/CoverageIndex.md)
17
20
  - [Branchproof/Error.md](doc/Branchproof/Error.md)
18
21
  - [Branchproof/Evidence.md](doc/Branchproof/Evidence.md)
22
+ - [Branchproof/FocusedReport.md](doc/Branchproof/FocusedReport.md)
19
23
  - [Branchproof/Instrumenter.md](doc/Branchproof/Instrumenter.md)
20
24
  - [Branchproof/Limits.md](doc/Branchproof/Limits.md)
21
25
  - [Branchproof/Loader.md](doc/Branchproof/Loader.md)
@@ -27,6 +31,7 @@ Not documented.
27
31
  - [Branchproof/Records.md](doc/Branchproof/Records.md)
28
32
  - [Branchproof/Report.md](doc/Branchproof/Report.md)
29
33
  - [Branchproof/Runtime.md](doc/Branchproof/Runtime.md)
34
+ - [Branchproof/SavedReport.md](doc/Branchproof/SavedReport.md)
30
35
  - [Branchproof/Source.md](doc/Branchproof/Source.md)
31
36
  - [Branchproof/Worker.md](doc/Branchproof/Worker.md)
32
37
  - [CHANGELOG.md](doc/CHANGELOG.md)
data/sig/branchproof.rbs CHANGED
@@ -2,6 +2,7 @@ module Branchproof
2
2
  VERSION: String
3
3
 
4
4
  type record = Hash[Symbol, untyped]
5
+ type document = Hash[String | Symbol, untyped]
5
6
  type limits = record
6
7
 
7
8
  class Error < StandardError
@@ -102,11 +103,40 @@ module Branchproof
102
103
  class Report
103
104
  SCHEMA_VERSION: String
104
105
  CRITERION_VERSION: String
105
- def initialize: (inventory: record, evidence: record, analysis: record?, minima: Array[record], baseline: record, diagnostics: Array[record], ?level: Integer, ?missing_only: bool) -> void
106
+ def initialize: (inventory: document, evidence: document, analysis: document?, minima: Array[document], baseline: document, diagnostics: Array[document], ?level: Integer, ?missing_only: bool, ?view: Symbol, ?run_metadata: document, ?saved_document: document?) -> void
107
+ def self.from_document: (document: document, ?level: Integer?, ?view: Symbol, ?missing_only: bool) -> Report
108
+ def diagnostic_message: (Hash[Symbol | String, untyped] diagnostic) -> String
109
+ def condition_explanation: (decision_id: String, condition_id: String) -> String
106
110
  def write: (io: _WriteIO, format: Symbol | String) -> nil
107
111
  def exit_code: () -> Integer
108
112
  end
109
113
 
114
+ class CoverageIndex
115
+ def initialize: (document: document) -> void
116
+ attr_reader conditions: Array[record]
117
+ attr_reader tests: Array[record]
118
+ end
119
+
120
+ class FocusedReport
121
+ def initialize: (document: document, view: Symbol, level: Integer, ?missing_only: bool) -> void
122
+ def render: () -> String
123
+ end
124
+
125
+ class SavedReport
126
+ def self.read: (String path) -> document
127
+ end
128
+
129
+ class Comparison
130
+ def initialize: (before: document, after: document) -> void
131
+ def call: () -> document
132
+ end
133
+
134
+ class ComparisonReport
135
+ def initialize: (document: document) -> void
136
+ def write: (io: _WriteIO, format: Symbol | String) -> nil
137
+ def exit_code: (?fail_on_regression: bool) -> Integer
138
+ end
139
+
110
140
  interface _WriteIO
111
141
  def write: (String) -> Integer
112
142
  end