branchproof 0.7.0 → 0.9.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 (75) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +53 -0
  3. data/README.md +255 -21
  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 +3 -0
  10. data/doc/Branchproof/DecisionSyntax.md +6 -4
  11. data/doc/Branchproof/DecisionTable.md +153 -0
  12. data/doc/Branchproof/DefaultInstrumentation.md +13 -0
  13. data/doc/Branchproof/DefaultRuntime.md +11 -0
  14. data/doc/Branchproof/DefaultSyntax.md +7 -0
  15. data/doc/Branchproof/ExceptionInstrumentation.md +9 -0
  16. data/doc/Branchproof/ExceptionRuntime.md +27 -0
  17. data/doc/Branchproof/ExceptionSyntax.md +9 -0
  18. data/doc/Branchproof/ExtendedAlternativeRuntime.md +19 -0
  19. data/doc/Branchproof/FocusedReport.md +5 -2
  20. data/doc/Branchproof/Instrumenter.md +1 -1
  21. data/doc/Branchproof/IterationInstrumentation.md +7 -0
  22. data/doc/Branchproof/IterationRuntime.md +21 -0
  23. data/doc/Branchproof/IterationSyntax.md +11 -0
  24. data/doc/Branchproof/Loader.md +0 -4
  25. data/doc/Branchproof/MinitestAdapter.md +0 -3
  26. data/doc/Branchproof/Report.md +18 -0
  27. data/doc/Branchproof/Runtime.md +60 -1
  28. data/doc/Branchproof/SavedReport.md +15 -0
  29. data/doc/Branchproof/Source.md +7 -1
  30. data/doc/Branchproof/ValueInstrumentation.md +7 -0
  31. data/doc/Branchproof/ValueRuntime.md +26 -0
  32. data/doc/Branchproof/ValueSyntax.md +26 -0
  33. data/doc/Branchproof.md +20 -3
  34. data/doc/CHANGELOG.md +53 -0
  35. data/doc/README.md +255 -21
  36. data/lib/branchproof/analyzer.rb +80 -44
  37. data/lib/branchproof/cli.rb +28 -19
  38. data/lib/branchproof/comparison.rb +207 -19
  39. data/lib/branchproof/comparison_report.rb +49 -1
  40. data/lib/branchproof/constraints.rb +363 -0
  41. data/lib/branchproof/coverage_index.rb +30 -1
  42. data/lib/branchproof/decision_syntax.rb +50 -24
  43. data/lib/branchproof/decision_table.rb +377 -0
  44. data/lib/branchproof/default_instrumentation.rb +140 -0
  45. data/lib/branchproof/default_runtime.rb +16 -0
  46. data/lib/branchproof/default_syntax.rb +94 -0
  47. data/lib/branchproof/evidence.rb +133 -31
  48. data/lib/branchproof/exception_instrumentation.rb +96 -0
  49. data/lib/branchproof/exception_runtime.rb +41 -0
  50. data/lib/branchproof/exception_syntax.rb +157 -0
  51. data/lib/branchproof/extended_alternative_runtime.rb +23 -0
  52. data/lib/branchproof/flow_instrumentation.rb +52 -23
  53. data/lib/branchproof/focused_report.rb +77 -9
  54. data/lib/branchproof/instrumenter.rb +93 -37
  55. data/lib/branchproof/iteration_instrumentation.rb +66 -0
  56. data/lib/branchproof/iteration_runtime.rb +79 -0
  57. data/lib/branchproof/iteration_syntax.rb +75 -0
  58. data/lib/branchproof/limits.rb +4 -1
  59. data/lib/branchproof/loader.rb +24 -7
  60. data/lib/branchproof/minimizer.rb +18 -13
  61. data/lib/branchproof/minitest_adapter.rb +11 -16
  62. data/lib/branchproof/records.rb +2 -0
  63. data/lib/branchproof/report.rb +301 -68
  64. data/lib/branchproof/runtime.rb +32 -30
  65. data/lib/branchproof/saved_report.rb +200 -14
  66. data/lib/branchproof/source.rb +140 -71
  67. data/lib/branchproof/value_instrumentation.rb +35 -0
  68. data/lib/branchproof/value_runtime.rb +73 -0
  69. data/lib/branchproof/value_syntax.rb +116 -0
  70. data/lib/branchproof/version.rb +1 -1
  71. data/lib/branchproof/worker.rb +1 -4
  72. data/lib/branchproof.rb +2 -0
  73. data/llms.txt +20 -3
  74. data/sig/branchproof.rbs +64 -1
  75. metadata +32 -1
