mutineer 1.5.0 → 1.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +473 -0
- data/README.md +352 -26
- data/lib/mutineer/child_coverage.rb +53 -0
- data/lib/mutineer/cli.rb +184 -24
- data/lib/mutineer/concern_blocks.rb +80 -0
- data/lib/mutineer/config.rb +148 -8
- data/lib/mutineer/coverage_map.rb +541 -92
- data/lib/mutineer/daemon_backend.rb +227 -86
- data/lib/mutineer/daemon_client.rb +72 -9
- data/lib/mutineer/daemon_server.rb +229 -51
- data/lib/mutineer/external_backend.rb +12 -1
- data/lib/mutineer/file_swap.rb +1 -1
- data/lib/mutineer/init.rb +124 -0
- data/lib/mutineer/isolation.rb +15 -2
- data/lib/mutineer/job_plan.rb +458 -0
- data/lib/mutineer/migrate.rb +705 -0
- data/lib/mutineer/minitest_integration.rb +48 -1
- data/lib/mutineer/mutator_registry.rb +6 -2
- data/lib/mutineer/mutators/base.rb +14 -7
- data/lib/mutineer/mutators/call_to_receiver.rb +135 -0
- data/lib/mutineer/mutators/chain_link.rb +1 -1
- data/lib/mutineer/orphan_guard.rb +100 -0
- data/lib/mutineer/project.rb +411 -21
- data/lib/mutineer/rails_run_env.rb +73 -0
- data/lib/mutineer/rails_worker_db.rb +1477 -48
- data/lib/mutineer/redaction.rb +98 -0
- data/lib/mutineer/reporter.rb +83 -12
- data/lib/mutineer/result.rb +49 -8
- data/lib/mutineer/runner.rb +66 -290
- data/lib/mutineer/statement_lines.rb +54 -7
- data/lib/mutineer/subject.rb +24 -2
- data/lib/mutineer/test_runners/rspec.rb +54 -3
- data/lib/mutineer/test_runners.rb +3 -2
- data/lib/mutineer/triage.rb +111 -0
- data/lib/mutineer/version.rb +1 -1
- data/lib/mutineer.rb +5 -0
- metadata +11 -1
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
5
|
+
module Mutineer
|
|
6
|
+
# Hides database credentials in text bound for a terminal: an error reply,
|
|
7
|
+
# a log line, a warning. Rails parses DATABASE_URL itself, so an error about
|
|
8
|
+
# a malformed one can quote the whole URL.
|
|
9
|
+
#
|
|
10
|
+
# Stdlib only: the app-side daemon loads this file without the rest of
|
|
11
|
+
# mutineer.
|
|
12
|
+
module Redaction
|
|
13
|
+
# The password in a `scheme://user:password@host` URL. Greedy up to the last
|
|
14
|
+
# `@` before an unescaped quote or line end, so a malformed password with a
|
|
15
|
+
# space, an unescaped `@`, or a quote `inspect` escaped (`\"`) is hidden too.
|
|
16
|
+
URL_PASSWORD = %r{([a-z][a-z0-9+.\-]*://[^\s:/@"'\\]*):(?:\\.|[^"'\r\n\\])*@}i
|
|
17
|
+
# What replaces a hidden password.
|
|
18
|
+
REDACTED = "[REDACTED]"
|
|
19
|
+
|
|
20
|
+
module_function
|
|
21
|
+
|
|
22
|
+
# Hide the password in any URL, and the DATABASE_URL password wherever it
|
|
23
|
+
# appears: whole, percent-decoded, `inspect` escaped, or cut short after
|
|
24
|
+
# its `scheme://user:` prefix.
|
|
25
|
+
#
|
|
26
|
+
# @param text [#to_s]
|
|
27
|
+
# @param env [Hash{String => String}] where DATABASE_URL is read.
|
|
28
|
+
# @return [String]
|
|
29
|
+
def text(text, env: ENV)
|
|
30
|
+
out = text.to_s
|
|
31
|
+
head, raw = database_url_parts(env["DATABASE_URL"].to_s)
|
|
32
|
+
secrets = raw ? password_forms(raw) : []
|
|
33
|
+
# Prefixes first, on the caller's text, so no replacement made here can
|
|
34
|
+
# be mistaken for part of a password.
|
|
35
|
+
[head, head&.inspect&.[](1...-1)].compact.uniq.each do |prefix|
|
|
36
|
+
out = secrets.reduce(out) { |acc, secret| redact_prefixes(acc, prefix, secret) }
|
|
37
|
+
end
|
|
38
|
+
out = out.gsub(URL_PASSWORD) { "#{Regexp.last_match(1)}:#{REDACTED}@" }
|
|
39
|
+
secrets.reduce(out) { |acc, secret| acc.gsub(secret, REDACTED) }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Split DATABASE_URL into its `scheme://user:` head and its raw password.
|
|
43
|
+
# Read by position, not URI or a pattern, because the URL may be the
|
|
44
|
+
# malformed one and its password may hold any character: the password runs
|
|
45
|
+
# from the first `:` after `scheme://` to the last `@`.
|
|
46
|
+
#
|
|
47
|
+
# @param url [String]
|
|
48
|
+
# @return [Array(String, String), nil]
|
|
49
|
+
def database_url_parts(url)
|
|
50
|
+
start = url.index("://")
|
|
51
|
+
at = url.rindex("@")
|
|
52
|
+
return unless start && at
|
|
53
|
+
|
|
54
|
+
colon = url.index(":", start + 3)
|
|
55
|
+
return unless colon && colon < at
|
|
56
|
+
|
|
57
|
+
raw = url[(colon + 1)...at]
|
|
58
|
+
[url[0..colon], raw] unless raw.empty?
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# The password as it may appear in a message: raw, percent-decoded, and
|
|
62
|
+
# each `inspect` escaped. Longest first, so a longer form is replaced
|
|
63
|
+
# before a form it contains.
|
|
64
|
+
#
|
|
65
|
+
# @param raw [String]
|
|
66
|
+
# @return [Array<String>]
|
|
67
|
+
def password_forms(raw)
|
|
68
|
+
decoded = begin
|
|
69
|
+
URI.decode_www_form_component(raw.gsub("+", "%2B"))
|
|
70
|
+
rescue ArgumentError
|
|
71
|
+
raw
|
|
72
|
+
end
|
|
73
|
+
[raw, decoded].flat_map { |form| [form, form.inspect[1...-1]] }
|
|
74
|
+
.uniq.reject(&:empty?).sort_by { |form| -form.length }
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Hide what follows each `head` while it matches the start of `secret`,
|
|
78
|
+
# for a message that cut the URL short inside the password.
|
|
79
|
+
#
|
|
80
|
+
# @param text [String]
|
|
81
|
+
# @param head [String] the DATABASE_URL `scheme://user:` prefix, raw or
|
|
82
|
+
# `inspect` escaped.
|
|
83
|
+
# @param secret [String]
|
|
84
|
+
# @return [String]
|
|
85
|
+
def redact_prefixes(text, head, secret)
|
|
86
|
+
out = +""
|
|
87
|
+
pos = 0
|
|
88
|
+
while (found = text.index(head, pos))
|
|
89
|
+
start = found + head.length
|
|
90
|
+
length = 0
|
|
91
|
+
length += 1 while length < secret.length && text[start + length] == secret[length]
|
|
92
|
+
out << text[pos...start] << (length.positive? ? REDACTED : "")
|
|
93
|
+
pos = start + length
|
|
94
|
+
end
|
|
95
|
+
out << text[pos..]
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
data/lib/mutineer/reporter.rb
CHANGED
|
@@ -26,7 +26,7 @@ module Mutineer
|
|
|
26
26
|
BROKEN_FLOOR = 1
|
|
27
27
|
|
|
28
28
|
# The JSON report's `schema_version` (see docs/json-schema.md).
|
|
29
|
-
SCHEMA_VERSION = "1.
|
|
29
|
+
SCHEMA_VERSION = "1.8"
|
|
30
30
|
|
|
31
31
|
# The warning both matrix renderers give under the redundant tests.
|
|
32
32
|
MATRIX_REDUNDANT_NOTE = "Delete redundant tests one at a time: two of them can be the only killers of one mutant."
|
|
@@ -74,11 +74,11 @@ module Mutineer
|
|
|
74
74
|
out.print rendered
|
|
75
75
|
end
|
|
76
76
|
|
|
77
|
+
return warn_errors_without_gate(err) unless threshold&.positive?
|
|
78
|
+
|
|
77
79
|
# Both ways a run can fail the gate on completeness, said here rather than in
|
|
78
80
|
# the human renderer: --format json is the documented CI path, and a run that
|
|
79
81
|
# exits 1 must say why on every format, not only the one a person reads.
|
|
80
|
-
return unless threshold&.positive?
|
|
81
|
-
|
|
82
82
|
if @agg.mutation_score.nil? && broken_nil_score?
|
|
83
83
|
err.puts "[mutineer] nothing could be scored (#{broken_counts_detail}), so the " \
|
|
84
84
|
"--threshold gate fails. See no_verdict[] in --format json for the cause of each."
|
|
@@ -89,6 +89,22 @@ module Mutineer
|
|
|
89
89
|
end
|
|
90
90
|
end
|
|
91
91
|
|
|
92
|
+
# Errors are out of the score, so a run with no --threshold passes however
|
|
93
|
+
# many mutants errored. Say so, and say what --threshold would enforce.
|
|
94
|
+
#
|
|
95
|
+
# @api private
|
|
96
|
+
# @param err [IO] error stream.
|
|
97
|
+
# @return [void]
|
|
98
|
+
def warn_errors_without_gate(err)
|
|
99
|
+
count = @agg.errored_count
|
|
100
|
+
return unless count.positive?
|
|
101
|
+
|
|
102
|
+
err.puts "[mutineer] #{count} #{count == 1 ? 'mutant' : 'mutants'} errored. Errors are not in the " \
|
|
103
|
+
"score, so they do not fail a run without a positive --threshold. With one, the run fails " \
|
|
104
|
+
"when nothing can be scored, or when more than one mutant has no verdict and they exceed " \
|
|
105
|
+
"#{(BROKEN_SHARE_LIMIT * 100).round}% of those attempted (no_verdict[] in --format json)."
|
|
106
|
+
end
|
|
107
|
+
|
|
92
108
|
# Renders the human report.
|
|
93
109
|
#
|
|
94
110
|
# @param out [IO] output stream.
|
|
@@ -96,11 +112,8 @@ module Mutineer
|
|
|
96
112
|
# @param threshold [Float] score threshold.
|
|
97
113
|
# @return [void]
|
|
98
114
|
def human_report(out, err, threshold)
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
"operators and are reached by the suite."
|
|
102
|
-
return
|
|
103
|
-
end
|
|
115
|
+
# The CLI explains an empty run for every format (CLI.warn_empty_run).
|
|
116
|
+
return if @agg.total.zero?
|
|
104
117
|
|
|
105
118
|
out.puts "Mutineer — Mutation Results"
|
|
106
119
|
out.puts "========================="
|
|
@@ -167,6 +180,8 @@ module Mutineer
|
|
|
167
180
|
total: @agg.total, killed: killed, survived: survived,
|
|
168
181
|
no_coverage: @agg.no_coverage_count,
|
|
169
182
|
uncapturable: @agg.uncapturable_count,
|
|
183
|
+
unplaceable: @agg.unplaceable_count,
|
|
184
|
+
ran_at_load: @agg.ran_at_load_count,
|
|
170
185
|
skipped_invalid: @agg.skipped_invalid_count,
|
|
171
186
|
errored: @agg.errored_count, timeout: @agg.timeout_count,
|
|
172
187
|
ignored: @agg.ignored_count,
|
|
@@ -194,6 +209,12 @@ module Mutineer
|
|
|
194
209
|
# Same shape as no_coverage; additive key.
|
|
195
210
|
uncapturable: @agg.results.select(&:uncapturable?).map { |r| mutant_json(r) }
|
|
196
211
|
.sort_by { |h| [h[:file], h[:line], h[:operator], h[:id].to_s] },
|
|
212
|
+
# Additive (1.7): owner-unknown mutants redefine did not run. Not in no_verdict.
|
|
213
|
+
unplaceable: @agg.results.select(&:unplaceable?).map { |r| mutant_json(r) }
|
|
214
|
+
.sort_by { |h| [h[:file], h[:line], h[:operator], h[:id].to_s] },
|
|
215
|
+
# Additive (1.7, #187): mutants on a line that ran at load. Not in no_verdict.
|
|
216
|
+
ran_at_load: @agg.results.select(&:ran_at_load?).map { |r| mutant_json(r) }
|
|
217
|
+
.sort_by { |h| [h[:file], h[:line], h[:operator], h[:id].to_s] },
|
|
197
218
|
# Every mutant that was attempted and produced no verdict, whatever the
|
|
198
219
|
# reason — the set the --threshold completeness gate counts. Named for the
|
|
199
220
|
# condition rather than one status, because summary.errored means :error
|
|
@@ -212,7 +233,7 @@ module Mutineer
|
|
|
212
233
|
# Equivalent mutants the user suppressed: emitted with their stable id so
|
|
213
234
|
# the user can audit what is silenced (and copy ids for survivors they
|
|
214
235
|
# want to add). Excluded from the score; never in `survivors`.
|
|
215
|
-
ignored: @agg.results.select(&:ignored?).map { |r|
|
|
236
|
+
ignored: @agg.results.select(&:ignored?).map { |r| ignored_json(r) }
|
|
216
237
|
.sort_by { |h| [h[:file], h[:line], h[:operator], h[:id].to_s] },
|
|
217
238
|
# Per-source breakdown (additive; baseline consumes it). Sorted by file so
|
|
218
239
|
# output is byte-stable. Reuses AggregateResult via by_source.
|
|
@@ -276,6 +297,7 @@ module Mutineer
|
|
|
276
297
|
#{summary_html}
|
|
277
298
|
#{per_source_html(per_source)}
|
|
278
299
|
#{survivors_html(survivors)}
|
|
300
|
+
#{ignored_html}
|
|
279
301
|
#{matrix_html}
|
|
280
302
|
</body>
|
|
281
303
|
</html>
|
|
@@ -287,7 +309,8 @@ module Mutineer
|
|
|
287
309
|
counts = {
|
|
288
310
|
"total" => @agg.total, "killed" => @agg.killed_count,
|
|
289
311
|
"survived" => @agg.survived_count, "no_coverage" => @agg.no_coverage_count,
|
|
290
|
-
"uncapturable" => @agg.uncapturable_count, "
|
|
312
|
+
"uncapturable" => @agg.uncapturable_count, "unplaceable" => @agg.unplaceable_count,
|
|
313
|
+
"ran_at_load" => @agg.ran_at_load_count, "ignored" => @agg.ignored_count,
|
|
291
314
|
"skipped" => @agg.skipped_invalid_count,
|
|
292
315
|
"errored" => @agg.errored_count, "timeout" => @agg.timeout_count
|
|
293
316
|
}
|
|
@@ -336,6 +359,28 @@ module Mutineer
|
|
|
336
359
|
"<h2>Surviving Mutants</h2>\n#{cards}"
|
|
337
360
|
end
|
|
338
361
|
|
|
362
|
+
# Ignored mutants, each with its id and its reason when the user gave one.
|
|
363
|
+
#
|
|
364
|
+
# @api private
|
|
365
|
+
# @return [String] HTML, or "" when none were ignored.
|
|
366
|
+
def ignored_html
|
|
367
|
+
rows = @agg.results.select(&:ignored?).map { |r| ignored_json(r) }
|
|
368
|
+
.sort_by { |h| [h[:file], h[:line], h[:operator], h[:id].to_s] }
|
|
369
|
+
return "" if rows.empty?
|
|
370
|
+
|
|
371
|
+
cards = rows.map do |row|
|
|
372
|
+
reason = row[:reason] ? "<div class=\"meta\">#{esc(row[:reason])}</div>" : ""
|
|
373
|
+
<<~CARD.chomp
|
|
374
|
+
<div class="survivor">
|
|
375
|
+
<h3>#{esc(row[:subject])}</h3>
|
|
376
|
+
<div class="meta">#{esc(row[:file])}:#{row[:line]} · #{esc(row[:operator])} · <span class="id">#{esc(row[:id])}</span></div>
|
|
377
|
+
#{reason}
|
|
378
|
+
</div>
|
|
379
|
+
CARD
|
|
380
|
+
end.join("\n")
|
|
381
|
+
"<h2>Ignored mutants</h2>\n#{cards}"
|
|
382
|
+
end
|
|
383
|
+
|
|
339
384
|
# The kill-matrix section of the HTML report: the counts, then the blind and
|
|
340
385
|
# redundant tests. Empty without --matrix.
|
|
341
386
|
#
|
|
@@ -622,8 +667,25 @@ module Mutineer
|
|
|
622
667
|
lines.size == 1 ? start_line.to_s : "#{start_line},#{lines.size}"
|
|
623
668
|
end
|
|
624
669
|
|
|
670
|
+
# One ignored mutant. `reason` is present only when the user wrote one.
|
|
671
|
+
#
|
|
672
|
+
# @api private
|
|
673
|
+
# @param result [Mutineer::Result] an ignored result.
|
|
674
|
+
# @return [Hash]
|
|
675
|
+
def ignored_json(result)
|
|
676
|
+
row = mutant_json(result)
|
|
677
|
+
text = result.reason
|
|
678
|
+
row[:reason] = text if text.is_a?(String) && !text.strip.empty?
|
|
679
|
+
row
|
|
680
|
+
end
|
|
681
|
+
|
|
625
682
|
# The fields that name one mutant, for the lists that point at mutants:
|
|
626
|
-
# `no_coverage`, `uncapturable`, `ignored` and
|
|
683
|
+
# `no_coverage`, `uncapturable`, `unplaceable`, `ran_at_load`, `ignored` and
|
|
684
|
+
# `baseline.new_survivors`.
|
|
685
|
+
#
|
|
686
|
+
# @api private
|
|
687
|
+
# @param result [Mutineer::Result] the mutant result.
|
|
688
|
+
# @return [Hash] subject, file, line, operator, token, and id.
|
|
627
689
|
def mutant_json(result)
|
|
628
690
|
m = result.mutation
|
|
629
691
|
file = result.subject.file
|
|
@@ -690,6 +752,13 @@ module Mutineer
|
|
|
690
752
|
out.puts format("Timeout: %-6d (over the per-mutant time limit)", @agg.timeout_count)
|
|
691
753
|
# A broken harness, not a coverage gap: report it distinctly from No coverage.
|
|
692
754
|
out.puts format("Uncapturable: %-6d (tests failed to run)", @agg.uncapturable_count)
|
|
755
|
+
# Not broken: the strategy cannot load these where the tests call them (no
|
|
756
|
+
# named class under redefine, or a concern's `included` or `prepended` block).
|
|
757
|
+
out.puts format("Unplaceable: %-6d (not loadable where the tests call it; verify with --test-command)",
|
|
758
|
+
@agg.unplaceable_count)
|
|
759
|
+
# Not a verdict: the line ran before the mutant was applied (#187).
|
|
760
|
+
out.puts format("Ran at load: %-6d (ran while the app or class loaded; mutineer cannot re-run that; " \
|
|
761
|
+
"verify with --test-command)", @agg.ran_at_load_count)
|
|
693
762
|
# Equivalent mutants the user suppressed; excluded from the denominator.
|
|
694
763
|
out.puts format("Ignored: %-6d (equivalent, suppressed)", @agg.ignored_count)
|
|
695
764
|
end
|
|
@@ -702,6 +771,7 @@ module Mutineer
|
|
|
702
771
|
def score_line(out, err)
|
|
703
772
|
score = @agg.mutation_score
|
|
704
773
|
excluded = "#{@agg.no_coverage_count} no-coverage, #{@agg.uncapturable_count} uncapturable, " \
|
|
774
|
+
"#{@agg.unplaceable_count} unplaceable, #{@agg.ran_at_load_count} ran at load, " \
|
|
705
775
|
"#{@agg.skipped_invalid_count} skipped, " \
|
|
706
776
|
"#{@agg.errored_count} errored, #{@agg.timeout_count} timeout, " \
|
|
707
777
|
"#{@agg.ignored_count} ignored excluded"
|
|
@@ -748,7 +818,8 @@ module Mutineer
|
|
|
748
818
|
end
|
|
749
819
|
|
|
750
820
|
# Mutants that were actually run. Deliberately not `total`: no_coverage,
|
|
751
|
-
# skipped-invalid and ignored mutants
|
|
821
|
+
# skipped-invalid and ignored mutants are not counted (a #254 no_coverage
|
|
822
|
+
# mutant ran its statement's tests but is not a verdict), so counting them
|
|
752
823
|
# would dilute the share and let a broken run slip under the limit.
|
|
753
824
|
#
|
|
754
825
|
# skipped-invalid is excluded from both sides: it means a mutant did not
|
data/lib/mutineer/result.rb
CHANGED
|
@@ -1,18 +1,38 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Mutineer
|
|
4
|
-
# Immutable outcome of running one mutant.
|
|
4
|
+
# Immutable outcome of running one mutant. Ten distinct states:
|
|
5
5
|
# killed - a test failed/errored, so the mutation was caught.
|
|
6
6
|
# survived - every test passed, so the mutation went undetected.
|
|
7
7
|
# error - the child crashed (unhandled exception): exit status 2.
|
|
8
8
|
# timeout - the parent SIGKILLed a child that overran its wall clock.
|
|
9
9
|
# skipped - the mutated source failed to re-parse (invalid); no fork.
|
|
10
10
|
# no_coverage - no test exercises the mutated line; not run, not scored.
|
|
11
|
+
# Also a mutant on a later line of a multi-line statement,
|
|
12
|
+
# in code that runs only sometimes, that the statement's
|
|
13
|
+
# tests ran but did not kill (#254): they may not reach it.
|
|
11
14
|
# uncapturable - the line's would-be covering test errored during capture,
|
|
12
15
|
# so coverage was lost. Excluded from the denominator exactly
|
|
13
16
|
# like no_coverage, but reported separately: it signals a
|
|
14
17
|
# broken harness (a test that failed to run), not a genuine
|
|
15
18
|
# coverage gap.
|
|
19
|
+
# unplaceable - the strategy cannot load the mutant where the tests call
|
|
20
|
+
# it, so it is not run. Under `--strategy redefine` the
|
|
21
|
+
# method belongs to a class or module that cannot be named
|
|
22
|
+
# statically (for example each class that includes a
|
|
23
|
+
# concern). Under either strategy the method is in a
|
|
24
|
+
# concern's `included` or `prepended` block, which does not
|
|
25
|
+
# run again in a class that already includes it (#256).
|
|
26
|
+
# Excluded from the denominator and, unlike uncapturable,
|
|
27
|
+
# from the no-verdict gate: nothing is broken.
|
|
28
|
+
# `--test-command` runs these mutants.
|
|
29
|
+
# ran_at_load - the mutated line ran while the app booted or its class
|
|
30
|
+
# loaded. That run happened before the mutant was applied,
|
|
31
|
+
# so a test can check a value the original code computed.
|
|
32
|
+
# A survivor on such a line, and a line that ran only at
|
|
33
|
+
# load, get this status; a kill stays killed. Excluded from
|
|
34
|
+
# the denominator and from the no-verdict gate, like
|
|
35
|
+
# unplaceable. `--test-command` verifies these mutants.
|
|
16
36
|
# ignored - a known-equivalent mutant the user suppressed, via an
|
|
17
37
|
# inline `# mutineer:disable-line` comment or a
|
|
18
38
|
# `.mutineer.yml` `ignore:` id. A pre-fork classification
|
|
@@ -21,9 +41,10 @@ module Mutineer
|
|
|
21
41
|
#
|
|
22
42
|
# `error` and `skipped` are deliberately distinct: skipped is a pre-fork
|
|
23
43
|
# validity failure (counted separately by the reporter), error is a runtime
|
|
24
|
-
# crash. Never conflate them via `details` string parsing. `
|
|
25
|
-
# `
|
|
26
|
-
#
|
|
44
|
+
# crash. Never conflate them via `details` string parsing. `uncapturable` and
|
|
45
|
+
# `unplaceable` are pre-fork results, and `no_coverage` and `ran_at_load` are
|
|
46
|
+
# pre-fork or a reclassified result (JobPlan.selection_verdict,
|
|
47
|
+
# JobPlan.load_verdict): all excluded from the score denominator.
|
|
27
48
|
#
|
|
28
49
|
# `subject`, `mutation`, and `id` are nil when the Result is built by
|
|
29
50
|
# Isolation/Runner (which only know the outcome); the orchestrator attaches
|
|
@@ -39,8 +60,9 @@ module Mutineer
|
|
|
39
60
|
# from a fork back to the process that forked it, so both ends always run the
|
|
40
61
|
# same code. Nothing persists a marshaled Result (reports, baselines and the
|
|
41
62
|
# coverage cache are JSON), so a new field needs no Marshal compatibility.
|
|
42
|
-
Result = Data.define(:status, :details, :subject, :mutation, :id, :kills) do
|
|
43
|
-
# Every field but `status` defaults to nil.
|
|
63
|
+
Result = Data.define(:status, :details, :subject, :mutation, :id, :kills, :reason) do
|
|
64
|
+
# Every field but `status` defaults to nil. `reason` is the text a user
|
|
65
|
+
# gave for an ignored mutant, from an inline marker or an `ignore:` mapping.
|
|
44
66
|
#
|
|
45
67
|
# @param status [Symbol] the outcome.
|
|
46
68
|
# @param details [String, nil] error or skip details.
|
|
@@ -48,7 +70,8 @@ module Mutineer
|
|
|
48
70
|
# @param mutation [Mutineer::Mutation, nil] the mutation.
|
|
49
71
|
# @param id [String, nil] the stable mutant id.
|
|
50
72
|
# @param kills [Mutineer::Kills, nil] the matrix row of a `--matrix` run.
|
|
51
|
-
|
|
73
|
+
# @param reason [String, nil] why an ignored mutant was suppressed.
|
|
74
|
+
def initialize(status:, details: nil, subject: nil, mutation: nil, id: nil, kills: nil, reason: nil)
|
|
52
75
|
super
|
|
53
76
|
end
|
|
54
77
|
|
|
@@ -89,6 +112,16 @@ module Mutineer
|
|
|
89
112
|
# @return [Mutineer::Result] uncapturable result.
|
|
90
113
|
def self.uncapturable = new(status: :uncapturable, details: nil, subject: nil, mutation: nil, id: nil)
|
|
91
114
|
|
|
115
|
+
# Builds an unplaceable result.
|
|
116
|
+
#
|
|
117
|
+
# @return [Mutineer::Result] unplaceable result.
|
|
118
|
+
def self.unplaceable = new(status: :unplaceable, details: nil, subject: nil, mutation: nil, id: nil)
|
|
119
|
+
|
|
120
|
+
# Builds a ran_at_load result.
|
|
121
|
+
#
|
|
122
|
+
# @return [Mutineer::Result] ran-at-load result.
|
|
123
|
+
def self.ran_at_load = new(status: :ran_at_load, details: nil, subject: nil, mutation: nil, id: nil)
|
|
124
|
+
|
|
92
125
|
# Builds an ignored result.
|
|
93
126
|
#
|
|
94
127
|
# @return [Mutineer::Result] ignored result.
|
|
@@ -108,6 +141,10 @@ module Mutineer
|
|
|
108
141
|
def no_coverage? = status == :no_coverage
|
|
109
142
|
# @return [Boolean] true when the status is uncapturable.
|
|
110
143
|
def uncapturable? = status == :uncapturable
|
|
144
|
+
# @return [Boolean] true when the status is unplaceable.
|
|
145
|
+
def unplaceable? = status == :unplaceable
|
|
146
|
+
# @return [Boolean] true when the status is ran_at_load.
|
|
147
|
+
def ran_at_load? = status == :ran_at_load
|
|
111
148
|
# @return [Boolean] true when the status is ignored.
|
|
112
149
|
def ignored? = status == :ignored
|
|
113
150
|
|
|
@@ -130,7 +167,7 @@ module Mutineer
|
|
|
130
167
|
|
|
131
168
|
# Aggregates a flat list of Results into counts, the mutation score, and the
|
|
132
169
|
# surviving-mutant list. The score denominator is killed + survived ONLY:
|
|
133
|
-
# no-coverage, uncapturable, skipped (invalid), errored, timeout, and ignored
|
|
170
|
+
# no-coverage, uncapturable, unplaceable, ran-at-load, skipped (invalid), errored, timeout, and ignored
|
|
134
171
|
# (equivalent-mutant suppression) are each excluded and surfaced separately,
|
|
135
172
|
# so suppressing every survivor reaches 100%. An empty denominator yields a
|
|
136
173
|
# nil score (rendered "N/A"), never 0.0, distinguishing "no testable mutants"
|
|
@@ -154,6 +191,10 @@ module Mutineer
|
|
|
154
191
|
def no_coverage_count = count(:no_coverage)
|
|
155
192
|
# @return [Integer] uncapturable count.
|
|
156
193
|
def uncapturable_count = count(:uncapturable)
|
|
194
|
+
# @return [Integer] unplaceable count.
|
|
195
|
+
def unplaceable_count = count(:unplaceable)
|
|
196
|
+
# @return [Integer] ran-at-load count.
|
|
197
|
+
def ran_at_load_count = count(:ran_at_load)
|
|
157
198
|
# @return [Integer] skipped-invalid count.
|
|
158
199
|
def skipped_invalid_count = count(:skipped)
|
|
159
200
|
# @return [Integer] errored count.
|