branchproof 0.6.0 → 0.8.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.
Files changed (52) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +45 -0
  3. data/README.md +279 -27
  4. data/doc/Branchproof/Analyzer.md +2 -2
  5. data/doc/Branchproof/CLI.md +4 -0
  6. data/doc/Branchproof/ComparisonReport.md +4 -0
  7. data/doc/Branchproof/Constraints/Solver.md +21 -0
  8. data/doc/Branchproof/Constraints.md +98 -0
  9. data/doc/Branchproof/CoverageIndex.md +6 -0
  10. data/doc/Branchproof/DecisionSyntax.md +22 -0
  11. data/doc/Branchproof/DecisionTable.md +153 -0
  12. data/doc/Branchproof/FlowInstrumentation.md +7 -0
  13. data/doc/Branchproof/FocusedReport.md +5 -2
  14. data/doc/Branchproof/Instrumenter.md +1 -0
  15. data/doc/Branchproof/Loader.md +0 -4
  16. data/doc/Branchproof/MinitestAdapter.md +0 -3
  17. data/doc/Branchproof/Report.md +18 -0
  18. data/doc/Branchproof/Runtime.md +23 -0
  19. data/doc/Branchproof/RuntimeFlow.md +27 -0
  20. data/doc/Branchproof/SavedReport.md +21 -0
  21. data/doc/Branchproof/Source.md +1 -0
  22. data/doc/Branchproof.md +9 -2
  23. data/doc/CHANGELOG.md +45 -0
  24. data/doc/README.md +279 -27
  25. data/lib/branchproof/analyzer.rb +222 -54
  26. data/lib/branchproof/cli.rb +28 -19
  27. data/lib/branchproof/comparison.rb +207 -19
  28. data/lib/branchproof/comparison_report.rb +49 -1
  29. data/lib/branchproof/constraints.rb +363 -0
  30. data/lib/branchproof/coverage_index.rb +120 -3
  31. data/lib/branchproof/decision_syntax.rb +310 -0
  32. data/lib/branchproof/decision_table.rb +377 -0
  33. data/lib/branchproof/evidence.rb +101 -36
  34. data/lib/branchproof/flow_instrumentation.rb +107 -0
  35. data/lib/branchproof/focused_report.rb +161 -26
  36. data/lib/branchproof/instrumenter.rb +65 -39
  37. data/lib/branchproof/limits.rb +4 -1
  38. data/lib/branchproof/loader.rb +18 -6
  39. data/lib/branchproof/minimizer.rb +18 -13
  40. data/lib/branchproof/minitest_adapter.rb +11 -16
  41. data/lib/branchproof/records.rb +2 -0
  42. data/lib/branchproof/report.rb +470 -66
  43. data/lib/branchproof/runtime.rb +25 -30
  44. data/lib/branchproof/runtime_flow.rb +58 -0
  45. data/lib/branchproof/saved_report.rb +438 -13
  46. data/lib/branchproof/source.rb +239 -41
  47. data/lib/branchproof/version.rb +1 -1
  48. data/lib/branchproof/worker.rb +1 -4
  49. data/lib/branchproof.rb +2 -0
  50. data/llms.txt +9 -2
  51. data/sig/branchproof.rbs +54 -1
  52. metadata +12 -1
@@ -1,13 +1,25 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # rubocop:disable Metrics/BlockLength
4
+
3
5
  # rubocop:disable Metrics/ClassLength, Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
4
6
 
5
7
  require "json"
8
+ # rubocop:disable-next Lint/RedundantRequireStatement -- supports standalone core entry
9
+ require "set"
10
+ require_relative "decision_table"
6
11
 
7
12
  module Branchproof
8
13
  # Reads and validates a persisted JSON report without loading the project.
9
14
  class SavedReport
10
- SUPPORTED_SCHEMAS = %w[1.0 1.1].freeze
15
+ SUPPORTED_SCHEMAS = %w[1.0 1.1 1.2 1.3].freeze
16
+ STRICT_FLOW_SCHEMAS = %w[1.2 1.3].freeze
17
+ DECISION_TABLE_STATUSES = %w[calculated not_calculated].freeze
18
+ RULE_CONDITION_VALUES = DecisionTable::CONDITION_VALUES
19
+ RULE_COVERAGE_STATUSES = DecisionTable::COVERAGE_STATUSES
20
+ RULE_REACHABILITY_STATUSES = DecisionTable::REACHABILITY_STATUSES
21
+ DECISION_KINDS = %w[boolean implicit multiway pattern exception].freeze
22
+ NONBOOLEAN_KINDS = (DECISION_KINDS - ["boolean"]).freeze
11
23
  CRITERION_VERSION = "masking_occurrence_v1"
