mutineer 0.11.2 → 0.11.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +67 -0
- data/README.md +2 -2
- data/lib/mutineer/baseline.rb +14 -16
- data/lib/mutineer/cli.rb +37 -39
- data/lib/mutineer/config.rb +17 -19
- data/lib/mutineer/coverage_map.rb +54 -52
- data/lib/mutineer/daemon_backend.rb +316 -0
- data/lib/mutineer/daemon_client.rb +60 -36
- data/lib/mutineer/daemon_server.rb +65 -61
- data/lib/mutineer/file_swap.rb +16 -15
- data/lib/mutineer/rails_worker_db.rb +44 -43
- data/lib/mutineer/reporter.rb +162 -41
- data/lib/mutineer/result.rb +25 -26
- data/lib/mutineer/runner.rb +69 -284
- data/lib/mutineer/version.rb +1 -1
- data/lib/mutineer/worker_pool.rb +23 -23
- metadata +2 -1
data/lib/mutineer/reporter.rb
CHANGED
|
@@ -7,19 +7,30 @@ require_relative "mutation"
|
|
|
7
7
|
|
|
8
8
|
module Mutineer
|
|
9
9
|
# Renders an AggregateResult: the summary block, mutation score, and per-file
|
|
10
|
-
# survivor diffs. Stream discipline
|
|
10
|
+
# survivor diffs. Stream discipline: the report goes to `out` (stdout),
|
|
11
11
|
# diagnostics/warnings go to `err` (stderr), so `mutineer ... > report.txt`
|
|
12
12
|
# captures only the report.
|
|
13
13
|
#
|
|
14
14
|
# `source_map` is { file_path => raw source string }, used to extract the
|
|
15
15
|
# containing source line for each survivor diff.
|
|
16
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
|
+
|
|
17
28
|
def initialize(aggregate, source_map)
|
|
18
29
|
@agg = aggregate
|
|
19
30
|
@source_map = source_map
|
|
20
31
|
end
|
|
21
32
|
|
|
22
|
-
# Single entry point
|
|
33
|
+
# Single entry point. Branches on `format` ("human" | "json" | "html") and
|
|
23
34
|
# routes the rendered report to `output` (a file, with a stderr confirmation)
|
|
24
35
|
# or to `out`. Diagnostics always go to `err`.
|
|
25
36
|
def report(out: $stdout, err: $stderr, threshold: 0.0, format: "human", output: nil, baseline: nil)
|
|
@@ -42,6 +53,20 @@ module Mutineer
|
|
|
42
53
|
else
|
|
43
54
|
out.print rendered
|
|
44
55
|
end
|
|
56
|
+
|
|
57
|
+
# Both ways a run can fail the gate on completeness, said here rather than in
|
|
58
|
+
# the human renderer: --format json is the documented CI path, and a run that
|
|
59
|
+
# exits 1 must say why on every format, not only the one a person reads.
|
|
60
|
+
return unless threshold&.positive?
|
|
61
|
+
|
|
62
|
+
if @agg.mutation_score.nil? && broken_nil_score?
|
|
63
|
+
err.puts "[mutineer] nothing could be scored (#{broken_counts_detail}), so the " \
|
|
64
|
+
"--threshold gate fails. See no_verdict[] in --format json for the cause of each."
|
|
65
|
+
elsif broken_share_exceeded?
|
|
66
|
+
err.puts "[mutineer] #{no_verdict_ratio}: #{broken_counts_detail}. The score covers " \
|
|
67
|
+
"only part of the run, so the --threshold gate fails. See no_verdict[] in " \
|
|
68
|
+
"--format json for the cause of each."
|
|
69
|
+
end
|
|
45
70
|
end
|
|
46
71
|
|
|
47
72
|
# Renders the human report.
|
|
@@ -85,15 +110,19 @@ module Mutineer
|
|
|
85
110
|
return 0 # pure no_coverage / ignored / empty — gate skipped
|
|
86
111
|
end
|
|
87
112
|
|
|
113
|
+
# A score computed over a small slice of what was attempted is not this
|
|
114
|
+
# suite's score. Without this, 90 errored mutants and 10 that ran (9 killed)
|
|
115
|
+
# reports 90% and exits 0, so CI cannot tell a complete run from a broken one.
|
|
116
|
+
return 1 if broken_share_exceeded?
|
|
117
|
+
|
|
88
118
|
score >= threshold ? 0 : 1
|
|
89
119
|
end
|
|
90
120
|
|
|
91
121
|
private
|
|
92
122
|
|
|
93
|
-
# Canonical machine-readable schema
|
|
94
|
-
#
|
|
95
|
-
# worker finish order
|
|
96
|
-
# Renders the JSON report.
|
|
123
|
+
# Canonical machine-readable schema. survivors/no_coverage are sorted by
|
|
124
|
+
# (file, line, operator) so output is byte-stable regardless of --jobs
|
|
125
|
+
# worker finish order.
|
|
97
126
|
#
|
|
98
127
|
# @api private
|
|
99
128
|
# @param baseline [Mutineer::Baseline::Delta, nil] baseline delta.
|
|
@@ -101,13 +130,13 @@ module Mutineer
|
|
|
101
130
|
def json_report(baseline = nil)
|
|
102
131
|
killed = @agg.killed_count
|
|
103
132
|
survived = @agg.survived_count
|
|
104
|
-
#
|
|
133
|
+
# null (not 0.0) on an empty denominator, matching the nil-vs-0.0
|
|
105
134
|
# discipline in AggregateResult; and the SAME rounding as the human report
|
|
106
135
|
# (one run must not yield two scores by --format).
|
|
107
136
|
score = @agg.mutation_score
|
|
108
137
|
|
|
109
138
|
doc = {
|
|
110
|
-
schema_version: "1.
|
|
139
|
+
schema_version: "1.2",
|
|
111
140
|
summary: {
|
|
112
141
|
total: @agg.total, killed: killed, survived: survived,
|
|
113
142
|
no_coverage: @agg.no_coverage_count,
|
|
@@ -115,37 +144,54 @@ module Mutineer
|
|
|
115
144
|
skipped_invalid: @agg.skipped_invalid_count,
|
|
116
145
|
errored: @agg.errored_count, timeout: @agg.timeout_count,
|
|
117
146
|
ignored: @agg.ignored_count,
|
|
147
|
+
# The gate is computed from these two, so a consumer never re-derives them.
|
|
148
|
+
attempted: attempted_count, no_verdict: no_verdict_count,
|
|
118
149
|
score: score
|
|
119
150
|
},
|
|
120
151
|
survivors: @agg.surviving_mutants.map { |r| survivor_json(r) }
|
|
121
152
|
.sort_by { |h| [h[:file], h[:line], h[:operator]] },
|
|
122
153
|
no_coverage: @agg.results.select(&:no_coverage?).map { |r| no_coverage_json(r) }
|
|
123
154
|
.sort_by { |h| [h[:file], h[:line]] },
|
|
124
|
-
#
|
|
155
|
+
# Same shape as no_coverage; additive key.
|
|
125
156
|
uncapturable: @agg.results.select(&:uncapturable?).map { |r| no_coverage_json(r) }
|
|
126
157
|
.sort_by { |h| [h[:file], h[:line]] },
|
|
127
|
-
#
|
|
128
|
-
#
|
|
129
|
-
#
|
|
158
|
+
# Every mutant that was attempted and produced no verdict, whatever the
|
|
159
|
+
# reason — the set the --threshold completeness gate counts. Named for the
|
|
160
|
+
# condition rather than one status, because summary.errored means :error
|
|
161
|
+
# alone and a key that reconciled with neither would be worse. `details`
|
|
162
|
+
# carries the cause where there is one. Uncapturable mutants also appear in
|
|
163
|
+
# uncapturable[]; that key keeps its lean shape for existing consumers.
|
|
164
|
+
# to_s/to_i because a pre-fork failure has no subject, so its file and line
|
|
165
|
+
# are null and would not compare against a real entry. id and status extend
|
|
166
|
+
# the key to a total order: these entries collide on (file, line) far more
|
|
167
|
+
# than survivors do — several mutants on one crashy line, every pre-fork
|
|
168
|
+
# entry on ("", 0) — and sort_by is not stable, so equal keys would leave
|
|
169
|
+
# worker finish order in the output and break the byte-stability promise.
|
|
170
|
+
no_verdict: @agg.results.select { |r| r.error? || r.timeout? || r.uncapturable? }
|
|
171
|
+
.map { |r| no_verdict_json(r) }
|
|
172
|
+
.sort_by { |h| [h[:file].to_s, h[:line].to_i, h[:id].to_s, h[:status].to_s, h[:details].to_s] },
|
|
173
|
+
# Equivalent mutants the user suppressed: emitted with their stable id so
|
|
174
|
+
# the user can audit what is silenced (and copy ids for survivors they
|
|
175
|
+
# want to add). Excluded from the score; never in `survivors`.
|
|
130
176
|
ignored: @agg.results.select(&:ignored?).map { |r| ignored_json(r) }
|
|
131
177
|
.sort_by { |h| [h[:file], h[:line], h[:operator]] },
|
|
132
|
-
#
|
|
178
|
+
# Per-source breakdown (additive; baseline consumes it). Sorted by file so
|
|
133
179
|
# output is byte-stable. Reuses AggregateResult via by_source.
|
|
134
180
|
per_source: @agg.by_source.map { |file, agg| per_source_json(file, agg) }
|
|
135
181
|
.sort_by { |h| h[:file] }
|
|
136
182
|
}
|
|
137
|
-
#
|
|
138
|
-
# consumers ignore the extra key; schema_version
|
|
183
|
+
# Additive baseline-delta block, present only with --baseline. Existing
|
|
184
|
+
# consumers ignore the extra key; it does not move schema_version on its own.
|
|
139
185
|
doc[:baseline] = baseline_json(baseline) if baseline
|
|
140
186
|
"#{JSON.generate(doc)}\n"
|
|
141
187
|
end
|
|
142
188
|
|
|
143
|
-
#
|
|
144
|
-
#
|
|
145
|
-
#
|
|
146
|
-
#
|
|
147
|
-
#
|
|
148
|
-
#
|
|
189
|
+
# One self-contained HTML file (inline CSS, no external assets): the overall
|
|
190
|
+
# score + summary counts, a per-source table, and every surviving mutant with
|
|
191
|
+
# its stable id and diff. All source/diff/identifier text is HTML-escaped
|
|
192
|
+
# (CGI.escapeHTML) so a `<`/`>` in source can never break the markup. Reuses
|
|
193
|
+
# survivor_json/per_source_json so one run yields one set of facts regardless
|
|
194
|
+
# of --format.
|
|
149
195
|
def html_report
|
|
150
196
|
score = @agg.mutation_score
|
|
151
197
|
survivors = @agg.surviving_mutants.map { |r| survivor_json(r) }
|
|
@@ -251,9 +297,9 @@ module Mutineer
|
|
|
251
297
|
# HTML-escapes any text destined for the document (stdlib CGI).
|
|
252
298
|
def esc(text) = CGI.escapeHTML(text.to_s)
|
|
253
299
|
|
|
254
|
-
#
|
|
300
|
+
# The same delta facts the human report prints, for dashboards. new_survivors
|
|
255
301
|
# reuse the ignored_json shape (subject/file/line/operator/token/id) and sort
|
|
256
|
-
# byte-stably so output
|
|
302
|
+
# byte-stably so output does not depend on --jobs finish order.
|
|
257
303
|
def baseline_json(delta)
|
|
258
304
|
{
|
|
259
305
|
regressed: delta.regressed,
|
|
@@ -300,7 +346,7 @@ module Mutineer
|
|
|
300
346
|
file: file,
|
|
301
347
|
line: start_line,
|
|
302
348
|
operator: m.operator.to_s,
|
|
303
|
-
#
|
|
349
|
+
# The stable, copy-pasteable id (next to the human-readable token) so a
|
|
304
350
|
# user can paste it straight into .mutineer.yml `ignore:`.
|
|
305
351
|
id: result.id,
|
|
306
352
|
token: token,
|
|
@@ -308,7 +354,7 @@ module Mutineer
|
|
|
308
354
|
}
|
|
309
355
|
end
|
|
310
356
|
|
|
311
|
-
# An entry under the JSON `ignored:` key
|
|
357
|
+
# An entry under the JSON `ignored:` key: what the user already suppressed.
|
|
312
358
|
def ignored_json(result)
|
|
313
359
|
m = result.mutation
|
|
314
360
|
file = result.subject.file
|
|
@@ -329,7 +375,7 @@ module Mutineer
|
|
|
329
375
|
# mutation's 1-based start line, the full original line-block it touches, the
|
|
330
376
|
# spliced mutated block, and a single-line token label for the header.
|
|
331
377
|
def diff_for(m, source)
|
|
332
|
-
# Byte math
|
|
378
|
+
# Byte math: Prism offsets are byte offsets; byteindex/byterindex/
|
|
333
379
|
# byteslice keep line splicing correct for multibyte sources.
|
|
334
380
|
line_begin = m.start_offset.zero? ? 0 : (source.byterindex("\n", m.start_offset - 1) || -1) + 1
|
|
335
381
|
line_end = source.byteindex("\n", m.end_offset) || source.bytesize
|
|
@@ -359,6 +405,31 @@ module Mutineer
|
|
|
359
405
|
}
|
|
360
406
|
end
|
|
361
407
|
|
|
408
|
+
# An entry under the JSON `no_verdict:` key: an attempted mutant with no verdict.
|
|
409
|
+
# A pre-fork failure has no subject or mutation attached, so those degrade to
|
|
410
|
+
# nulls rather than dropping the entry — the count must still reconcile with
|
|
411
|
+
# `summary.no_verdict`.
|
|
412
|
+
#
|
|
413
|
+
# @api private
|
|
414
|
+
# @param result [Mutineer::Result] an errored or timed-out result.
|
|
415
|
+
# @return [Hash] no-verdict JSON object.
|
|
416
|
+
def no_verdict_json(result)
|
|
417
|
+
file = result.subject&.file
|
|
418
|
+
line =
|
|
419
|
+
if result.mutation && file
|
|
420
|
+
source = @source_map[file] || File.read(file)
|
|
421
|
+
source.byteslice(0, result.mutation.start_offset).count("\n") + 1
|
|
422
|
+
end
|
|
423
|
+
{
|
|
424
|
+
subject: result.subject&.qualified_name,
|
|
425
|
+
file: file,
|
|
426
|
+
line: line,
|
|
427
|
+
id: result.id,
|
|
428
|
+
status: result.status.to_s,
|
|
429
|
+
details: result.details
|
|
430
|
+
}
|
|
431
|
+
end
|
|
432
|
+
|
|
362
433
|
# Writes the summary block.
|
|
363
434
|
#
|
|
364
435
|
# @param out [IO] output stream.
|
|
@@ -370,9 +441,9 @@ module Mutineer
|
|
|
370
441
|
out.puts format("Survived: %-6d No coverage: %d", @agg.survived_count, @agg.no_coverage_count)
|
|
371
442
|
out.puts format("Skipped: %-6d Errored: %d", @agg.skipped_invalid_count,
|
|
372
443
|
@agg.errored_count + @agg.timeout_count)
|
|
373
|
-
#
|
|
444
|
+
# A broken harness, not a coverage gap: report it distinctly from No coverage.
|
|
374
445
|
out.puts format("Uncapturable: %-6d (tests failed to run)", @agg.uncapturable_count)
|
|
375
|
-
#
|
|
446
|
+
# Equivalent mutants the user suppressed; excluded from the denominator.
|
|
376
447
|
out.puts format("Ignored: %-6d (equivalent, suppressed)", @agg.ignored_count)
|
|
377
448
|
end
|
|
378
449
|
|
|
@@ -389,10 +460,10 @@ module Mutineer
|
|
|
389
460
|
"#{@agg.ignored_count} ignored excluded"
|
|
390
461
|
if score.nil?
|
|
391
462
|
out.puts "Mutation score: N/A (no covered mutants)"
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
463
|
+
# Only the benign case here: the gate-failure explanation is emitted once
|
|
464
|
+
# from {report}, for every format, so it cannot be said twice or only to
|
|
465
|
+
# the reader of the human report.
|
|
466
|
+
unless broken_nil_score?
|
|
396
467
|
err.puts "[mutineer] no covered mutations; mutation score is N/A and the threshold check is skipped."
|
|
397
468
|
end
|
|
398
469
|
else
|
|
@@ -409,11 +480,58 @@ module Mutineer
|
|
|
409
480
|
(@agg.errored_count + @agg.timeout_count + @agg.uncapturable_count).positive?
|
|
410
481
|
end
|
|
411
482
|
|
|
412
|
-
#
|
|
483
|
+
# Mutants that were attempted but produced no verdict, over everything
|
|
484
|
+
# attempted. Above the limit the score describes too small a slice of the run
|
|
485
|
+
# to gate on. A few flaky mutants in a large run stay under it.
|
|
486
|
+
#
|
|
487
|
+
# @api private
|
|
488
|
+
# @return [Boolean] true when too much of the run failed to produce a verdict.
|
|
489
|
+
def broken_share_exceeded?
|
|
490
|
+
attempted = attempted_count
|
|
491
|
+
attempted.positive? && no_verdict_count > BROKEN_FLOOR &&
|
|
492
|
+
no_verdict_count > attempted * BROKEN_SHARE_LIMIT
|
|
493
|
+
end
|
|
494
|
+
|
|
495
|
+
# Mutants that were attempted and produced no verdict, whatever the reason.
|
|
496
|
+
#
|
|
497
|
+
# @api private
|
|
498
|
+
# @return [Integer] errored + timed out + uncapturable.
|
|
499
|
+
def no_verdict_count
|
|
500
|
+
@agg.errored_count + @agg.timeout_count + @agg.uncapturable_count
|
|
501
|
+
end
|
|
502
|
+
|
|
503
|
+
# Mutants that were actually run. Deliberately not `total`: no_coverage,
|
|
504
|
+
# skipped-invalid and ignored mutants were never attempted, so counting them
|
|
505
|
+
# would dilute the share and let a broken run slip under the limit.
|
|
506
|
+
#
|
|
507
|
+
# skipped-invalid is excluded from both sides: it means a mutant did not
|
|
508
|
+
# re-parse and was correctly never run, which is a validity outcome rather
|
|
509
|
+
# than a broken harness. Cost: an overwhelmingly-skipped run still scores on
|
|
510
|
+
# what little ran; that is our operator misbehaving and wants its own signal.
|
|
511
|
+
#
|
|
512
|
+
# @api private
|
|
513
|
+
# @return [Integer] killed + survived + no-verdict.
|
|
514
|
+
def attempted_count
|
|
515
|
+
@agg.killed_count + @agg.survived_count + no_verdict_count
|
|
516
|
+
end
|
|
517
|
+
|
|
518
|
+
# The sentence both the verdict line and the stderr note are built from, so a
|
|
519
|
+
# user cannot read one number in the report and a different one beside it.
|
|
520
|
+
#
|
|
521
|
+
# @api private
|
|
522
|
+
# @return [String] e.g. "90 of 100 attempted mutants produced no verdict (90.0%, limit 10%)".
|
|
523
|
+
def no_verdict_ratio
|
|
524
|
+
pct = (no_verdict_count * 100.0 / attempted_count).round(1)
|
|
525
|
+
"#{no_verdict_count} of #{attempted_count} attempted mutants produced no verdict " \
|
|
526
|
+
"(#{pct}%, limit #{(BROKEN_SHARE_LIMIT * 100).round}%)"
|
|
527
|
+
end
|
|
528
|
+
|
|
529
|
+
# Human-readable counts of the states that produced no verdict. Used by both
|
|
530
|
+
# the nil-score message and the completeness gate, so they agree.
|
|
413
531
|
#
|
|
414
532
|
# @api private
|
|
415
533
|
# @return [String]
|
|
416
|
-
def
|
|
534
|
+
def broken_counts_detail
|
|
417
535
|
parts = []
|
|
418
536
|
parts << "#{@agg.errored_count} errored" if @agg.errored_count.positive?
|
|
419
537
|
parts << "#{@agg.timeout_count} timeout" if @agg.timeout_count.positive?
|
|
@@ -421,10 +539,9 @@ module Mutineer
|
|
|
421
539
|
parts.join(", ")
|
|
422
540
|
end
|
|
423
541
|
|
|
424
|
-
#
|
|
425
|
-
#
|
|
426
|
-
#
|
|
427
|
-
# Writes the per-source breakdown.
|
|
542
|
+
# One line per source after the global summary, so a multi-source run shows
|
|
543
|
+
# which file is weak. Omitted for a single-source run: the global summary
|
|
544
|
+
# already says everything (no redundant one-line block).
|
|
428
545
|
#
|
|
429
546
|
# @param out [IO] output stream.
|
|
430
547
|
# @return [void]
|
|
@@ -443,7 +560,7 @@ module Mutineer
|
|
|
443
560
|
end
|
|
444
561
|
end
|
|
445
562
|
|
|
446
|
-
#
|
|
563
|
+
# The --baseline delta, appended after the normal report. Names every NEW
|
|
447
564
|
# survivor (subject (file:line) operator) and the score delta when it dropped,
|
|
448
565
|
# then a one-line REGRESSION/OK verdict so CI logs show which gate fired.
|
|
449
566
|
def baseline_section(out, delta)
|
|
@@ -507,13 +624,17 @@ module Mutineer
|
|
|
507
624
|
score = @agg.mutation_score
|
|
508
625
|
if score.nil?
|
|
509
626
|
if broken_nil_score?
|
|
510
|
-
out.puts "FAILED: no covered mutants (#{
|
|
627
|
+
out.puts "FAILED: no covered mutants (#{broken_counts_detail}); " \
|
|
511
628
|
"threshold #{threshold}% cannot pass with a broken harness"
|
|
512
629
|
end
|
|
513
630
|
return
|
|
514
631
|
end
|
|
515
632
|
|
|
516
|
-
|
|
633
|
+
# Same rule as exit_code, or the report says PASSED on a run that exits 1 —
|
|
634
|
+
# and with --output that wrong verdict is what gets archived.
|
|
635
|
+
if broken_share_exceeded?
|
|
636
|
+
out.puts "FAILED: #{no_verdict_ratio}; #{score}% covers only part of the run"
|
|
637
|
+
elsif score >= threshold
|
|
517
638
|
out.puts "PASSED: #{score}% >= threshold #{threshold}%"
|
|
518
639
|
else
|
|
519
640
|
out.puts "FAILED: #{score}% < threshold #{threshold}%"
|
data/lib/mutineer/result.rb
CHANGED
|
@@ -1,20 +1,20 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Mutineer
|
|
4
|
-
# Immutable outcome of running one mutant.
|
|
5
|
-
# killed
|
|
6
|
-
# survived
|
|
7
|
-
# error
|
|
8
|
-
# timeout
|
|
9
|
-
# skipped
|
|
10
|
-
# no_coverage
|
|
11
|
-
# uncapturable
|
|
12
|
-
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
15
|
-
#
|
|
16
|
-
# ignored
|
|
17
|
-
#
|
|
4
|
+
# Immutable outcome of running one mutant. Eight distinct states:
|
|
5
|
+
# killed - a test failed/errored, so the mutation was caught.
|
|
6
|
+
# survived - every test passed, so the mutation went undetected.
|
|
7
|
+
# error - the child crashed (unhandled exception): exit status 2.
|
|
8
|
+
# timeout - the parent SIGKILLed a child that overran its wall clock.
|
|
9
|
+
# skipped - the mutated source failed to re-parse (invalid); no fork.
|
|
10
|
+
# no_coverage - no test exercises the mutated line; not run, not scored.
|
|
11
|
+
# uncapturable - the line's would-be covering test errored during capture,
|
|
12
|
+
# so coverage was lost. Excluded from the denominator exactly
|
|
13
|
+
# like no_coverage, but reported separately: it signals a
|
|
14
|
+
# broken harness (a test that failed to run), not a genuine
|
|
15
|
+
# coverage gap.
|
|
16
|
+
# ignored - a known-equivalent mutant the user suppressed, via an
|
|
17
|
+
# inline `# mutineer:disable-line` comment or a
|
|
18
18
|
# `.mutineer.yml` `ignore:` id. A pre-fork classification
|
|
19
19
|
# (never run); excluded from the denominator so a strong
|
|
20
20
|
# file can reach 100%.
|
|
@@ -22,14 +22,14 @@ module Mutineer
|
|
|
22
22
|
# `error` and `skipped` are deliberately distinct: skipped is a pre-fork
|
|
23
23
|
# validity failure (counted separately by the reporter), error is a runtime
|
|
24
24
|
# crash. Never conflate them via `details` string parsing. `no_coverage` and
|
|
25
|
-
# `uncapturable` are pre-fork selection results
|
|
26
|
-
#
|
|
25
|
+
# `uncapturable` are pre-fork selection results: both excluded from the score
|
|
26
|
+
# denominator.
|
|
27
27
|
#
|
|
28
28
|
# `subject`, `mutation`, and `id` are nil when the Result is built by
|
|
29
29
|
# Isolation/Runner (which only know the outcome); the orchestrator attaches
|
|
30
30
|
# them afterwards via `result.with(subject:, mutation:, id:)` so the Reporter
|
|
31
31
|
# can render survivor diffs and emit the stable id. `id` is the content-based
|
|
32
|
-
# MutantId
|
|
32
|
+
# MutantId.
|
|
33
33
|
Result = Data.define(:status, :details, :subject, :mutation, :id) do
|
|
34
34
|
# Builds a killed result.
|
|
35
35
|
#
|
|
@@ -93,12 +93,12 @@ module Mutineer
|
|
|
93
93
|
end
|
|
94
94
|
|
|
95
95
|
# Aggregates a flat list of Results into counts, the mutation score, and the
|
|
96
|
-
# surviving-mutant list. The score denominator is killed + survived ONLY
|
|
97
|
-
#
|
|
98
|
-
#
|
|
99
|
-
#
|
|
100
|
-
#
|
|
101
|
-
#
|
|
96
|
+
# surviving-mutant list. The score denominator is killed + survived ONLY:
|
|
97
|
+
# no-coverage, uncapturable, skipped (invalid), errored, timeout, and ignored
|
|
98
|
+
# (equivalent-mutant suppression) are each excluded and surfaced separately,
|
|
99
|
+
# so suppressing every survivor reaches 100%. An empty denominator yields a
|
|
100
|
+
# nil score (rendered "N/A"), never 0.0, distinguishing "no testable mutants"
|
|
101
|
+
# from "0% killed".
|
|
102
102
|
class AggregateResult
|
|
103
103
|
attr_reader :results
|
|
104
104
|
|
|
@@ -151,9 +151,8 @@ module Mutineer
|
|
|
151
151
|
# @return [Array<Mutineer::Result>] surviving results.
|
|
152
152
|
def surviving_mutants = @results.select(&:survived?)
|
|
153
153
|
|
|
154
|
-
#
|
|
155
|
-
#
|
|
156
|
-
# reuse the same aggregate math.
|
|
154
|
+
# Groups results by source file so the Reporter (per-source breakdown) and
|
|
155
|
+
# baseline diff can reuse the same aggregate math.
|
|
157
156
|
#
|
|
158
157
|
# @return [Hash<String, Mutineer::AggregateResult>] source-file groups.
|
|
159
158
|
def by_source
|