branchproof 0.11.1 → 0.12.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 +82 -0
  3. data/README.md +440 -29
  4. data/doc/Branchproof/CLI.md +1 -1
  5. data/doc/Branchproof/ChangedCoverage.md +19 -0
  6. data/doc/Branchproof/ChangedCoveragePolicy.md +24 -0
  7. data/doc/Branchproof/ChangedDecisionMap.md +20 -0
  8. data/doc/Branchproof/ChangedScope.md +18 -0
  9. data/doc/Branchproof/Collation.md +23 -0
  10. data/doc/Branchproof/CollationManifest.md +29 -0
  11. data/doc/Branchproof/CoverageSummary.md +20 -0
  12. data/doc/Branchproof/Doctor/TerminalRenderer.md +15 -0
  13. data/doc/Branchproof/Doctor.md +34 -0
  14. data/doc/Branchproof/FallbackInstrumentation.md +8 -0
  15. data/doc/Branchproof/FallbackRuntime.md +12 -0
  16. data/doc/Branchproof/FallbackSyntax.md +13 -0
  17. data/doc/Branchproof/GitChanges.md +19 -0
  18. data/doc/Branchproof/GithubReport.md +25 -0
  19. data/doc/Branchproof/HtmlReport.md +22 -0
  20. data/doc/Branchproof/Instrumenter.md +1 -1
  21. data/doc/Branchproof/MinitestAdapter.md +4 -0
  22. data/doc/Branchproof/RakeTask.md +121 -0
  23. data/doc/Branchproof/Report.md +41 -3
  24. data/doc/Branchproof/ReportSelection.md +10 -1
  25. data/doc/Branchproof/Runtime.md +4 -1
  26. data/doc/Branchproof/Source.md +1 -1
  27. data/doc/Branchproof/SummaryRanking/Decision.md +86 -0
  28. data/doc/Branchproof/SummaryRanking/SourceFile.md +43 -0
  29. data/doc/Branchproof/SummaryRanking.md +36 -0
  30. data/doc/Branchproof/SummaryReport.md +42 -0
  31. data/doc/Branchproof/Worker.md +1 -1
  32. data/doc/Branchproof.md +21 -1
  33. data/doc/CHANGELOG.md +82 -0
  34. data/doc/README.md +440 -29
  35. data/lib/branchproof/analyzer.rb +2 -52
  36. data/lib/branchproof/changed_coverage.rb +177 -0
  37. data/lib/branchproof/changed_decision_map.rb +359 -0
  38. data/lib/branchproof/changed_scope.rb +93 -0
  39. data/lib/branchproof/cli.rb +242 -21
  40. data/lib/branchproof/collation.rb +525 -0
  41. data/lib/branchproof/collation_manifest.rb +129 -0
  42. data/lib/branchproof/comparison.rb +11 -3
  43. data/lib/branchproof/comparison_report.rb +2 -0
  44. data/lib/branchproof/configuration.rb +12 -2
  45. data/lib/branchproof/coverage_policy.rb +122 -0
  46. data/lib/branchproof/coverage_summary.rb +94 -0
  47. data/lib/branchproof/doctor.rb +241 -0
  48. data/lib/branchproof/evidence.rb +5 -1
  49. data/lib/branchproof/exception_instrumentation.rb +4 -3
  50. data/lib/branchproof/exception_syntax.rb +15 -1
  51. data/lib/branchproof/fallback_instrumentation.rb +25 -0
  52. data/lib/branchproof/fallback_runtime.rb +14 -0
  53. data/lib/branchproof/fallback_syntax.rb +90 -0
  54. data/lib/branchproof/focused_report.rb +81 -22
  55. data/lib/branchproof/git_changes.rb +188 -0
  56. data/lib/branchproof/github_report.rb +292 -0
  57. data/lib/branchproof/html_report.rb +412 -0
  58. data/lib/branchproof/instrumenter.rb +2 -0
  59. data/lib/branchproof/minimizer.rb +4 -2
  60. data/lib/branchproof/minitest_adapter.rb +45 -5
  61. data/lib/branchproof/rails_support.rb +2 -2
  62. data/lib/branchproof/rake_task.rb +186 -0
  63. data/lib/branchproof/report.rb +383 -71
  64. data/lib/branchproof/report_selection.rb +26 -5
  65. data/lib/branchproof/runtime.rb +2 -0
  66. data/lib/branchproof/saved_report.rb +323 -5
  67. data/lib/branchproof/source.rb +15 -3
  68. data/lib/branchproof/summary_ranking.rb +165 -0
  69. data/lib/branchproof/summary_report.rb +241 -0
  70. data/lib/branchproof/version.rb +1 -1
  71. data/lib/branchproof/worker.rb +14 -9
  72. data/lib/branchproof.rb +12 -0
  73. data/llms.txt +21 -1
  74. data/sig/branchproof.rbs +206 -5
  75. metadata +39 -23
