mutineer-mongoid 2.0.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 (68) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +1194 -0
  3. data/LICENSE +21 -0
  4. data/README.md +659 -0
  5. data/bin/mutineer +6 -0
  6. data/lib/mutineer/baseline.rb +185 -0
  7. data/lib/mutineer/changed_lines.rb +136 -0
  8. data/lib/mutineer/child_stdout.rb +35 -0
  9. data/lib/mutineer/cli.rb +681 -0
  10. data/lib/mutineer/config.rb +372 -0
  11. data/lib/mutineer/coverage_map.rb +1366 -0
  12. data/lib/mutineer/daemon_backend.rb +351 -0
  13. data/lib/mutineer/daemon_client.rb +407 -0
  14. data/lib/mutineer/daemon_server.rb +351 -0
  15. data/lib/mutineer/external_backend.rb +288 -0
  16. data/lib/mutineer/file_swap.rb +194 -0
  17. data/lib/mutineer/isolation.rb +321 -0
  18. data/lib/mutineer/job_plan.rb +352 -0
  19. data/lib/mutineer/kill_channel.rb +284 -0
  20. data/lib/mutineer/kill_matrix.rb +88 -0
  21. data/lib/mutineer/minitest_integration/kill_recorder.rb +301 -0
  22. data/lib/mutineer/minitest_integration/outer_reporter.rb +126 -0
  23. data/lib/mutineer/minitest_integration/stop_at_first_failure.rb +145 -0
  24. data/lib/mutineer/minitest_integration.rb +137 -0
  25. data/lib/mutineer/mongoid_failures.rb +82 -0
  26. data/lib/mutineer/mongoid_worker_db.rb +97 -0
  27. data/lib/mutineer/mutant_id.rb +126 -0
  28. data/lib/mutineer/mutation.rb +27 -0
  29. data/lib/mutineer/mutator_registry.rb +109 -0
  30. data/lib/mutineer/mutators/arithmetic.rb +35 -0
  31. data/lib/mutineer/mutators/array_literal.rb +44 -0
  32. data/lib/mutineer/mutators/base.rb +105 -0
  33. data/lib/mutineer/mutators/boolean_connector.rb +52 -0
  34. data/lib/mutineer/mutators/boolean_literal.rb +56 -0
  35. data/lib/mutineer/mutators/chain_link.rb +132 -0
  36. data/lib/mutineer/mutators/collection_method.rb +44 -0
  37. data/lib/mutineer/mutators/comparison.rb +35 -0
  38. data/lib/mutineer/mutators/condition_forcing.rb +134 -0
  39. data/lib/mutineer/mutators/condition_negation.rb +49 -0
  40. data/lib/mutineer/mutators/literal_mutation.rb +51 -0
  41. data/lib/mutineer/mutators/negation_removal.rb +44 -0
  42. data/lib/mutineer/mutators/operand_removal.rb +58 -0
  43. data/lib/mutineer/mutators/operator_assignment.rb +113 -0
  44. data/lib/mutineer/mutators/range_literal.rb +54 -0
  45. data/lib/mutineer/mutators/regex_literal.rb +72 -0
  46. data/lib/mutineer/mutators/return_nil.rb +72 -0
  47. data/lib/mutineer/mutators/safe_navigation.rb +76 -0
  48. data/lib/mutineer/mutators/statement_removal.rb +33 -0
  49. data/lib/mutineer/mutators/string_literal.rb +34 -0
  50. data/lib/mutineer/orphan_guard.rb +28 -0
  51. data/lib/mutineer/pairing.rb +298 -0
  52. data/lib/mutineer/parser.rb +45 -0
  53. data/lib/mutineer/progress.rb +46 -0
  54. data/lib/mutineer/project.rb +506 -0
  55. data/lib/mutineer/project_path.rb +57 -0
  56. data/lib/mutineer/rails_worker_db.rb +212 -0
  57. data/lib/mutineer/reporter.rb +926 -0
  58. data/lib/mutineer/result.rb +240 -0
  59. data/lib/mutineer/runner.rb +418 -0
  60. data/lib/mutineer/statement_lines.rb +183 -0
  61. data/lib/mutineer/subject.rb +38 -0
  62. data/lib/mutineer/test_runners/minitest.rb +22 -0
  63. data/lib/mutineer/test_runners/rspec.rb +330 -0
  64. data/lib/mutineer/test_runners.rb +30 -0
  65. data/lib/mutineer/version.rb +6 -0
  66. data/lib/mutineer/worker_pool.rb +147 -0
  67. data/lib/mutineer.rb +48 -0
  68. metadata +143 -0
