mutineer 1.3.0 → 1.5.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.
Files changed (44) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +317 -0
  3. data/README.md +200 -28
  4. data/lib/mutineer/changed_lines.rb +57 -2
  5. data/lib/mutineer/cli.rb +142 -92
  6. data/lib/mutineer/config.rb +215 -56
  7. data/lib/mutineer/coverage_map.rb +161 -14
  8. data/lib/mutineer/daemon_backend.rb +3 -0
  9. data/lib/mutineer/daemon_client.rb +170 -13
  10. data/lib/mutineer/daemon_server.rb +2 -1
  11. data/lib/mutineer/external_backend.rb +2 -2
  12. data/lib/mutineer/isolation.rb +91 -6
  13. data/lib/mutineer/kill_channel.rb +284 -0
  14. data/lib/mutineer/kill_matrix.rb +88 -0
  15. data/lib/mutineer/minitest_integration/kill_recorder.rb +301 -0
  16. data/lib/mutineer/minitest_integration/outer_reporter.rb +126 -0
  17. data/lib/mutineer/minitest_integration/stop_at_first_failure.rb +14 -68
  18. data/lib/mutineer/minitest_integration.rb +27 -10
  19. data/lib/mutineer/mutant_id.rb +5 -4
  20. data/lib/mutineer/mutator_registry.rb +12 -3
  21. data/lib/mutineer/mutators/array_literal.rb +0 -7
  22. data/lib/mutineer/mutators/base.rb +63 -4
  23. data/lib/mutineer/mutators/chain_link.rb +0 -7
  24. data/lib/mutineer/mutators/collection_method.rb +1 -1
  25. data/lib/mutineer/mutators/condition_forcing.rb +134 -0
  26. data/lib/mutineer/mutators/operand_removal.rb +0 -7
  27. data/lib/mutineer/mutators/operator_assignment.rb +113 -0
  28. data/lib/mutineer/mutators/return_nil.rb +5 -11
  29. data/lib/mutineer/mutators/string_literal.rb +1 -1
  30. data/lib/mutineer/pairing.rb +222 -15
  31. data/lib/mutineer/parser.rb +12 -3
  32. data/lib/mutineer/project.rb +7 -0
  33. data/lib/mutineer/rails_worker_db.rb +1 -1
  34. data/lib/mutineer/reporter.rb +288 -52
  35. data/lib/mutineer/result.rb +37 -1
  36. data/lib/mutineer/runner.rb +133 -16
  37. data/lib/mutineer/statement_lines.rb +143 -0
  38. data/lib/mutineer/test_runners/minitest.rb +4 -2
  39. data/lib/mutineer/test_runners/rspec.rb +243 -3
  40. data/lib/mutineer/test_runners.rb +5 -3
  41. data/lib/mutineer/version.rb +1 -1
  42. data/lib/mutineer/worker_pool.rb +4 -2
  43. data/lib/mutineer.rb +3 -0
  44. metadata +10 -3
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "json"
4
4
  require "open3"
5
+ require_relative "external_backend"
5
6
 
6
7
  module Mutineer
7
8
  # Raised when the daemon cannot be booted or is gone for good: a bad boot path, an
@@ -13,10 +14,12 @@ module Mutineer
13
14
 
14
15
  # Tool-side handle for the app-side daemon.
15
16
  #
16
- # Spawns `daemon_server.rb` UNDER THE APP'S BUNDLE/RUBY (cleaned env so the
17
- # gem's bundler context never leaks; the daemon file is loaded by absolute path
18
- # with `-r`, which bypasses the app bundle that has no mutineer), completes the
19
- # ready handshake, then ships per-mutant payloads and reads structured verdicts.
17
+ # Spawns `daemon_server.rb` UNDER THE APP'S BUNDLE/RUBY. The child gets the
18
+ # environment from before the tool's Bundler activated, with
19
+ # <tt>unsetenv_others</tt>, because Open3 keeps a key the hash omits. The
20
+ # daemon file is loaded by absolute path with `-r`, which bypasses the app
21
+ # bundle that has no mutineer. The client completes the ready handshake,
22
+ # then ships per-mutant payloads and reads structured verdicts.
20
23
  # If the daemon dies mid-run it respawns (bounded) and marks the in-flight
