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
@@ -8,14 +8,19 @@ require "pathname"
8
8
  module Branchproof
9
9
  # Renders versioned terminal and JSON analysis reports.
10
10
  class Report
11
- SCHEMA_VERSION = "1.2"
11
+ SCHEMA_VERSION = "1.3"
12
12
  CRITERION_VERSION = "masking_occurrence_v1"
13
+ VIEWS = %i[decisions conditions tests decision_tables].freeze
14
+ DECISION_TABLE_LABELS = { "true" => "T", "false" => "F", "dont_care" => "-" }.freeze
15
+ DECISION_TABLE_STATUS_LABELS = { "covered" => "COVERED", "missing" => "MISSING",
16
+ "excluded" => "EXCLUDED" }.freeze
13
17
 
14
18
  def initialize(inventory:, evidence:, analysis:, minima:, baseline:, diagnostics:, level: 3, missing_only: false,
15
19
  view: :decisions, run_metadata: {}, saved_document: nil)
16
20
  raise ArgumentError, "level must be 1, 2, or 3" unless [1, 2, 3].include?(level.to_i)
17
- raise ArgumentError, "view must be :decisions, :conditions, or :tests" unless %i[decisions conditions
18
- tests].include?(view.to_sym)
21
+ unless VIEWS.include?(view.to_sym)
22
+ raise ArgumentError, "view must be :decisions, :conditions, :tests, or :decision_tables"
23
+ end
19
24
 
20
25
  @inventory = inventory || {}
21
26
  @evidence = evidence || {}
@@ -32,8 +37,8 @@ module Branchproof
32
37
 
33
38
  def self.from_document(document:, level: nil, view: :decisions, missing_only: false)
34
39
  data = document || {}
35
- new(inventory: data[:source_inventory] || data["source_inventory"] || data[:inventory] || data["inventory"],
36
- evidence: data[:observations] || data["observations"] || data[:evidence] || data["evidence"],
40
+ new(inventory: data[:source_inventory] || data["source_inventory"],
41
+ evidence: data[:observations] || data["observations"],
37
42
  analysis: data[:analysis] || data["analysis"], minima: data[:minima] || data["minima"],
38
43
  baseline: data[:baseline] || data["baseline"], diagnostics: data[:diagnostics] || data["diagnostics"],
39
44
  level: level || (data[:analysis] || data["analysis"] ? 3 : 1), view: view, missing_only: missing_only,
@@ -131,7 +136,7 @@ module Branchproof
131
136
  def terminal_document
132
137
  unless @view == :decisions
133
138
  return FocusedReport.new(document: json_document, view: @view, level: @level,
134
- missing_only: @missing_only).render
139
+ missing_only: @missing_only, coordinator: self).render
135
140
  end
136
141
 
137
142
  @terminal_ids = terminal_ids
@@ -183,6 +188,7 @@ module Branchproof
183
188
  end
184
189
  end
185
190
  render_decision_coverage(lines, decision) if coverage_available?
191
+ render_decision_table(lines, decision) if analysis_available? && !nonboolean_decision?(decision)
186
192
  vectors_to_render(decision).each do |vector|
187
193
  values = Array(value(vector, :values)).map do |item|
188
194
  if item.nil?
@@ -230,7 +236,7 @@ module Branchproof
230
236
  decision_coverage = value(value(analysis_for(decision), :coverage), :decision)
231
237
  all_coverage = value(analysis_for(decision), :coverage) || {}
232
238
  statuses = [["D", :decision], ["C", :condition], ["C/D", :condition_decision],
233
- ["MC/DC", :mcdc]].filter_map do |label, key|
239
+ ["MC/DC", :mcdc], ["DT", :decision_table]].filter_map do |label, key|
234
240
  row = value(all_coverage, key)
235
241
  status = value(row, :status)
236
242
  status ? criterion_status_text(label, row) : nil
@@ -242,11 +248,11 @@ module Branchproof
242
248
  return unless @level >= 2
243
249
 
244
250
  Array(value(coverage, :outcomes)).each do |outcome|
245
- value_label = value(outcome, :value) ? "true" : "false"
251
+ value_label = decision_outcome_label(decision, value(outcome, :value))
246
252
  owners = Array(value(outcome, :test_ids)).map { |id| test_label(id) }
