mutineer 1.6.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 +248 -0
- data/README.md +324 -23
- data/lib/mutineer/child_coverage.rb +53 -0
- data/lib/mutineer/cli.rb +182 -23
- data/lib/mutineer/concern_blocks.rb +80 -0
- data/lib/mutineer/config.rb +148 -8
- data/lib/mutineer/coverage_map.rb +175 -12
- data/lib/mutineer/daemon_backend.rb +182 -56
- data/lib/mutineer/daemon_client.rb +30 -0
- data/lib/mutineer/daemon_server.rb +157 -25
- data/lib/mutineer/external_backend.rb +12 -1
- data/lib/mutineer/init.rb +124 -0
- data/lib/mutineer/isolation.rb +14 -2
- data/lib/mutineer/job_plan.rb +129 -23
- data/lib/mutineer/migrate.rb +705 -0
- 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 +72 -0
- data/lib/mutineer/project.rb +80 -6
- data/lib/mutineer/rails_run_env.rb +73 -0
- data/lib/mutineer/rails_worker_db.rb +1408 -53
- data/lib/mutineer/redaction.rb +98 -0
- data/lib/mutineer/reporter.rb +66 -12
- data/lib/mutineer/result.rb +22 -13
- data/lib/mutineer/runner.rb +24 -12
- data/lib/mutineer/statement_lines.rb +15 -8
- data/lib/mutineer/subject.rb +22 -4
- data/lib/mutineer/test_runners/rspec.rb +15 -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 +4 -0
- metadata +9 -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 "========================="
|
|
@@ -220,7 +233,7 @@ module Mutineer
|
|
|
220
233
|
# Equivalent mutants the user suppressed: emitted with their stable id so
|
|
221
234
|
# the user can audit what is silenced (and copy ids for survivors they
|
|
222
235
|
# want to add). Excluded from the score; never in `survivors`.
|
|
223
|
-
ignored: @agg.results.select(&:ignored?).map { |r|
|
|
236
|
+
ignored: @agg.results.select(&:ignored?).map { |r| ignored_json(r) }
|
|
224
237
|
.sort_by { |h| [h[:file], h[:line], h[:operator], h[:id].to_s] },
|
|
225
238
|
# Per-source breakdown (additive; baseline consumes it). Sorted by file so
|
|
226
239
|
# output is byte-stable. Reuses AggregateResult via by_source.
|
|
@@ -284,6 +297,7 @@ module Mutineer
|
|
|
284
297
|
#{summary_html}
|
|
285
298
|
#{per_source_html(per_source)}
|
|
286
299
|
#{survivors_html(survivors)}
|
|
300
|
+
#{ignored_html}
|
|
287
301
|
#{matrix_html}
|
|
288
302
|
</body>
|
|
289
303
|
</html>
|
|
@@ -345,6 +359,28 @@ module Mutineer
|
|
|
345
359
|
"<h2>Surviving Mutants</h2>\n#{cards}"
|
|
346
360
|
end
|
|
347
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
|
+
|
|
348
384
|
# The kill-matrix section of the HTML report: the counts, then the blind and
|
|
349
385
|
# redundant tests. Empty without --matrix.
|
|
350
386
|
#
|
|
@@ -631,9 +667,25 @@ module Mutineer
|
|
|
631
667
|
lines.size == 1 ? start_line.to_s : "#{start_line},#{lines.size}"
|
|
632
668
|
end
|
|
633
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
|
+
|
|
634
682
|
# The fields that name one mutant, for the lists that point at mutants:
|
|
635
683
|
# `no_coverage`, `uncapturable`, `unplaceable`, `ran_at_load`, `ignored` and
|
|
636
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.
|
|
637
689
|
def mutant_json(result)
|
|
638
690
|
m = result.mutation
|
|
639
691
|
file = result.subject.file
|
|
@@ -700,8 +752,9 @@ module Mutineer
|
|
|
700
752
|
out.puts format("Timeout: %-6d (over the per-mutant time limit)", @agg.timeout_count)
|
|
701
753
|
# A broken harness, not a coverage gap: report it distinctly from No coverage.
|
|
702
754
|
out.puts format("Uncapturable: %-6d (tests failed to run)", @agg.uncapturable_count)
|
|
703
|
-
# Not broken:
|
|
704
|
-
|
|
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)",
|
|
705
758
|
@agg.unplaceable_count)
|
|
706
759
|
# Not a verdict: the line ran before the mutant was applied (#187).
|
|
707
760
|
out.puts format("Ran at load: %-6d (ran while the app or class loaded; mutineer cannot re-run that; " \
|
|
@@ -765,7 +818,8 @@ module Mutineer
|
|
|
765
818
|
end
|
|
766
819
|
|
|
767
820
|
# Mutants that were actually run. Deliberately not `total`: no_coverage,
|
|
768
|
-
# 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
|
|
769
823
|
# would dilute the share and let a broken run slip under the limit.
|
|
770
824
|
#
|
|
771
825
|
# skipped-invalid is excluded from both sides: it means a mutant did not
|
data/lib/mutineer/result.rb
CHANGED
|
@@ -8,17 +8,24 @@ module Mutineer
|
|
|
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.
|
|
16
|
-
# unplaceable -
|
|
17
|
-
#
|
|
18
|
-
#
|
|
19
|
-
#
|
|
20
|
-
#
|
|
21
|
-
#
|
|
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.
|
|
22
29
|
# ran_at_load - the mutated line ran while the app booted or its class
|
|
23
30
|
# loaded. That run happened before the mutant was applied,
|
|
24
31
|
# so a test can check a value the original code computed.
|
|
@@ -34,10 +41,10 @@ module Mutineer
|
|
|
34
41
|
#
|
|
35
42
|
# `error` and `skipped` are deliberately distinct: skipped is a pre-fork
|
|
36
43
|
# validity failure (counted separately by the reporter), error is a runtime
|
|
37
|
-
# crash. Never conflate them via `details` string parsing. `
|
|
38
|
-
# `
|
|
39
|
-
# pre-fork or a reclassified
|
|
40
|
-
# denominator.
|
|
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.
|
|
41
48
|
#
|
|
42
49
|
# `subject`, `mutation`, and `id` are nil when the Result is built by
|
|
43
50
|
# Isolation/Runner (which only know the outcome); the orchestrator attaches
|
|
@@ -53,8 +60,9 @@ module Mutineer
|
|
|
53
60
|
# from a fork back to the process that forked it, so both ends always run the
|
|
54
61
|
# same code. Nothing persists a marshaled Result (reports, baselines and the
|
|
55
62
|
# coverage cache are JSON), so a new field needs no Marshal compatibility.
|
|
56
|
-
Result = Data.define(:status, :details, :subject, :mutation, :id, :kills) do
|
|
57
|
-
# 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.
|
|
58
66
|
#
|
|
59
67
|
# @param status [Symbol] the outcome.
|
|
60
68
|
# @param details [String, nil] error or skip details.
|
|
@@ -62,7 +70,8 @@ module Mutineer
|
|
|
62
70
|
# @param mutation [Mutineer::Mutation, nil] the mutation.
|
|
63
71
|
# @param id [String, nil] the stable mutant id.
|
|
64
72
|
# @param kills [Mutineer::Kills, nil] the matrix row of a `--matrix` run.
|
|
65
|
-
|
|
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)
|
|
66
75
|
super
|
|
67
76
|
end
|
|
68
77
|
|
data/lib/mutineer/runner.rb
CHANGED
|
@@ -16,6 +16,7 @@ require_relative "file_swap"
|
|
|
16
16
|
require_relative "external_backend"
|
|
17
17
|
require_relative "daemon_backend"
|
|
18
18
|
require_relative "job_plan"
|
|
19
|
+
require_relative "rails_run_env"
|
|
19
20
|
|
|
20
21
|
module Mutineer
|
|
21
22
|
# Orchestrates one mutation end-to-end: apply it textually, validate the
|
|
@@ -43,6 +44,10 @@ module Mutineer
|
|
|
43
44
|
# @return [Array(Mutineer::AggregateResult, Hash<String, String>, Hash)] aggregate,
|
|
44
45
|
# source map, and run extras.
|
|
45
46
|
def self.execute(config)
|
|
47
|
+
# Rails reads PARALLEL_WORKERS when a test helper calls parallelize.
|
|
48
|
+
# Set it before any boot or child spawn, then put the caller's value back.
|
|
49
|
+
previous_parallel_workers = ENV["PARALLEL_WORKERS"]
|
|
50
|
+
RailsRunEnv.pin_process!
|
|
46
51
|
operator_classes = MutatorRegistry.resolve(config.operators || MutatorRegistry::DEFAULT_NAMES)
|
|
47
52
|
|
|
48
53
|
# External backend: run the suite as a subprocess in the app's own runtime.
|
|
@@ -55,6 +60,15 @@ module Mutineer
|
|
|
55
60
|
# payloads (Prism), so branch before any in-process boot.
|
|
56
61
|
return DaemonBackend.execute(config, operator_classes) if config.daemon
|
|
57
62
|
|
|
63
|
+
# Whole-file reload writes .mutineer_mutant*.rb into each source dir (so
|
|
64
|
+
# require_relative resolves). A SIGKILL'd child skips the tempfile's
|
|
65
|
+
# ensure-unlink, orphaning it. `ensure` is unreliable vs SIGKILL, so the
|
|
66
|
+
# PARENT sweeps each source dir before and after the run. Orphans are
|
|
67
|
+
# impossible after a normal run. The first sweep runs before the boot: a
|
|
68
|
+
# file an older version left without the dot would stop Zeitwerk (#253).
|
|
69
|
+
dirs = JobPlan.source_dirs(config)
|
|
70
|
+
JobPlan.sweep_orphans(dirs)
|
|
71
|
+
|
|
58
72
|
# Boot mode: require the boot file ONCE so the app env (e.g. Rails) is booted
|
|
59
73
|
# in the parent and inherited by every fork. Do NOT manually require the
|
|
60
74
|
# sources. Under Zeitwerk a manual require of an autoloadable file raises;
|
|
@@ -102,6 +116,7 @@ module Mutineer
|
|
|
102
116
|
boot_path: File.expand_path(config.boot, config.project_root),
|
|
103
117
|
require_paths: config.require_paths, # loaded above; here only for the cache digest
|
|
104
118
|
verbose: config.verbose,
|
|
119
|
+
coverage_from: config.coverage_from && File.expand_path(config.coverage_from, config.project_root),
|
|
105
120
|
capture_timeout: config.capture_timeout || CoverageMap::DEFAULT_CAPTURE_TIMEOUT
|
|
106
121
|
).build_via_fork(after_fork: (config.rails ? -> { reconnect_active_record } : nil))
|
|
107
122
|
# Nothing reads Coverage once the map is built (cache hit or not), so
|
|
@@ -127,15 +142,7 @@ module Mutineer
|
|
|
127
142
|
# Collect every (subject, mutation) up front so the pool can fan them out.
|
|
128
143
|
jobs, ignored_results, source_map, extras = JobPlan.collect_jobs(config, operator_classes)
|
|
129
144
|
|
|
130
|
-
jobs = JobPlan.
|
|
131
|
-
|
|
132
|
-
# Whole-file reload writes mutineer_mutant*.rb into each source dir (so
|
|
133
|
-
# require_relative resolves). A SIGKILL'd child skips the tempfile's
|
|
134
|
-
# ensure-unlink, orphaning it. `ensure` is unreliable vs SIGKILL, so the
|
|
135
|
-
# PARENT sweeps each source dir before and after the run. Orphans are
|
|
136
|
-
# impossible after a normal run.
|
|
137
|
-
dirs = JobPlan.source_dirs(config)
|
|
138
|
-
JobPlan.sweep_orphans(dirs)
|
|
145
|
+
jobs, ignored_results, extras[:unscoped_mutants] = JobPlan.scope_since(jobs, ignored_results, source_map, config)
|
|
139
146
|
|
|
140
147
|
strategy = config.strategy
|
|
141
148
|
# Fail-fast must be serial: a parallel stop_when fires on the first survivor
|
|
@@ -165,6 +172,9 @@ module Mutineer
|
|
|
165
172
|
end
|
|
166
173
|
|
|
167
174
|
[AggregateResult.new(results + ignored_results), source_map, extras]
|
|
175
|
+
ensure
|
|
176
|
+
# A caller that set PARALLEL_WORKERS keeps that value after the run.
|
|
177
|
+
RailsRunEnv.restore_process!(previous_parallel_workers)
|
|
168
178
|
end
|
|
169
179
|
|
|
170
180
|
# A `--matrix` error that carries no {Kills} row (the worker crashed, or its
|
|
@@ -206,7 +216,7 @@ module Mutineer
|
|
|
206
216
|
FileSwap.restore_orphans(dirs)
|
|
207
217
|
|
|
208
218
|
jobs, ignored_results, source_map, extras = JobPlan.collect_jobs(config, operator_classes)
|
|
209
|
-
jobs = JobPlan.
|
|
219
|
+
jobs, ignored_results, extras[:unscoped_mutants] = JobPlan.scope_since(jobs, ignored_results, source_map, config)
|
|
210
220
|
|
|
211
221
|
# Nothing to mutate: return before the smoke check, which runs the whole
|
|
212
222
|
# --test set to calibrate a timeout no mutant would use (#76).
|
|
@@ -316,7 +326,8 @@ module Mutineer
|
|
|
316
326
|
# narrow identically (score parity).
|
|
317
327
|
kind, payload = JobPlan.coverage_selection(source_file, mutation, subject, source, coverage_map)
|
|
318
328
|
return payload if kind == :verdict
|
|
319
|
-
|
|
329
|
+
# A :maybe mutant that cannot be placed stays no_coverage, as before #254.
|
|
330
|
+
return kind == :maybe ? Result.no_coverage : Result.unplaceable if subject&.unplaceable?(strategy)
|
|
320
331
|
|
|
321
332
|
abs_tests = payload
|
|
322
333
|
|
|
@@ -338,7 +349,8 @@ module Mutineer
|
|
|
338
349
|
end
|
|
339
350
|
end
|
|
340
351
|
result = relative_kills(result, coverage_map.project_root)
|
|
341
|
-
JobPlan.load_verdict(result, source_file, mutation, subject, source, coverage_map)
|
|
352
|
+
result = JobPlan.load_verdict(result, source_file, mutation, subject, source, coverage_map)
|
|
353
|
+
JobPlan.selection_verdict(kind, result)
|
|
342
354
|
end
|
|
343
355
|
|
|
344
356
|
# Rewrites the test files of a result's {Kills} row relative to the project
|
|
@@ -12,12 +12,17 @@ module Mutineer
|
|
|
12
12
|
# @param def_node [Prism::DefNode] the method that holds the position.
|
|
13
13
|
# @param source [String] the source text of the file.
|
|
14
14
|
# @param offset [Integer] byte offset of the position.
|
|
15
|
+
# @param loose [Boolean] also return the lines when the position is in
|
|
16
|
+
# code that runs only sometimes when the statement runs (the right side of
|
|
17
|
+
# `a && b`, a branch, the body of `x if c`). The tests that ran the
|
|
18
|
+
# statement then include every test that ran the position, but may not
|
|
19
|
+
# have run it (#254).
|
|
15
20
|
# @return [Array<Integer>] the lines of the statement, with the body of each
|
|
16
21
|
# heredoc in it. Empty when no statement in the body holds the position.
|
|
17
22
|
# A first or last line that holds other code is left out, because Ruby
|
|
18
23
|
# can count that line without the statement: the `def` line counts when
|
|
19
24
|
# the file loads, and `x if done; y(` counts when `y` does not run.
|
|
20
|
-
def self.for(def_node, source, offset)
|
|
25
|
+
def self.for(def_node, source, offset, loose: false)
|
|
21
26
|
return [] unless def_node.body
|
|
22
27
|
|
|
23
28
|
path = path_to(def_node.body, offset) || []
|
|
@@ -25,13 +30,15 @@ module Mutineer
|
|
|
25
30
|
return [] unless index
|
|
26
31
|
|
|
27
32
|
statement = path[index]
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
33
|
+
unless loose
|
|
34
|
+
# Code that runs only sometimes inside the statement: the tests that ran
|
|
35
|
+
# the statement need not have run it.
|
|
36
|
+
return [] if path[index..].each_cons(2).any? { |parent, child| sometimes?(parent, child) }
|
|
37
|
+
|
|
38
|
+
# Ruby counts the line of `x while c` or `x if c` when it checks `c`,
|
|
39
|
+
# also when `x` never runs. The path is the modifier, its body, statement.
|
|
40
|
+
return [] if index >= 2 && modifier?(path[index - 2])
|
|
41
|
+
end
|
|
35
42
|
|
|
36
43
|
location = statement.location
|
|
37
44
|
lines = (location.start_line..last_line(statement)).to_a
|
data/lib/mutineer/subject.rb
CHANGED
|
@@ -8,11 +8,17 @@ module Mutineer
|
|
|
8
8
|
# (`module ::X` inside `Outer` owns into `X`); `lexical` is the class/module
|
|
9
9
|
# chain as written (`["Outer", "::X"]`), which the redefine strategy needs to
|
|
10
10
|
# rebuild the same Module.nesting as a whole-file reload (#145). `block_owner`
|
|
11
|
-
# is the constant a `Data.define`-style block is assigned to, as written
|
|
12
|
-
# `
|
|
13
|
-
#
|
|
11
|
+
# is the constant a `Data.define`-style block is assigned to, as written, or
|
|
12
|
+
# the `ClassMethods` of a concern's `class_methods` block (#252);
|
|
13
|
+
# `owner_unknown` is true when the class that owns the method cannot be named
|
|
14
|
+
# (an unassigned builder block, or a concern block whose class or module no
|
|
15
|
+
# constant names, such as an `included` block that runs in each includer),
|
|
16
|
+
# so the redefine strategy cannot load a mutant onto it. `hook_block` is true
|
|
17
|
+
# for a def anywhere inside a concern's `included` or `prepended` block, which
|
|
18
|
+
# ActiveSupport runs only when a class first includes the concern, so the
|
|
19
|
+
# reload strategy cannot load a mutant onto it either (#256).
|
|
14
20
|
Subject = Struct.new(:file, :namespace, :name, :singleton, :def_node, :lexical, :block_owner, :owner_unknown,
|
|
15
|
-
keyword_init: true) do
|
|
21
|
+
:hook_block, keyword_init: true) do
|
|
16
22
|
# Returns the fully-qualified subject name.
|
|
17
23
|
#
|
|
18
24
|
# @return [String] namespaced method name like `Billing::Invoice#total`.
|
|
@@ -28,6 +34,18 @@ module Mutineer
|
|
|
28
34
|
lexical || namespace
|
|
29
35
|
end
|
|
30
36
|
|
|
37
|
+
# True when the strategy cannot load a mutant of this method where the tests
|
|
38
|
+
# call it, so the mutant is not run. Redefine needs a named owner. Reload
|
|
39
|
+
# loads the whole file again, which redefines everything except what a
|
|
40
|
+
# concern's `included` or `prepended` block defines: that block does not run
|
|
41
|
+
# again in a class that already includes the concern.
|
|
42
|
+
#
|
|
43
|
+
# @param strategy [String] `"reload"` or `"redefine"`.
|
|
44
|
+
# @return [Boolean]
|
|
45
|
+
def unplaceable?(strategy)
|
|
46
|
+
strategy == "redefine" ? owner_unknown == true : hook_block == true
|
|
47
|
+
end
|
|
48
|
+
|
|
31
49
|
# Returns the body location for the subject, if any.
|
|
32
50
|
#
|
|
33
51
|
# @return [Prism::Location, nil] body location or nil for empty methods.
|
|
@@ -12,7 +12,8 @@ module Mutineer
|
|
|
12
12
|
# Child-process-only RSpec runner.
|
|
13
13
|
#
|
|
14
14
|
# Mirrors MinitestIntegration's contract: run the given spec files and
|
|
15
|
-
# return 0 (all passed) or
|
|
15
|
+
# return 0 (all passed), 1 (an example failed) or 2 (only an error
|
|
16
|
+
# outside examples, such as a spec file that raised while loading).
|
|
16
17
|
module RSpec
|
|
17
18
|
# Runs the given RSpec files.
|
|
18
19
|
#
|
|
@@ -21,7 +22,7 @@ module Mutineer
|
|
|
21
22
|
# first failing example (RSpec `--fail-fast`).
|
|
22
23
|
# @param record_to [IO, nil] a `--matrix` run: send each example's outcome
|
|
23
24
|
# to this channel (see {KillFormatter}) and run every example.
|
|
24
|
-
# @return [Integer] 0 on success,
|
|
25
|
+
# @return [Integer] 0 on success, else {.failure_code}.
|
|
25
26
|
# @raise [ArgumentError] when both options are given: a matrix run never stops.
|
|
26
27
|
def self.run(spec_files, stop_at_first_failure: false, record_to: nil)
|
|
27
28
|
if stop_at_first_failure && record_to
|
|
@@ -56,11 +57,22 @@ module Mutineer
|
|
|
56
57
|
# early, so `end` needs a reported outcome for every planned example.
|
|
57
58
|
KillChannel.write_end(record_to) if record_to && KillFormatter.last&.saw_every_example?
|
|
58
59
|
|
|
59
|
-
status.zero? ? 0 :
|
|
60
|
+
status.zero? ? 0 : failure_code
|
|
60
61
|
ensure
|
|
61
62
|
FileOrder.rank = nil
|
|
62
63
|
end
|
|
63
64
|
|
|
65
|
+
# The exit status of a failed run: 2 when no example failed and RSpec
|
|
66
|
+
# recorded an error outside examples, else 1.
|
|
67
|
+
#
|
|
68
|
+
# @api private
|
|
69
|
+
# @return [Integer] 1 or 2.
|
|
70
|
+
def self.failure_code
|
|
71
|
+
world = ::RSpec.world
|
|
72
|
+
outside_examples = world.respond_to?(:non_example_failure) && world.non_example_failure
|
|
73
|
+
outside_examples && ::RSpec.configuration.reporter.failed_examples.empty? ? 2 : 1
|
|
74
|
+
end
|
|
75
|
+
|
|
64
76
|
# Runs the top-level example groups in the order of `spec_files` (#203),
|
|
65
77
|
# after RSpec's own ordering, which then decides the order within a file.
|
|
66
78
|
#
|
|
@@ -7,8 +7,9 @@ module Mutineer
|
|
|
7
7
|
# Picks the test-framework runner.
|
|
8
8
|
#
|
|
9
9
|
# Each runner responds to
|
|
10
|
-
# `.run(files, stop_at_first_failure: false, record_to: nil) -> 0/1`
|
|
11
|
-
# (0 = all passed, 1 = any failure
|
|
10
|
+
# `.run(files, stop_at_first_failure: false, record_to: nil) -> 0/1/2`
|
|
11
|
+
# (0 = all passed, 1 = any failure, 2 = only an error outside tests, which
|
|
12
|
+
# the RSpec runner reports) and is called only inside a forked child.
|
|
12
13
|
# Only the mutant path passes `stop_at_first_failure: true`; a `--matrix`
|
|
13
14
|
# mutant run passes `record_to:` instead (a {KillChannel} pipe) and runs every
|
|
14
15
|
# test. The fork boundary silences stdout (see ChildStdout); the runners do not.
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "optparse"
|
|
5
|
+
require "yaml"
|
|
6
|
+
|
|
7
|
+
module Mutineer
|
|
8
|
+
# Prints `.mutineer.yml` `ignore:` entries for survivors in a JSON report.
|
|
9
|
+
# The command writes to stdout and does not edit files. The user pastes the
|
|
10
|
+
# lines under `ignore:`.
|
|
11
|
+
module Triage
|
|
12
|
+
# Usage line for a bad invocation.
|
|
13
|
+
USAGE = "Usage: mutineer triage REPORT.json --reason TEXT (--id ID ... | --all)"
|
|
14
|
+
|
|
15
|
+
# Reads argv, prints the entries, and returns the process exit code.
|
|
16
|
+
# 0 is success. 2 is a usage error.
|
|
17
|
+
#
|
|
18
|
+
# @param argv [Array<String>] arguments after the `triage` command.
|
|
19
|
+
# @param out [IO] where the entries go.
|
|
20
|
+
# @param err [IO] where usage errors go.
|
|
21
|
+
# @return [Integer] 0 or 2.
|
|
22
|
+
def self.run(argv, out: $stdout, err: $stderr)
|
|
23
|
+
reason = nil
|
|
24
|
+
ids = []
|
|
25
|
+
all = false
|
|
26
|
+
parser = OptionParser.new do |o|
|
|
27
|
+
o.banner = USAGE
|
|
28
|
+
o.on("--reason TEXT") { |text| reason = text }
|
|
29
|
+
o.on("--id ID") { |id| ids << id }
|
|
30
|
+
o.on("--all") { all = true }
|
|
31
|
+
end
|
|
32
|
+
begin
|
|
33
|
+
parser.parse!(argv)
|
|
34
|
+
rescue OptionParser::InvalidOption, OptionParser::MissingArgument => e
|
|
35
|
+
err.puts "mutineer: #{e.message}"
|
|
36
|
+
return 2
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
report = argv.shift
|
|
40
|
+
if argv.any?
|
|
41
|
+
err.puts "mutineer: triage takes one report file"
|
|
42
|
+
return 2
|
|
43
|
+
end
|
|
44
|
+
if report.nil? || report.empty?
|
|
45
|
+
err.puts "mutineer: triage requires a JSON report file"
|
|
46
|
+
return 2
|
|
47
|
+
end
|
|
48
|
+
text = reason.to_s.strip
|
|
49
|
+
if reason.nil? || text.empty?
|
|
50
|
+
err.puts "mutineer: triage requires --reason TEXT"
|
|
51
|
+
return 2
|
|
52
|
+
end
|
|
53
|
+
if all && !ids.empty?
|
|
54
|
+
err.puts "mutineer: triage takes --id or --all, not both"
|
|
55
|
+
return 2
|
|
56
|
+
end
|
|
57
|
+
if !all && ids.empty?
|
|
58
|
+
err.puts "mutineer: triage requires --id ID or --all"
|
|
59
|
+
return 2
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
survivors = survivor_ids(report, err)
|
|
63
|
+
return 2 if survivors.nil?
|
|
64
|
+
|
|
65
|
+
chosen = all ? survivors : ids
|
|
66
|
+
missing = chosen.reject { |id| survivors.include?(id) }
|
|
67
|
+
unless missing.empty?
|
|
68
|
+
err.puts "mutineer: triage id is not a survivor: #{missing.join(', ')}"
|
|
69
|
+
return 2
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
entries = chosen.uniq.map { |id| { "id" => id, "reason" => text } }
|
|
73
|
+
if entries.empty?
|
|
74
|
+
err.puts "mutineer: triage has nothing to paste under ignore:"
|
|
75
|
+
return 0
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
body = YAML.dump(entries).sub(/\A---\n/, "")
|
|
79
|
+
out.write(body.end_with?("\n") ? body : "#{body}\n")
|
|
80
|
+
0
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Survivor ids from a JSON report, in report order. Nil when the file
|
|
84
|
+
# cannot be used, after a message on `err`.
|
|
85
|
+
#
|
|
86
|
+
# @param path [String] report path.
|
|
87
|
+
# @param err [IO] usage errors.
|
|
88
|
+
# @return [Array<String>, nil]
|
|
89
|
+
def self.survivor_ids(path, err)
|
|
90
|
+
unless File.file?(path)
|
|
91
|
+
err.puts "mutineer: triage report not found: #{path}"
|
|
92
|
+
return nil
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
doc = JSON.parse(File.read(path))
|
|
96
|
+
rows = doc.is_a?(Hash) ? doc["survivors"] : nil
|
|
97
|
+
unless rows.is_a?(Array)
|
|
98
|
+
err.puts "mutineer: triage report has no survivors list"
|
|
99
|
+
return nil
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
rows.filter_map { |row| row["id"] if row.is_a?(Hash) && row["id"].is_a?(String) }
|
|
103
|
+
rescue JSON::ParserError => e
|
|
104
|
+
err.puts "mutineer: triage report is not JSON: #{e.message}"
|
|
105
|
+
nil
|
|
106
|
+
rescue SystemCallError
|
|
107
|
+
err.puts "mutineer: triage report cannot be read: #{path}"
|
|
108
|
+
nil
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|