@@ -5,11 +5,19 @@
5
5
  # rubocop:disable Metrics/ClassLength, Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
6
6
 
7
7
  require "json"
8
+ # rubocop:disable-next Lint/RedundantRequireStatement -- supports standalone core entry
9
+ require "set"
10
+ require_relative "decision_table"
8
11
 
9
12
  module Branchproof
10
13
  # Reads and validates a persisted JSON report without loading the project.
11
14
  class SavedReport
12
- SUPPORTED_SCHEMAS = %w[1.0 1.1 1.2].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
13
21
  DECISION_KINDS = %w[boolean implicit multiway pattern exception].freeze
14
22
  NONBOOLEAN_KINDS = (DECISION_KINDS - ["boolean"]).freeze
15
23
  CRITERION_VERSION = "masking_occurrence_v1"
@@ -107,7 +115,7 @@ module Branchproof
107
115
  end
108
116
  fail_with("duplicate condition id") unless @condition_ids.uniq.length == @condition_ids.length
109
117
  @conditions_by_decision = decisions.to_h do |decision|
110
- [decision["id"], Array(decision["conditions"]).map { |condition| condition["id"] }]
118
+ [decision["id"], Array(decision["conditions"]).to_set { |condition| condition["id"] }]
111
119
  end
112
120
  sources.each do |source|
113
121
  validate_string_field(source, "relative_path", nullable: true)
@@ -142,11 +150,12 @@ module Branchproof
142
150
  fail_with("tests must be an array") unless tests.is_a?(Array)
143
151
  fail_with("vectors must be an array") unless vectors.is_a?(Array)
144
152
  @test_ids = unique_ids(tests, "id", "test")
153
+ @test_id_set = @test_ids.to_set
145
154
  tests.each { |test| validate_test(test) }
146
155
  vector_ids = unique_ids(vectors, "id", "vector")
147
156
  vectors.each do |vector|
148
157
  fail_with("invalid vector") unless hash_with_string_keys?(vector)
149
- 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"])
150
159
  decision = @decisions_by_id.fetch(vector["decision_id"])
151
160
  values = vector["values"]
152
161
  valid_values = values.is_a?(Array) && values.all? do |value|
@@ -162,7 +171,7 @@ module Branchproof
162
171
  fail_with("vector outcome must be boolean") unless [true, false].include?(vector["outcome"])
163
172
  validate_flow_vector(vector, decision) if strict_flow_decision?(decision)
164
173
  fail_with("unknown vector test") unless strings?(vector["test_ids"]) && vector["test_ids"].all? do |id|
165
- @test_ids.include?(id)
174
+ @test_id_set.include?(id)
166
175
  end
167
176
  validate_phases(vector)
168
177
  validate_integer_field(vector, "count")
@@ -171,6 +180,7 @@ module Branchproof
171
180
  @vector_ids = vector_ids
172
181
  @vector_decision_by_id = vectors.to_h { |vector| [vector["id"], vector["decision_id"]] }
173
182
  @vectors_by_id = vectors.to_h { |vector| [vector["id"], vector] }
183
+ @vectors_by_decision = vectors.group_by { |vector| vector["decision_id"] }
174
184
  aborts = observations["abort_counts"]
175
185
  return if aborts.nil? || aborts.is_a?(Integer) || (aborts.is_a?(Hash) && aborts.values.all?(Integer))
176
186
 
@@ -199,7 +209,7 @@ module Branchproof
199
209
  decisions.each do |decision|
200
210
  fail_with("invalid analysis decision") unless hash_with_string_keys?(decision)
201
211
  id = decision["decision_id"]
202
- 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)
203
213
  seen[id] = true
204
214
  results = decision["condition_results"]
205
215
  fail_with("condition results must be an array") unless results.is_a?(Array)
@@ -207,6 +217,9 @@ module Branchproof
207
217
  if nonboolean_kind?(decision_kind(inventory_decision))