21
24
  # mutant `error` rather than corrupting the run. Reuses the cleaned-env spawn
22
25
  # and stderr-drain proven in the spike driver and the spawn discipline of
@@ -26,11 +29,15 @@ module Mutineer
26
29
  DAEMON_PATH = File.expand_path("daemon_server.rb", __dir__)
27
30
  # How many times to respawn a crashing daemon before aborting the run.
28
31
  MAX_RESTARTS = 3
32
+ # Bundler's marker for a variable that was unset before it activated.
33
+ BUNDLER_UNSET = "BUNDLER_ENVIRONMENT_PRESERVER_INTENTIONALLY_NIL"
29
34
 
30
35
  # @param boot [Hash] boot config sent to the daemon: project_root, boot,
31
36
  # load_paths, framework, rails.
32
37
  # @param app_root [String] directory to spawn the daemon in (the app root).
33
- # @param ruby_version [String, nil] RBENV_VERSION for the app's Ruby (nil = inherit).
38
+ # @param ruby_version [String, nil] RBENV_VERSION for the app's Ruby.
39
+ # nil keeps a pin already in the environment. That pin is often the
40
+ # Ruby that started the tool.
34
41
  # @param gemfile [String, nil] BUNDLE_GEMFILE for the app's bundle (nil = app_root/Gemfile).
35
42
  # @param errio [IO] where daemon stderr is drained.
36
43
  def initialize(boot:, app_root:, ruby_version: nil, gemfile: nil, errio: $stderr)
@@ -117,25 +124,174 @@ module Mutineer
117
124
 
118
125
  private
119
126
 
120
- # Cleaned environment for the app bundle: strip the gem's bundler/Ruby context
121
- # so `bundle exec` resolves the APP's Gemfile under the requested Ruby.
127
+ # Full environment for the daemon child. Spawn with +unsetenv_others+ so a
128
+ # key deleted here does not leak back from the parent. Undo only what
129
+ # Bundler saved before it activated, then point the child at the target app.
130
+ #
131
+ # @api private
132
+ # @return [Hash{String => String}]
122
133
  def app_env
123
- env = ENV.to_h.reject { |k, _| k.start_with?("BUNDLE_", "RUBY", "GEM_") }
134
+ env = restored_user_env
135
+ scrub_managed_ruby_bins!(env)
136
+ env.delete("BUNDLER_SETUP")
124
137
  env["BUNDLE_GEMFILE"] = @gemfile
125
138
  env["RBENV_VERSION"] = @ruby_version if @ruby_version
126
- env["RAILS_ENV"] ||= "test" if @boot[:rails] || @boot["rails"]
139
+ env["RAILS_ENV"] = "test" if rails_boot? && !env.key?("RAILS_ENV")
140
+ env
141
+ end
142
+
143
+ # Environment with every <tt>BUNDLER_ORIG_*</tt> value put back. Bundler
144
+ # writes one saved key per variable it replaced. Restoring all of them
145
+ # undoes the tool bundle without a copied key list.
146
+ #
147
+ # @api private
148
+ # @return [Hash{String => String}]
149
+ def restored_user_env
150
+ env = ENV.to_h
151
+ restored = []
152
+ env.keys.each do |saved_key|
153
+ next unless saved_key.start_with?("BUNDLER_ORIG_")
154
+
155
+ key = saved_key.delete_prefix("BUNDLER_ORIG_")
156
+ saved = env.delete(saved_key)
157
+ restored << key
158
+ if saved == BUNDLER_UNSET
159
+ env.delete(key)
160
+ else
161
+ env[key] = saved
162
+ end
163
+ end
164
+ # A parent require must not run in the app, even when Bundler saved it.
165
+ %w[RUBYOPT RUBYLIB].each { |key| env.delete(key) }
166
+ # No saved original means the value belongs to the tool process.
167
+ %w[GEM_HOME GEM_PATH BUNDLE_PATH BUNDLE_WITHOUT BUNDLER_VERSION].each do |key|
168
+ env.delete(key) unless restored.include?(key)
169
+ end
127
170
  env
