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.
@@ -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 deletes a heredoc opener and leaves its body.
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 removes a heredoc opener whose body hangs
68
- # past the replaced range.
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 leave a heredoc body behind.
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
- opener = @source.byteslice(opening.start_offset, opening.end_offset - opening.start_offset)
83
- return true if opener.empty? || !mutation.replacement.include?(opener)
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
@@ -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
- return super unless builds_class?(node)
140
-
141
- anonymous = !@assigned&.first.equal?(node)
142
- with_block_owner(anonymous ? [nil, @namespace_stack, true] : @assigned.last, anonymous) do
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