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,292 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# rubocop:disable Metrics/AbcSize, Metrics/MethodLength, Metrics/ClassLength, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
4
|
+
require_relative "summary_report"
|
|
5
|
+
|
|
6
|
+
module Branchproof
|
|
7
|
+
# GitHub Actions output: workflow-command annotations for the ranked gaps and
|
|
8
|
+
# a Markdown job summary. Both reuse the summary view's ranking and wording.
|
|
9
|
+
class GithubReport
|
|
10
|
+
MARKDOWN_ROWS = 20
|
|
11
|
+
|
|
12
|
+
# path_prefix maps project-relative paths to repository-relative ones
|
|
13
|
+
# when the project is not at the repository root.
|
|
14
|
+
def initialize(document:, level:, coordinator:, selection: ReportSelection.new, path_prefix: nil)
|
|
15
|
+
@document = document || {}
|
|
16
|
+
@path_prefix = path_prefix
|
|
17
|
+
@level = level.to_i
|
|
18
|
+
@coordinator = coordinator
|
|
19
|
+
@selection = selection
|
|
20
|
+
@ranking = SummaryRanking.new(document: @document, selection: selection)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Workflow commands for stdout, one warning per ranked decision gap.
|
|
24
|
+
def annotations(path_prefix: @path_prefix)
|
|
25
|
+
path_prefix = path_prefix.to_s.empty? ? nil : path_prefix.to_s.delete_suffix("/")
|
|
26
|
+
lines = error_lines
|
|
27
|
+
if @coordinator.collation_lines.any?
|
|
28
|
+
status = fetch(fetch(@document, :collation), :collection_status)
|
|
29
|
+
lines << command("notice", @coordinator.collation_lines.join("\n"),
|
|
30
|
+
title: "Branchproof collation: #{status}")
|
|
31
|
+
end
|
|
32
|
+
lines << command("notice", changed_scope_message, title: "Branchproof changed scope") if changed_scope?
|
|
33
|
+
gaps, hidden = @selection.limit(ranked_gaps)
|
|
34
|
+
gaps.each_with_index do |decision, position|
|
|
35
|
+
lines << command("warning", annotation_message(decision),
|
|
36
|
+
title: "Branchproof ##{position + 1}: #{SummaryReport.gap_summary(decision)}",
|
|
37
|
+
file: annotation_path(decision.relative_path, path_prefix), line: decision.line)
|
|
38
|
+
end
|
|
39
|
+
lines << command("notice", notice_message(hidden), title: "Branchproof coverage")
|
|
40
|
+
lines.join("\n") << "\n"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Markdown for $GITHUB_STEP_SUMMARY.
|
|
44
|
+
def step_summary
|
|
45
|
+
status = fetch(fetch(@document, :baseline) || {}, :status) || "INCOMPLETE"
|
|
46
|
+
lines = ["## Branchproof coverage", "", "**Tests:** #{status}", ""]
|
|
47
|
+
if changed_scope?
|
|
48
|
+
focus_notice = changed_focus_notice
|
|
49
|
+
lines << focus_notice if focus_notice
|
|
50
|
+
lines << "**Whole-run coverage and policy**"
|
|
51
|
+
lines << ""
|
|
52
|
+
end
|
|
53
|
+
ladder = ladder_lines
|
|
54
|
+
unless ladder.empty?
|
|
55
|
+
lines.concat(ladder.map { |line| "- #{line}" })
|
|
56
|
+
lines << ""
|
|
57
|
+
end
|
|
58
|
+
if changed_scope?
|
|
59
|
+
lines.concat(@coordinator.changed_scope_lines.map { |line| "- #{code(line)}" })
|
|
60
|
+
lines << ""
|
|
61
|
+
end
|
|
62
|
+
collation = @coordinator.collation_lines
|
|
63
|
+
unless collation.empty?
|
|
64
|
+
lines.concat(collation.map { |line| "- #{code(line.strip)}" })
|
|
65
|
+
lines << ""
|
|
66
|
+
end
|
|
67
|
+
render_diagnostics(lines)
|
|
68
|
+
render_policy(lines)
|
|
69
|
+
render_gap_table(lines)
|
|
70
|
+
lines.join("\n") << "\n"
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
def changed_scope?
|
|
76
|
+
!fetch(@document, :changed_scope).nil?
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def changed_scope_message
|
|
80
|
+
@coordinator.changed_scope_lines.join("\n")
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def changed_scope_focus_empty?
|
|
84
|
+
changed_scope? && @selection.focus_active? && @selection.selected_decision_ids(@document).empty?
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def changed_focus_notice
|
|
88
|
+
return unless changed_scope? && @selection.focus_active?
|
|
89
|
+
|
|
90
|
+
if changed_scope_focus_empty?
|
|
91
|
+
"**Focus:** no matching changed decisions for #{code(@selection.focus_label)}"
|
|
92
|
+
else
|
|
93
|
+
"**Focus:** #{code(@selection.focus_label)}"
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Every non-zero exit status gets at least one error that names a reason.
|
|
98
|
+
def error_lines
|
|
99
|
+
lines = []
|
|
100
|
+
baseline = fetch(@document, :baseline) || {}
|
|
101
|
+
status = (fetch(baseline, :status) || "INCOMPLETE").to_s
|
|
102
|
+
if status != "PASSED"
|
|
103
|
+
lines << command("error", "Tests #{status}", title: "Branchproof tests")
|
|
104
|
+
elsif fetch(baseline, :finalized) != true || (fetch(@document, :completeness) || {}).values.include?(false)
|
|
105
|
+
lines << command("error", "Run incomplete; coverage counts are lower bounds", title: "Branchproof run")
|
|
106
|
+
end
|
|
107
|
+
unmet_gates.each do |gate|
|
|
108
|
+
lines << command("error", gate_text(gate), title: "Branchproof coverage policy")
|
|
109
|
+
end
|
|
110
|
+
exit_code = @coordinator.exit_code
|
|
111
|
+
if lines.empty? && exit_code.positive?
|
|
112
|
+
lines << command("error", "Exit status #{exit_code}; see the terminal report diagnostics", title: "Branchproof")
|
|
113
|
+
end
|
|
114
|
+
diagnostics.each do |diagnostic|
|
|
115
|
+
diagnostic_code = fetch(diagnostic, :code).to_s
|
|
116
|
+
lines << command(annotation_severity(diagnostic), @coordinator.diagnostic_message(diagnostic),
|
|
117
|
+
title: "Branchproof diagnostic (#{diagnostic_code})")
|
|
118
|
+
end
|
|
119
|
+
lines
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def diagnostics
|
|
123
|
+
Array(fetch(@document, :diagnostics))
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def diagnostic_severity(diagnostic)
|
|
127
|
+
fetch(diagnostic, :severity).to_s.downcase
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def annotation_severity(diagnostic)
|
|
131
|
+
severity = diagnostic_severity(diagnostic)
|
|
132
|
+
%w[error warning].include?(severity) ? severity : "notice"
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def render_diagnostics(lines)
|
|
136
|
+
details = diagnostics
|
|
137
|
+
return if details.empty?
|
|
138
|
+
|
|
139
|
+
lines << "### Diagnostics"
|
|
140
|
+
lines << ""
|
|
141
|
+
details.each do |diagnostic|
|
|
142
|
+
severity = diagnostic_severity(diagnostic).upcase
|
|
143
|
+
diagnostic_code = fetch(diagnostic, :code).to_s
|
|
144
|
+
message = @coordinator.diagnostic_message(diagnostic)
|
|
145
|
+
lines << "- **#{severity} (#{diagnostic_code}):** #{code(message)}"
|
|
146
|
+
end
|
|
147
|
+
lines << ""
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def annotation_path(path, path_prefix)
|
|
151
|
+
return nil if path.nil?
|
|
152
|
+
|
|
153
|
+
path_prefix ? "#{path_prefix}/#{path}" : path
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def ranked_gaps
|
|
157
|
+
@ranking.available? ? @ranking.gaps : []
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def annotation_message(decision)
|
|
161
|
+
lines = [SummaryReport.one_line(decision.expression)]
|
|
162
|
+
if @level >= 2
|
|
163
|
+
lines << "Cases to test: #{decision.cases_to_test}"
|
|
164
|
+
lines.concat(SummaryReport.case_lines(decision, @coordinator))
|
|
165
|
+
end
|
|
166
|
+
lines.join("\n")
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def notice_message(hidden)
|
|
170
|
+
lines = ladder_lines
|
|
171
|
+
lines = ["Coverage ladder unavailable"] if lines.empty?
|
|
172
|
+
focus_notice = changed_focus_notice
|
|
173
|
+
lines.unshift(focus_notice) if focus_notice
|
|
174
|
+
lines.unshift("Whole-run coverage and policy:") if changed_scope?
|
|
175
|
+
lines.concat(@coordinator.changed_scope_lines) if changed_scope?
|
|
176
|
+
lines << if @ranking.available?
|
|
177
|
+
"Decisions with gaps: #{ranked_gaps.length}"
|
|
178
|
+
else
|
|
179
|
+
"Ranking unavailable: the report has no analysis"
|
|
180
|
+
end
|
|
181
|
+
lines << "Not annotated (--top): #{hidden}" if hidden.positive?
|
|
182
|
+
if @ranking.unsupported_count.positive?
|
|
183
|
+
lines << "Unsupported decisions: #{@ranking.unsupported_count} (not ranked)"
|
|
184
|
+
end
|
|
185
|
+
lines.join("\n")
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def render_policy(lines)
|
|
189
|
+
policy = @coordinator.coverage_policy
|
|
190
|
+
return if (fetch(policy, :minimum) || {}).empty?
|
|
191
|
+
|
|
192
|
+
lines << "**Coverage policy:** #{fetch(policy, :status).to_s.upcase}"
|
|
193
|
+
lines << ""
|
|
194
|
+
lines << "| Criterion | Covered | Threshold | Status | Reason |"
|
|
195
|
+
lines << "| --- | --- | ---: | --- | --- |"
|
|
196
|
+
Array(fetch(policy, :gates)).each do |gate|
|
|
197
|
+
lines << "| #{cell(fetch(gate, :criterion))} | #{gate_count(gate)} | #{fetch(gate, :minimum)} | " \
|
|
198
|
+
"#{fetch(gate, :status).to_s.upcase} | #{cell(fetch(gate, :reason))} |"
|
|
199
|
+
end
|
|
200
|
+
lines << ""
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def render_gap_table(lines)
|
|
204
|
+
lines << "### Where to start"
|
|
205
|
+
lines << ""
|
|
206
|
+
unless @ranking.available?
|
|
207
|
+
lines << "Ranking unavailable: the report has no analysis."
|
|
208
|
+
return
|
|
209
|
+
end
|
|
210
|
+
gaps = ranked_gaps
|
|
211
|
+
if gaps.empty?
|
|
212
|
+
lines << if changed_scope? && (changed_scope_focus_empty? || !@coordinator.changed_coverage_available?)
|
|
213
|
+
"Changed-scope gaps are unavailable from the captured evidence."
|
|
214
|
+
else
|
|
215
|
+
(changed_scope? ? "No changed-scope gaps found." : "No missing coverage.")
|
|
216
|
+
end
|
|
217
|
+
return
|
|
218
|
+
end
|
|
219
|
+
limit = @selection.top || MARKDOWN_ROWS
|
|
220
|
+
lines << SummaryReport::ORDER_NOTE
|
|
221
|
+
lines << ""
|
|
222
|
+
lines << "| # | Location | Decision | Gaps | Cases to test |"
|
|
223
|
+
lines << "| ---: | --- | --- | --- | ---: |"
|
|
224
|
+
gaps.first(limit).each_with_index do |decision, position|
|
|
225
|
+
lines << "| #{position + 1} | #{code(location(decision))} | #{code(SummaryReport.one_line(decision.expression,
|
|
226
|
+
80))} | " \
|
|
227
|
+
"#{cell(SummaryReport.gap_summary(decision))} | #{decision.cases_to_test} |"
|
|
228
|
+
end
|
|
229
|
+
hidden = gaps.length - limit
|
|
230
|
+
lines << "" << "#{hidden} more #{hidden == 1 ? "decision" : "decisions"} with gaps not shown." if hidden.positive?
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def ladder_lines
|
|
234
|
+
@coordinator.coverage_ladder_lines.map(&:strip).reject { |line| line.empty? || line == "Coverage ladder:" }
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def unmet_gates
|
|
238
|
+
Array(fetch(@coordinator.coverage_policy, :gates)).reject { |gate| fetch(gate, :status).to_s == "passed" }
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def gate_text(gate)
|
|
242
|
+
text = "#{fetch(gate, :criterion)}: #{gate_count(gate)}, threshold #{fetch(gate, :minimum)}"
|
|
243
|
+
status = fetch(gate, :status).to_s
|
|
244
|
+
text += ", #{status}" unless status == "failed"
|
|
245
|
+
reason = fetch(gate, :reason)
|
|
246
|
+
reason ? "#{text}; reason: #{reason}" : text
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def gate_count(gate)
|
|
250
|
+
denominator = fetch(gate, :denominator)
|
|
251
|
+
return "N/A" if denominator.nil? || denominator.to_i.zero?
|
|
252
|
+
|
|
253
|
+
"#{fetch(gate, :numerator) || "N/A"}/#{denominator}"
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def location(decision)
|
|
257
|
+
return "location unavailable" if decision.relative_path.nil?
|
|
258
|
+
|
|
259
|
+
decision.line ? "#{decision.relative_path}:#{decision.line}" : decision.relative_path
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
def command(kind, message, title:, file: nil, line: nil)
|
|
263
|
+
properties = []
|
|
264
|
+
properties << "file=#{property(file)}" if file
|
|
265
|
+
properties << "line=#{line.to_i}" if file && line
|
|
266
|
+
properties << "title=#{property(title)}"
|
|
267
|
+
"::#{kind} #{properties.join(",")}::#{data(message)}"
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
# Escaping rules from the GitHub Actions workflow command toolkit.
|
|
271
|
+
def data(text) = text.to_s.gsub("%", "%25").gsub("\r", "%0D").gsub("\n", "%0A")
|
|
272
|
+
def property(text) = data(text).gsub(":", "%3A").gsub(",", "%2C")
|
|
273
|
+
|
|
274
|
+
# The fence must be longer than any backtick run inside the text.
|
|
275
|
+
def code(text)
|
|
276
|
+
text = cell(text)
|
|
277
|
+
longest = text.scan(/`+/).map(&:length).max || 0
|
|
278
|
+
fence = "`" * (longest + 1)
|
|
279
|
+
padding = longest.positive? ? " " : ""
|
|
280
|
+
"#{fence}#{padding}#{text}#{padding}#{fence}"
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
def cell(text) = text.to_s.gsub(/\s+/, " ").gsub("|", "\\|")
|
|
284
|
+
|
|
285
|
+
def fetch(hash, key)
|
|
286
|
+
return nil unless hash.respond_to?(:key?)
|
|
287
|
+
|
|
288
|
+
hash.key?(key) ? hash[key] : hash[key.to_s]
|
|
289
|
+
end
|
|
290
|
+
end
|
|
291
|
+
end
|
|
292
|
+
# rubocop:enable Metrics/AbcSize, Metrics/MethodLength, Metrics/ClassLength, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|