12
24
  REQUIRED_FIELDS = %w[schema_version tool_version criterion_version runtime run_ids source_inventory baseline
13
25
  observations analysis minima metrics diagnostics completeness].freeze
@@ -33,7 +45,8 @@ module Branchproof
33
45
 
34
46
  def validate!
35
47
  fail_with("document must be an object") unless hash_with_string_keys?(@document)
36
- schema = @document["schema_version"]
48
+ @schema_version = @document["schema_version"]
49
+ schema = @schema_version
37
50
  fail_with("unsupported schema version") unless SUPPORTED_SCHEMAS.include?(schema)
38
51
  missing = REQUIRED_FIELDS.reject { |field| @document.key?(field) }
39
52
  fail_with("missing field: #{missing.first}") unless missing.empty?
@@ -66,6 +79,9 @@ module Branchproof
66
79
  decisions.each do |decision|
67
80
  fail_with("invalid decision") unless hash_with_string_keys?(decision)
68
81
  fail_with("unknown decision source") unless source_ids.include?(decision["source_id"])
82
+ kind = decision_kind(decision)
83
+ fail_with("invalid decision kind") unless DECISION_KINDS.include?(kind)
84
+ validate_string_field(decision, "context", nullable: true)
69
85
  validate_string_field(decision, "expression")
70
86
  validate_integer_field(decision, "line")
71
87
  conditions = decision["conditions"]
@@ -81,9 +97,17 @@ module Branchproof
81
97
  fail_with("condition indexes must be consecutive") unless conditions.map do |condition|
82
98
  condition["index"]
83
99
  end.sort == (0...conditions.length).to_a
100
+ alternatives = decision["alternatives"]
101
+ if nonboolean_kind?(kind)
102
+ fail_with("nonboolean decision tree must be null") unless decision.key?("tree") && decision["tree"].nil?
103
+ validate_alternatives(alternatives)
104
+ elsif alternatives && (!alternatives.is_a?(Array) || !alternatives.empty?)
105
+ fail_with("boolean decision alternatives must be empty")
106
+ end
84
107
  end
85
108
  @source_ids = source_ids
86
109
  @decision_ids = decision_ids
110
+ @decisions_by_id = decisions.to_h { |decision| [decision["id"], decision] }
87
111
  @condition_ids = decisions.flat_map do |decision|
88
112
  Array(decision["conditions"]).map do |condition|
89
113
  condition["id"]
@@ -91,7 +115,7 @@ module Branchproof
91
115
  end
92
116
  fail_with("duplicate condition id") unless @condition_ids.uniq.length == @condition_ids.length
93
117
  @conditions_by_decision = decisions.to_h do |decision|
94
- [decision["id"], Array(decision["conditions"]).map { |condition| condition["id"] }]
118
+ [decision["id"], Array(decision["conditions"]).to_set { |condition| condition["id"] }]
95
119
  end
96
120
  sources.each do |source|
97
121
  validate_string_field(source, "relative_path", nullable: true)
@@ -102,6 +126,18 @@ module Branchproof
102
126
  paths = sources.map { |source| source["relative_path"] }.compact
103
127
  fail_with("duplicate source relative path") unless paths.uniq.length == paths.length
104
128
  @condition_counts = decisions.to_h { |decision| [decision["id"], decision["conditions"].length] }
129
+ @alternative_counts = decisions.to_h do |decision|
130
+ [decision["id"], nonboolean_kind?(decision_kind(decision)) ? decision.fetch("alternatives").length : 0]
131
+ end
132
+ @alternative_ids_by_decision = decisions.to_h do |decision|
133
+ [decision["id"], if nonboolean_kind?(decision_kind(decision))
134
+ decision.fetch("alternatives").map do |alternative|
135
+ alternative["id"]
136
+ end
137
+ else
138
+ []
139
+ end]
140
+ end
105
141
  @supported_ids = decisions.filter_map do |decision|
106
142
  decision["id"] unless decision["support_status"] == "UNSUPPORTED"
107
143
  end
@@ -114,21 +150,28 @@ module Branchproof
114
150
  fail_with("tests must be an array") unless tests.is_a?(Array)
115
151
  fail_with("vectors must be an array") unless vectors.is_a?(Array)
116
152
  @test_ids = unique_ids(tests, "id", "test")
153
+ @test_id_set = @test_ids.to_set
117
154
  tests.each { |test| validate_test(test) }