@@ -0,0 +1,412 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "erb"
4
+ require_relative "summary_ranking"
5
+ require_relative "summary_report"
6
+
7
+ module Branchproof
8
+ # Portable, offline HTML presentation of a saved report document.
9
+ # rubocop:disable Metrics/AbcSize, Metrics/MethodLength, Metrics/ClassLength, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
10
+ class HtmlReport
11
+ # Keep complete HTML fragments together so their semantic structure is visible.
12
+ # rubocop:disable Layout/LineLength
13
+ STYLE = <<~CSS
14
+ :root{color-scheme:light;--paper:#f5f2e9;--ink:#202d2c;--muted:#566967;--teal:#116b66;--line:#c9d3cd;--amber:#8a4b08;--panel:#fffdf7;--soft:#e7eee9}
15
+ *{box-sizing:border-box}html{scroll-behavior:smooth}body{margin:0;background:var(--paper);color:var(--ink);font:16px/1.55 system-ui,-apple-system,"Segoe UI",sans-serif;overflow-wrap:anywhere}
16
+ a{color:var(--teal);text-underline-offset:.18em}a:focus-visible,summary:focus-visible{outline:3px solid #bd741d;outline-offset:3px}
17
+ .skip{position:absolute;left:-9999px;top:0;background:var(--panel);padding:.6rem}.skip:focus{left:1rem;z-index:2}
18
+ header,.layout,footer{max-width:1280px;margin:auto;padding:1.4rem 1.5rem}header{border-bottom:1px solid var(--line)}h1,h2,h3{font-family:Georgia,"Times New Roman",serif;line-height:1.2}h1{font-size:2.2rem;margin:.2rem 0}h2{font-size:1.55rem;margin:0 0 .7rem}h3{font-size:1.15rem;margin:.4rem 0}
19
+ .eyebrow,.status,.meta{color:var(--muted);font-size:.9rem}.warning,.gap{color:var(--amber)}.warning{font-weight:700}.layout{display:grid;grid-template-columns:minmax(14rem,18rem) minmax(0,1fr);gap:2rem;align-items:start}
20
+ nav{position:sticky;top:1rem;border-right:1px solid var(--line);padding-right:1rem}nav ul{list-style:none;padding:0;margin:.5rem 0}nav li{padding:.55rem 0;border-bottom:1px solid var(--line)}nav a{font-weight:650}nav small{display:block;color:var(--muted);font-weight:400}
21
+ main{min-width:0}section{margin:0 0 2rem}.overview,.decision{background:var(--panel);border:1px solid var(--line);padding:1rem 1.2rem;margin:0 0 1rem}.decision{border-top:3px solid var(--teal);scroll-margin-top:1rem}.decision.has-gap{border-top-color:#bd741d}
22
+ .expression,pre,code,.mono{font-family:ui-monospace,SFMono-Regular,Consolas,monospace}.expression{white-space:pre-wrap;overflow-wrap:anywhere;background:var(--soft);padding:.75rem;border-radius:2px}.meta{display:flex;gap:.5rem 1rem;flex-wrap:wrap}.badge{display:inline-block;border:1px solid var(--line);border-radius:2px;padding:.08rem .4rem;font-size:.82rem}
23
+ table{border-collapse:collapse;width:100%;min-width:34rem}th,td{text-align:left;vertical-align:top;padding:.45rem .6rem;border-bottom:1px solid var(--line)}th{background:var(--soft)}.table-wrap{max-width:100%;overflow-x:auto;margin:.7rem 0}.table-wrap:focus-visible{outline:3px solid #bd741d;outline-offset:3px}
24
+ details{margin:.5rem 0}summary{cursor:pointer;font-weight:650}ul.evidence{padding-left:1.3rem}footer{border-top:1px solid var(--line);color:var(--muted);font-size:.9rem}
25
+ @media(max-width:760px){.layout{display:block;padding-top:1rem}nav{position:static;border:0;border-bottom:1px solid var(--line);padding:0 0 1rem;margin-bottom:1.5rem}nav ul{display:grid;grid-template-columns:repeat(auto-fit,minmax(10rem,1fr));gap:0 1rem}header,.layout,footer{padding-left:1rem;padding-right:1rem}}
26
+ @media print{body{background:white;color:black;font-size:11pt}.layout{display:block;max-width:none}nav{position:static;border:1px solid #aaa;break-after:avoid}a{color:inherit;text-decoration:none}.overview,.decision{background:white;border-color:#aaa;break-inside:avoid}details>*{display:block!important}.table-wrap{overflow:visible}table{min-width:0}header,.layout,footer{max-width:none;padding:.5rem}}
27
+ CSS
28
+
29
+ def initialize(document:, level:, coordinator:, missing_only: false, selection: ReportSelection.new)
30
+ @document = document || {}
31
+ @level = level.to_i
32
+ @coordinator = coordinator
33
+ @missing_only = missing_only ? true : false
34
+ @selection = selection
35
+ @ranking = SummaryRanking.new(document: @document, selection: selection)
36
+ @index = @ranking.index
37
+ @conditions = @index.conditions.group_by { |row| row[:decision_id].to_s }
38
+ @alternatives = @index.alternatives.group_by { |row| row[:decision_id].to_s }
39
+ @tables = @index.decision_tables.to_h { |row| [row[:decision_id].to_s, row] }
40
+ @tests = @index.tests.to_h { |row| [row[:id].to_s, row] }
41
+ end
42
+
43
+ def render
44
+ out = String.new
45
+ out << "<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\">"
46
+ out << "<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">"
47
+ out << "<meta http-equiv=\"Content-Security-Policy\" content=\"default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'\">"
48
+ out << "<title>Branchproof report</title><style>#{STYLE}</style></head><body>"
49
+ out << "<a class=\"skip\" href=\"#report-content\">Skip to report</a>"
50
+ out << header_html
51
+ out << "<div class=\"layout\">#{navigation_html}<main id=\"report-content\">"
52
+ out << overview_html
53
+ out << changed_scope_html
54
+ out << collation_html
55
+ out << decisions_html
56
+ out << diagnostics_html
57
+ out << "</main></div><footer>Generated from the saved Branchproof snapshot. This document works offline.</footer></body></html>\n"
58
+ out
59
+ end
60
+
61
+ private
62
+
63
+ def header_html
64
+ baseline = fetch(@document, :baseline) || {}
65
+ status = fetch(baseline, :status) || "INCOMPLETE"
66
+ completeness = fetch(@document, :completeness) || {}
67
+ incomplete = completeness.values.include?(false) || status.to_s != "PASSED" || fetch(baseline,
68
+ :finalized) == false
69
+ out = +"<header><div class=\"eyebrow\">Branchproof saved report</div><h1>Coverage report</h1>"
70
+ out << "<p class=\"meta\"><span>Tests: <strong>#{escape(status)}</strong></span>"
71
+ out << "<span>Analysis: <strong>#{@ranking.available? ? "available" : "unavailable"}</strong></span></p>"
72
+ out << "<p class=\"warning\">Failed or incomplete run; counts are lower bounds.</p>" if incomplete
73
+ out << "</header>"
74
+ end
75
+
76
+ def navigation_html
77
+ entries = visible_decisions
78
+ ordinals = {}.compare_by_identity
79
+ entries.each_with_index { |decision, index| ordinals[decision] = index + 1 }
80
+ file_order = @ranking.files.each_with_index.to_h { |file, index| [file.relative_path, index] }
81
+ files = entries.group_by(&:relative_path).sort_by { |path, _rows| file_order.fetch(path, Float::INFINITY) }
82
+ out = +"<nav aria-label=\"Source files\"><h2>Files</h2>"
83
+ if files.empty?
84
+ out << "<p class=\"status\">No decisions in this selection.</p>"
85
+ else
86
+ out << "<ul>"
87
+ files.each do |path, rows|
88
+ first = ordinals.fetch(rows.first)
89
+ gaps = rows.count(&:gap?)
90
+ out << "<li><a href=\"#decision-#{first}\">#{escape(path || "location unavailable")}</a>"
91
+ out << "<small>#{gaps}/#{rows.length} decisions with known gaps</small></li>"
92
+ end
93
+ out << "</ul>"
94
+ end
95
+ out << "</nav>"
96
+ end
97
+
98
+ def overview_html
99
+ lines = @coordinator.coverage_ladder_lines
100
+ out = +"<section class=\"overview\" aria-labelledby=\"overview-title\"><h2 id=\"overview-title\">Whole-run coverage and policy</h2>"
101
+ if @selection.focus_active?
102
+ label = @selection.focus_label
103
+ out << "<p>Focus: #{escape(label)}</p>"
104
+ end
105
+ if @selection.decision_ids == []
106
+ out << "<p class=\"warning\">An empty decision selection does not establish full coverage.</p>"
107
+ end
108
+ if @selection.decision_ids == []
109
+ out << "<p>Decision selection: 0 decisions. Coverage is <strong>not established</strong>.</p>"
110
+ end
111
+ coverage_lines = lines.reject { |line| line.strip.empty? || line.strip == "Coverage ladder:" }
112
+ coverage_items = coverage_lines.map { |line| "<li>#{escape(line.strip)}</li>" }.join
113
+ coverage_html = if lines.empty?
114
+ "<p class=\"status\">Coverage analysis unavailable; coverage is N/A.</p>"
115
+ else
116
+ "<ul>#{coverage_items}</ul>"
117
+ end
118
+ out << coverage_html
119
+ policy_lines = @coordinator.coverage_policy_lines
120
+ unless policy_lines.empty?
121
+ out << "<h3>Whole-run coverage policy</h3><ul>"
122
+ policy_lines.reject { |line| line.strip.empty? }.each { |line| out << "<li>#{escape(line.strip)}</li>" }
123
+ out << "</ul>"
124
+ end
125
+ if @ranking.available?
126
+ out << "<p>#{@ranking.decisions.length} supported decisions in this selection; #{@ranking.gaps.length} with known gaps.</p>"
127
+ else
128
+ out << "<p>Analysis unavailable. Source inventory remains available; no missing scenarios are inferred.</p>"
129
+ end
130
+ if @selection.top && hidden_decision_count.positive?
131
+ noun = @ranking.available? ? "ranked decisions" : "inventory decisions"
132
+ out << "<p>Display limit: top #{@selection.top}; #{hidden_decision_count} #{noun} hidden.</p>"
133
+ end
134
+ unsupported = @ranking.unsupported_count
135
+ out << "<p>Unsupported decisions: #{unsupported} (not ranked; MC/DC unavailable) (run-wide).</p>" if unsupported.positive?
136
+ out << "</section>"
137
+ end
138
+
139
+ def changed_scope_html
140
+ return "" unless fetch(@document, :changed_scope)
141
+
142
+ lines = @coordinator.changed_scope_lines
143
+ return "" if lines.empty?
144
+
145
+ out = +"<section class=\"overview\" aria-labelledby=\"changed-title\"><h2 id=\"changed-title\">Captured changed scope</h2>"
146
+ out << "<p>Changed-scope ranking is informational. Whole-run policy above remains global.</p><ul>"
147
+ lines.each { |line| out << "<li>#{escape(line.strip)}</li>" unless line.strip.empty? }
148
+ out << "</ul></section>"
149
+ end
150
+
151
+ def collation_html
152
+ lines = @coordinator.collation_lines
153
+ return "" if lines.empty?
154
+
155
+ out = +"<section class=\"overview\" aria-labelledby=\"collation-title\"><h2 id=\"collation-title\">Collation provenance</h2><ul>"
156
+ lines.each { |line| out << "<li>#{escape(line.strip)}</li>" }
157
+ out << "</ul></section>"
158
+ end
159
+
160
+ def decisions_html
161
+ return inventory_html unless @ranking.available?
162
+
163
+ entries = visible_decisions
164
+ out = +"<section aria-labelledby=\"decisions-title\"><h2 id=\"decisions-title\">Ranked decisions</h2>"
165
+ if entries.empty?
166
+ message = if @selection.decision_ids == []
167
+ "No decisions are selected; full coverage is not established."
168
+ elsif @selection.focus_active? || @selection.decision_ids
169
+ "No decisions match this selection."
170
+ else
171
+ "No decisions are available."
172
+ end
173
+ out << "<p class=\"status\">#{escape(message)}</p>"
174
+ return out << "</section>"
175
+ end
176
+ entries.each_with_index { |decision, position| out << decision_html(decision, position + 1) }
177
+ out << "</section>"
178
+ end
179
+
180
+ def inventory_html
181
+ inventory = fetch(@document, :source_inventory) || fetch(@document, :inventory) || {}
182
+ sources = Array(fetch(inventory, :source_units)).to_h do |source|
183
+ [fetch(source, :source_id).to_s, fetch(source, :relative_path)]
184
+ end
185
+ decisions = shown_inventory_decisions
186
+ out = +"<section aria-labelledby=\"inventory-title\"><h2 id=\"inventory-title\">Source inventory</h2>"
187
+ if decisions.empty?
188
+ out << "<p>Analysis unavailable. No decisions were inventoried.</p>"
189
+ else
190
+ out << "<ul>"
191
+ decisions.each do |decision|
192
+ path = sources[fetch(decision, :source_id).to_s]
193
+ out << "<li>#{escape(path || "location unavailable")} — <code>#{escape(fetch(decision,
194
+ :expression))}</code>; analysis unavailable</li>"
195
+ end
196
+ out << "</ul>"
197
+ end
198
+ out << "</section>"
199
+ end
200
+
201
+ def decision_html(decision, ordinal)
202
+ gap = decision.gap?
203
+ location = if decision.relative_path
204
+ [decision.relative_path,
205
+ decision.line].compact.join(":")
206
+ else
207
+ "location unavailable"
208
+ end
209
+ out = "<article class=\"decision#{" has-gap" if gap}\" id=\"decision-#{ordinal}\"><h3>Decision #{ordinal}: #{escape(location)}</h3>"
210
+ out << "<div class=\"expression\">#{escape(decision.expression)}</div><p class=\"meta\">"
211
+ out << "<span>#{escape(decision.kind)}</span><span>#{gap ? "Known gap" : "No known gap"}</span>"
212
+ out << "<span>#{escape(SummaryReport.gap_summary(decision))}</span></p>"
213
+ out << "<p>Cases to test: #{decision.cases_to_test}</p>" if @level >= 2
214
+ out << missing_scenarios(decision) if @level >= 2
215
+ out << condition_details(decision, ordinal) if @level >= 2
216
+ out << table_details(decision, ordinal) if @level >= 2
217
+ out << alternative_details(decision) if @level >= 2
218
+ out << contributing_tests(decision) if @level >= 3
219
+ out << "</article>"
220
+ end
221
+
222
+ def condition_details(decision, ordinal)
223
+ rows = @conditions.fetch(decision.id, [])
224
+ return "" if rows.empty?
225
+
226
+ out = +"<h3>Conditions and MC/DC evidence</h3>"
227
+ out << "<div class=\"table-wrap\" tabindex=\"0\" role=\"region\" aria-label=\"Decision #{ordinal} condition evidence\">"
228
+ out << "<table><thead><tr><th scope=\"col\">Condition</th><th scope=\"col\">Status</th>"
229
+ out << "<th scope=\"col\">Evidence</th></tr></thead><tbody>"
230
+ rows.each do |row|
231
+ status = row[:status].to_s
232
+ owners = Array(row[:witness_owner_details])
233
+ details = []
234
+ true_tests = test_labels(row[:observed_true])
235
+ false_tests = test_labels(row[:observed_false])
236
+ short_circuited_tests = test_labels(row[:short_circuited])
237
+ if @level >= 3
238
+ details << "true observed by #{true_tests.join(", ")}" unless true_tests.empty?
239
+ details << "false observed by #{false_tests.join(", ")}" unless false_tests.empty?
240
+ details << "short-circuited by #{short_circuited_tests.join(", ")}" unless short_circuited_tests.empty?
241
+ details << (owners.empty? ? "Proof owner: none recorded" : "Proof owner: #{owners.join(", ")}")
242
+ else
243
+ details << "true observed: #{true_tests.empty? ? "no" : "yes"}"
244
+ details << "false observed: #{false_tests.empty? ? "no" : "yes"}"
245
+ details << "short-circuited: #{short_circuited_tests.empty? ? "no" : "yes"}"
246
+ end
247
+ out << "<tr><th scope=\"row\"><code>#{escape(row[:expression])}</code></th><td>#{escape(status)}</td><td>#{escape(details.join("; "))}</td></tr>"
248
+ end
249
+ out << "</tbody></table></div>"
250
+ end
251
+
252
+ def missing_scenarios(decision)
253
+ cases = SummaryReport.case_lines(decision, @coordinator)
254
+ return "<p>No missing scenarios are known.</p>" if cases.empty? && !decision.unexecuted
255
+
256
+ out = +"<h3>Missing scenarios</h3><ul>"
257
+ cases.each { |item| out << "<li><code>#{escape(item)}</code></li>" }
258
+ out << "<li>No observed execution for this decision.</li>" if decision.unexecuted
259
+ out << "</ul>"
260
+ end
261
+
262
+ def table_details(decision, ordinal)
263
+ table = @tables[decision.id]
264
+ unless decision.table_calculated
265
+ table = @tables[decision.id]
266
+ status = table && table[:status]
267
+ reason = table && table[:reason]
268
+ message = status.to_s.empty? ? "Decision table: not calculated." : "Decision table: #{status}."
269
+ message += " Reason: #{reason}." if reason
270
+ return "<p>#{escape(message)}</p>"
271
+ end
272
+ return "" unless table
273
+
274
+ excluded = table[:impossible_rules].to_i
275
+ reachability = table_reachability_status(table)
276
+ out = "<h3>Decision-table rules</h3><p>Required denominator: #{decision.required_rules} rules. "
277
+ out << "Excluded: #{excluded} impossible rules. Reachability analysis: #{reachability}."
278
+ out << " #{table[:generated_rules]} generated rules recorded." if table[:generated_rules]
279
+ out << "</p><div class=\"table-wrap\" tabindex=\"0\" role=\"region\" aria-label=\"Decision #{ordinal} decision-table rules\">"
280
+ out << "<table><thead><tr><th scope=\"col\">Rule</th><th scope=\"col\">Requirements</th>"
281
+ out << "<th scope=\"col\">Expected result</th><th scope=\"col\">Coverage</th>"
282
+ out << "<th scope=\"col\">Reachability</th><th scope=\"col\">Reason</th></tr></thead><tbody>"
283
+ table[:rules].each do |rule|
284
+ expressions = decision.conditions.sort_by { |row| row[:index].to_i }.map { |row| row[:expression] }
285
+ status = fetch(rule, :coverage).to_s
286
+ label = fetch(rule, :label)
287
+ requirements = SummaryReport.rule_text(rule, expressions, @coordinator).sub(/\A[^:]+:\s*/, "")
288
+ outcome = fetch(rule, :outcome) ? "true" : "false"
289
+ rule_reachability = rule_reachability_status(rule)
290
+ reason = reachability_reason(rule)
291
+ out << "<tr><th scope=\"row\">#{escape(label)}</th><td>#{escape(requirements)}</td>"
292
+ out << "<td>#{outcome}</td><td>#{escape(status)}</td>"
293
+ out << "<td>#{escape(rule_reachability)}</td><td>#{escape(reason)}</td></tr>"
294
+ end
295
+ out << "</tbody></table></div>"
296
+ end
297
+
298
+ def table_reachability_status(table)
299
+ case table[:reachability_analyzed]
300
+ when true then "analyzed"
301
+ when false then "not analyzed"
302
+ else "unknown (status not recorded)"
303
+ end
304
+ end
305
+
306
+ def rule_reachability_status(rule)
307
+ reachability = @coordinator.decision_table_reachability(rule)
308
+ return "UNKNOWN" if reachability.to_s.empty? || reachability.to_s == "unknown"
309
+ return "STATICALLY IMPOSSIBLE (excluded from denominator)" if reachability == "STATICALLY IMPOSSIBLE"
310
+
311
+ reachability.to_s
312
+ end
313
+
314
+ def reachability_reason(rule)
315
+ reason = fetch(rule, :reachability_reason)
316
+ return "not recorded" if reason.to_s.empty?
317
+
318
+ Branchproof::Constraints.message(reason)
319
+ end
320
+
321
+ def alternative_details(decision)
322
+ rows = @alternatives.fetch(decision.id, [])
323
+ return "" if rows.empty?
324
+
325
+ out = +"<h3>Alternatives</h3><ul>"
326
+ rows.each do |row|
327
+ out << "<li><code>#{escape(row[:expression])}</code>: #{escape(row[:status])}"
328
+ out << " — missing" if row[:missing]
329
+ out << "</li>"
330
+ end
331
+ out << "</ul>"
332
+ end
333
+
334
+ def contributing_tests(decision)
335
+ ids = decision.test_ids
336
+ out = "<details><summary>Contributing tests; Tests reaching this decision (#{ids.length})</summary>"
337
+ if ids.empty?
338
+ out << "<p>No reaching tests recorded.</p>"
339
+ else
340
+ out << "<ul class=\"evidence\">"
341
+ ids.each do |id|
342
+ test = @tests[id] || {}
343
+ label = fetch(test, :name) || id
344
+ location = [fetch(test, :relative_path), fetch(test, :line)].compact.join(":")
345
+ out << "<li>#{escape(label)}#{" (#{escape(location)})" unless location.empty?}</li>"
346
+ end
347
+ out << "</ul><p>Reaching a decision is not proof of MC/DC.</p>"
348
+ end
349
+ out << "</details>"
350
+ end
351
+
352
+ def diagnostics_html
353
+ diagnostics = Array(fetch(@document, :diagnostics))
354
+ return "" if diagnostics.empty?
355
+
356
+ out = +"<section class=\"overview\" aria-labelledby=\"diagnostics-title\"><h2 id=\"diagnostics-title\">Diagnostics</h2><ul>"
357
+ diagnostics.each { |item| out << "<li>#{escape(@coordinator.diagnostic_message(item))}</li>" }
358
+ out << "</ul></section>"
359
+ end
360
+
361
+ def visible_decisions
362
+ return [] unless @ranking.available?
363
+
364
+ entries = @ranking.decisions
365
+ entries = entries.select(&:gap?) if @missing_only
366
+ @shown_decisions, @hidden_decisions = @selection.limit(entries)
367
+ @shown_decisions
368
+ end
369
+
370
+ def hidden_decision_count
371
+ visible_decisions
372
+ return @hidden_decisions || 0 if @ranking.available?
373
+
374
+ inventory = fetch(@document, :source_inventory) || fetch(@document, :inventory) || {}
375
+ decisions = @selection.filter_decisions(Array(fetch(inventory, :decisions)), inventory: inventory)
376
+ @selection.limit(decisions).last
377
+ end
378
+
379
+ def test_labels(ids)
380
+ Array(ids).map do |id|
381
+ test = @tests[id.to_s]
382
+ next id.to_s unless test
383
+
384
+ label = test[:name].to_s
385
+ location = [test[:relative_path], test[:line]].compact.join(":")
386
+ location.empty? ? label : "#{label} (#{location})"
387
+ end
388
+ end
389
+
390
+ def inventory_decisions
391
+ inventory = fetch(@document, :source_inventory) || fetch(@document, :inventory) || {}
392
+ @selection.filter_decisions(Array(fetch(inventory, :decisions)), inventory: inventory)
393
+ end
394
+
395
+ def shown_inventory_decisions
396
+ shown, @inventory_hidden = @selection.limit(inventory_decisions)
397
+ shown
398
+ end
399
+
400
+ def escape(value)
401
+ ERB::Util.html_escape(value.to_s)
402
+ end
403
+
404
+ def fetch(hash, key)
405
+ return nil unless hash.respond_to?(:key?)
406
+
407
+ hash.key?(key) ? hash[key] : hash[key.to_s]
408
+ end
409
+ end
410
+ # rubocop:enable Layout/LineLength
411
+ # rubocop:enable Metrics/AbcSize, Metrics/MethodLength, Metrics/ClassLength, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
412
+ end
@@ -3,6 +3,7 @@
3
3
  require_relative "flow_instrumentation"
4
4
  require_relative "iteration_instrumentation"
5
5
  require_relative "exception_instrumentation"
6
+ require_relative "fallback_instrumentation"
6
7
  require_relative "default_instrumentation"
7
8
  require_relative "value_instrumentation"
8
9
 
@@ -14,6 +15,7 @@ module Branchproof
14
15
  include FlowInstrumentation
15
16
  include IterationInstrumentation
16
17
  include ExceptionInstrumentation
18
+ include FallbackInstrumentation
17
19
  prepend DefaultInstrumentation
18
20
  prepend ValueInstrumentation
19
21
 
@@ -146,9 +146,11 @@ module Branchproof
146
146
  covered = Set.new
147
147
  until covered >= target
148
148
  available = ids.reject { |candidate| chosen_set.include?(candidate) }
149
- id = available.max_by { |candidate| [(candidates[candidate] - covered).length, -positions[candidate]] }
149
+ id = available.max_by do |candidate|
150
+ [candidates[candidate].count { |obligation| !covered.include?(obligation) }, -positions[candidate]]
151
+ end
150
152
  break unless id
151
- break if (candidates[id] - covered).empty?
153
+ break if candidates[id].all? { |obligation| covered.include?(obligation) }
152
154
 
153
155
  chosen << id
154
156
  chosen_set << id
@@ -5,6 +5,38 @@ module Branchproof
5
5
  class MinitestAdapter
6
6
  class << self
7
7
  attr_accessor :active_adapter
8
+
9
+ def load_framework!
10
+ require_framework!("minitest")
11
+
12
+ version = Gem.loaded_specs["minitest"]&.version || Gem::Version.new(Minitest::VERSION)
13
+ requirement = Gem::Requirement.new(">= 5.25.5", "< 7")
14
+ unless requirement.satisfied_by?(Gem::Version.new(version))
15
+ failure = ArgumentError.new(
16
+ "Minitest #{version} is unsupported; supported version range is >= 5.25.5, < 7"
17
+ )
18
+ def failure.diagnostic_code = "minitest_unsupported_version"
19
+ raise failure
20
+ end
21
+
22
+ require_framework!("minitest/test")
23
+ Minitest
24
+ end
25
+
26
+ private
27
+
28
+ def require_framework!(entrypoint)
29
+ require entrypoint
30
+ rescue LoadError => e
31
+ raise unless e.path == entrypoint
32
+
33
+ failure = ArgumentError.new(
34
+ "Minitest entrypoint #{entrypoint} is unavailable; " \
35
+ "add minitest >= 5.25.5, < 7 to the application's test bundle"
36
+ )
37
+ def failure.diagnostic_code = "minitest_missing"
38
+ raise failure
39
+ end
8
40
  end
9
41
 
10
42
  def initialize(runtime:)
@@ -18,9 +50,8 @@ module Branchproof
18
50
  raise ArgumentError, "on_complete must respond to call" unless on_complete.respond_to?(:call)
19
51
 
20
52
  reject_runner_args!(runner_args)
53
+ self.class.load_framework!
21
54
  @runner_args = Array(runner_args).dup.freeze
22
- require "minitest"
23
- require "minitest/test"
24
55
  self.class.active_adapter = self
25
56
  install_lifecycle_hooks
26
57
  install_runner_guard
@@ -44,6 +75,8 @@ module Branchproof
44
75
 
45
76
  def validate_runner!
46
77
  runnables = defined?(Minitest::Runnable) ? Minitest::Runnable.runnables : []
78
+ raise ArgumentError, "Minitest server runner integration is unsupported" if server_integration_active?
79
+
47
80
  parallel = parallel_executor_active? || runnables.any? { |runnable| parallel_runnable?(runnable) }
48
81
  raise ArgumentError, "parallel test scheduling is unsupported" if parallel
49
82
 
@@ -112,7 +145,14 @@ module Branchproof
112
145
  parallel_module = defined?(Minitest::Parallel::Test) && Minitest::Parallel::Test
113
146
  return true if parallel_module && runnable.ancestors.include?(parallel_module)
114
147
 
115
- runnable.respond_to?(:test_order) && runnable.test_order == :parallel
148
+ (runnable.respond_to?(:test_order) && runnable.test_order == :parallel) ||
149
+ (runnable.respond_to?(:run_order) && runnable.run_order == :parallel)
150
+ end
151
+
152
+ def server_integration_active?
153
+ ENV["MINITEST_SERVER"] || Minitest.instance_variable_get(:@server) ||
154
+ (Minitest.respond_to?(:reporter) && defined?(Minitest::ServerReporter) &&
155
+ Minitest.reporter&.reporters&.any?(Minitest::ServerReporter))
116
156
  end
117
157
 
118
158
  def begin_test(test)
@@ -231,8 +271,8 @@ module Branchproof
231
271
 
232
272
  def reject_runner_args!(args)
233
273
  forbidden = Array(args).select do |arg|
234
- %w[--parallel --parallelize --fork --processes
235
- --runner].include?(arg.to_s) || arg.to_s.start_with?("--parallel=", "--fork=", "--processes=")
274
+ %w[--parallel --parallelize --fork --processes --runner -b --bisect --server].include?(arg.to_s) ||
275
+ arg.to_s.start_with?("--parallel=", "--fork=", "--processes=", "--bisect=", "--server=")
236
276
  end
237
277
  raise ArgumentError, "parallel, forked, and custom runners are unsupported" unless forbidden.empty?
238
278
  end
@@ -123,12 +123,12 @@ module Branchproof
123
123
  ruby_version = Gem::Version.new(rails_metadata.fetch(:ruby_version).to_s)
124
124
  rspec_version = Gem::Version.new(rails_metadata.fetch(:rspec_rails_version).to_s)
125
125
  return true if rails_version.segments.first(2) == SUPPORTED_RAILS.segments.first(2) &&
126
- ruby_version.segments.first(2) == [3, 4] &&
126
+ ruby_version >= Gem::Version.new("4.0") &&
127
127
  rspec_version.segments.first == SUPPORTED_RSPEC_RAILS_MAJOR
128
128
 
129
129
  raise Error,
130
130
  "unsupported Rails/Ruby/RSpec Rails tuple: Rails #{rails_version}, Ruby #{ruby_version}, " \
131
- "RSpec Rails #{rspec_version} (supported Rails 8.1, Ruby 3.4.x, RSpec Rails 8.x)"
131
+ "RSpec Rails #{rspec_version} (supported Rails 8.1, Ruby 4.0+, RSpec Rails 8.x)"
132
132
  end
133
133
 
134
134
  def rspec_rails_version