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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +1194 -0
- data/LICENSE +21 -0
- data/README.md +659 -0
- data/bin/mutineer +6 -0
- data/lib/mutineer/baseline.rb +185 -0
- data/lib/mutineer/changed_lines.rb +136 -0
- data/lib/mutineer/child_stdout.rb +35 -0
- data/lib/mutineer/cli.rb +681 -0
- data/lib/mutineer/config.rb +372 -0
- data/lib/mutineer/coverage_map.rb +1366 -0
- data/lib/mutineer/daemon_backend.rb +351 -0
- data/lib/mutineer/daemon_client.rb +407 -0
- data/lib/mutineer/daemon_server.rb +351 -0
- data/lib/mutineer/external_backend.rb +288 -0
- data/lib/mutineer/file_swap.rb +194 -0
- data/lib/mutineer/isolation.rb +321 -0
- data/lib/mutineer/job_plan.rb +352 -0
- data/lib/mutineer/kill_channel.rb +284 -0
- data/lib/mutineer/kill_matrix.rb +88 -0
- data/lib/mutineer/minitest_integration/kill_recorder.rb +301 -0
- data/lib/mutineer/minitest_integration/outer_reporter.rb +126 -0
- data/lib/mutineer/minitest_integration/stop_at_first_failure.rb +145 -0
- data/lib/mutineer/minitest_integration.rb +137 -0
- data/lib/mutineer/mongoid_failures.rb +82 -0
- data/lib/mutineer/mongoid_worker_db.rb +97 -0
- data/lib/mutineer/mutant_id.rb +126 -0
- data/lib/mutineer/mutation.rb +27 -0
- data/lib/mutineer/mutator_registry.rb +109 -0
- data/lib/mutineer/mutators/arithmetic.rb +35 -0
- data/lib/mutineer/mutators/array_literal.rb +44 -0
- data/lib/mutineer/mutators/base.rb +105 -0
- data/lib/mutineer/mutators/boolean_connector.rb +52 -0
- data/lib/mutineer/mutators/boolean_literal.rb +56 -0
- data/lib/mutineer/mutators/chain_link.rb +132 -0
- data/lib/mutineer/mutators/collection_method.rb +44 -0
- data/lib/mutineer/mutators/comparison.rb +35 -0
- data/lib/mutineer/mutators/condition_forcing.rb +134 -0
- data/lib/mutineer/mutators/condition_negation.rb +49 -0
- data/lib/mutineer/mutators/literal_mutation.rb +51 -0
- data/lib/mutineer/mutators/negation_removal.rb +44 -0
- data/lib/mutineer/mutators/operand_removal.rb +58 -0
- data/lib/mutineer/mutators/operator_assignment.rb +113 -0
- data/lib/mutineer/mutators/range_literal.rb +54 -0
- data/lib/mutineer/mutators/regex_literal.rb +72 -0
- data/lib/mutineer/mutators/return_nil.rb +72 -0
- data/lib/mutineer/mutators/safe_navigation.rb +76 -0
- data/lib/mutineer/mutators/statement_removal.rb +33 -0
- data/lib/mutineer/mutators/string_literal.rb +34 -0
- data/lib/mutineer/orphan_guard.rb +28 -0
- data/lib/mutineer/pairing.rb +298 -0
- data/lib/mutineer/parser.rb +45 -0
- data/lib/mutineer/progress.rb +46 -0
- data/lib/mutineer/project.rb +506 -0
- data/lib/mutineer/project_path.rb +57 -0
- data/lib/mutineer/rails_worker_db.rb +212 -0
- data/lib/mutineer/reporter.rb +926 -0
- data/lib/mutineer/result.rb +240 -0
- data/lib/mutineer/runner.rb +418 -0
- data/lib/mutineer/statement_lines.rb +183 -0
- data/lib/mutineer/subject.rb +38 -0
- data/lib/mutineer/test_runners/minitest.rb +22 -0
- data/lib/mutineer/test_runners/rspec.rb +330 -0
- data/lib/mutineer/test_runners.rb +30 -0
- data/lib/mutineer/version.rb +6 -0
- data/lib/mutineer/worker_pool.rb +147 -0
- data/lib/mutineer.rb +48 -0
- 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]} · #{esc(s[:operator])} · <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
|