118
155
  vector_ids = unique_ids(vectors, "id", "vector")
119
156
  vectors.each do |vector|
120
157
  fail_with("invalid vector") unless hash_with_string_keys?(vector)
121
- fail_with("unknown vector decision") unless @decision_ids.include?(vector["decision_id"])
158
+ fail_with("unknown vector decision") unless @decisions_by_id.key?(vector["decision_id"])
159
+ decision = @decisions_by_id.fetch(vector["decision_id"])
122
160
  values = vector["values"]
123
161
  valid_values = values.is_a?(Array) && values.all? do |value|
124
162
  value.nil? || value == true || value == false
125
163
  end
126
164
  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
165
+ expected_length = if @alternative_counts.fetch(vector["decision_id"]).positive?
166
+ @alternative_counts.fetch(vector["decision_id"])
167
+ else
168
+ @condition_counts.fetch(vector["decision_id"])
169
+ end
170
+ fail_with("vector values do not match decision dimensions") unless values.length == expected_length
129
171
  fail_with("vector outcome must be boolean") unless [true, false].include?(vector["outcome"])
172
+ validate_flow_vector(vector, decision) if strict_flow_decision?(decision)
130
173
  fail_with("unknown vector test") unless strings?(vector["test_ids"]) && vector["test_ids"].all? do |id|
131
- @test_ids.include?(id)
174
+ @test_id_set.include?(id)
132
175
  end
133
176
  validate_phases(vector)
134
177
  validate_integer_field(vector, "count")
@@ -136,6 +179,8 @@ module Branchproof
136
179
  end
137
180
  @vector_ids = vector_ids
138
181
  @vector_decision_by_id = vectors.to_h { |vector| [vector["id"], vector["decision_id"]] }
182
+ @vectors_by_id = vectors.to_h { |vector| [vector["id"], vector] }
183
+ @vectors_by_decision = vectors.group_by { |vector| vector["decision_id"] }
139
184
  aborts = observations["abort_counts"]
140
185
  return if aborts.nil? || aborts.is_a?(Integer) || (aborts.is_a?(Hash) && aborts.values.all?(Integer))
141
186
 
@@ -164,10 +209,18 @@ module Branchproof
164
209
  decisions.each do |decision|
165
210
  fail_with("invalid analysis decision") unless hash_with_string_keys?(decision)
166
211
  id = decision["decision_id"]
167
- fail_with("unknown analysis decision") unless @decision_ids.include?(id) && !seen.key?(id)
212
+ fail_with("unknown analysis decision") unless @decisions_by_id.key?(id) && !seen.key?(id)
168
213
  seen[id] = true
169
214
  results = decision["condition_results"]
170
215
  fail_with("condition results must be an array") unless results.is_a?(Array)
216
+ inventory_decision = @decisions_by_id[id]
217
+ if nonboolean_kind?(decision_kind(inventory_decision))
218
+ fail_with("nonboolean condition results must be empty") unless results.empty?
219
+ validate_nonboolean_analysis(decision, inventory_decision)
220
+ elsif decision.key?("decision_table")
221
+ fail_with("decision tables are unsupported in legacy report schemas") unless @schema_version == "1.3"
222
+ validate_decision_table(decision["decision_table"], inventory_decision)
223
+ end
171
224
  seen_conditions = {}
172
225
  results.each do |result|
173
226
  condition_id = result["condition_id"] if hash_with_string_keys?(result)
@@ -181,13 +234,375 @@ module Branchproof
181
234
 
182
235
  pair = result["canonical_pair"]
183
236
  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"]
237
+ @vectors_by_id.key?(id) && @vector_decision_by_id[id] == decision["decision_id"]
185
238
  end
186
239
  fail_with("canonical pair must reference vectors") unless valid_pair
187
240
  end
188
241
  end
189
242
  end
190
243
 
