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
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "config"
|
|
3
4
|
require_relative "mutators/arithmetic"
|
|
4
5
|
require_relative "mutators/comparison"
|
|
5
6
|
require_relative "mutators/boolean_connector"
|
|
@@ -15,6 +16,7 @@ require_relative "mutators/safe_navigation"
|
|
|
15
16
|
require_relative "mutators/range_literal"
|
|
16
17
|
require_relative "mutators/negation_removal"
|
|
17
18
|
require_relative "mutators/chain_link"
|
|
19
|
+
require_relative "mutators/call_to_receiver"
|
|
18
20
|
require_relative "mutators/operand_removal"
|
|
19
21
|
require_relative "mutators/array_literal"
|
|
20
22
|
require_relative "mutators/condition_forcing"
|
|
@@ -46,6 +48,7 @@ module Mutineer
|
|
|
46
48
|
"range" => Mutators::RangeLiteral,
|
|
47
49
|
"negation_removal" => Mutators::NegationRemoval,
|
|
48
50
|
"chain_link" => Mutators::ChainLink,
|
|
51
|
+
"call_to_receiver" => Mutators::CallToReceiver,
|
|
49
52
|
"operand_removal" => Mutators::OperandRemoval,
|
|
50
53
|
"array_literal" => Mutators::ArrayLiteral,
|
|
51
54
|
"condition_true" => Mutators::ConditionTrue,
|
|
@@ -58,7 +61,7 @@ module Mutineer
|
|
|
58
61
|
# Tier-2 operators that remain opt-in.
|
|
59
62
|
TIER2_NAMES = %w[return_nil literal_mutation condition_negation string_literal regex collection_method
|
|
60
63
|
safe_navigation range negation_removal
|
|
61
|
-
chain_link operand_removal array_literal
|
|
64
|
+
chain_link call_to_receiver operand_removal array_literal
|
|
62
65
|
condition_true condition_false operator_assignment].freeze
|
|
63
66
|
|
|
64
67
|
# Short human-readable descriptions for each operator.
|
|
@@ -78,6 +81,7 @@ module Mutineer
|
|
|
78
81
|
"range" => ".. <-> ...",
|
|
79
82
|
"negation_removal" => "!x, not x -> x",
|
|
80
83
|
"chain_link" => "drop one call from a chain: a.b.c -> a.c",
|
|
84
|
+
"call_to_receiver" => "drop the last call of a chain: foo.bar -> foo",
|
|
81
85
|
"operand_removal" => "a && b -> a, b",
|
|
82
86
|
"array_literal" => "[a, b] -> []",
|
|
83
87
|
"condition_true" => "if/elsif/unless/ternary/modifier/case-in guard condition -> true",
|
|
@@ -91,7 +95,7 @@ module Mutineer
|
|
|
91
95
|
# @return [Array<Class>] mutator classes in the requested order.
|
|
92
96
|
# @raise [ArgumentError] when a name is unknown.
|
|
93
97
|
def self.resolve(names = DEFAULT_NAMES)
|
|
94
|
-
names.map { |n| ALL.fetch(n) { raise ArgumentError, "Unknown operator: #{n.inspect}" } }
|
|
98
|
+
names.map { |n| ALL.fetch(n) { raise ArgumentError, "Unknown operator: #{n.inspect}#{Mutineer.did_you_mean(n, ALL.keys)}" } }
|
|
95
99
|
end
|
|
96
100
|
|
|
97
101
|
# Returns whether the operator is part of the default Tier-1 set.
|
|
@@ -52,24 +52,27 @@ module Mutineer
|
|
|
52
52
|
|
|
53
53
|
private
|
|
54
54
|
|
|
55
|
-
# Drops a mutation that
|
|
55
|
+
# Drops a mutation that splits a heredoc opener from its body.
|
|
56
56
|
#
|
|
57
57
|
# The body sits past the opener's node. Replacing the opener with text
|
|
58
58
|
# that does not contain it turns the body into Ruby, which parses and
|
|
59
59
|
# then raises. A replacement that still contains the opener keeps the
|
|
60
|
-
# body attached, so that mutation stays.
|
|
60
|
+
# body attached, so that mutation stays. The reverse also breaks: when
|
|
61
|
+
# the replaced range holds the body but the replacement keeps only the
|
|
62
|
+
# opener, the opener has no body and the source does not parse. A
|
|
63
|
+
# replacement that copies the opener and the body together stays.
|
|
61
64
|
#
|
|
62
65
|
# @return [void]
|
|
63
66
|
def drop_dangling_heredocs
|
|
64
67
|
@mutations.reject! { |mutation| dangling_heredoc?(mutation) }
|
|
65
68
|
end
|
|
66
69
|
|
|
67
|
-
# Returns whether the mutation
|
|
68
|
-
#
|
|
70
|
+
# Returns whether the mutation replaces a heredoc opener and keeps the
|
|
71
|
+
# opener without its body, or the body without its opener.
|
|
69
72
|
#
|
|
70
73
|
# @param mutation [Mutineer::Mutation] candidate edit.
|
|
71
74
|
# @param node [Prism::Node, nil] subtree to search. Defaults to the body.
|
|
72
|
-
# @return [Boolean] true when the edit would
|
|
75
|
+
# @return [Boolean] true when the edit would split a heredoc from its body.
|
|
73
76
|
def dangling_heredoc?(mutation, node = @body)
|
|
74
77
|
return false if node.nil? || !node.is_a?(Prism::Node)
|
|
75
78
|
|
|
@@ -78,9 +81,13 @@ module Mutineer
|
|
|
78
81
|
if opening && opening.start_offset >= mutation.start_offset && opening.start_offset < mutation.end_offset
|
|
79
82
|
closing = node.respond_to?(:closing_loc) ? node.closing_loc : nil
|
|
80
83
|
hangs = closing.nil? || closing.end_offset > mutation.end_offset
|
|
84
|
+
opener = @source.byteslice(opening.start_offset, opening.end_offset - opening.start_offset)
|
|
85
|
+
kept = !opener.empty? && mutation.replacement.include?(opener)
|
|
81
86
|
if hangs
|
|
82
|
-
|
|
83
|
-
|
|
87
|
+
return true unless kept
|
|
88
|
+
elsif kept
|
|
89
|
+
whole = @source.byteslice(opening.start_offset, closing.end_offset - opening.start_offset)
|
|
90
|
+
return true unless mutation.replacement.include?(whole)
|
|
84
91
|
end
|
|
85
92
|
end
|
|
86
93
|
end
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base"
|
|
4
|
+
require_relative "chain_link"
|
|
5
|
+
|
|
6
|
+
module Mutineer
|
|
7
|
+
module Mutators
|
|
8
|
+
# Call-to-receiver mutator (Tier-2).
|
|
9
|
+
#
|
|
10
|
+
# Replaces the last call of a chain with its receiver, with the arguments
|
|
11
|
+
# and the block of that call: `user.full_name` becomes `user`, and
|
|
12
|
+
# `ids.uniq.sort` becomes `ids.uniq`. A chain begins at a receiver that is
|
|
13
|
+
# not a dotted call, as in {ChainLink}. A call in an argument or a block
|
|
14
|
+
# starts a chain of its own.
|
|
15
|
+
#
|
|
16
|
+
# The mutant survives when no test checks what the last call does: a strip,
|
|
17
|
+
# a uniq, a compact or a lookup the tests never see.
|
|
18
|
+
#
|
|
19
|
+
# The operator does not overlap with {ChainLink}. `chain_link` drops a link
|
|
20
|
+
# in the middle of a chain, and this operator drops the last call. A call
|
|
21
|
+
# that is the receiver of a dotted call, or of a call write or a call
|
|
22
|
+
# target, is a link, so it gets no mutant here. The two operators give the
|
|
23
|
+
# same source only when the last call repeats the link before it, as in
|
|
24
|
+
# `node.name.name`.
|
|
25
|
+
#
|
|
26
|
+
# Calls in {ChainLink::SKIPPED} are never replaced. On the last call,
|
|
27
|
+
# `x.to_s` is the same as `x` for the same reason as on a link. `Foo.new`
|
|
28
|
+
# becomes the class, and almost any use of the class raises.
|
|
29
|
+
#
|
|
30
|
+
# A call is not replaced when its name is an operator (`a.+(1)`), or a
|
|
31
|
+
# setter (`a.b = 1`). A call with no dot (`a + b`, `a[0]`) is not replaced.
|
|
32
|
+
class CallToReceiver < Base
|
|
33
|
+
# Matches the name of a call written as a word, not as an operator.
|
|
34
|
+
WORD_NAME = /\A[[:alpha:]_]/
|
|
35
|
+
|
|
36
|
+
# Resets the per-subject record of calls that are links.
|
|
37
|
+
#
|
|
38
|
+
# @param subject [Mutineer::Subject] subject whose body is visited.
|
|
39
|
+
# @param source [String] full source text for byte-based slicing.
|
|
40
|
+
# @return [Array<Mutineer::Mutation>] collected mutations.
|
|
41
|
+
def mutations_for(subject, source)
|
|
42
|
+
@links = {}.compare_by_identity
|
|
43
|
+
super
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Visits a call. A dotted call that is not a link is the last call of its
|
|
47
|
+
# chain.
|
|
48
|
+
#
|
|
49
|
+
# @param node [Prism::CallNode] call node to inspect.
|
|
50
|
+
# @return [void]
|
|
51
|
+
def visit_call_node(node)
|
|
52
|
+
mark(node)
|
|
53
|
+
replace(node) if dotted?(node) && !@links.key?(node)
|
|
54
|
+
super
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Visits `a.b.c += 1`. Its receiver is a link.
|
|
58
|
+
#
|
|
59
|
+
# @param node [Prism::CallOperatorWriteNode] node to inspect.
|
|
60
|
+
# @return [void]
|
|
61
|
+
def visit_call_operator_write_node(node)
|
|
62
|
+
mark(node)
|
|
63
|
+
super
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Visits `a.b.c ||= 1`. Its receiver is a link.
|
|
67
|
+
#
|
|
68
|
+
# @param node [Prism::CallOrWriteNode] node to inspect.
|
|
69
|
+
# @return [void]
|
|
70
|
+
def visit_call_or_write_node(node)
|
|
71
|
+
mark(node)
|
|
72
|
+
super
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Visits `a.b.c &&= 1`. Its receiver is a link.
|
|
76
|
+
#
|
|
77
|
+
# @param node [Prism::CallAndWriteNode] node to inspect.
|
|
78
|
+
# @return [void]
|
|
79
|
+
def visit_call_and_write_node(node)
|
|
80
|
+
mark(node)
|
|
81
|
+
super
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Visits a call used as an assignment target, as in `a.b.c, d = 1, 2`.
|
|
85
|
+
# Its receiver is a link.
|
|
86
|
+
#
|
|
87
|
+
# @param node [Prism::CallTargetNode] node to inspect.
|
|
88
|
+
# @return [void]
|
|
89
|
+
def visit_call_target_node(node)
|
|
90
|
+
mark(node)
|
|
91
|
+
super
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
private
|
|
95
|
+
|
|
96
|
+
# Records the receiver of a node as a link when the node is a dotted call
|
|
97
|
+
# or a call write or target. The receiver of a call with no dot is not a
|
|
98
|
+
# link. It is the last call of a chain of its own.
|
|
99
|
+
#
|
|
100
|
+
# @param node [Prism::Node] node that responds to `receiver`.
|
|
101
|
+
# @return [void]
|
|
102
|
+
def mark(node)
|
|
103
|
+
@links[node.receiver] = true if dotted?(node) || !node.is_a?(Prism::CallNode)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Emits the replacement of a call by its receiver, unless the name is an
|
|
107
|
+
# operator, a setter, or in {ChainLink::SKIPPED}.
|
|
108
|
+
#
|
|
109
|
+
# @param node [Prism::CallNode] the dotted call to replace.
|
|
110
|
+
# @return [void]
|
|
111
|
+
def replace(node)
|
|
112
|
+
name = node.name.to_s
|
|
113
|
+
return unless name.match?(WORD_NAME)
|
|
114
|
+
return if name.end_with?("=")
|
|
115
|
+
return if ChainLink::SKIPPED.include?(node.name)
|
|
116
|
+
|
|
117
|
+
receiver = node.receiver.location
|
|
118
|
+
@mutations << Mutation.new(
|
|
119
|
+
start_offset: node.location.start_offset,
|
|
120
|
+
end_offset: node.location.end_offset,
|
|
121
|
+
replacement: @source.byteslice(receiver.start_offset, receiver.end_offset - receiver.start_offset),
|
|
122
|
+
operator: :call_to_receiver
|
|
123
|
+
)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Returns whether a node is a call through `.`, `&.` or `::`.
|
|
127
|
+
#
|
|
128
|
+
# @param node [Prism::Node, nil] node to inspect.
|
|
129
|
+
# @return [Boolean] true for a call node with a call operator.
|
|
130
|
+
def dotted?(node)
|
|
131
|
+
node.is_a?(Prism::CallNode) && !node.call_operator_loc.nil?
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
@@ -25,7 +25,7 @@ module Mutineer
|
|
|
25
25
|
# the tests.
|
|
26
26
|
class ChainLink < Base
|
|
27
27
|
# Method names whose link is never dropped: core conversions and copies,
|
|
28
|
-
# plus `new`.
|
|
28
|
+
# plus `new`. {CallToReceiver} uses the same list for the last call.
|
|
29
29
|
SKIPPED = %i[
|
|
30
30
|
to_s to_str to_sym to_i to_int to_f to_r to_c to_a to_ary to_h to_hash to_proc to_set
|
|
31
31
|
dup clone freeze itself
|
|
@@ -10,6 +10,10 @@ module Mutineer
|
|
|
10
10
|
module OrphanGuard
|
|
11
11
|
# Seconds between checks. A forked child gets no signal on parent death.
|
|
12
12
|
POLL = 0.5
|
|
13
|
+
# `Tempfile.create([".mutineer_daemon", ".rb"])` names the file
|
|
14
|
+
# `.mutineer_daemon<YYYYMMDD>-<pid>-<rand>.rb` (without the dot before #253).
|
|
15
|
+
# The pid is the child that is loading the mutant.
|
|
16
|
+
DAEMON_TEMP_PID = /\A\.?mutineer_daemon\d{8}-(\d+)-/
|
|
13
17
|
|
|
14
18
|
# Starts the watchdog in the current (child) process.
|
|
15
19
|
#
|
|
@@ -24,5 +28,73 @@ module Mutineer
|
|
|
24
28
|
Process.kill(:KILL, Process.getpgrp == Process.pid ? 0 : Process.pid)
|
|
25
29
|
end
|
|
26
30
|
end
|
|
31
|
+
|
|
32
|
+
# True when a daemon mutant file still belongs to a live process. A boot
|
|
33
|
+
# sweep must leave it alone. The database lock is taken later, so the
|
|
34
|
+
# sweep is the step that used to delete another run's file.
|
|
35
|
+
#
|
|
36
|
+
# The creating pid is in the file name. A process that still holds the
|
|
37
|
+
# file lock counts too, even when that pid is already gone. A dead pid
|
|
38
|
+
# and a free lock is an orphan a killed child left behind.
|
|
39
|
+
#
|
|
40
|
+
# @param path [String] absolute path of a `.mutineer_daemon*.rb` file.
|
|
41
|
+
# @return [Boolean]
|
|
42
|
+
def self.mutant_file_in_use?(path)
|
|
43
|
+
creator_alive?(path) || locked_by_another?(path)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# True when the pid embedded in a daemon mutant file name is still running.
|
|
47
|
+
# Pid 0 is this process group, so it is never treated as an owner.
|
|
48
|
+
#
|
|
49
|
+
# @param path [String]
|
|
50
|
+
# @return [Boolean]
|
|
51
|
+
def self.creator_alive?(path)
|
|
52
|
+
pid_text = File.basename(path)[DAEMON_TEMP_PID, 1]
|
|
53
|
+
return false unless pid_text
|
|
54
|
+
|
|
55
|
+
pid = Integer(pid_text, 10)
|
|
56
|
+
return false if pid <= 0
|
|
57
|
+
|
|
58
|
+
Process.kill(0, pid)
|
|
59
|
+
!zombie?(pid)
|
|
60
|
+
rescue Errno::ESRCH, Errno::EINVAL, RangeError
|
|
61
|
+
false
|
|
62
|
+
rescue SystemCallError
|
|
63
|
+
true
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# True when `pid` has exited but nobody reaped it. A container whose pid 1
|
|
67
|
+
# does not reap orphans keeps a dead mutant child as a zombie for good, and
|
|
68
|
+
# `Process.kill(0)` still finds it (#250). Linux only: elsewhere there is no
|
|
69
|
+
# `/proc`, so a zombie still counts as running.
|
|
70
|
+
#
|
|
71
|
+
# @param pid [Integer]
|
|
72
|
+
# @return [Boolean]
|
|
73
|
+
def self.zombie?(pid)
|
|
74
|
+
stat = File.read("/proc/#{pid}/stat")
|
|
75
|
+
# The name before the state is in parentheses and may hold ") " itself.
|
|
76
|
+
# An empty read (the process exited mid-read) has no ")": not a zombie.
|
|
77
|
+
close = stat.rindex(")")
|
|
78
|
+
!close.nil? && stat[close + 2] == "Z"
|
|
79
|
+
rescue SystemCallError
|
|
80
|
+
false
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# True when another process holds an exclusive lock on `path`. A lock the
|
|
84
|
+
# kernel rejects as unsupported is not ownership: the pid check still applies.
|
|
85
|
+
# A file we cannot inspect is left in place.
|
|
86
|
+
#
|
|
87
|
+
# @param path [String]
|
|
88
|
+
# @return [Boolean]
|
|
89
|
+
def self.locked_by_another?(path)
|
|
90
|
+
File.open(path, File::RDONLY) do |file|
|
|
91
|
+
file.flock(File::LOCK_EX | File::LOCK_NB) == false
|
|
92
|
+
end
|
|
93
|
+
rescue Errno::ENOENT, Errno::ENOTSUP, Errno::EOPNOTSUPP
|
|
94
|
+
false
|
|
95
|
+
rescue SystemCallError
|
|
96
|
+
true
|
|
97
|
+
end
|
|
98
|
+
private_class_method :creator_alive?, :zombie?, :locked_by_another?
|
|
27
99
|
end
|
|
28
100
|
end
|
data/lib/mutineer/project.rb
CHANGED
|
@@ -31,6 +31,9 @@ module Mutineer
|
|
|
31
31
|
# Calls whose block is the body of the class they build: receiver name => method.
|
|
32
32
|
CLASS_BUILDERS = { "Data" => :define, "Struct" => :new, "Class" => :new, "Module" => :new }.freeze
|
|
33
33
|
|
|
34
|
+
# ActiveSupport::Concern calls whose block another module or class runs (see {#concern_block}).
|
|
35
|
+
CONCERN_BLOCKS = %i[class_methods included prepended].freeze
|
|
36
|
+
|
|
34
37
|
# A constant name, or a path of them; Ruby allows Unicode in both.
|
|
35
38
|
CONSTANT_PATH = /\A[[:upper:]][[:word:]]*(?:::[[:upper:]][[:word:]]*)*\z/
|
|
36
39
|
|
|
@@ -78,6 +81,8 @@ module Mutineer
|
|
|
78
81
|
@self_unknown = false # inside a def or a block that is not a builder, so `self` is not the namespace
|
|
79
82
|
@self_scope = nil # the def or block whose `self` the code runs with
|
|
80
83
|
@singleton_uncertain = false # the open `class << self` was inside a def or a non-builder block
|
|
84
|
+
@concern_modules = Set.new # the {#module_key} of each module that ran `extend ActiveSupport::Concern`
|
|
85
|
+
@hook_block = false # inside a concern's `included` or `prepended` block (see {#hook_block?})
|
|
81
86
|
super()
|
|
82
87
|
end
|
|
83
88
|
|
|
@@ -117,7 +122,11 @@ module Mutineer
|
|
|
117
122
|
# block's defs belong to the class it builds; one not assigned to a
|
|
118
123
|
# constant has no name, so its subjects are marked `owner_unknown`, and a
|
|
119
124
|
# `module_function :name` in it promotes nothing. Elsewhere it promotes the
|
|
120
|
-
# methods of the same module, matched by {#module_key}.
|
|
125
|
+
# methods of the same module, matched by {#module_key}. A module that runs
|
|
126
|
+
# `extend ActiveSupport::Concern` is a concern from then on in this file. A
|
|
127
|
+
# `class_methods` block in its body (see {#concern_block}) is visited like a
|
|
128
|
+
# builder block that builds the module's `ClassMethods`, so its defs belong to
|
|
129
|
+
# that module (#252). The defs in any other such block have their owner unknown.
|
|
121
130
|
#
|
|
122
131
|
# @param node [Prism::CallNode] call node.
|
|
123
132
|
# @return [void]
|
|
@@ -136,10 +145,30 @@ module Mutineer
|
|
|
136
145
|
end
|
|
137
146
|
end
|
|
138
147
|
end
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
148
|
+
if node.name == :extend && (node.receiver.nil? || node.receiver.is_a?(Prism::SelfNode)) &&
|
|
149
|
+
!@self_unknown && @singleton_depth.zero?
|
|
150
|
+
args = node.arguments&.arguments || []
|
|
151
|
+
concern = args.any? { |arg| arg.slice.delete_prefix("::") == "ActiveSupport::Concern" }
|
|
152
|
+
@concern_modules << module_key if concern
|
|
153
|
+
end
|
|
154
|
+
case concern_block(node)
|
|
155
|
+
when :owner_unknown
|
|
156
|
+
hook = hook_block?(node)
|
|
157
|
+
return preserving_scope do
|
|
158
|
+
@hook_block ||= hook
|
|
159
|
+
@owner_unknown = true
|
|
160
|
+
super
|
|
161
|
+
end
|
|
162
|
+
when :class_methods
|
|
163
|
+
owner = named_owner((@block_namespace || @namespace_stack) + ["ClassMethods"])
|
|
164
|
+
anonymous = false
|
|
165
|
+
else
|
|
166
|
+
return super unless builds_class?(node)
|
|
167
|
+
|
|
168
|
+
anonymous = !@assigned&.first.equal?(node)
|
|
169
|
+
owner = anonymous ? [nil, @namespace_stack, true] : @assigned.last
|
|
170
|
+
end
|
|
171
|
+
with_block_owner(owner, anonymous) do
|
|
143
172
|
@self_scope = node.block
|
|
144
173
|
super
|
|
145
174
|
end
|
|
@@ -221,6 +250,7 @@ module Mutineer
|
|
|
221
250
|
lexical: @lexical_stack.dup,
|
|
222
251
|
block_owner: @block_owner,
|
|
223
252
|
owner_unknown: @owner_unknown,
|
|
253
|
+
hook_block: @hook_block,
|
|
224
254
|
name: node.name,
|
|
225
255
|
singleton: !node.receiver.nil? || @singleton_depth.positive? || @module_function_active,
|
|
226
256
|
def_node: node
|
|
@@ -331,7 +361,7 @@ module Mutineer
|
|
|
331
361
|
# Visitor state a class/module body or builder block sets for what it contains.
|
|
332
362
|
SCOPE_STATE = %i[@namespace_stack @lexical_stack @module_function_active @block_owner @block_namespace
|
|
333
363
|
@owner_unknown @singleton_depth @singleton_cref @namespace_unknown @anonymous_block @body
|
|
334
|
-
@self_unknown @self_scope @singleton_uncertain].freeze
|
|
364
|
+
@self_unknown @self_scope @singleton_uncertain @hook_block].freeze
|
|
335
365
|
|
|
336
366
|
# Runs the block and then restores every {SCOPE_STATE} variable, so a
|
|
337
367
|
# nested body's state never leaks out of it.
|
|
@@ -448,6 +478,50 @@ module Mutineer
|
|
|
448
478
|
CLASS_BUILDERS[node.receiver&.slice&.delete_prefix("::")] == node.name
|
|
449
479
|
end
|
|
450
480
|
|
|
481
|
+
# Sorts an ActiveSupport::Concern block by where redefine can load its defs.
|
|
482
|
+
# ActiveSupport evaluates a `class_methods` block in the module's `ClassMethods`,
|
|
483
|
+
# which it extends onto every includer, so the test calls `Includer.name` on
|
|
484
|
+
# that module's method, not on the concern (#252). In a class or module body,
|
|
485
|
+
# or a builder block, with a known owner, that module is `<namespace>::ClassMethods`.
|
|
486
|
+
# The block keeps the lexical scope, so redefine reopens the concern's chain and
|
|
487
|
+
# loads the mutant with `::Concern::ClassMethods.class_eval`. In a def, a block
|
|
488
|
+
# that does not build a class (Rails' `concerning :Name do`), `class << self`, or
|
|
489
|
+
# under an owner no constant names (an unassigned `Module.new`), no constant here
|
|
490
|
+
# names that `ClassMethods`. ActiveSupport evaluates an `included` or `prepended`
|
|
491
|
+
# block in each includer, which no constant names either. The call has no
|
|
492
|
+
# receiver or `self`, no arguments, and a block.
|
|
493
|
+
#
|
|
494
|
+
# Another library can define methods with these names and run the block in
|
|
495
|
+
# the module itself. So a `class_methods` block counts only in a module that ran
|
|
496
|
+
# `extend ActiveSupport::Concern` earlier in this file, in this body or an earlier
|
|
497
|
+
# one ({#module_key}). Any other block with these names is unplaceable: guessing
|
|
498
|
+
# its owner would load a mutant where the tests never call it.
|
|
499
|
+
#
|
|
500
|
+
# @param node [Prism::CallNode] call node.
|
|
501
|
+
# @return [Symbol, nil] `:class_methods` when redefine can name the module,
|
|
502
|
+
# `:owner_unknown` for any other Concern block, or nil for any other call.
|
|
503
|
+
def concern_block(node)
|
|
504
|
+
return unless CONCERN_BLOCKS.include?(node.name) && node.arguments.nil? && node.block.is_a?(Prism::BlockNode)
|
|
505
|
+
return unless node.receiver.nil? || node.receiver.is_a?(Prism::SelfNode)
|
|
506
|
+
placeable = node.name == :class_methods && !@self_unknown && @singleton_depth.zero? && !@owner_unknown &&
|
|
507
|
+
@concern_modules.include?(module_key)
|
|
508
|
+
placeable ? :class_methods : :owner_unknown
|
|
509
|
+
end
|
|
510
|
+
|
|
511
|
+
# True for an `included` or `prepended` block of a module that ran `extend
|
|
512
|
+
# ActiveSupport::Concern` earlier in this file (see {#concern_block}).
|
|
513
|
+
# ActiveSupport runs the block only when a class first includes or prepends
|
|
514
|
+
# the concern, so loading the file again stores the new block but never
|
|
515
|
+
# runs it in a class that already has the concern (#256). Everything the
|
|
516
|
+
# block defines, a nested class or a builder block included, stays as the
|
|
517
|
+
# original wrote it under `--strategy reload`.
|
|
518
|
+
#
|
|
519
|
+
# @param node [Prism::CallNode] a call {#concern_block} sorted as a concern block.
|
|
520
|
+
# @return [Boolean]
|
|
521
|
+
def hook_block?(node)
|
|
522
|
+
%i[included prepended].include?(node.name) && @concern_modules.include?(module_key)
|
|
523
|
+
end
|
|
524
|
+
|
|
451
525
|
# Runs the block with the defs it visits owned by the class a builder block builds.
|
|
452
526
|
# The block body defines instance methods of that class, even inside `class << self`,
|
|
453
527
|
# but its constants still land where the enclosing `class << self` puts them.
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mutineer
|
|
4
|
+
# Pins the environment Rails and Spring read during a mutant run.
|
|
5
|
+
#
|
|
6
|
+
# Rails reads +PARALLEL_WORKERS+ inside +parallelize+ and uses it instead of
|
|
7
|
+
# the +workers:+ argument. A value of 1 keeps that run in one process.
|
|
8
|
+
# +--test-command+ also sets +DISABLE_SPRING+ so the Spring preloader does
|
|
9
|
+
# not start. {pin_process!} prints the replacement line once. The caller
|
|
10
|
+
# puts the process value back with {restore_process!} when the run ends.
|
|
11
|
+
module RailsRunEnv
|
|
12
|
+
# Value of +PARALLEL_WORKERS+ that keeps Rails test workers off.
|
|
13
|
+
PARALLEL_WORKERS = "1"
|
|
14
|
+
|
|
15
|
+
# Value of +DISABLE_SPRING+ that stops the Spring preloader.
|
|
16
|
+
DISABLE_SPRING = "1"
|
|
17
|
+
|
|
18
|
+
# Sets +PARALLEL_WORKERS+ to {PARALLEL_WORKERS} in this process.
|
|
19
|
+
# Prints one line when the previous value is set and is not already 1.
|
|
20
|
+
#
|
|
21
|
+
# @return [void]
|
|
22
|
+
def self.pin_process!
|
|
23
|
+
current = ENV["PARALLEL_WORKERS"]
|
|
24
|
+
warn(replacement_line(current)) if replace?(current)
|
|
25
|
+
ENV["PARALLEL_WORKERS"] = PARALLEL_WORKERS
|
|
26
|
+
nil
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Puts the caller's +PARALLEL_WORKERS+ back. +nil+ means it was unset.
|
|
30
|
+
#
|
|
31
|
+
# @param previous [String, nil]
|
|
32
|
+
# @return [void]
|
|
33
|
+
def self.restore_process!(previous)
|
|
34
|
+
if previous.nil?
|
|
35
|
+
ENV.delete("PARALLEL_WORKERS")
|
|
36
|
+
else
|
|
37
|
+
ENV["PARALLEL_WORKERS"] = previous
|
|
38
|
+
end
|
|
39
|
+
nil
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Forces +PARALLEL_WORKERS+ on a child environment.
|
|
43
|
+
# +spring+ also sets +DISABLE_SPRING+. The hash is the spawn delta or the
|
|
44
|
+
# full daemon environment. This does not change the parent process.
|
|
45
|
+
#
|
|
46
|
+
# @param env [Hash{String => String, nil}]
|
|
47
|
+
# @param spring [Boolean] set +DISABLE_SPRING+ when true.
|
|
48
|
+
# @return [void]
|
|
49
|
+
def self.pin_child!(env, spring: false)
|
|
50
|
+
env["PARALLEL_WORKERS"] = PARALLEL_WORKERS
|
|
51
|
+
env["DISABLE_SPRING"] = DISABLE_SPRING if spring
|
|
52
|
+
nil
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# True when Mutineer must replace a +PARALLEL_WORKERS+ value the user set.
|
|
56
|
+
#
|
|
57
|
+
# @param current [String, nil]
|
|
58
|
+
# @return [Boolean]
|
|
59
|
+
def self.replace?(current)
|
|
60
|
+
!current.nil? && current != PARALLEL_WORKERS
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# The one line printed when {replace?} is true.
|
|
64
|
+
#
|
|
65
|
+
# @param current [String]
|
|
66
|
+
# @return [String]
|
|
67
|
+
def self.replacement_line(current)
|
|
68
|
+
"[mutineer] PARALLEL_WORKERS was #{current.inspect}; using 1 so each mutant's tests run in one process. " \
|
|
69
|
+
"Unset it or set it to 1 to drop this line."
|
|
70
|
+
end
|
|
71
|
+
private_class_method :replace?, :replacement_line
|
|
72
|
+
end
|
|
73
|
+
end
|