branchproof 0.11.0 → 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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +89 -0
- data/README.md +440 -29
- data/doc/Branchproof/CLI.md +1 -1
- data/doc/Branchproof/ChangedCoverage.md +19 -0
- data/doc/Branchproof/ChangedCoveragePolicy.md +24 -0
- data/doc/Branchproof/ChangedDecisionMap.md +20 -0
- data/doc/Branchproof/ChangedScope.md +18 -0
- data/doc/Branchproof/Collation.md +23 -0
- data/doc/Branchproof/CollationManifest.md +29 -0
- data/doc/Branchproof/CoverageSummary.md +20 -0
- data/doc/Branchproof/Doctor/TerminalRenderer.md +15 -0
- data/doc/Branchproof/Doctor.md +34 -0
- data/doc/Branchproof/FallbackInstrumentation.md +8 -0
- data/doc/Branchproof/FallbackRuntime.md +12 -0
- data/doc/Branchproof/FallbackSyntax.md +13 -0
- data/doc/Branchproof/GitChanges.md +19 -0
- data/doc/Branchproof/GithubReport.md +25 -0
- data/doc/Branchproof/HtmlReport.md +22 -0
- data/doc/Branchproof/Instrumenter.md +1 -1
- data/doc/Branchproof/MinitestAdapter.md +4 -0
- data/doc/Branchproof/RakeTask.md +121 -0
- data/doc/Branchproof/Report.md +41 -3
- data/doc/Branchproof/ReportSelection.md +10 -1
- data/doc/Branchproof/Runtime.md +4 -1
- data/doc/Branchproof/Source.md +6 -1
- data/doc/Branchproof/SummaryRanking/Decision.md +86 -0
- data/doc/Branchproof/SummaryRanking/SourceFile.md +43 -0
- data/doc/Branchproof/SummaryRanking.md +36 -0
- data/doc/Branchproof/SummaryReport.md +42 -0
- data/doc/Branchproof/Worker.md +1 -1
- data/doc/Branchproof.md +21 -1
- data/doc/CHANGELOG.md +89 -0
- data/doc/README.md +440 -29
- data/lib/branchproof/analyzer.rb +2 -52
- data/lib/branchproof/changed_coverage.rb +177 -0
- data/lib/branchproof/changed_decision_map.rb +359 -0
- data/lib/branchproof/changed_scope.rb +93 -0
- data/lib/branchproof/cli.rb +242 -21
- data/lib/branchproof/collation.rb +525 -0
- data/lib/branchproof/collation_manifest.rb +129 -0
- data/lib/branchproof/comparison.rb +11 -3
- data/lib/branchproof/comparison_report.rb +2 -0
- data/lib/branchproof/configuration.rb +12 -2
- data/lib/branchproof/coverage_policy.rb +122 -0
- data/lib/branchproof/coverage_summary.rb +94 -0
- data/lib/branchproof/doctor.rb +241 -0
- data/lib/branchproof/evidence.rb +5 -1
- data/lib/branchproof/exception_instrumentation.rb +4 -3
- data/lib/branchproof/exception_syntax.rb +15 -1
- data/lib/branchproof/fallback_instrumentation.rb +25 -0
- data/lib/branchproof/fallback_runtime.rb +14 -0
- data/lib/branchproof/fallback_syntax.rb +90 -0
- data/lib/branchproof/focused_report.rb +81 -22
- data/lib/branchproof/git_changes.rb +188 -0
- data/lib/branchproof/github_report.rb +292 -0
- data/lib/branchproof/html_report.rb +412 -0
- data/lib/branchproof/instrumenter.rb +2 -0
- data/lib/branchproof/minimizer.rb +4 -2
- data/lib/branchproof/minitest_adapter.rb +45 -5
- data/lib/branchproof/rails_support.rb +2 -2
- data/lib/branchproof/rake_task.rb +186 -0
- data/lib/branchproof/report.rb +383 -71
- data/lib/branchproof/report_selection.rb +26 -5
- data/lib/branchproof/runtime.rb +2 -0
- data/lib/branchproof/saved_report.rb +323 -5
- data/lib/branchproof/source.rb +25 -4
- data/lib/branchproof/summary_ranking.rb +165 -0
- data/lib/branchproof/summary_report.rb +241 -0
- data/lib/branchproof/version.rb +1 -1
- data/lib/branchproof/worker.rb +14 -9
- data/lib/branchproof.rb +12 -0
- data/llms.txt +21 -1
- data/sig/branchproof.rbs +206 -5
- metadata +38 -22
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|