244
+ # The persisted decision table must describe the same decision it was
245
+ # derived from, so a saved report can be reported on and compared offline.
246
+ def validate_decision_table(table, decision)
247
+ fail_with("decision_table must be an object") unless hash_with_string_keys?(table)
248
+ fail_with("decision_table status is invalid") unless DECISION_TABLE_STATUSES.include?(table["status"])
249
+ fail_with("decision_table decision id mismatch") unless table["decision_id"] == decision["id"]
250
+ %w[schema_version constraint_analysis_version].each do |field|
251
+ fail_with("decision_table #{field} must be an integer") unless table[field].is_a?(Integer)
252
+ end
253
+ unless table["schema_version"] == DecisionTable::SCHEMA_VERSION
254
+ fail_with("unsupported decision_table schema version")
255
+ end
256
+ solver_version = table["constraint_analysis_version"]
257
+ supported_solver = [1, DecisionTable::CONSTRAINT_ANALYSIS_VERSION].include?(solver_version) &&
258
+ solver_version <= DecisionTable::CONSTRAINT_ANALYSIS_VERSION
259
+ fail_with("unsupported decision_table constraint analysis version") unless supported_solver
260
+ validate_string_field(table, "reason", nullable: true)
261
+ unless DecisionTable::COVERAGE_SUMMARY_STATUSES.include?(table["coverage_status"])
262
+ fail_with("invalid decision_table coverage status")
263
+ end
264
+ %w[generated_rules impossible_rules required_rules covered_rules missing_rules].each do |field|
265
+ unless table[field].is_a?(Integer) && table[field] >= 0
266
+ fail_with("decision_table #{field} must be a nonnegative integer")
267
+ end
268
+ end
269
+ rules = table["rules"]
270
+ fail_with("decision_table rules must be an array") unless rules.is_a?(Array)
271
+ unique_ids(rules, "id", "decision table rule")
272
+ indexes = rules.map { |rule| rule["index"] }
273
+ fail_with("decision table rule indexes must be consecutive") unless indexes.sort == (0...rules.length).to_a
274
+ condition_count = decision["conditions"].length
275
+ rules.each { |rule| validate_decision_table_rule(rule, decision, condition_count) }
276
+ validate_decision_table_counts(table, rules)
277
+ validate_decision_table_summary(table, rules)
278
+ end
279
+
280
+ def validate_decision_table_rule(rule, decision, condition_count)
281
+ fail_with("invalid decision table rule") unless hash_with_string_keys?(rule)
282
+ validate_string_field(rule, "label")
283
+ validate_integer_field(rule, "index")
284
+ unless rule["index"].is_a?(Integer) && rule["index"] >= 0
285
+ fail_with("decision table rule index must be nonnegative")
286
+ end
287
+ fail_with("decision table rule label does not match its index") unless rule["label"] == "R#{rule["index"] + 1}"
288
+ conditions = rule["conditions"]
289
+ valid = conditions.is_a?(Array) && conditions.length == condition_count &&
290
+ conditions.all? { |value| RULE_CONDITION_VALUES.include?(value) }
291
+ fail_with("decision table rule conditions do not match the decision") unless valid
292
+ fail_with("decision table rule outcome must be boolean") unless [true, false].include?(rule["outcome"])
293
+ expected_id = DecisionTable.rule_id(decision["id"], conditions, rule["outcome"])
294
+ unless rule["id"] == expected_id
295
+ fail_with("decision table rule id does not match its decision, conditions, and outcome")
296
+ end
297
+ fail_with("invalid decision table rule coverage") unless RULE_COVERAGE_STATUSES.include?(rule["coverage"])
298
+ unless RULE_REACHABILITY_STATUSES.include?(rule["reachability"])
299
+ fail_with("invalid decision table rule reachability")
300
+ end
301
+ validate_string_field(rule, "reachability_reason", nullable: true)
302
+ if rule["reachability"] == "statically_impossible" && !rule["reachability_reason"].is_a?(String)
303
+ fail_with("statically impossible rule requires a reason code")
304
+ end
305
+ if %w[observed unknown].include?(rule["reachability"]) && !rule["reachability_reason"].nil?
306
+ fail_with("observed or unknown rule must not have a reachability reason")
307
+ end
308
+ fail_with("decision table rule tests must be strings") unless strings?(rule["tests"])
309
+ fail_with("unknown decision table rule test") unless rule["tests"].all? { |id| @test_id_set.include?(id) }
310
+ fail_with("decision table rule vector_ids must be strings") unless strings?(rule["vector_ids"])
311
+ unless rule["vector_ids"].uniq.length == rule["vector_ids"].length
312
+ fail_with("duplicate decision table rule vector")
313
+ end
314
+ fail_with("unknown decision table rule vector") unless rule["vector_ids"].all? do |id|
315
+ @vector_decision_by_id[id] == decision["id"]
316
+ end
317
+ validate_decision_table_evidence(rule, decision)
318
+ validate_integer_field(rule, "unattributed_count")
319
+ unless rule["unattributed_count"].is_a?(Integer) && rule["unattributed_count"] >= 0
320
+ fail_with("decision table rule unattributed count must be nonnegative")
321
+ end
322
+ return unless rule["coverage"] == "covered" && rule["vector_ids"].empty?
323
+
324
+ fail_with("covered decision table rule requires evidence")
325
+ end
326
+
327
+ def validate_decision_table_counts(table, rules)
328
+ if table["status"] == "not_calculated"
329
+ fail_with("not_calculated decision table must not contain rules") unless rules.empty?
330
+ %w[generated_rules impossible_rules required_rules covered_rules missing_rules].each do |field|
331
+ fail_with("not_calculated decision table #{field} must be zero") unless table[field].zero?
332
+ end
333
+ return
334
+ end
335
+
336
+ fail_with("decision_table generated_rules mismatch") unless table["generated_rules"] == rules.length
337
+ impossible = rules.count { |rule| rule["coverage"] == "excluded" }
338
+ covered = rules.count { |rule| rule["coverage"] == "covered" }
339
+ fail_with("decision_table impossible_rules mismatch") unless table["impossible_rules"] == impossible
340
+ fail_with("decision_table required_rules mismatch") unless table["required_rules"] == rules.length - impossible
341
+ fail_with("decision_table covered_rules mismatch") unless table["covered_rules"] == covered
342
+ return if table["missing_rules"] == table["required_rules"] - table["covered_rules"]
343
+
344
+ fail_with("decision_table missing_rules mismatch")
345
+ end
346
+
347
+ def validate_decision_table_summary(table, rules)
348
+ if table["status"] == "not_calculated"
349
+ allowed_reason = DecisionTable::NOT_CALCULATED_REASONS.include?(table["reason"])
350
+ fail_with("not_calculated decision table requires a supported reason") unless allowed_reason
351
+ expected_status = table["reason"] == "unsupported_decision" ? "unsupported" : "not_calculated"
352
+ unless table["coverage_status"] == expected_status
353
+ fail_with("not_calculated decision table coverage status mismatch")
354
+ end
355
+ fail_with("not_calculated decision table percentage must be null") unless table["percentage"].nil?
356
+ unless table["reachability_analyzed"] == false
357
+ fail_with("not_calculated decision table reachability must be false")
358
+ end
359
+ return
360
+ end
361
+
362
+ fail_with("calculated decision table reason must be null") unless table["reason"].nil?
363
+ reachability_analyzed = table["reachability_analyzed"]
364
+ unless [true, false].include?(reachability_analyzed)
365
+ fail_with("decision_table reachability_analyzed must be boolean")
366
+ end
367
+ required = table["required_rules"]
368
+ covered = table["covered_rules"]
369
+ generated = table["generated_rules"]
370
+ expected_status = DecisionTable.coverage_status(covered, required, generated)
371
+ fail_with("decision_table coverage status mismatch") unless table["coverage_status"] == expected_status
372
+ expected_percentage = DecisionTable.percentage(covered, required)
373
+ fail_with("decision_table percentage mismatch") unless table["percentage"] == expected_percentage
374
+ return unless table["reachability_analyzed"] == false
375
+
376
+ fail_with("un-analyzed reachability cannot exclude rules") if rules.any? do |rule|
377
+ rule["coverage"] == "excluded"
378
+ end
379
+ fail_with("un-analyzed reachability contains impossible rule") if rules.any? do |rule|
380
+ rule["reachability"] == "statically_impossible"
381
+ end
382
+ end
383
+
384
+ def validate_decision_table_evidence(rule, _decision)
385
+ rule["vector_ids"].each do |vector_id|
386
+ vector = @vectors_by_id.fetch(vector_id)
387
+ matches = vector["outcome"] == rule["outcome"] && rule["conditions"].each_with_index.all? do |required, index|
388
+ required == DecisionTable::DONT_CARE || vector["values"][index] == (required == DecisionTable::TRUE_VALUE)
389
+ end
390
+ fail_with("decision table rule evidence does not match its conditions and outcome") unless matches
391
+ end
392
+ expected_tests = rule["vector_ids"].flat_map { |id| @vectors_by_id.fetch(id)["test_ids"] }.uniq.sort
393
+ fail_with("decision table rule tests do not match its evidence") unless rule["tests"].sort == expected_tests
394
+ expected_unattributed = rule["vector_ids"].sum { |id| @vectors_by_id.fetch(id).fetch("unattributed_count", 0) }
395
+ unless rule["unattributed_count"] == expected_unattributed
396
+ fail_with("decision table rule unattributed count does not match its evidence")
397
+ end
398
+ if rule["coverage"] == "covered" && rule["reachability"] != "observed"
399
+ fail_with("covered decision table rule requires observed reachability")
400
+ end
401
+ if rule["coverage"] == "excluded" && rule["reachability"] != "statically_impossible"
402
+ fail_with("excluded decision table rule requires static impossibility")
403
+ end
404
+ if rule["coverage"] == "missing" && rule["reachability"] != "unknown"
405
+ fail_with("missing decision table rule requires unknown reachability")
406
+ end
407
+ return unless %w[missing excluded].include?(rule["coverage"])
408
+
409
+ fail_with("noncovered decision table rule must not contain evidence") unless rule["vector_ids"].empty? &&
410
+ rule["tests"].empty? &&
411
+ rule["unattributed_count"].zero?
412
+ end
413
+
414
+ def decision_kind(decision)
415
+ kind = decision && decision["kind"]
416
+ kind.nil? || kind.empty? ? "boolean" : kind
417
+ end
418
+
419
+ def nonboolean_kind?(kind)
420
+ NONBOOLEAN_KINDS.include?(kind)
421
+ end
422
+
423
+ def validate_alternatives(alternatives)
424
+ fail_with("alternatives must be an array") unless alternatives.is_a?(Array)
425
+ ids = unique_ids(alternatives, "id", "alternative")
426
+ alternatives.each do |alternative|
427
+ fail_with("alternative index must be an integer") unless alternative["index"].is_a?(Integer)
428
+ fail_with("alternative expression must be a string") unless alternative["expression"].is_a?(String)
429
+ %w[line column byte_start byte_length].each do |field|
430
+ validate_integer_field(alternative, field, nullable: true)
431
+ end
432
+ end
433
+ indexes = alternatives.map { |alternative| alternative["index"] }
434
+ fail_with("alternative indexes must be consecutive") unless indexes.sort == (0...alternatives.length).to_a
435
+ ids
436
+ end
437
+
438
+ def validate_nonboolean_analysis(analysis_decision, inventory_decision)
439
+ conditions = analysis_decision["conditions"]
440
+ fail_with("nonboolean conditions must be empty") unless conditions.nil? || conditions == []
441
+ coverage = analysis_decision["coverage"]
442
+ if coverage.nil?
443
+ return unless strict_flow_decision?(inventory_decision)
444
+
445
+ fail_with("complete flow analysis requires coverage")
446
+ end
447
+
448
+ fail_with("flow coverage must be an object") unless hash_with_string_keys?(coverage)
449
+ fail_with("alternative coverage must be an object") unless hash_with_string_keys?(coverage["alternative"])
450
+ validate_alternative_coverage(coverage["alternative"], inventory_decision)
451
+ mcdc = coverage["mcdc"]
452
+ if mcdc.nil?
453
+ fail_with("flow MC/DC status must be explicit") if strict_flow_decision?(inventory_decision)
454
+ return
455
+ end
456
+
457
+ allowed = if inventory_decision["support_status"].to_s.upcase == "UNSUPPORTED"
458
+ %w[not_applicable unsupported]
459
+ else
460
+ ["not_applicable"]
461
+ end
462
+ fail_with("nonboolean MC/DC must be not_applicable") unless hash_with_string_keys?(mcdc) &&
463
+ allowed.include?(mcdc["status"])
464
+ end
465
+
466
+ def validate_alternative_coverage(coverage, decision)
467
+ allowed_statuses = %w[covered partial unexecuted]
468
+ allowed_statuses << "unsupported" if decision["support_status"].to_s.upcase == "UNSUPPORTED"
469
+ fail_with("invalid alternative coverage status") unless allowed_statuses.include?(coverage["status"])
470
+ %w[covered_alternatives required_alternatives].each do |field|
471
+ validate_integer_field(coverage, field)
472
+ end
473
+ expected = decision.fetch("alternatives")
474
+ expected_for_coverage = coverage["status"] == "unsupported" ? [] : expected
475
+ if strict_flow_decision?(decision)
476
+ %w[covered_alternatives required_alternatives].each do |field|
477
+ fail_with("#{field} must be an integer") unless coverage[field].is_a?(Integer)
478
+ end
479
+ end
480
+ unless coverage["required_alternatives"] == expected_for_coverage.length
481
+ fail_with("alternative coverage count mismatch")
482
+ end
483
+ if strict_flow_decision?(decision)
484
+ fail_with("alternative coverage count must be nonnegative") unless coverage["covered_alternatives"] >= 0 &&
485
+ coverage["required_alternatives"] >= 0
486
+ fail_with("covered alternatives exceed required alternatives") unless coverage["covered_alternatives"] <=
487
+ coverage["required_alternatives"]
488
+ end
489
+ alternatives = coverage["alternatives"]
490
+ fail_with("alternative coverage alternatives must be an array") unless alternatives.is_a?(Array)
491
+ seen = {}
492
+ alternatives.each do |row|
493
+ fail_with("invalid alternative coverage row") unless hash_with_string_keys?(row)
494
+ id = row["alternative_id"]
495
+ inventory = expected_for_coverage.find { |alternative| alternative["id"] == id }
496
+ fail_with("unknown alternative coverage id") unless inventory && !seen.key?(id)
497
+ seen[id] = true
498
+ unless row["index"].is_a?(Integer) && row["index"] == inventory["index"]
499
+ fail_with("alternative coverage index mismatch")
500
+ end
501
+ fail_with("alternative coverage expression must be a string") unless row["expression"].is_a?(String)
502
+ %w[selected not_selected skipped].each do |state|
503
+ if strict_flow_decision?(decision)
504
+ validate_alternative_evidence(row[state], decision, inventory["index"], state)
505
+ else
506
+ validate_alternative_evidence(row[state])
507
+ end
508
+ end
509
+ end
510
+ fail_with("alternative coverage missing rows") unless seen.keys.sort == expected_for_coverage.map { |alternative|
511
+ alternative["id"]
512
+ }.sort
513
+ missing = coverage["missing_alternatives"]
514
+ fail_with("missing alternatives must be an array of strings") unless strings?(missing)
515
+ if strict_flow_decision?(decision)
516
+ expected_missing = alternatives.filter_map do |row|
517
+ row["alternative_id"] unless row.dig("selected", "observed")
518
+ end
519
+ fail_with("missing alternatives do not match selected evidence") unless missing.sort == expected_missing.sort
520
+ covered = alternatives.count { |row| row.dig("selected", "observed") }
521
+ unless coverage["covered_alternatives"] == covered
522
+ fail_with("covered alternatives do not match selected evidence")
523
+ end
524
+ expected_status = if covered.zero?
525
+ "unexecuted"
526
+ elsif covered == expected.length
527
+ "covered"
528
+ else
529
+ "partial"
530
+ end
531
+ fail_with("alternative coverage status does not match evidence") unless coverage["status"] == expected_status
532
+ end
533
+ fail_with("unknown missing alternative") unless (missing - expected.map do |alternative|
534
+ alternative["id"]
535
+ end).empty?
536
+ end
537
+
538
+ def validate_alternative_evidence(evidence, decision = nil, index = nil, state = nil)
539
+ fail_with("alternative evidence must be an object") unless hash_with_string_keys?(evidence)
540
+ fail_with("alternative evidence observed must be boolean") unless [true, false].include?(evidence["observed"])
541
+ fail_with("alternative evidence vector_ids must be strings") unless strings?(evidence["vector_ids"])
542
+ unless evidence["vector_ids"].uniq.length == evidence["vector_ids"].length
543
+ fail_with("duplicate alternative evidence vector")
544
+ end
545
+ fail_with("unknown alternative evidence vector") unless evidence["vector_ids"].all? do |id|
546
+ @vectors_by_id.key?(id)
547
+ end
548
+ fail_with("alternative evidence test_ids must be strings") unless strings?(evidence["test_ids"])
549
+ unless evidence["test_ids"].uniq.length == evidence["test_ids"].length
550
+ fail_with("duplicate alternative evidence test")
551
+ end
552
+ fail_with("unknown alternative evidence test") unless evidence["test_ids"].all? do |id|
553
+ @test_id_set.include?(id)
554
+ end
555
+ validate_integer_field(evidence, "unattributed_count")
556
+ return unless decision
557
+
558
+ vectors = evidence["vector_ids"].map { |id| @vectors_by_id.fetch(id) }
559
+ fail_with("alternative evidence references another decision") unless vectors.all? do |vector|
560
+ vector["decision_id"] == decision["id"]
561
+ end
562
+ expected_value = lambda do |vector|
563
+ value = vector["values"][index]
564
+ if state == "selected"
565
+ value == true
566
+ else
567
+ state == "not_selected" ? value == false : value.nil?
568
+ end
569
+ end
570
+ fail_with("alternative evidence state mismatch") unless vectors.all?(&expected_value)
571
+ decision_vectors = @vectors_by_decision.fetch(decision["id"], [])
572
+ expected_vectors = decision_vectors.select(&expected_value)
573
+ expected_vector_ids = expected_vectors.map { |vector| vector["id"] }.sort
574
+ fail_with("alternative evidence is incomplete") unless evidence["vector_ids"].sort == expected_vector_ids
575
+ expected_tests = vectors.flat_map { |vector| vector["test_ids"] }.uniq.sort
576
+ fail_with("alternative evidence test owners mismatch") unless evidence["test_ids"].sort == expected_tests
577
+ expected_unattributed = vectors.sum { |vector| vector.fetch("unattributed_count", 0).to_i }
578
+ fail_with("alternative evidence unattributed count mismatch") unless evidence.fetch("unattributed_count",
579
+ 0) == expected_unattributed
580
+ fail_with("alternative evidence observed mismatch") unless evidence["observed"] == !vectors.empty?
581
+ end
582
+
583
+ def validate_flow_vector(vector, decision)
584
+ values = vector["values"]
585
+ valid = if decision_kind(decision) == "implicit"
586
+ vector["outcome"] == true && values.length == 2 && values.all? do |value|
587
+ [true, false].include?(value)
588
+ end && values.count(true) == 1
589
+ else
590
+ observations = values.each_with_index.filter_map { |value, index| [index, value] unless value.nil? }
591
+ vector["outcome"] == true &&
592
+ observations.any? &&
593
+ observations.each_with_index.all? do |(index, value), position|
594
+ index == position && value == (position == observations.length - 1)
595
+ end
596
+ end
597
+ fail_with("invalid flow vector trace") unless valid
598
+ end
599
+
600
+ def strict_flow_decision?(decision)
601
+ STRICT_FLOW_SCHEMAS.include?(@schema_version) &&
602
+ nonboolean_kind?(decision_kind(decision)) &&
603
+ decision["support_status"].to_s.upcase != "UNSUPPORTED"
604
+ end
605
+
191
606
  def validate_completeness(completeness)