128
171
  end
129
172
 
173
+ # Drop rbenv and asdf version bins so shims can select the app Ruby.
174
+ # Leave those bins in place when no shim directory exists, so +bundle+
175
+ # stays on PATH. Leave chruby bins in place.
176
+ #
177
+ # @api private
178
+ # @param env [Hash{String => String}]
179
+ # @return [void]
180
+ def scrub_managed_ruby_bins!(env)
181
+ parts = env["PATH"].to_s.split(File::PATH_SEPARATOR)
182
+ rbenv = rbenv_shim_dirs(env)
183
+ asdf = asdf_shim_dirs(env)
184
+ dropped_rbenv = false
185
+ dropped_asdf = false
186
+ kept = parts.reject do |part|
187
+ if rbenv_version_bin?(part, env) && rbenv.any?
188
+ dropped_rbenv = true
189
+ elsif asdf_version_bin?(part, env) && asdf.any?
190
+ dropped_asdf = true
191
+ else
192
+ false
193
+ end
194
+ end
195
+ return if kept.size == parts.size
196
+
197
+ shims = []
198
+ shims.concat(rbenv) if dropped_rbenv
199
+ shims.concat(asdf) if dropped_asdf
200
+ env["PATH"] = (shims + kept).uniq.join(File::PATH_SEPARATOR)
201
+ end
202
+
203
+ # Existing rbenv shim directory. +RBENV_ROOT+ wins. Otherwise the home
204
+ # directory's <tt>.rbenv</tt> is used.
205
+ #
206
+ # @api private
207
+ # @param env [Hash{String => String}]
208
+ # @return [Array<String>]
209
+ def rbenv_shim_dirs(env)
210
+ root = rbenv_root(env)
211
+ return [] if root.nil?
212
+
213
+ dir = File.join(root, "shims")
214
+ File.directory?(dir) ? [dir] : []
215
+ end
216
+
217
+ # rbenv install root. +RBENV_ROOT+ wins over <tt>~/.rbenv</tt>.
218
+ #
219
+ # @api private
220
+ # @param env [Hash{String => String}]
221
+ # @return [String, nil]
222
+ def rbenv_root(env)
223
+ root = env["RBENV_ROOT"]
224
+ return File.expand_path(root) if root && !root.empty?
225
+
226
+ home = env["HOME"]
227
+ return nil if home.nil? || home.empty?
228
+
229
+ File.join(home, ".rbenv")
230
+ end
231
+
232
+ # Existing asdf shim directories. A custom install uses +ASDF_DATA_DIR+.
233
+ #
234
+ # @api private
235
+ # @param env [Hash{String => String}]
236
+ # @return [Array<String>]
237
+ def asdf_shim_dirs(env)
238
+ dirs = []
239
+ data = env["ASDF_DATA_DIR"]
240
+ dirs << File.join(data, "shims") if data && !data.empty?
241
+ home = env["HOME"]
242
+ dirs << File.join(home, ".asdf", "shims") if home && !home.empty?
243
+ dirs.select { |dir| File.directory?(dir) }.uniq
244
+ end
245
+
246
+ # True when `part` is a version bin under the active rbenv root.
247
+ #
248
+ # @api private
249
+ # @param part [String] one PATH entry.
250
+ # @param env [Hash{String => String}]
251
+ # @return [Boolean]
252
+ def rbenv_version_bin?(part, env)
253
+ root = rbenv_root(env)
254
+ return false if root.nil?
255
+
256
+ prefix = File.join(root, "versions")
257
+ File.expand_path(part).match?(%r{\A#{Regexp.escape(prefix)}/[^/]+/bin/?\z})
258
+ end
259
+
260
+ # True when `part` is an asdf Ruby version bin under ~/.asdf or +ASDF_DATA_DIR+.
261
+ #
262
+ # @api private
263
+ # @param part [String] one PATH entry.
264
+ # @param env [Hash{String => String}]
265
+ # @return [Boolean]
266
+ def asdf_version_bin?(part, env)
267
+ return true if part.match?(%r{/\.asdf/installs/ruby/[^/]+/bin/?\z})
268
+
269
+ data = env["ASDF_DATA_DIR"]
270
+ return false if data.nil? || data.empty?
271
+
272
+ prefix = File.join(File.expand_path(data), "installs", "ruby")
273
+ File.expand_path(part).match?(%r{\A#{Regexp.escape(prefix)}/[^/]+/bin/?\z})
274
+ end
275
+
276
+ # True when this daemon boots a Rails app.
277
+ #
278
+ # @api private
279
+ # @return [Boolean]
280
+ def rails_boot?
281
+ @boot[:rails] || @boot["rails"]
282
+ end
283
+
130
284
  # Spawn the daemon under the app bundle and complete the ready handshake.
131
285
  #
132
286
  # @return [void]
133
287
  # @raise [Mutineer::DaemonBootError] when the daemon fails to boot.
134
288
  def spawn_daemon
135
289
  # Plain `bundle exec ruby`, NOT `rbenv exec`, which would break CI and any
136
- # non-rbenv setup. When bundler/ruby are rbenv shims, the RBENV_VERSION
137
- # carried in app_env still selects the app's Ruby; otherwise the active
138
- # Ruby is used.
290
+ # non-rbenv setup. An explicit ruby_version replaces RBENV_VERSION.
291
+ # With no argument, a pin already in the environment stays. That pin is
292
+ # often the Ruby that started the tool. `.ruby-version` applies only
293
+ # when no pin is set. rbenv and asdf version bins leave PATH when a shim
294
+ # directory exists. chruby bins stay.
139
295
  # Everything up to the handshake is terminal, not one mutant's problem: a spawn
140
296
  # the OS refuses (EMFILE/ENOMEM under --jobs N, ENOENT when `bundle` does not
141
297
  # resolve) and a daemon that dies before accepting the boot payload (EPIPE on
@@ -145,7 +301,8 @@ module Mutineer
145
301
  begin
146
302
  @stdin, @stdout, @stderr, @wait_thr = Open3.popen3(
147
303
  app_env, "bundle", "exec", "ruby",
148
- "-r", DAEMON_PATH, "-e", "Mutineer::DaemonServer.run", chdir: @app_root
304
+ "-r", DAEMON_PATH, "-e", "Mutineer::DaemonServer.run",
305
+ unsetenv_others: true, chdir: @app_root
149
306
  )
150
307
  # Drain daemon stderr to the tool's stderr so child/boot errors are visible.
151
308
  # Tracked (not fire-and-forget) so close_io can reclaim it on quit/respawn;
@@ -149,7 +149,8 @@ module Mutineer
149
149
  cmap = CoverageMap.new(
150
150
  source_paths: Array(@cfg["sources"]), test_paths: Array(@cfg["tests"]),
151
151
  load_paths: Array(@cfg["load_paths"]), project_root: root,
152
- boot_path: @cfg["boot"], framework: @framework, cache_dir: File.join(root, ".mutineer")
152
+ boot_path: @cfg["boot"], framework: @framework, cache_dir: @cfg["cache_dir"] || File.join(root, ".mutineer"),
153
+ capture_timeout: @cfg["capture_timeout"] || CoverageMap::DEFAULT_CAPTURE_TIMEOUT
153
154
  ).build_via_fork(after_fork: coverage_after_fork)
154
155
  { "map" => cmap.map, "failed_test_files" => cmap.failed_test_files,
155
156
  "failed_clean_tests" => cmap.failed_clean_tests }
@@ -52,11 +52,11 @@ module Mutineer
52
52
  ].freeze
53
53
 
54
54
  # Turn a command template into an argv array (no shell → no eval, no
55
- # injection). The `%{files}` token expands IN PLACE to N separate argv
55
+ # injection). The <tt>%{files}</tt> token expands IN PLACE to N separate argv
56
56
  # elements — one per path, unescaped — so a path containing a space stays a
57
57
  # single argument. It is not a space-joined string.
58
58
  #
59
- # @param command [String] the --test-command template (contains %{files}).
59
+ # @param command [String] the --test-command template (contains <tt>%{files}</tt>).
60
60
  # @param files [Array<String>] test file paths to substitute.
61
61
  # @return [Array<String>] argv.
62
62
  def self.build_argv(command, files)
@@ -4,6 +4,7 @@ require "tempfile"
4
4
  require_relative "result"
5
5
  require_relative "parser"
6
6
  require_relative "child_stdout"
7
+ require_relative "kill_channel"
7
8
 
8
9
  module Mutineer
9
10
  # Fork-based isolation for running one mutant. The block runs in a child
@@ -23,6 +24,9 @@ module Mutineer
23
24
  class Isolation
24
25
  DEFAULT_TIMEOUT = 10 # seconds
25
26
 
27
+ # Seconds between the parent's checks on a running child.
28
+ POLL_INTERVAL = 0.005
29
+
26
30
  # Runs the block in a forked child. The block's return value (an Integer
27
31
  # exit code) or any explicit `exit` is honoured; an unhandled exception
28
32
  # becomes exit 2 with the cause written to STDERR.
@@ -30,18 +34,30 @@ module Mutineer
30
34
  # The child silences its stdout (see {ChildStdout.silence}) before the
31
35
  # block runs, so test output never reaches the user. Stderr stays open.
32
36
  #
37
+ # With `channel: true` (a `--matrix` run) the block gets the write end of a
38
+ # pipe for {KillChannel} lines. The parent reads it while it waits, so a
39
+ # child with more than a pipe buffer to say never blocks, and attaches the
40
+ # lines to the Result as {Kills}. The verdict is the one a run without
41
+ # `--matrix` gives (see {.finish}).
42
+ #
33
43
  # @param timeout [Integer] timeout in seconds.
44
+ # @param channel [Boolean] open a {KillChannel} pipe for the block.
45
+ # @yieldparam channel [IO, nil] the pipe's write end, or nil without `channel`.
34
46
  # @yieldreturn [Integer] child exit status.
35
47
  # @return [Mutineer::Result] result from the child process.
36
- def self.run(timeout: DEFAULT_TIMEOUT)
48
+ def self.run(timeout: DEFAULT_TIMEOUT, channel: false)
49
+ rd, wr = IO.pipe if channel
50
+ wr&.sync = true
37
51
  pid = fork do
52
+ rd&.close
38
53
  # Own process group so a timeout kill can reap grandchildren (match
39
54
  # daemon/external backends). Best-effort: if setpgid fails, kill the pid.
40
55
  Process.setpgid(0, 0) rescue nil # rubocop:disable Style/RescueModifier
41
56
  code = 0
42
57
  begin
43
58
  ChildStdout.silence
44
- result = yield
59
+ # A block that takes no channel (a zero-arity lambda) still runs.
60
+ result = channel ? yield(wr) : yield
45
61
  code = result.is_a?(Integer) ? result : 0
46
62
  rescue SystemExit => e
47
63
  code = e.status
@@ -57,13 +73,17 @@ module Mutineer
57
73
  exit!(code)
58
74
  end
59
75
 
76
+ wr&.close
77
+ buffer = +"" if rd
78
+ reading = !rd.nil?
79
+
60
80
  # Single-threaded deadline poll: we are the ONLY caller of waitpid on this
61
81
  # pid, so we never reap-then-kill. SIGKILL the process group only after
62
82
  # WNOHANG shows the child is still alive past the deadline.
63
83
  deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
64
84
  loop do
65
85
  reaped, status = Process.waitpid2(pid, Process::WNOHANG)
66
- return decode(status) if reaped
86
+ return finish(decode(status), rd, buffer, finished: true) if reaped
67
87
 
68
88
  if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
69
89
  begin
@@ -74,14 +94,79 @@ module Mutineer
74
94
  begin
75
95
  _reaped, status = Process.waitpid2(pid)
76
96
  # Child may have finished cleanly between WNOHANG and kill; honor it.
77
- return decode(status) if status && status.exited? && !status.signaled?
97
+ return finish(decode(status), rd, buffer, finished: true) if status && status.exited? && !status.signaled?
78
98
  rescue Errno::ECHILD
79
99
  # already reaped
80
100
  end
81
- return Result.timeout
101
+ return finish(Result.timeout, rd, buffer, finished: false)
102
+ end
103
+ if reading
104
+ IO.select([rd], nil, nil, POLL_INTERVAL)
105
+ reading = drain(rd, buffer)
106
+ else
107
+ sleep POLL_INTERVAL
82
108
  end
83
- sleep 0.005
84
109
  end
110
+ ensure
111
+ rd&.close
112
+ # Closed after the fork in the normal path; still open if fork raised.
113
+ wr.close if wr && !wr.closed?
114
+ end
115
+
116
+ # Reads what the channel holds now, without blocking.
117
+ #
118
+ # @api private
119
+ # @param rd [IO] the channel's read end.
120
+ # @param buffer [String] bytes read so far; appended to.
121
+ # @return [Boolean] false once the channel is at end of file.
122
+ def self.drain(rd, buffer)
123
+ loop do
124
+ chunk = rd.read_nonblock(65_536, exception: false)
125
+ return true if chunk == :wait_readable
126
+ return false if chunk.nil?
127
+
128
+ buffer << chunk
129
+ end
130
+ end
131
+
132
+ # The Result to return. Without a channel it is `result` itself. With one,
133
+ # the rest of the channel is read, and the Result carries the {Kills} it
134
+ # names.
135
+ #
136
+ # The verdict matches a run without `--matrix`, which skips everything after
137
+ # the first failing serial test except the code around it (see
138
+ # {KillChannel}). So when a serial test has named a kill and the child then
139
+ # ended inside a region that run would skip (an exit, a crash or the
140
+ # timeout in a later test, class or group), the mutant is `killed`: the
141
+ # plain run never reached that point. An end anywhere else keeps the exit
142
+ # status, because the plain run reaches that code too: the rest of the
143
+ # failing test's class wrapper or group hooks, or the suite hooks. A kill in
144
+ # the parallel tests of a Minitest run also keeps it, because the stop
145
+ # cannot skip tests already queued.
146
+ #
147
+ # The row is complete only when the child ended before the timeout, sent a
148
+ # valid stream with both `start` and `end`, lost no line, and its kills
149
+ # agree with the verdict (a killed mutant names a killer; a survivor names
150
+ # none). An invalid stream promotes nothing, and its tests are dropped: an
151
+ # out-of-order line means the parent cannot tell which outcomes are real.
152
+ #
153
+ # @api private
154
+ # @param result [Mutineer::Result] the verdict from the exit status or the timeout.
155
+ # @param rd [IO, nil] the channel's read end.
156
+ # @param buffer [String, nil] bytes read so far.
157
+ # @param finished [Boolean] the child ended before the timeout.
158
+ # @return [Mutineer::Result]
159
+ def self.finish(result, rd, buffer, finished:)
160
+ return result unless rd
161
+
162
+ drain(rd, buffer)
163
+ report = KillChannel.parse(buffer)
164
+ return result.with(kills: Kills.new(killed_by: [], ran: [], complete: false)) if report.invalid
165
+
166
+ result = Result.killed if report.started && report.serial_kill && report.skipping.positive?
167
+ agrees = result.killed? ? report.killed.any? : result.survived? && report.killed.empty?
168
+ complete = finished && report.started && report.finished && report.lost.zero? && agrees
169
+ result.with(kills: Kills.new(killed_by: report.killed, ran: report.ran, complete: complete))
85
170
  end
86
171
 
87
172
  # Strategy 7a (default): write the whole mutated file and `load` it, which