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.
Files changed (75) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +89 -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 +6 -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 +89 -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 +25 -4
  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 +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