192
607
  fail_with("completeness must be an object") unless hash_with_string_keys?(completeness)
193
608
  COMPLETENESS_FIELDS.each do |field|
@@ -217,7 +632,7 @@ module Branchproof
217
632
 
218
633
  valid = hash_with_string_keys?(phases) && phases.all? do |test_id, values|
219
634
  valid_phases = %w[setup body teardown suite unattributed]
220
- @test_ids.include?(test_id) && strings?(values) && values.all? { |phase| valid_phases.include?(phase) }
635
+ @test_id_set.include?(test_id) && strings?(values) && values.all? { |phase| valid_phases.include?(phase) }
221
636
  end
222
637
  fail_with("invalid phases_by_test") unless valid
223
638
  end
@@ -277,9 +692,17 @@ module Branchproof
277
692
  analysis = @document["analysis"]
278
693
  return unless analysis && analysis.dig("completeness", "analysis") == true
279
694
 
280
- results = analysis["decisions"].to_h { |decision| [decision["decision_id"], decision["condition_results"]] }
695
+ analysis_by_id = analysis["decisions"].to_h { |decision| [decision["decision_id"], decision] }
281
696
  @supported_ids.each do |id|
282
- actual = Array(results[id]).map { |result| result["condition_id"] }.sort
697
+ inventory_decision = @decisions_by_id.fetch(id)
698
+ analysis_decision = analysis_by_id[id]
699
+ if strict_flow_decision?(inventory_decision)
700
+ fail_with("complete analysis is missing flow decision") unless analysis_decision
701
+ validate_nonboolean_analysis(analysis_decision, inventory_decision)
702
+ end
703
+ actual = Array(analysis_decision && analysis_decision["condition_results"]).map do |result|
704
+ result["condition_id"]
705
+ end.sort
283
706
  fail_with("complete analysis is missing condition results") unless actual == @conditions_by_decision[id].sort
284
707
  end
285
708
  end
@@ -319,7 +742,7 @@ module Branchproof
319
742
  return unless locations
320
743
 
321
744
  valid = hash_with_string_keys?(locations) && locations.all? do |test_id, location|
322
- @test_ids.include?(test_id) && hash_with_string_keys?(location) &&
745
+ @test_id_set.include?(test_id) && hash_with_string_keys?(location) &&
323
746
  (!location.key?("relative_path") || location["relative_path"].nil? ||
324
747
  location["relative_path"].is_a?(String)) &&
325
748
  (!location.key?("line") || location["line"].nil? || location["line"].is_a?(Integer))
@@ -364,3 +787,5 @@ module Branchproof
364
787
  end
365
788
 
366
789
  # rubocop:enable Metrics/ClassLength, Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
790
+
791
+ # rubocop:enable Metrics/BlockLength