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.
@@ -62,7 +62,12 @@ module Mutineer
62
62
  # Drop runnables inherited from the parent suite (this is the child's
63
63
  # private copy — the parent is unaffected) so only the target test runs.
64
64
  Minitest::Runnable.reset
65
- Array(test_files).each { |f| load f }
65
+ # Each test class's file position: the file that first defined it.
66
+ rank = {}
67
+ Array(test_files).each_with_index do |f, i|
68
+ load f
69
+ Minitest::Runnable.runnables.each { |klass| rank[klass] ||= i }
70
+ end
66
71
 
67
72
  armed =
68
73
  if record_to
@@ -73,6 +78,7 @@ module Mutineer
73
78
  # Pin the seed only when a hook is armed; an unknown Minitest shape gets
74
79
  # the normal full, randomly ordered run.
75
80
  args = armed && !ENV["SEED"] ? ["--seed", STOP_AT_FIRST_FAILURE_SEED.to_s] : []
81
+ keep_file_order(rank) if armed
76
82
  # No silencing here: the fork boundary that calls this method has already
77
83
  # pointed stdout at File::NULL (see ChildStdout).
78
84
  passed = Minitest.run(args)
@@ -86,5 +92,46 @@ module Mutineer
86
92
  StopAtFirstFailure.disarm!
87
93
  KillRecorder.disarm!
88
94
  end
95
+
96
+ # Runs the test classes in the order of their files (#203). Minitest
97
+ # shuffles the classes with the seed, then runs the serial classes before
98
+ # the parallel (`parallelize_me!`) ones. A stable sort by file position
99
+ # after that shuffle puts the files in the given order and keeps the
100
+ # seeded order within one file. The serial-then-parallel split is
101
+ # unchanged. Only the child's own class list gets {FileOrder}.
102
+ #
103
+ # @api private
104
+ # @param rank [Hash{Class => Integer}] each class's file position.
105
+ # @return [void]
106
+ def self.keep_file_order(rank)
107
+ FileOrder.rank = rank
108
+ ::Minitest::Runnable.runnables.extend(FileOrder)
109
+ end
110
+
111
+ # Extends the list of test classes: its `shuffle` keeps the file order.
112
+ module FileOrder
113
+ class << self
114
+ # Each test class's file position.
115
+ #
116
+ # @return [Hash{Class => Integer}, nil]
117
+ attr_accessor :rank
118
+ end
119
+
120
+ # The seeded shuffle, then a stable sort by file position.
121
+ #
122
+ # @return [Array<Class>]
123
+ def shuffle(...)
124
+ rank = FileOrder.rank || {}
125
+ super.sort_by.with_index { |klass, i| [rank.fetch(klass, rank.size), i] }
126
+ end
127
+
128
+ # Minitest 5.15 and older drop empty classes before the shuffle, so the
129
+ # filtered list keeps the file order too.
130
+ #
131
+ # @return [Array<Class>]
132
+ def reject(...)
133
+ super.extend(FileOrder)
134
+ end
135
+ end
89
136
  end
90
137
  end
@@ -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
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mutineer
4
+ # Ends a forked child's process group once the process that forked it is
5
+ # gone (#101). A mutant or capture child leads its own process group, so a
6
+ # parent that dies, or is killed by a client that gave up on it, does not
7
+ # take the child's tests with it; without this, a hung test would run on and
8
+ # keep its worker database open. Needs no other part of Mutineer, so the
9
+ # app-side daemon can load it.
10
+ module OrphanGuard
11
+ # Seconds between checks. A forked child gets no signal on parent death.
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+)-/
17
+
18
+ # Starts the watchdog in the current (child) process.
19
+ #
20
+ # @param parent [Integer] the parent's pid, read in the parent before
21
+ # `fork`, so a parent that dies before this call is still noticed.
22
+ # @return [Thread]
23
+ def self.start(parent)
24
+ Thread.new do
25
+ sleep(POLL) while Process.ppid == parent
26
+ # Group 0 only when this child leads its own group: a failed setpgid
27
+ # leaves it in the parent's group, which must not be killed.
28
+ Process.kill(:KILL, Process.getpgrp == Process.pid ? 0 : Process.pid)
29
+ end
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?
99
+ end
100
+ end