@@ -0,0 +1,926 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "stringio"
5
+ require "cgi"
6
+ require_relative "mutation"
7
+
8
+ module Mutineer
9
+ # Renders an AggregateResult: the summary block, mutation score, and per-file
10
+ # survivor diffs. Stream discipline: the report goes to `out` (stdout),
11
+ # diagnostics/warnings go to `err` (stderr), so `mutineer ... > report.txt`
12
+ # captures only the report.
13
+ #
14
+ # `source_map` is { file_path => raw source string }, used to extract the
15
+ # containing source line for each survivor diff.
16
+ class Reporter
17
+ # Share of attempted mutants that may produce no verdict before `--threshold`
18
+ # stops trusting the score. A handful of flaky mutants in a large run is noise;
19
+ # a mostly-broken run is not a score. Deliberately not a flag: no one has needed
20
+ # a different number yet.
21
+ BROKEN_SHARE_LIMIT = 0.10
22
+
23
+ # A share alone gives a small run no tolerance at all: on a `--since` PR that
24
+ # yields 8 mutants, one timeout is 12.5%. README recommends exactly that
25
+ # workflow, so one bad mutant never fails the gate on its own, at any size.
26
+ BROKEN_FLOOR = 1
27
+
28
+ # The JSON report's `schema_version` (see docs/json-schema.md).
29
+ SCHEMA_VERSION = "1.7"
30
+
31
+ # The warning both matrix renderers give under the redundant tests.
32
+ MATRIX_REDUNDANT_NOTE = "Delete redundant tests one at a time: two of them can be the only killers of one mutant."
33
+
34
+ # How many blind or redundant tests the human report lists before it points
35
+ # to `--format json` for the rest.
36
+ MATRIX_LIST_LIMIT = 20
37
+
38
+ # @param aggregate [Mutineer::AggregateResult] the run's results.
39
+ # @param source_map [Hash{String => String}] source file path => source text.
40
+ # @param matrix [Mutineer::KillMatrix, nil] the kill matrix of a `--matrix`
41
+ # run; nil leaves every format exactly as it is without the flag.
42
+ def initialize(aggregate, source_map, matrix: nil)
43
+ @agg = aggregate
44
+ @source_map = source_map
45
+ @matrix = matrix
46
+ end
47
+
48
+ # Single entry point. Branches on `format` ("human" | "json" | "html") and
49
+ # routes the rendered report to `output` (a file, with a stderr confirmation)
50
+ # or to `out`. Diagnostics always go to `err`. `scoped` marks a diff-scoped
51
+ # (`--since`) run; the JSON report records it so a consumer (or a later
52
+ # `--baseline` load) knows the score covers only the changed-line mutants.
53
+ # `legacy_id_matches` (`{ignore:, baseline:}`) counts stored ids still in the
54
+ # old format (#126); only the JSON report records it (`summary.legacy_id_matches`).
55
+ def report(out: $stdout, err: $stderr, threshold: 0.0, format: "human", output: nil,
56
+ baseline: nil, scoped: false, legacy_id_matches: { ignore: 0, baseline: 0 })
57
+ rendered =
58
+ if format == "json"
59
+ json_report(baseline, scoped: scoped, legacy_id_matches: legacy_id_matches)
60
+ elsif format == "html"
61
+ html_report
62
+ else
63
+ sio = StringIO.new
64
+ human_report(sio, err, threshold)
65
+ baseline_section(sio, baseline) if baseline
66
+ sio.string
67
+ end
68
+
69
+ if output
70
+ abs = File.expand_path(output)
71
+ File.write(abs, rendered)
72
+ err.puts "Report written to #{abs}"
73
+ else
74
+ out.print rendered
75
+ end
76
+
77
+ # Both ways a run can fail the gate on completeness, said here rather than in
78
+ # the human renderer: --format json is the documented CI path, and a run that
79
+ # exits 1 must say why on every format, not only the one a person reads.
80
+ return unless threshold&.positive?
81
+
82
+ if @agg.mutation_score.nil? && broken_nil_score?
83
+ err.puts "[mutineer] nothing could be scored (#{broken_counts_detail}), so the " \
84
+ "--threshold gate fails. See no_verdict[] in --format json for the cause of each."
85
+ elsif broken_share_exceeded?
86
+ err.puts "[mutineer] #{no_verdict_ratio}: #{broken_counts_detail}. The score covers " \
87
+ "only part of the run, so the --threshold gate fails. See no_verdict[] in " \
88
+ "--format json for the cause of each."
89
+ end
90
+ end
91
+
92
+ # Renders the human report.
93
+ #
94
+ # @param out [IO] output stream.
95
+ # @param err [IO] error stream.
96
+ # @param threshold [Float] score threshold.
97
+ # @return [void]
98
+ def human_report(out, err, threshold)
99
+ if @agg.total.zero?
100
+ err.puts "No mutations generated — verify target files contain in-scope " \
101
+ "operators and are reached by the suite."
102
+ return
103
+ end
104
+
105
+ out.puts "Mutineer — Mutation Results"
106
+ out.puts "========================="
107
+ out.puts
108
+ summary(out)
109
+ out.puts
110
+ score_line(out, err)
111
+ per_source(out)
112
+
113
+ survivors(out)
114
+ matrix_section(out) if @matrix
115
+ verdict(out, threshold) if threshold && threshold.positive?
116
+ end
117
+
118
+ # 0 pass / 1 below threshold or untestable-with-errors. Usage errors (exit 2)
119
+ # are the CLI's job. When the score is nil (nothing killed or survived), pure
120
+ # no_coverage / all-ignored / empty still skip the gate; if any mutant was
121
+ # errored, timed out, or uncapturable, fail the gate so a broken harness
122
+ # cannot green CI under --threshold.
123
+ def exit_code(threshold:)
124
+ return 0 if threshold.nil? || threshold <= 0
125
+
126
+ score = @agg.mutation_score
127
+ if score.nil?
128
+ broken = @agg.errored_count + @agg.timeout_count + @agg.uncapturable_count
129
+ return 1 if @agg.total.positive? && broken.positive?
130
+
131
+ return 0 # pure no_coverage / ignored / empty — gate skipped
132
+ end
133
+
134
+ # A score computed over a small slice of what was attempted is not this
135
+ # suite's score. Without this, 90 errored mutants and 10 that ran (9 killed)
136
+ # reports 90% and exits 0, so CI cannot tell a complete run from a broken one.
137
+ return 1 if broken_share_exceeded?
138
+
139
+ score >= threshold ? 0 : 1
140
+ end
141
+
142
+ private
143
+
144
+ # Canonical machine-readable schema. survivors/no_coverage are sorted by
145
+ # (file, line, operator) so output is byte-stable regardless of --jobs
146
+ # worker finish order.
147
+ #
148
+ # @api private
149
+ # @param baseline [Mutineer::Baseline::Delta, nil] baseline delta.
150
+ # @param scoped [Boolean] the run was diff-scoped (`--since`), so its score
151
+ # covers only the changed-line mutants (additive `summary.scoped` key).
152
+ # @param legacy_id_matches [Hash{Symbol => Integer}] `{ignore:, baseline:}`:
153
+ # old-format ignore entries that matched, and survivors matched in an
154
+ # old-format baseline only through their old id (#126).
155
+ # @return [String] JSON text.
156
+ def json_report(baseline = nil, scoped: false, legacy_id_matches: { ignore: 0, baseline: 0 })
157
+ killed = @agg.killed_count
158
+ survived = @agg.survived_count
159
+ # null (not 0.0) on an empty denominator, matching the nil-vs-0.0
160
+ # discipline in AggregateResult; and the SAME rounding as the human report
161
+ # (one run must not yield two scores by --format).
162
+ score = @agg.mutation_score
163
+
164
+ doc = {
165
+ schema_version: SCHEMA_VERSION,
166
+ summary: {
167
+ total: @agg.total, killed: killed, survived: survived,
168
+ no_coverage: @agg.no_coverage_count,
169
+ uncapturable: @agg.uncapturable_count,
170
+ unplaceable: @agg.unplaceable_count,
171
+ ran_at_load: @agg.ran_at_load_count,
172
+ skipped_invalid: @agg.skipped_invalid_count,
173
+ errored: @agg.errored_count, timeout: @agg.timeout_count,
174
+ ignored: @agg.ignored_count,
175
+ # The gate is computed from these two, so a consumer never re-derives them.
176
+ attempted: attempted_count, no_verdict: no_verdict_count,
177
+ score: score,
178
+ # Additive: true when the run was diff-scoped (--since). The score then
179
+ # covers only the changed-line mutants, so it is not comparable to a
180
+ # full-run score; Baseline#diff reads this to skip the score-drop gate.
181
+ scoped: scoped,
182
+ # Additive (1.4, #126): ids hash the project-relative file path. A
183
+ # baseline without this key stores old-format ids; Baseline#diff then
184
+ # also matches on old ids.
185
+ id_format: 2,
186
+ # Additive (1.4, #126): stored ids still in the old format. `ignore` is
187
+ # the number of old-format ignore entries that matched; `baseline` the
188
+ # survivors matched in the baseline only through their old id.
189
+ legacy_id_matches: legacy_id_matches
190
+ },
191
+ survivors: @agg.surviving_mutants.map { |r| survivor_json(r) }
192
+ .sort_by { |h| [h[:file], h[:line], h[:operator]] },
193
+ # id extends the key to a total order: several mutants share a line.
194
+ no_coverage: @agg.results.select(&:no_coverage?).map { |r| mutant_json(r) }
195
+ .sort_by { |h| [h[:file], h[:line], h[:operator], h[:id].to_s] },
196
+ # Same shape as no_coverage; additive key.
197
+ uncapturable: @agg.results.select(&:uncapturable?).map { |r| mutant_json(r) }
198
+ .sort_by { |h| [h[:file], h[:line], h[:operator], h[:id].to_s] },
199
+ # Additive (1.7): owner-unknown mutants redefine did not run. Not in no_verdict.
200
+ unplaceable: @agg.results.select(&:unplaceable?).map { |r| mutant_json(r) }
201
+ .sort_by { |h| [h[:file], h[:line], h[:operator], h[:id].to_s] },
202
+ # Additive (1.7, #187): mutants on a line that ran at load. Not in no_verdict.
203
+ ran_at_load: @agg.results.select(&:ran_at_load?).map { |r| mutant_json(r) }
204
+ .sort_by { |h| [h[:file], h[:line], h[:operator], h[:id].to_s] },
205
+ # Every mutant that was attempted and produced no verdict, whatever the
206
+ # reason — the set the --threshold completeness gate counts. Named for the
207
+ # condition rather than one status, because summary.errored means :error
208
+ # alone and a key that reconciled with neither would be worse. `details`
209
+ # carries the cause where there is one. Uncapturable mutants also appear in
210
+ # uncapturable[], which stays for consumers that already read it.
211
+ # to_s/to_i because a pre-fork failure has no subject, so its file and line
212
+ # are null and would not compare against a real entry. id and status extend
213
+ # the key to a total order: these entries collide on (file, line) far more
214
+ # than survivors do — several mutants on one crashy line, every pre-fork
215
+ # entry on ("", 0) — and sort_by is not stable, so equal keys would leave
216
+ # worker finish order in the output and break the byte-stability promise.
217
+ no_verdict: @agg.results.select { |r| r.error? || r.timeout? || r.uncapturable? }
218
+ .map { |r| no_verdict_json(r) }
219
+ .sort_by { |h| [h[:file].to_s, h[:line].to_i, h[:id].to_s, h[:status].to_s, h[:details].to_s] },
220
+ # Equivalent mutants the user suppressed: emitted with their stable id so
221
+ # the user can audit what is silenced (and copy ids for survivors they
222
+ # want to add). Excluded from the score; never in `survivors`.
223
+ ignored: @agg.results.select(&:ignored?).map { |r| mutant_json(r) }
224
+ .sort_by { |h| [h[:file], h[:line], h[:operator], h[:id].to_s] },
225
+ # Per-source breakdown (additive; baseline consumes it). Sorted by file so
226
+ # output is byte-stable. Reuses AggregateResult via by_source.
227
+ per_source: @agg.by_source.map { |file, agg| per_source_json(file, agg) }
228
+ .sort_by { |h| h[:file] }
229
+ }
230
+ # Additive (1.5): the kill matrix, present only with --matrix.
231
+ doc[:matrix] = matrix_json if @matrix
232
+ # Additive baseline-delta block, present only with --baseline. Existing
233
+ # consumers ignore the extra key; it does not move schema_version on its own.
234
+ doc[:baseline] = baseline_json(baseline) if baseline
235
+ "#{JSON.generate(doc)}\n"
236
+ end
237
+
238
+ # One self-contained HTML file (inline CSS, no external assets): the overall
239
+ # score + summary counts, a per-source table, and every surviving mutant with
240
+ # its stable id and diff. All source/diff/identifier text is HTML-escaped
241
+ # (CGI.escapeHTML) so a `<`/`>` in source can never break the markup. Reuses
242
+ # survivor_json/per_source_json so one run yields one set of facts regardless
243
+ # of --format.
244
+ def html_report
245
+ score = @agg.mutation_score
246
+ survivors = @agg.surviving_mutants.map { |r| survivor_json(r) }
247
+ .sort_by { |h| [h[:file], h[:line], h[:operator]] }
248
+ per_source = @agg.by_source.map { |file, agg| per_source_json(file, agg) }
249
+ .sort_by { |h| h[:file] }
250
+
251
+ <<~HTML
252
+ <!DOCTYPE html>
253
+ <html lang="en">
254
+ <head>
255
+ <meta charset="utf-8">
256
+ <meta name="viewport" content="width=device-width, initial-scale=1">
257
+ <title>Mutineer Mutation Report</title>
258
+ <style>
259
+ body { font-family: -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif;
260
+ margin: 2rem; color: #1b1b1b; background: #fafafa; }
261
+ h1 { margin: 0 0 .25rem; }
262
+ .score { font-size: 2.5rem; font-weight: 700; }
263
+ .counts { color: #444; margin: .5rem 0 1.5rem; }
264
+ .counts span { display: inline-block; margin-right: 1rem; white-space: nowrap; }
265
+ table { border-collapse: collapse; width: 100%; margin-bottom: 2rem; background: #fff; }
266
+ th, td { border: 1px solid #ddd; padding: .4rem .6rem; text-align: left; }
267
+ th { background: #f0f0f0; }
268
+ td.num { text-align: right; font-variant-numeric: tabular-nums; }
269
+ .survivor { background: #fff; border: 1px solid #ddd; border-radius: 4px;
270
+ padding: .75rem 1rem; margin-bottom: 1rem; }
271
+ .survivor h3 { margin: 0 0 .25rem; font-size: 1rem; }
272
+ .meta { color: #555; font-size: .85rem; margin-bottom: .5rem; }
273
+ .id { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
274
+ pre.diff { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
275
+ background: #f6f8fa; padding: .5rem .75rem; overflow-x: auto;
276
+ margin: 0; border-radius: 4px; }
277
+ .diff .add { color: #116329; }
278
+ .diff .del { color: #82071e; }
279
+ </style>
280
+ </head>
281
+ <body>
282
+ <h1>Mutineer — Mutation Report</h1>
283
+ <div class="score">Score: #{score.nil? ? 'N/A' : "#{score}%"}</div>
284
+ #{summary_html}
285
+ #{per_source_html(per_source)}
286
+ #{survivors_html(survivors)}
287
+ #{matrix_html}
288
+ </body>
289
+ </html>
290
+ HTML
291
+ end
292
+
293
+ # The summary counts block for the HTML header.
294
+ def summary_html
295
+ counts = {
296
+ "total" => @agg.total, "killed" => @agg.killed_count,
297
+ "survived" => @agg.survived_count, "no_coverage" => @agg.no_coverage_count,
298
+ "uncapturable" => @agg.uncapturable_count, "unplaceable" => @agg.unplaceable_count,
299
+ "ran_at_load" => @agg.ran_at_load_count, "ignored" => @agg.ignored_count,
300
+ "skipped" => @agg.skipped_invalid_count,
301
+ "errored" => @agg.errored_count, "timeout" => @agg.timeout_count
302
+ }
303
+ spans = counts.map { |k, v| "<span><strong>#{v}</strong> #{esc(k)}</span>" }.join("\n ")
304
+ "<div class=\"counts\">\n #{spans}\n</div>"
305
+ end
306
+
307
+ # The per-source breakdown table.
308
+ def per_source_html(per_source)
309
+ return "" if per_source.empty?
310
+
311
+ rows = per_source.map do |h|
312
+ score = h[:score].nil? ? "N/A" : "#{h[:score]}%"
313
+ "<tr><td>#{esc(h[:file])}</td><td class=\"num\">#{score}</td>" \
314
+ "<td class=\"num\">#{h[:killed]}</td><td class=\"num\">#{h[:survived]}</td>" \
315
+ "<td class=\"num\">#{h[:no_coverage]}</td></tr>"
316
+ end.join("\n ")
317
+ <<~HTML.chomp
318
+ <h2>Per-source</h2>
319
+ <table>
320
+ <tr><th>File</th><th>Score</th><th>Killed</th><th>Survived</th><th>No coverage</th></tr>
321
+ #{rows}
322
+ </table>
323
+ HTML
324
+ end
325
+
326
+ # The surviving-mutants list, each with subject, location, operator, stable
327
+ # id, and a colorized diff. All text is HTML-escaped.
328
+ def survivors_html(survivors)
329
+ return "<h2>Surviving Mutants</h2>\n<p>None — every covered mutant was killed.</p>" if survivors.empty?
330
+
331
+ cards = survivors.map do |s|
332
+ diff_lines = s[:diff].each_line.map do |line|
333
+ cls = line.start_with?("+") ? "add" : (line.start_with?("-") ? "del" : nil)
334
+ text = esc(line.chomp)
335
+ cls ? "<span class=\"#{cls}\">#{text}</span>" : text
336
+ end.join("\n")
337
+ <<~CARD.chomp
338
+ <div class="survivor">
339
+ <h3>#{esc(s[:subject])}</h3>
340
+ <div class="meta">#{esc(s[:file])}:#{s[:line]} &middot; #{esc(s[:operator])} &middot; <span class="id">#{esc(s[:id])}</span></div>
341
+ <pre class="diff">#{diff_lines}</pre>
342
+ </div>
343
+ CARD
344
+ end.join("\n")
345
+ "<h2>Surviving Mutants</h2>\n#{cards}"
346
+ end
347
+
348
+ # The kill-matrix section of the HTML report: the counts, then the blind and
349
+ # redundant tests. Empty without --matrix.
350
+ #
351
+ # @api private
352
+ # @return [String] HTML.
353
+ def matrix_html
354
+ return "" unless @matrix
355
+
356
+ lists = { "Blind tests" => @matrix.blind, "Redundant tests" => @matrix.redundant }.map do |title, tests|
357
+ items = tests.map { |test| "<li><span class=\"id\">#{esc(test[0])}</span> #{esc(test_label(test))}</li>" }
358
+ body = items.empty? ? "<p>None.</p>" : "<ul>\n#{items.join("\n")}\n</ul>"
359
+ "<h3>#{esc(title)} (#{tests.size})</h3>\n#{body}"
360
+ end
361
+ note = @matrix.complete? ? "" : "\n<p>#{esc(matrix_incomplete_note)}</p>\n#{matrix_incomplete_html}"
362
+ delete = @matrix.redundant.empty? ? "" : "\n<p>#{esc(MATRIX_REDUNDANT_NOTE)}</p>"
363
+ "<h2>Kill matrix</h2>\n<p>#{esc(matrix_counts_line)}</p>#{note}\n#{lists.join("\n")}#{delete}"
364
+ end
365
+
366
+ # Every incomplete row of the HTML kill-matrix section.
367
+ #
368
+ # @api private
369
+ # @return [String] HTML.
370
+ def matrix_incomplete_html
371
+ items = @matrix.incomplete_rows.map { |r| "<li>#{esc(matrix_row_label(r))}</li>" }
372
+ "<h3>Incomplete rows (#{items.size})</h3>\n<ul>\n#{items.join("\n")}\n</ul>"
373
+ end
374
+
375
+ # The JSON `matrix` block: every test with its kill count, one row per
376
+ # mutant that ran (its killers as indexes into `tests`), and the blind and
377
+ # redundant tests. Rows sort like survivors, then by id, so output is
378
+ # byte-stable regardless of --jobs.
379
+ #
380
+ # @api private
381
+ # @return [Hash] the matrix JSON object.
382
+ def matrix_json
383
+ tests = @matrix.tests
384
+ index = tests.each_with_index.to_h
385
+ {
386
+ complete: @matrix.complete?,
387
+ tests: tests.map { |test| test_json(test).merge(kills: @matrix.kill_count(test)) },
388
+ mutants: @matrix.rows.map { |r| matrix_row_json(r, index) }
389
+ .sort_by { |h| [h[:file].to_s, h[:line].to_i, h[:operator].to_s, h[:id].to_s] },
390
+ blind: @matrix.blind.map { |test| test_json(test) },
391
+ redundant: @matrix.redundant.map { |test| test_json(test) }
392
+ }
393
+ end
394
+
395
+ # One test as JSON: its file, display name and id.
396
+ #
397
+ # @api private
398
+ # @param test [Array(String, String, String)] a `[file, name, id]` test.
399
+ # @return [Hash]
400
+ def test_json(test)
401
+ file, name, id = test
402
+ { file: file, name: name, id: id }
403
+ end
404
+
405
+ # A test's name for people: the display name, plus the id when it differs
406
+ # (an RSpec example id, which tells apart examples that share a name and
407
+ # runs exactly that example with `rspec`).
408
+ #
409
+ # @api private
410
+ # @param test [Array(String, String, String)] a `[file, name, id]` test.
411
+ # @return [String]
412
+ def test_label(test)
413
+ _file, name, id = test
414
+ id == name ? name : "#{name} (#{id})"
415
+ end
416
+
417
+ # One mutant's row under `matrix.mutants`.
418
+ #
419
+ # @api private
420
+ # @param result [Mutineer::Result] a result with a {Kills} row.
421
+ # @param index [Hash{Array => Integer}] test => its index in `matrix.tests`.
422
+ # @return [Hash] the row JSON object.
423
+ def matrix_row_json(result, index)
424
+ file = result.subject&.file
425
+ kills = result.kills
426
+ {
427
+ subject: result.subject&.qualified_name, file: file, line: result_line(result),
428
+ operator: result.mutation&.operator&.to_s, id: result.id, status: result.status.to_s,
429
+ killed_by: kills.killed_by.map { |test| index.fetch(test) }.sort,
430
+ ran: kills.ran.size, complete: kills.complete
431
+ }
432
+ end
433
+
434
+ # The kill-matrix section of the human report.
435
+ #
436
+ # @api private
437
+ # @param out [IO] output stream.
438
+ # @return [void]
439
+ def matrix_section(out)
440
+ out.puts
441
+ out.puts "Kill matrix"
442
+ out.puts "-----------"
443
+ out.puts matrix_counts_line
444
+ matrix_incomplete_section(out) unless @matrix.complete?
445
+ matrix_list(out, "Blind tests (ran, killed no mutant)", @matrix.blind)
446
+ matrix_list(out, "Redundant tests (each mutant they kill has another killer)", @matrix.redundant)
447
+ return if @matrix.redundant.empty?
448
+
449
+ out.puts MATRIX_REDUNDANT_NOTE
450
+ end
451
+
452
+ # The human report's warning about incomplete rows, with the first
453
+ # {MATRIX_LIST_LIMIT} of them named.
454
+ #
455
+ # @api private
456
+ # @param out [IO] output stream.
457
+ # @return [void]
458
+ def matrix_incomplete_section(out)
459
+ rows = @matrix.incomplete_rows
460
+ out.puts matrix_incomplete_note
461
+ rows.first(MATRIX_LIST_LIMIT).each { |r| out.puts " #{printable(matrix_row_label(r))}" }
462
+ rest = rows.size - MATRIX_LIST_LIMIT
463
+ out.puts " and #{rest} more" if rest.positive?
464
+ out.puts "The HTML and JSON reports list every incomplete row (JSON: matrix.mutants with complete: false)."
465
+ end
466
+
467
+ # A row's label: subject, place, operator, status and id.
468
+ #
469
+ # @api private
470
+ # @param result [Mutineer::Result] a result with a {Kills} row.
471
+ # @return [String]
472
+ def matrix_row_label(result)
473
+ place = [result.subject&.file, result_line(result)].compact.join(":")
474
+ [result.subject&.qualified_name, ("(#{place})" unless place.empty?), result.mutation&.operator,
475
+ result.status, result.id].compact.join(" ")
476
+ end
477
+
478
+ # The 1-based line of a result's mutation, or nil without one (a pre-fork
479
+ # failure has no subject or mutation).
480
+ #
481
+ # @api private
482
+ # @param result [Mutineer::Result] any result.
483
+ # @return [Integer, nil]
484
+ def result_line(result)
485
+ file = result.subject&.file
486
+ return unless result.mutation && file
487
+
488
+ source = @source_map[file] || File.read(file)
489
+ source.byteslice(0, result.mutation.start_offset).count("\n") + 1
490
+ end
491
+
492
+ # One titled list of tests in the human kill-matrix section, cut at
493
+ # {MATRIX_LIST_LIMIT}; the JSON report has the whole list.
494
+ #
495
+ # @api private
496
+ # @param out [IO] output stream.
497
+ # @param title [String] the list's title.
498
+ # @param tests [Array<Array(String, String, String)>] `[file, name, id]` tests.
499
+ # @return [void]
500
+ def matrix_list(out, title, tests)
501
+ out.puts
502
+ out.puts "#{title}: #{tests.size}"
503
+ tests.first(MATRIX_LIST_LIMIT).each { |test| out.puts " #{printable(test[0])} #{printable(test_label(test))}" }
504
+ rest = tests.size - MATRIX_LIST_LIMIT
505
+ out.puts " and #{rest} more; see --format json" if rest.positive?
506
+ end
507
+
508
+ # The counts sentence both matrix renderers open with.
509
+ #
510
+ # @api private
511
+ # @return [String]
512
+ def matrix_counts_line
513
+ "#{@matrix.tests.size} tests ran against #{@matrix.rows.size} mutants " \
514
+ "(every test in each mutant's covering files): " \
515
+ "#{@matrix.blind.size} blind, #{@matrix.redundant.size} redundant."
516
+ end
517
+
518
+ # The warning both matrix renderers give when a row is incomplete.
519
+ #
520
+ # @api private
521
+ # @return [String]
522
+ def matrix_incomplete_note
523
+ "#{@matrix.incomplete_rows.size} mutants could not be verified as complete (a timeout, an error, " \
524
+ "an exit, an interrupt or a broken stream), so a blind test may have killed one of them. " \
525
+ "Do not delete a blind test until these rows are complete:"
526
+ end
527
+
528
+ # HTML-escapes any text destined for the document (stdlib CGI).
529
+ def esc(text) = CGI.escapeHTML(text.to_s)
530
+
531
+ # The same delta facts the human report prints, for dashboards. new_survivors
532
+ # reuse the mutant_json shape (subject/file/line/operator/token/id) and sort
533
+ # byte-stably so output does not depend on --jobs finish order.
534
+ def baseline_json(delta)
535
+ {
536
+ regressed: delta.regressed,
537
+ score_before: delta.score_before,
538
+ score_after: delta.score_after,
539
+ score_dropped: delta.score_drop,
540
+ # Additive: false when the score-drop check was skipped (a nil score or
541
+ # a diff-scoped side), so a consumer knows not to render the two scores
542
+ # as a comparison.
543
+ score_comparable: delta.score_comparable,
544
+ new_survivors: delta.new_survivors.map { |r| mutant_json(r) }
545
+ .sort_by { |h| [h[:file], h[:line], h[:operator], h[:id].to_s] },
546
+ fixed_survivors: delta.fixed_survivors.map do |h|
547
+ { subject: h["subject"], file: h["file"], line: h["line"],
548
+ operator: h["operator"], id: h["id"] }
549
+ end.sort_by { |h| [h[:file].to_s, h[:line].to_i, h[:operator].to_s] }
550
+ }
551
+ end
552
+
553
+ # Builds per-source JSON.
554
+ #
555
+ # @api private
556
+ # @param file [String] source file path.
557
+ # @param agg [Mutineer::AggregateResult] source aggregate.
558
+ # @return [Hash] per-source JSON object.
559
+ def per_source_json(file, agg)
560
+ {
561
+ file: file, total: agg.total,
562
+ killed: agg.killed_count, survived: agg.survived_count,
563
+ no_coverage: agg.no_coverage_count, score: agg.mutation_score
564
+ }
565
+ end
566
+
567
+ # Builds survivor JSON.
568
+ #
569
+ # @api private
570
+ # @param result [Mutineer::Result] survivor result.
571
+ # @return [Hash] survivor JSON object.
572
+ def survivor_json(result)
573
+ m = result.mutation
574
+ file = result.subject.file
575
+ source = @source_map[file] || File.read(file)
576
+ start_line, original_block, mutated_block, token = diff_for(m, source)
577
+ # A block at the end of the file has no newline after it; elsewhere the
578
+ # file's next byte is one. Each side's own text decides its marker.
579
+ at_eof = source.byteindex("\n", m.end_offset).nil?
580
+ old_lines = side_lines(original_block, at_eof)
581
+ new_lines = side_lines(mutated_block, at_eof)
582
+ {
583
+ subject: result.subject.qualified_name,
584
+ file: file,
585
+ line: start_line,
586
+ operator: m.operator.to_s,
587
+ # The stable, copy-pasteable id (next to the human-readable token) so a
588
+ # user can paste it straight into .mutineer.yml `ignore:`.
589
+ id: result.id,
590
+ token: token,
591
+ diff: "--- a/#{file}\n+++ b/#{file}\n" \
592
+ "@@ -#{hunk_range(start_line, old_lines)} +#{hunk_range(start_line, new_lines)} @@\n" \
593
+ "#{diff_body("-", old_lines)}#{diff_body("+", new_lines)}"
594
+ }
595
+ end
596
+
597
+ # One side of the diff as the file holds it: the {#diff_for} block plus the
598
+ # newline that follows it, unless the block ends the file. Each line keeps
599
+ # its own ending, so a CRLF line keeps its "\r" and a last line with no
600
+ # newline is told apart from one with a newline.
601
+ #
602
+ # @api private
603
+ # @param block [String] the block's text.
604
+ # @param at_eof [Boolean] no newline follows the block in the file.
605
+ # @return [Array<String>] its lines, with their endings.
606
+ def side_lines(block, at_eof)
607
+ (at_eof ? block : "#{block}\n").lines
608
+ end
609
+
610
+ # The `-` or `+` lines of one side. A line with no newline is the file's
611
+ # last, so it gets the unified diff's no-newline marker (#106).
612
+ #
613
+ # @api private
614
+ # @param sign [String] "-" or "+".
615
+ # @param lines [Array<String>] the side's lines, with their endings.
616
+ # @return [String]
617
+ def diff_body(sign, lines)
618
+ lines.map { |l| l.end_with?("\n") ? "#{sign}#{l}" : "#{sign}#{l}\n\\n" }.join
619
+ end
620
+
621
+ # One side of a unified diff hunk header: `start` for one line, else
622
+ # `start,count`; an empty side is `start-1,0` (#106).
623
+ #
624
+ # @api private
625
+ # @param start_line [Integer] 1-based first line of the block.
626
+ # @param lines [Array<String>] the block's lines.
627
+ # @return [String] the range, e.g. "3", "3,2" or "2,0".
628
+ def hunk_range(start_line, lines)
629
+ return "#{start_line - 1},0" if lines.empty? # an empty side names the line before it
630
+
631
+ lines.size == 1 ? start_line.to_s : "#{start_line},#{lines.size}"
632
+ end
633
+
634
+ # The fields that name one mutant, for the lists that point at mutants:
635
+ # `no_coverage`, `uncapturable`, `unplaceable`, `ran_at_load`, `ignored` and
636
+ # `baseline.new_survivors`.
637
+ def mutant_json(result)
638
+ m = result.mutation
639
+ file = result.subject.file
640
+ source = @source_map[file] || File.read(file)
641
+ start_line, _orig, _mut, token = diff_for(m, source)
642
+ {
643
+ subject: result.subject.qualified_name,
644
+ file: file,
645
+ line: start_line,
646
+ operator: m.operator.to_s,
647
+ token: token,
648
+ id: result.id
649
+ }
650
+ end
651
+
652
+ # Builds a line-aligned diff for a mutation whose byte range may span several
653
+ # lines (e.g. statement-removal of a multi-line statement). Returns the
654
+ # mutation's 1-based start line, the full original line-block it touches, the
655
+ # spliced mutated block, and a single-line token label for the header.
656
+ def diff_for(m, source)
657
+ # Byte math: Prism offsets are byte offsets; byteindex/byterindex/
658
+ # byteslice keep line splicing correct for multibyte sources.
659
+ line_begin = m.start_offset.zero? ? 0 : (source.byterindex("\n", m.start_offset - 1) || -1) + 1
660
+ line_end = source.byteindex("\n", m.end_offset) || source.bytesize
661
+ before = source.byteslice(line_begin...m.start_offset)
662
+ after = source.byteslice(m.end_offset...line_end)
663
+ original_block = source.byteslice(line_begin...line_end)
664
+ mutated_block = "#{before}#{m.replacement}#{after}"
665
+ start_line = source.byteslice(0, m.start_offset).count("\n") + 1
666
+ token = source.byteslice(m.start_offset...m.end_offset).gsub(/\s+/, " ").strip
667
+ token = "#{token[0, 47]}..." if token.length > 50
668
+ [start_line, original_block, mutated_block, token]
669
+ end
670
+
671
+ # An entry under the JSON `no_verdict:` key: an attempted mutant with no verdict.
672
+ # A pre-fork failure has no subject or mutation attached, so those degrade to
673
+ # nulls rather than dropping the entry — the count must still reconcile with
674
+ # `summary.no_verdict`.
675
+ #
676
+ # @api private
677
+ # @param result [Mutineer::Result] an errored or timed-out result.
678
+ # @return [Hash] no-verdict JSON object.
679
+ def no_verdict_json(result)
680
+ {
681
+ subject: result.subject&.qualified_name,
682
+ file: result.subject&.file,
683
+ line: result_line(result),
684
+ id: result.id,
685
+ status: result.status.to_s,
686
+ details: result.details
687
+ }
688
+ end
689
+
690
+ # Writes the summary block.
691
+ #
692
+ # @param out [IO] output stream.
693
+ # @return [void]
694
+ def summary(out)
695
+ out.puts "Summary"
696
+ out.puts "-------"
697
+ out.puts format("Total: %-6d Killed: %d", @agg.total, @agg.killed_count)
698
+ out.puts format("Survived: %-6d No coverage: %d", @agg.survived_count, @agg.no_coverage_count)
699
+ out.puts format("Skipped: %-6d Errored: %d", @agg.skipped_invalid_count, @agg.errored_count)
700
+ out.puts format("Timeout: %-6d (over the per-mutant time limit)", @agg.timeout_count)
701
+ # A broken harness, not a coverage gap: report it distinctly from No coverage.
702
+ out.puts format("Uncapturable: %-6d (tests failed to run)", @agg.uncapturable_count)
703
+ # Not broken: redefine has no named class to load these onto; reload runs them.
704
+ out.puts format("Unplaceable: %-6d (class cannot be named statically; --strategy reload runs these)",
705
+ @agg.unplaceable_count)
706
+ # Not a verdict: the line ran before the mutant was applied (#187).
707
+ out.puts format("Ran at load: %-6d (ran while the app or class loaded; mutineer cannot re-run that; " \
708
+ "verify with --test-command)", @agg.ran_at_load_count)
709
+ # Equivalent mutants the user suppressed; excluded from the denominator.
710
+ out.puts format("Ignored: %-6d (equivalent, suppressed)", @agg.ignored_count)
711
+ end
712
+
713
+ # Writes the score line.
714
+ #
715
+ # @param out [IO] output stream.
716
+ # @param err [IO] error stream.
717
+ # @return [void]
718
+ def score_line(out, err)
719
+ score = @agg.mutation_score
720
+ excluded = "#{@agg.no_coverage_count} no-coverage, #{@agg.uncapturable_count} uncapturable, " \
721
+ "#{@agg.unplaceable_count} unplaceable, #{@agg.ran_at_load_count} ran at load, " \
722
+ "#{@agg.skipped_invalid_count} skipped, " \
723
+ "#{@agg.errored_count} errored, #{@agg.timeout_count} timeout, " \
724
+ "#{@agg.ignored_count} ignored excluded"
725
+ if score.nil?
726
+ out.puts "Mutation score: N/A (no covered mutants)"
727
+ # Only the benign case here: the gate-failure explanation is emitted once
728
+ # from {report}, for every format, so it cannot be said twice or only to
729
+ # the reader of the human report.
730
+ unless broken_nil_score?
731
+ err.puts "[mutineer] no covered mutations; mutation score is N/A and the threshold check is skipped."
732
+ end
733
+ else
734
+ out.puts "Mutation score: #{score}% (killed / (killed + survived); #{excluded})"
735
+ end
736
+ end
737
+
738
+ # True when nil score is due to errors/timeouts/uncapturable (gate must fail).
739
+ #
740
+ # @api private
741
+ # @return [Boolean]
742
+ def broken_nil_score?
743
+ @agg.total.positive? &&
744
+ (@agg.errored_count + @agg.timeout_count + @agg.uncapturable_count).positive?
745
+ end
746
+
747
+ # Mutants that were attempted but produced no verdict, over everything
748
+ # attempted. Above the limit the score describes too small a slice of the run
749
+ # to gate on. A few flaky mutants in a large run stay under it.
750
+ #
751
+ # @api private
752
+ # @return [Boolean] true when too much of the run failed to produce a verdict.
753
+ def broken_share_exceeded?
754
+ attempted = attempted_count
755
+ attempted.positive? && no_verdict_count > BROKEN_FLOOR &&
756
+ no_verdict_count > attempted * BROKEN_SHARE_LIMIT
757
+ end
758
+
759
+ # Mutants that were attempted and produced no verdict, whatever the reason.
760
+ #
761
+ # @api private
762
+ # @return [Integer] errored + timed out + uncapturable.
763
+ def no_verdict_count
764
+ @agg.errored_count + @agg.timeout_count + @agg.uncapturable_count
765
+ end
766
+
767
+ # Mutants that were actually run. Deliberately not `total`: no_coverage,
768
+ # skipped-invalid and ignored mutants were never attempted, so counting them
769
+ # would dilute the share and let a broken run slip under the limit.
770
+ #
771
+ # skipped-invalid is excluded from both sides: it means a mutant did not
772
+ # re-parse and was correctly never run, which is a validity outcome rather
773
+ # than a broken harness. Cost: an overwhelmingly-skipped run still scores on
774
+ # what little ran; that is our operator misbehaving and wants its own signal.
775
+ #
776
+ # @api private
777
+ # @return [Integer] killed + survived + no-verdict.
778
+ def attempted_count
779
+ @agg.killed_count + @agg.survived_count + no_verdict_count
780
+ end
781
+
782
+ # The sentence both the verdict line and the stderr note are built from, so a
783
+ # user cannot read one number in the report and a different one beside it.
784
+ #
785
+ # @api private
786
+ # @return [String] e.g. "90 of 100 attempted mutants produced no verdict (90.0%, limit 10%)".
787
+ def no_verdict_ratio
788
+ pct = (no_verdict_count * 100.0 / attempted_count).round(1)
789
+ "#{no_verdict_count} of #{attempted_count} attempted mutants produced no verdict " \
790
+ "(#{pct}%, limit #{(BROKEN_SHARE_LIMIT * 100).round}%)"
791
+ end
792
+
793
+ # Human-readable counts of the states that produced no verdict. Used by both
794
+ # the nil-score message and the completeness gate, so they agree.
795
+ #
796
+ # @api private
797
+ # @return [String]
798
+ def broken_counts_detail
799
+ parts = []
800
+ parts << "#{@agg.errored_count} errored" if @agg.errored_count.positive?
801
+ parts << "#{@agg.timeout_count} timeout" if @agg.timeout_count.positive?
802
+ parts << "#{@agg.uncapturable_count} uncapturable" if @agg.uncapturable_count.positive?
803
+ parts.join(", ")
804
+ end
805
+
806
+ # One line per source after the global summary, so a multi-source run shows
807
+ # which file is weak. Omitted for a single-source run: the global summary
808
+ # already says everything (no redundant one-line block).
809
+ #
810
+ # @param out [IO] output stream.
811
+ # @return [void]
812
+ def per_source(out)
813
+ sources = @agg.by_source
814
+ return if sources.size <= 1
815
+
816
+ out.puts
817
+ out.puts "Per-source"
818
+ out.puts "----------"
819
+ sources.sort.each do |file, agg|
820
+ score = agg.mutation_score
821
+ out.puts format("%s %s (%d killed / %d survived / %d no-cov)",
822
+ printable(file), score.nil? ? "N/A" : "#{score}%",
823
+ agg.killed_count, agg.survived_count, agg.no_coverage_count)
824
+ end
825
+ end
826
+
827
+ # The --baseline delta, appended after the normal report. Names every NEW
828
+ # survivor (subject (file:line) operator) and the score delta when it dropped,
829
+ # then a one-line REGRESSION/OK verdict so CI logs show which gate fired.
830
+ def baseline_section(out, delta)
831
+ out.puts
832
+ out.puts "Baseline comparison"
833
+ out.puts "-------------------"
834
+ out.puts "killed #{@agg.killed_count}, #{delta.new_survivors.size} new survivors vs baseline"
835
+ delta.new_survivors
836
+ .sort_by { |r| [r.subject.file, r.mutation.start_offset] }
837
+ .each do |r|
838
+ file = r.subject.file
839
+ source = @source_map[file] || File.read(file)
840
+ line, = diff_for(r.mutation, source)
841
+ out.puts " + #{printable(r.subject.qualified_name)} (#{printable(file)}:#{line}) #{r.mutation.operator}"
842
+ end
843
+ out.puts "score dropped #{delta.score_before}% -> #{delta.score_after}%" if delta.score_drop
844
+ # An OK verdict must not imply a check that never ran: say when the score
845
+ # comparison was skipped (a diff-scoped side or a nil score).
846
+ out.puts "score-drop check skipped (scores not comparable)" unless delta.score_comparable
847
+ out.puts(delta.regressed ? "REGRESSION vs baseline" : "OK: no regression vs baseline")
848
+ end
849
+
850
+ # Writes the survivors block.
851
+ #
852
+ # @param out [IO] output stream.
853
+ # @return [void]
854
+ def survivors(out)
855
+ mutants = @agg.surviving_mutants
856
+ return if mutants.empty?
857
+
858
+ out.puts
859
+ out.puts "Surviving Mutants"
860
+ out.puts "-----------------"
861
+ mutants.group_by { |r| r.subject.file }.sort.each do |file, group|
862
+ out.puts
863
+ out.puts printable(file)
864
+ group.sort_by { |r| r.mutation.start_offset }.each { |r| survivor(out, file, r) }
865
+ end
866
+ end
867
+
868
+ # Writes one survivor entry.
869
+ #
870
+ # @param out [IO] output stream.
871
+ # @param file [String] source file path.
872
+ # @param result [Mutineer::Result] survivor result.
873
+ # @return [void]
874
+ def survivor(out, file, result)
875
+ m = result.mutation
876
+ source = @source_map[file] || File.read(file)
877
+ start_line, original_block, mutated_block, token = diff_for(m, source)
878
+
879
+ out.puts " #{printable(result.subject.qualified_name)} (#{printable(File.basename(file))}:#{start_line})"
880
+ out.puts " Operator: #{m.operator} (#{printable(token)} -> #{printable(m.replacement)})"
881
+ original_block.each_line { |l| out.puts " - #{printable(l.chomp)}" }
882
+ mutated_block.each_line { |l| out.puts " + #{printable(l.chomp)}" }
883
+ end
884
+
885
+ # Source text, a method name or a file path made safe for a terminal (#163):
886
+ # each control character except tab becomes its Ruby escape (ESC prints as
887
+ # `\e`). The text is read as UTF-8 whatever the locale, and each byte that is
888
+ # not valid UTF-8 (a Latin-1 source, say) prints as `\xNN`, so nothing is
889
+ # lost and the regex never raises. JSON and HTML escape on their own.
890
+ #
891
+ # @api private
892
+ # @param text [String] source text.
893
+ # @return [String] the text with control characters and invalid bytes escaped.
894
+ def printable(text)
895
+ text.dup.force_encoding(Encoding::UTF_8)
896
+ .scrub { |bytes| bytes.unpack("C*").map { |b| format("\\x%02X", b) }.join }
897
+ .gsub(/[[:cntrl:]&&[^\t]]/) { |c| c.dump[1..-2] }
898
+ end
899
+
900
+ # Writes the final verdict line.
901
+ #
902
+ # @param out [IO] output stream.
903
+ # @param threshold [Float] score threshold.
904
+ # @return [void]
905
+ def verdict(out, threshold)
906
+ score = @agg.mutation_score
907
+ if score.nil?
908
+ if broken_nil_score?
909
+ out.puts "FAILED: no covered mutants (#{broken_counts_detail}); " \
910
+ "threshold #{threshold}% cannot pass with a broken harness"
911
+ end
912
+ return
913
+ end
914
+
915
+ # Same rule as exit_code, or the report says PASSED on a run that exits 1 —
916
+ # and with --output that wrong verdict is what gets archived.
917
+ if broken_share_exceeded?
918
+ out.puts "FAILED: #{no_verdict_ratio}; #{score}% covers only part of the run"
919
+ elsif score >= threshold
920
+ out.puts "PASSED: #{score}% >= threshold #{threshold}%"
921
+ else
922
+ out.puts "FAILED: #{score}% < threshold #{threshold}%"
923
+ end
924
+ end
925
+ end
926
+ end