208
218
  fail_with("nonboolean condition results must be empty") unless results.empty?
209
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)
210
223
  end
211
224
  seen_conditions = {}
212
225
  results.each do |result|
@@ -221,13 +234,183 @@ module Branchproof
221
234
 
222
235
  pair = result["canonical_pair"]
223
236
  valid_pair = pair.is_a?(Array) && pair.length == 2 && pair.uniq.length == 2 && pair.all? do |id|
224
- @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"]
225
238
  end
226
239
  fail_with("canonical pair must reference vectors") unless valid_pair
227
240
  end
228
241
  end
229
242
  end
230
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
+
231
414
  def decision_kind(decision)
232
415
  kind = decision && decision["kind"]
233
416
  kind.nil? || kind.empty? ? "boolean" : kind
@@ -359,12 +542,16 @@ module Branchproof
359
542
  unless evidence["vector_ids"].uniq.length == evidence["vector_ids"].length
360
543
  fail_with("duplicate alternative evidence vector")
361
544
  end
362
- fail_with("unknown alternative evidence vector") unless (evidence["vector_ids"] - @vector_ids).empty?
545
+ fail_with("unknown alternative evidence vector") unless evidence["vector_ids"].all? do |id|
546
+ @vectors_by_id.key?(id)
547
+ end
363
548
  fail_with("alternative evidence test_ids must be strings") unless strings?(evidence["test_ids"])
364
549
  unless evidence["test_ids"].uniq.length == evidence["test_ids"].length
365
550
  fail_with("duplicate alternative evidence test")
366
551
  end
367
- fail_with("unknown alternative evidence test") unless (evidence["test_ids"] - @test_ids).empty?
552
+ fail_with("unknown alternative evidence test") unless evidence["test_ids"].all? do |id|
553
+ @test_id_set.include?(id)
554
+ end
368
555
  validate_integer_field(evidence, "unattributed_count")
369
556
  return unless decision
370
557
 
@@ -381,9 +568,8 @@ module Branchproof
381
568
  end
382
569
  end
383
570
  fail_with("alternative evidence state mismatch") unless vectors.all?(&expected_value)
384
- expected_vectors = @vectors_by_id.values.select do |vector|
385
- vector["decision_id"] == decision["id"] && expected_value.call(vector)
386
- end
571
+ decision_vectors = @vectors_by_decision.fetch(decision["id"], [])
572
+ expected_vectors = decision_vectors.select(&expected_value)
387
573
  expected_vector_ids = expected_vectors.map { |vector| vector["id"] }.sort
388
574
  fail_with("alternative evidence is incomplete") unless evidence["vector_ids"].sort == expected_vector_ids
389
575
  expected_tests = vectors.flat_map { |vector| vector["test_ids"] }.uniq.sort
@@ -412,7 +598,7 @@ module Branchproof
412
598
  end
413
599
 
414
600
  def strict_flow_decision?(decision)
415
- @schema_version == "1.2" &&
601
+ STRICT_FLOW_SCHEMAS.include?(@schema_version) &&
416
602
  nonboolean_kind?(decision_kind(decision)) &&
417
603
  decision["support_status"].to_s.upcase != "UNSUPPORTED"
418
604
  end
@@ -446,7 +632,7 @@ module Branchproof
446
632
 
447
633
  valid = hash_with_string_keys?(phases) && phases.all? do |test_id, values|
448
634
  valid_phases = %w[setup body teardown suite unattributed]
449
- @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) }
450
636
  end
451
637
  fail_with("invalid phases_by_test") unless valid
452
638
  end
@@ -556,7 +742,7 @@ module Branchproof
556
742
  return unless locations
557
743
 
558
744
  valid = hash_with_string_keys?(locations) && locations.all? do |test_id, location|
559
- @test_ids.include?(test_id) && hash_with_string_keys?(location) &&
745
+ @test_id_set.include?(test_id) && hash_with_string_keys?(location) &&
560
746
  (!location.key?("relative_path") || location["relative_path"].nil? ||
561
747
  location["relative_path"].is_a?(String)) &&
562
748
  (!location.key?("line") || location["line"].nil? || location["line"].is_a?(Integer))