247
253
  owners << "unattributed" if value(outcome, :unattributed_count).to_i.positive?
248
254
  evidence = owners.empty? ? "none recorded" : owners.uniq.join(", ")
249
- lines << " Outcome #{value_label}: #{value(outcome, :observed) ? "observed" : "missing"}; tests: #{evidence}"
255
+ lines << " #{value_label}: #{value(outcome, :observed) ? "observed" : "missing"}; tests: #{evidence}"
250
256
  end
251
257
  missing = Array(value(coverage, :missing_outcomes))
252
258
  return if missing.empty?
@@ -256,6 +262,164 @@ module Branchproof
256
262
  end.join(", ")}"
257
263
  end
258
264
 
265
+ # Renders the reduced decision table, its runtime overlay, and the
266
+ # actionable detail for rules that no observation reached.
267
+ def render_decision_table(lines, decision)
268
+ table = decision_table_for(decision)
269
+ return unless table
270
+
271
+ unless value(table, :status).to_s == "calculated"
272
+ return if value(table, :reason).to_s == "unsupported_decision" && unsupported?(decision)
273
+
274
+ lines << " Decision Table: NOT CALCULATED"
275
+ lines << " Reason: #{value(table, :reason)}"
276
+ return
277
+ end
278
+
279
+ covered = value(table, :covered_rules).to_i
280
+ required = value(table, :required_rules).to_i
281
+ percentage = value(table, :percentage)
282
+ lines << " Decision Table: #{covered}/#{required} rules covered" \
283
+ "#{" (#{percentage}%)" unless percentage.nil?}"
284
+ impossible = value(table, :impossible_rules).to_i
285
+ lines << " Statically impossible rules excluded: #{impossible}" if impossible.positive?
286
+ lines << " Reachability: not analyzed" unless value(table, :reachability_analyzed)
287
+ rules = decision_table_rules_to_render(table)
288
+ lines.concat(decision_table_rows(decision, rules))
289
+ return unless @level >= 2
290
+
291
+ rules.each { |rule| render_decision_table_rule(lines, decision, rule) }
292
+ end
293
+
294
+ def decision_table_rows(decision, rules)
295
+ return [] if rules.empty?
296
+
297
+ conditions = Array(value(decision, :conditions))
298
+ header = ["Rule"] + conditions.map { |condition| truncate(value(condition, :expression).to_s, 24) } +
299
+ %w[Result Status]
300
+ body = rules.map do |rule|
301
+ [value(rule, :label).to_s] +
302
+ Array(value(rule, :conditions)).map { |item| DECISION_TABLE_LABELS.fetch(item.to_s, item.to_s) } +
303
+ [value(rule, :outcome) ? "T" : "F", decision_table_rule_status(rule)]
304
+ end
305
+ return [] unless body.all? { |row| row.length == header.length }
306
+
307
+ widths = ([header] + body).transpose.map { |column| column.map(&:length).max }
308
+ ([header] + body).map do |row|
309
+ " #{row.each_with_index.map { |cell, index| cell.ljust(widths[index]) }.join(" ").rstrip}"
310
+ end
311
+ end
312
+
313
+ def decision_table_rule_status(rule)
314
+ DECISION_TABLE_STATUS_LABELS.fetch(value(rule, :coverage).to_s, value(rule, :coverage).to_s.upcase)
315
+ end
316
+
317
+ def render_decision_table_rule(lines, decision, rule)
318
+ case value(rule, :coverage).to_s
319
+ when "covered" then render_covered_rule(lines, rule)
320
+ when "excluded" then lines.concat(impossible_rule_lines(rule))
321
+ else lines.concat(missing_rule_lines(decision, rule))
322
+ end
323
+ end
324
+
325
+ def render_covered_rule(lines, rule)
326
+ owners = Array(value(rule, :tests)).map { |id| test_label(id) }
327
+ owners << "unattributed" if value(rule, :unattributed_count).to_i.positive?
328
+ lines << " #{value(rule, :label)} tests: #{owners.empty? ? "none recorded" : owners.uniq.join(", ")}"
329
+ return unless value(rule, :impossible_withdrawn)
330
+
331
+ lines << " #{value(rule, :label)} note: runtime evidence overrides the static impossibility claim " \
332
+ "(#{value(rule, :withdrawn_reason)}); diagnostic constraint_model_conflict"
333
+ end
334
+
335
+ # Impossible rules stay visible even though they leave the denominator.
336
+ def impossible_rule_lines(rule)
337
+ [" #{value(rule, :label)} #{rule_signature(rule)}",
338
+ " Status:", " EXCLUDED",
339
+ " Reachability:", " STATICALLY IMPOSSIBLE",
340
+ " Reason:", " #{Constraints.message(value(rule, :reachability_reason))}"]
341
+ end
342
+
343
+ # Condition values only: Branchproof never claims which application inputs
344
+ # would produce them.
345
+ def missing_rule_lines(decision, rule)
346
+ requirements = decision_table_requirements(decision, rule)
347
+ width = requirements.map { |name, _| name.length }.max.to_i
348
+ lines = [" #{value(rule, :label)}", " Need:"]
349
+ requirements.each { |name, requirement| lines << " #{name.ljust(width)} #{requirement}" }
350
+ lines << " #{decision_table_expected_heading(decision)}"
351
+ lines << " #{value(rule, :outcome) ? "true" : "false"}"
352
+ lines << " Reachability:"
353
+ lines << " #{decision_table_reachability(rule)}"
354
+ lines
355
+ end
356
+
357
+ def decision_table_requirements(decision, rule)
358
+ Array(value(rule, :conditions)).each_with_index.map do |required, index|
359
+ expression = condition_expression(decision, index) || "condition #{index}"
360
+ requirement = decision_table_requirement(required) || "not evaluated (short-circuited)"
361
+ [expression.to_s, "= #{requirement}"]
362
+ end
363
+ end
364
+
365
+ def decision_table_requirement(value)
366
+ case value.to_s
367
+ when "true" then "truthy"
368
+ when "false" then "falsey"
369
+ end
370
+ end
371
+
372
+ def decision_table_reachability(rule)
373
+ return "STATICALLY IMPOSSIBLE" if value(rule, :reachability).to_s == "statically_impossible"
374
+
375
+ value(rule, :reachability).to_s
376
+ end
377
+
378
+ def decision_outcome_label(decision, outcome)
379
+ prefix = %w[unless until].include?(value(decision, :context).to_s) ? "Predicate" : "Outcome"
380
+ "#{prefix} #{outcome ? "true" : "false"}"
381
+ end
382
+
383
+ def decision_table_expected_heading(decision)
384
+ %w[unless until].include?(value(decision, :context).to_s) ? "Expected predicate:" : "Expected decision:"
385
+ end
386
+
387
+ def rule_signature(rule)
388
+ values = Array(value(rule, :conditions)).map { |item| DECISION_TABLE_LABELS.fetch(item.to_s, item.to_s) }
389
+ "#{values.join} => #{value(rule, :outcome) ? "T" : "F"}"
390
+ end
391
+
392
+ def decision_table_rules_to_render(table)
393
+ rules = Array(value(table, :rules))
394
+ return rules unless @missing_only
395
+
396
+ rules.select { |rule| value(rule, :coverage).to_s == "missing" }
397
+ end
398
+
399
+ def decision_table_for(decision)
400
+ value(analysis_for(decision), :decision_table)
401
+ end
402
+
403
+ def missing_decision_table_rules(decision)
404
+ table = decision_table_for(decision)
405
+ return [] unless table && value(table, :status).to_s == "calculated"
406
+
407
+ Array(value(table, :rules)).select { |rule| value(rule, :coverage).to_s == "missing" }
408
+ end
409
+
410
+ def decision_table_unavailable?(decision)
411
+ table = decision_table_for(decision)
412
+ table && value(table, :status).to_s != "calculated"
413
+ end
414
+
415
+ def unavailable_decision_table_count
416
+ inventory_decisions.count { |decision| decision_table_unavailable?(decision) }
417
+ end
418
+
419
+ def truncate(text, width)
420
+ text.length <= width ? text : "#{text[0, width - 3]}..."
421
+ end
422
+
259
423
  def render_alternative_coverage(lines, decision)
260
424
  coverage = value(value(analysis_for(decision), :coverage), :alternative) || {}
261
425
  status = value(coverage, :status)
@@ -492,8 +656,15 @@ module Branchproof
492
656
  end
493
657
 
494
658
  def condition_result(decision, condition)
495
- Array(value(analysis_for(decision), :condition_results)).find do |item|
496
- value(item, :condition_id).to_s == value(condition, :id).to_s
659
+ condition_results_index.dig(value(decision, :id).to_s, value(condition, :id).to_s)
660
+ end
661
+
662
+ def condition_results_index
663
+ @condition_results_index ||= Array(value(@analysis, :decisions)).each_with_object({}) do |item, index|
664
+ by_condition_id = index[value(item, :decision_id).to_s] ||= {}
665
+ Array(value(item, :condition_results)).each do |result|
666
+ by_condition_id[value(result, :condition_id).to_s] = result
667
+ end
497
668
  end
498
669
  end
499
670
 
@@ -592,7 +763,11 @@ module Branchproof
592
763
  id = value(constraint, :existing_vector_id).to_s
593
764
  return if id.empty?
594
765
 
595
- vectors.find { |vector| value(vector, :id).to_s == id }
766
+ vectors_by_id[id]
767
+ end
768
+
769
+ def vectors_by_id
770
+ @vectors_by_id ||= vectors.to_h { |vector| [value(vector, :id).to_s, vector] }
596
771
  end
597
772
 
598
773
  def values_legend
@@ -639,37 +814,41 @@ module Branchproof
639
814
  end
640
815
 
641
816
  def metrics
642
- decisions = inventory_decisions
643
- unsupported, supported = decisions.partition { |decision| unsupported?(decision) }
644
- eligible = supported.sum { |decision| Array(value(decision, :conditions)).length }
645
- observed = vectors.map { |vector| value(vector, :decision_id).to_s }.uniq
646
- proven = @analysis ? value(@analysis, :proven_count).to_i : 0
647
- kind_counts = decisions.group_by { |decision| decision_kind(decision) }.transform_values(&:length)
648
- context_counts = decisions.group_by { |decision| value(decision, :context).to_s }
649
- context_counts = context_counts.reject { |context, _| context.empty? }.transform_values(&:length)
650
- { discovered: decisions.length, supported: supported.length, unsupported: unsupported.length,
651
- unsupported_conditions: unsupported.sum do |decision|
652
- discovered_conditions(decision)
653
- end, eligible_conditions: eligible,
654
- opaque: decisions.sum { |decision| Array(value(decision, :opaque_ranges)).length },
655
- unexecuted: supported.count { |decision| !observed.include?(value(decision, :id).to_s) },
656
- eligible_alternatives: supported.sum do |decision|
657
- nonboolean_decision?(decision) ? Array(value(decision, :alternatives)).length : 0
658
- end,
659
- completed: vectors.sum do |vector|
660
- value(vector, :count).to_i
661
- end, aborted: numeric_hash_value(@evidence, :abort_counts),
662
- unattributed: vectors.sum { |vector| value(vector, :unattributed_count).to_i }, limited: incomplete? ? 1 : 0,
663
- proven: proven, percentage: percentage(eligible, proven), kind_counts: kind_counts,
664
- decision_kinds: kind_counts, context_counts: context_counts }
817
+ @metrics ||= begin
818
+ decisions = inventory_decisions
819
+ unsupported, supported = decisions.partition { |decision| unsupported?(decision) }
820
+ eligible = supported.sum { |decision| Array(value(decision, :conditions)).length }
821
+ observed = vectors.map { |vector| value(vector, :decision_id).to_s }.uniq
822
+ proven = @analysis ? value(@analysis, :proven_count).to_i : 0
823
+ kind_counts = decisions.group_by { |decision| decision_kind(decision) }.transform_values(&:length)
824
+ context_counts = decisions.group_by { |decision| value(decision, :context).to_s }
825
+ context_counts = context_counts.reject { |context, _| context.empty? }.transform_values(&:length)
826
+ { discovered: decisions.length, supported: supported.length, unsupported: unsupported.length,
827
+ unsupported_conditions: unsupported.sum do |decision|
828
+ discovered_conditions(decision)
829
+ end, eligible_conditions: eligible,
830
+ opaque: decisions.sum { |decision| Array(value(decision, :opaque_ranges)).length },
831
+ unexecuted: supported.count { |decision| !observed.include?(value(decision, :id).to_s) },
832
+ eligible_alternatives: supported.sum do |decision|
833
+ nonboolean_decision?(decision) ? Array(value(decision, :alternatives)).length : 0
834
+ end,
835
+ completed: vectors.sum do |vector|
836
+ value(vector, :count).to_i
837
+ end, aborted: numeric_hash_value(@evidence, :abort_counts),
838
+ unattributed: vectors.sum { |vector| value(vector, :unattributed_count).to_i }, limited: incomplete? ? 1 : 0,
839
+ proven: proven, percentage: percentage(eligible, proven), kind_counts: kind_counts,
840
+ decision_kinds: kind_counts, context_counts: context_counts }
841
+ end
665
842
  end
666
843
 
667
844
  def completeness
668
- evidence = value(@evidence, :completeness) || {}
669
- analysis = value(@analysis, :completeness) || {}
670
- { observation: completeness_value?(evidence, analysis, :observation),
671
- attribution: completeness_value?(evidence, analysis, :attribution),
672
- analysis: @analysis ? value(analysis, :analysis) == true : value(evidence, :analysis) == true }
845
+ @completeness ||= begin
846
+ evidence = value(@evidence, :completeness) || {}
847
+ analysis = value(@analysis, :completeness) || {}
848
+ { observation: completeness_value?(evidence, analysis, :observation),
849
+ attribution: completeness_value?(evidence, analysis, :attribution),
850
+ analysis: @analysis ? value(analysis, :analysis) == true : value(evidence, :analysis) == true }
851
+ end
673
852
  end
674
853
 
675
854
  def completeness_value?(evidence, analysis, key)
@@ -708,9 +887,11 @@ module Branchproof
708
887
  end
709
888
 
710
889
  def vectors_for(decision)
711
- vectors.select do |vector|
712
- value(vector, :decision_id).to_s == value(decision, :id).to_s
713
- end
890
+ vectors_by_decision_id[value(decision, :id).to_s] || []
891
+ end
892
+
893
+ def vectors_by_decision_id
894
+ @vectors_by_decision_id ||= vectors.group_by { |vector| value(vector, :decision_id).to_s }
714
895
  end
715
896
 
716
897
  def decisions_to_render
@@ -720,7 +901,12 @@ module Branchproof
720
901
  inventory_decisions
721
902
  .reject { |decision| unsupported?(decision) }
722
903
  .select do |decision|
723
- nonboolean_decision?(decision) ? missing_alternatives_for(decision).any? : conditions_to_render(decision).any?
904
+ if nonboolean_decision?(decision)
905
+ missing_alternatives_for(decision).any?
906
+ else
907
+ conditions_to_render(decision).any? || missing_decision_table_rules(decision).any? ||
908
+ decision_table_unavailable?(decision)
909
+ end
724
910
  end
725
911
  end
726
912
 
@@ -748,7 +934,6 @@ module Branchproof
748
934
 
749
935
  def vectors_to_render(decision)
750
936
  return vectors_for(decision) unless @missing_only
751
- return [] if nonboolean_decision?(decision)
752
937
 
753
938
  []
754
939
  end
@@ -793,6 +978,24 @@ module Branchproof
793
978
  end
794
979
  lines << " #{label} (#{description}): #{shown} (#{numerator}/#{denominator} #{denominator_label})#{qualifier}"
795
980
  end
981
+ table = value(aggregate, :decision_table)
982
+ if table
983
+ covered = value(table, :covered_rules) || 0
984
+ required = value(table, :required_rules) || 0
985
+ percentage = value(table, :percentage)
986
+ shown = percentage.nil? ? "N/A" : "#{percentage}%"
987
+ qualifier = incomplete? ? " (lower bound)" : ""
988
+ lines << " DT (Decision table coverage): #{shown} (#{covered}/#{required} rules)#{qualifier}"
989
+ fully_covered = value(table, :fully_covered_decisions) || 0
990
+ analyzed = value(table, :decisions_analyzed) || 0
991
+ decision_percentage = value(table, :decision_percentage)
992
+ lines << " Decision tables fully covered: #{fully_covered}/#{analyzed} decisions" \
993
+ "#{" (#{decision_percentage}%)" unless decision_percentage.nil?}"
994
+ impossible = value(table, :impossible_rules).to_i
995
+ lines << " Statically impossible rules excluded: #{impossible}" if impossible.positive?
996
+ not_calculated = value(table, :not_calculated_decisions).to_i
997
+ lines << " Decision tables not calculated: #{not_calculated} decisions" if not_calculated.positive?
998
+ end
796
999
  alternative = value(aggregate, :alternative)
797
1000
  if alternative
798
1001
  covered = value(alternative, :covered_alternatives) || 0
@@ -818,6 +1021,10 @@ module Branchproof
818
1021
  "#{value(row, :covered_conditions) || 0}/#{value(row, :condition_count) || 0} conditions fully covered)"
819
1022
  when "MC/DC"
820
1023
  "#{label}=#{status} (#{value(row, :proven_conditions) || 0}/#{value(row, :condition_count) || 0} conditions)"
1024
+ when "DT"
1025
+ return "#{label}=NOT CALCULATED (#{value(row, :reason)})" if raw_status == "not_calculated"
1026
+
1027
+ "#{label}=#{status} (#{value(row, :covered_rules) || 0}/#{value(row, :required_rules) || 0} rules)"
821
1028
  else
822
1029
  "#{label}=#{status}"
823
1030
  end
@@ -834,25 +1041,36 @@ module Branchproof
834
1041
  def missing_summary_line
835
1042
  return "Missing conditions: Cannot identify missing conditions (analysis unavailable)" unless analysis_available?
836
1043
  return "Missing conditions: Cannot identify missing conditions (analysis incomplete)" unless analysis_complete?
837
- return "No missing conditions or alternatives" if analysis_complete? && missing_condition_count.zero? &&
838
- missing_alternatives_count.zero?
839
- return "No eligible conditions" if metrics[:eligible_conditions].zero? && metrics[:eligible_alternatives].zero?
840
1044
 
841
- count = decisions_to_render.length
842
- if missing_alternatives_count.zero?
843
- label = count == 1 ? "decision" : "decisions"
844
- return "Missing conditions: #{missing_condition_count} across #{count} #{label}"
845
- end
846
- if missing_condition_count.zero?
847
- label = count == 1 ? "decision" : "decisions"
848
- return "Missing alternatives: #{missing_alternatives_count} across #{count} #{label}"
1045
+ unavailable = unavailable_decision_table_count
1046
+ unavailable_suffix = if unavailable.positive?
1047
+ "; decision-table analysis unavailable for #{unavailable} " \
1048
+ "#{unavailable == 1 ? "decision" : "decisions"}"
1049
+ else
1050
+ ""
1051
+ end
1052
+ if missing_condition_count.zero? && missing_alternatives_count.zero? && missing_rule_count.zero?
1053
+ return "No missing conditions, alternatives, or decision-table rules#{unavailable_suffix}"
849
1054
  end
850
1055
 
1056
+ count = decisions_to_render.length
851
1057
  label = count == 1 ? "decision" : "decisions"
852
- conditions = missing_condition_count
853
- alternatives = missing_alternatives_count
854
- "Missing conditions: #{conditions}; alternatives: #{alternatives} " \
855
- "across #{count} #{label}"
1058
+ suffix = missing_rule_count.positive? ? "; decision-table rules: #{missing_rule_count}" : ""
1059
+ summary = if missing_condition_count.positive? && missing_alternatives_count.positive?
1060
+ "Missing conditions: #{missing_condition_count}; alternatives: #{missing_alternatives_count} " \
1061
+ "across #{count} #{label}#{suffix}"
1062
+ elsif missing_condition_count.positive?
1063
+ "Missing conditions: #{missing_condition_count} across #{count} #{label}#{suffix}"
1064
+ elsif missing_alternatives_count.positive?
1065
+ "Missing alternatives: #{missing_alternatives_count} across #{count} #{label}#{suffix}"
1066
+ else
1067
+ "Missing decision-table rules: #{missing_rule_count} across #{count} #{label}"
1068
+ end
1069
+ "#{summary}#{unavailable_suffix}"
1070
+ end
1071
+
1072
+ def missing_rule_count
1073
+ decisions_to_render.sum { |decision| missing_decision_table_rules(decision).length }
856
1074
  end
857
1075
 
858
1076
  def missing_alternatives_count
@@ -860,19 +1078,33 @@ module Branchproof
860
1078
  end
861
1079
 
862
1080
  def analysis_for(decision)
863
- Array(value(@analysis, :decisions)).find do |item|
864
- value(item, :decision_id).to_s == value(decision, :id).to_s
1081
+ analysis_by_decision_id[value(decision, :id).to_s]
1082
+ end
1083
+
1084
+ def analysis_by_decision_id
1085
+ @analysis_by_decision_id ||= Array(value(@analysis, :decisions)).to_h do |item|
1086
+ [value(item, :decision_id).to_s, item]
865
1087
  end
866
1088
  end
867
1089
 
868
1090
  def source_for(decision)
869
- Array(value(@inventory, :source_units)).find do |source|
870
- value(source, :source_id).to_s == value(decision, :source_id).to_s
871
- end || decision
1091
+ source_by_source_id[value(decision, :source_id).to_s] || decision
1092
+ end
1093
+
1094
+ def source_by_source_id
1095
+ @source_by_source_id ||= Array(value(@inventory, :source_units)).to_h do |source|
1096
+ [value(source, :source_id).to_s, source]
1097
+ end
1098
+ end
1099
+
1100
+ def inventory_decisions
1101
+ @inventory_decisions ||= Array(value(@inventory, :decisions)).freeze
1102
+ end
1103
+
1104
+ def vectors
1105
+ @vectors ||= Array(value(@evidence, :vectors)).freeze
872
1106
  end
873
1107
 
874
- def inventory_decisions = Array(value(@inventory, :decisions))
875
- def vectors = Array(value(@evidence, :vectors))
876
1108
  def unsupported?(decision) = value(decision, :support_status).to_s.upcase == "UNSUPPORTED"
877
1109
 
878
1110
  def discovered_conditions(decision)
@@ -918,7 +1150,8 @@ module Branchproof
918
1150
  def normalize_unknown(object)
919
1151
  object.to_s
920
1152
  end
921
- public :condition_coverage_evidence, :coverage_ladder_lines, :coverage_status_label
1153
+ public :condition_coverage_evidence, :coverage_ladder_lines, :coverage_status_label,
1154
+ :decision_table_requirement, :decision_table_reachability, :decision_table_expected_heading
922
1155
  end
923
1156
  end
924
1157
 
@@ -2,6 +2,11 @@
2
2
 
3
3
  require "securerandom"
4
4
  require_relative "runtime_flow"
5
+ require_relative "extended_alternative_runtime"
6
+ require_relative "exception_runtime"
7
+ require_relative "default_runtime"
8
+ require_relative "value_runtime"
9
+ require_relative "iteration_runtime"
5
10
 
6
11
  module Branchproof
7
12
  # Process-local execution recorder. It deliberately never coerces or stores
@@ -9,6 +14,13 @@ module Branchproof
9
14
  # Captures condition evaluations while preserving application values.
10
15
  module Runtime
11
16
  extend RuntimeFlow
17
+ extend ExtendedAlternativeRuntime
18
+ extend ExceptionRuntime
19
+ extend DefaultRuntime
20
+ extend ValueRuntime
21
+ extend IterationRuntime
22
+
23
+ FRAME_STATE_KEY = :branchproof_runtime_frame_state
12
24
 
13
25
  class << self
14
26
  def boot(evidence:)
@@ -17,8 +29,6 @@ module Branchproof
17
29
  @evidence = evidence
18
30
  @run_id = evidence.respond_to?(:run_id) ? evidence.run_id.to_s : SecureRandom.hex(16)
19
31
  @process_id = Process.pid
20
- @frames = {}
21
- @contexts = {}
22
32
  @diagnostics = []
23
33
  @storage_disabled = false
24
34
  @booted = true
@@ -26,9 +36,8 @@ module Branchproof
26
36
  end
27
37
 
28
38
  def enter(decision_id)
29
- state[:frames] << { decision_id: String(decision_id), context: state[:context]&.dup,
30
- observations: [], outcome: nil, finished: false,
31
- execution_id: SecureRandom.hex(12) }
39
+ state[:frames] << { decision_id: decision_id, context: state[:context]&.dup,
40
+ observations: [], outcome: nil, finished: false }
32
41
  nil
33
42
  end
34
43
 
@@ -36,7 +45,7 @@ module Branchproof
36
45
  frame = current_frame(decision_id)
37
46
  if frame
38
47
  truth = value ? true : false
39
- frame[:observations] << [Integer(index), truth]
48
+ frame[:observations] << [index, truth]
40
49
  end
41
50
  value
42
51
  end
@@ -51,9 +60,10 @@ module Branchproof
51
60
  end
52
61
 
53
62
  def leave(decision_id)
63
+ detect_fork
54
64
  frames = state[:frames]
55
65
  frame = frames.pop
56
- unless frame && frame[:decision_id] == String(decision_id)
66
+ unless frame && frame[:decision_id] == decision_id
57
67
  latch("runtime_frame_mismatch", "decision frame stack is not balanced")
58
68
  cleanup_state
59
69
  return nil
@@ -62,12 +72,10 @@ module Branchproof
62
72
 
63
73
  execution = {
64
74
  run_id: @run_id,
65
- execution_id: frame[:execution_id],
66
75
  decision_id: frame[:decision_id],
67
76
  test_id: frame[:context]&.fetch(:test_id, nil),
68
77
  phase: frame[:context]&.fetch(:phase, "unattributed") || "unattributed",
69
- owner: owner_tuple,
70
- observations: frame[:observations].map(&:dup),
78
+ observations: frame[:observations],
71
79
  outcome: frame[:finished] ? frame[:outcome] : nil,
72
80
  status: frame[:finished] ? "completed" : "aborted"
73
81
  }
@@ -120,38 +128,32 @@ module Branchproof
120
128
  private
121
129
 
122
130
  def state
123
- @frames ||= {}
124
- if @process_id && Process.pid != @process_id
125
- @process_id = Process.pid
126
- @run_id = SecureRandom.hex(16)
127
- @frames = {}
128
- @diagnostics = [{ code: "forked_process", severity: "error",
129
- message: "runtime process identity changed; evidence storage disabled",
130
- source_id: nil, decision_id: nil, execution_id: nil, test_id: nil, details: {} }]
131
- @storage_disabled = true
132
- end
133
- key = [Process.pid, Thread.current, Fiber.current]
134
- @frames[key] ||= { frames: [], context: nil }
131
+ Thread.current[FRAME_STATE_KEY] ||= { frames: [], context: nil }
135
132
  end
136
133
 
137
134
  def cleanup_state
138
- key = [Process.pid, Thread.current, Fiber.current]
139
- current = @frames[key]
140
- @frames.delete(key) if current && current[:frames].empty? && current[:context].nil?
135
+ current = Thread.current[FRAME_STATE_KEY]
136
+ Thread.current[FRAME_STATE_KEY] = nil if current && current[:frames].empty? && current[:context].nil?
137
+ end
138
+
139
+ def detect_fork
140
+ return unless @process_id && Process.pid != @process_id
141
+
142
+ @process_id = Process.pid
143
+ @diagnostics = [{ code: "forked_process", severity: "error",
144
+ message: "runtime process identity changed; evidence storage disabled",
145
+ source_id: nil, decision_id: nil, execution_id: nil, test_id: nil, details: {} }]
146
+ @storage_disabled = true
141
147
  end
142
148
 
143
149
  def current_frame(decision_id)
144
150
  frame = state[:frames].last
145
- return frame if frame && frame[:decision_id] == String(decision_id)
151
+ return frame if frame && frame[:decision_id] == decision_id
146
152
 
147
153
  latch("runtime_frame_mismatch", "no active frame for #{decision_id}") if frame
148
154
  nil
149
155
  end
150
156
 
151
- def owner_tuple
152
- { process_id: Process.pid, thread_id: Thread.current.object_id, fiber_id: Fiber.current.object_id }
153
- end
154
-
155
157
  def safely_record(execution)
156
158
  result = @evidence.record(execution: execution)
157
159
  unless result.is_a?(Hash) && result[:status].to_s == "recorded"