mutineer-mongoid 2.0.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 (68) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +1194 -0
  3. data/LICENSE +21 -0
  4. data/README.md +659 -0
  5. data/bin/mutineer +6 -0
  6. data/lib/mutineer/baseline.rb +185 -0
  7. data/lib/mutineer/changed_lines.rb +136 -0
  8. data/lib/mutineer/child_stdout.rb +35 -0
  9. data/lib/mutineer/cli.rb +681 -0
  10. data/lib/mutineer/config.rb +372 -0
  11. data/lib/mutineer/coverage_map.rb +1366 -0
  12. data/lib/mutineer/daemon_backend.rb +351 -0
  13. data/lib/mutineer/daemon_client.rb +407 -0
  14. data/lib/mutineer/daemon_server.rb +351 -0
  15. data/lib/mutineer/external_backend.rb +288 -0
  16. data/lib/mutineer/file_swap.rb +194 -0
  17. data/lib/mutineer/isolation.rb +321 -0
  18. data/lib/mutineer/job_plan.rb +352 -0
  19. data/lib/mutineer/kill_channel.rb +284 -0
  20. data/lib/mutineer/kill_matrix.rb +88 -0
  21. data/lib/mutineer/minitest_integration/kill_recorder.rb +301 -0
  22. data/lib/mutineer/minitest_integration/outer_reporter.rb +126 -0
  23. data/lib/mutineer/minitest_integration/stop_at_first_failure.rb +145 -0
  24. data/lib/mutineer/minitest_integration.rb +137 -0
  25. data/lib/mutineer/mongoid_failures.rb +82 -0
  26. data/lib/mutineer/mongoid_worker_db.rb +97 -0
  27. data/lib/mutineer/mutant_id.rb +126 -0
  28. data/lib/mutineer/mutation.rb +27 -0
  29. data/lib/mutineer/mutator_registry.rb +109 -0
  30. data/lib/mutineer/mutators/arithmetic.rb +35 -0
  31. data/lib/mutineer/mutators/array_literal.rb +44 -0
  32. data/lib/mutineer/mutators/base.rb +105 -0
  33. data/lib/mutineer/mutators/boolean_connector.rb +52 -0
  34. data/lib/mutineer/mutators/boolean_literal.rb +56 -0
  35. data/lib/mutineer/mutators/chain_link.rb +132 -0
  36. data/lib/mutineer/mutators/collection_method.rb +44 -0
  37. data/lib/mutineer/mutators/comparison.rb +35 -0
  38. data/lib/mutineer/mutators/condition_forcing.rb +134 -0
  39. data/lib/mutineer/mutators/condition_negation.rb +49 -0
  40. data/lib/mutineer/mutators/literal_mutation.rb +51 -0
  41. data/lib/mutineer/mutators/negation_removal.rb +44 -0
  42. data/lib/mutineer/mutators/operand_removal.rb +58 -0
  43. data/lib/mutineer/mutators/operator_assignment.rb +113 -0
  44. data/lib/mutineer/mutators/range_literal.rb +54 -0
  45. data/lib/mutineer/mutators/regex_literal.rb +72 -0
  46. data/lib/mutineer/mutators/return_nil.rb +72 -0
  47. data/lib/mutineer/mutators/safe_navigation.rb +76 -0
  48. data/lib/mutineer/mutators/statement_removal.rb +33 -0
  49. data/lib/mutineer/mutators/string_literal.rb +34 -0
  50. data/lib/mutineer/orphan_guard.rb +28 -0
  51. data/lib/mutineer/pairing.rb +298 -0
  52. data/lib/mutineer/parser.rb +45 -0
  53. data/lib/mutineer/progress.rb +46 -0
  54. data/lib/mutineer/project.rb +506 -0
  55. data/lib/mutineer/project_path.rb +57 -0
  56. data/lib/mutineer/rails_worker_db.rb +212 -0
  57. data/lib/mutineer/reporter.rb +926 -0
  58. data/lib/mutineer/result.rb +240 -0
  59. data/lib/mutineer/runner.rb +418 -0
  60. data/lib/mutineer/statement_lines.rb +183 -0
  61. data/lib/mutineer/subject.rb +38 -0
  62. data/lib/mutineer/test_runners/minitest.rb +22 -0
  63. data/lib/mutineer/test_runners/rspec.rb +330 -0
  64. data/lib/mutineer/test_runners.rb +30 -0
  65. data/lib/mutineer/version.rb +6 -0
  66. data/lib/mutineer/worker_pool.rb +147 -0
  67. data/lib/mutineer.rb +48 -0
  68. metadata +143 -0
@@ -0,0 +1,407 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "io/wait"
5
+ require "open3"
6
+ require_relative "external_backend"
7
+
8
+ module Mutineer
9
+ # Raised when the daemon cannot be booted or is gone for good: a bad boot path, an
10
+ # app error, a failed handshake, a spawn the OS refused, or MAX_RESTARTS crashes.
11
+ # It means "stop the run" — a backend that scored the remaining mutants against a
12
+ # dead daemon would report a score covering a fraction of the work. The CLI maps it
13
+ # to a runtime error (exit 1).
14
+ class DaemonBootError < StandardError; end
15
+
16
+ # A boot that has not answered the handshake within DaemonClient::BOOT_TIMEOUT
17
+ # (#101). Unlike other boot errors it is not worth retrying with a second
18
+ # daemon, which would wait just as long.
19
+ class DaemonBootTimeout < DaemonBootError; end
20
+
21
+ # Tool-side handle for the app-side daemon.
22
+ #
23
+ # Spawns `daemon_server.rb` UNDER THE APP'S BUNDLE/RUBY. The child gets the
24
+ # environment from before the tool's Bundler activated, with
25
+ # <tt>unsetenv_others</tt>, because Open3 keeps a key the hash omits. The
26
+ # daemon file is loaded by absolute path with `-r`, which bypasses the app
27
+ # bundle that has no mutineer. The client completes the ready handshake,
28
+ # then ships per-mutant payloads and reads structured verdicts.
29
+ # If the daemon dies mid-run it respawns (bounded) and marks the in-flight
30
+ # mutant `error` rather than corrupting the run. Reuses the cleaned-env spawn
31
+ # and stderr-drain proven in the spike driver and the spawn discipline of
32
+ # ExternalBackend.
33
+ class DaemonClient
34
+ # Absolute path to the daemon entry, loaded app-side by `-r` (bypasses the bundle).
35
+ DAEMON_PATH = File.expand_path("daemon_server.rb", __dir__)
36
+ # How many times to respawn a crashing daemon before aborting the run.
37
+ MAX_RESTARTS = 3
38
+ # Seconds a verdict may lag the request's own timeout. The daemon enforces
39
+ # that timeout on the mutant child, so a reply later than this means the
40
+ # daemon itself is wedged (#101): it is killed and respawned.
41
+ REPLY_GRACE = 30
42
+ # Seconds the daemon gets to boot the app and answer the handshake. A boot
43
+ # that never returns ends the run instead of hanging it (#101).
44
+ BOOT_TIMEOUT = 600
45
+ # Bundler's marker for a variable that was unset before it activated.
46
+ BUNDLER_UNSET = "BUNDLER_ENVIRONMENT_PRESERVER_INTENTIONALLY_NIL"
47
+
48
+ # @param boot [Hash] boot config sent to the daemon: project_root, boot,
49
+ # load_paths, framework, rails.
50
+ # @param app_root [String] directory to spawn the daemon in (the app root).
51
+ # @param ruby_version [String, nil] RBENV_VERSION for the app's Ruby.
52
+ # nil keeps a pin already in the environment. That pin is often the
53
+ # Ruby that started the tool.
54
+ # @param gemfile [String, nil] BUNDLE_GEMFILE for the app's bundle (nil = app_root/Gemfile).
55
+ # @param errio [IO] where daemon stderr is drained.
56
+ def initialize(boot:, app_root:, ruby_version: nil, gemfile: nil, errio: $stderr)
57
+ @boot = boot
58
+ @app_root = app_root
59
+ @ruby_version = ruby_version
60
+ @gemfile = gemfile || File.join(app_root, "Gemfile")
61
+ @errio = errio
62
+ @restarts = 0
63
+ end
64
+
65
+ # Spawn the daemon and complete the ready handshake. Raises DaemonBootError on
66
+ # failure (surfaced by the CLI as a clean runtime error, not a hang).
67
+ #
68
+ # @return [self]
69
+ def start
70
+ spawn_daemon
71
+ self
72
+ end
73
+
74
+ # Run one mutant: ship the payload + covering tests, return the verdict string.
75
+ # On a daemon crash (EOF/dead pipe) respawn (bounded) and return `"error"` for
76
+ # this mutant. Never a wrong verdict, never a wedged run.
77
+ #
78
+ # @param id [Integer] request id (echoed back for ordering safety).
79
+ # @param payload [Hash] mutated ruby under the "code" key, path under "source_file".
80
+ # @param tests [Array<String>] covering test file paths.
81
+ # @param timeout [Numeric] per-mutant wall-clock timeout (seconds).
82
+ # @param worker [Integer] worker slot; the daemon routes the fork to
83
+ # `<db>-<worker>` for isolation. Defaults to 0 (serial).
84
+ # @return [String] one of survived/killed/error/timeout.
85
+ def request(id:, payload:, tests:, timeout:, worker: 0)
86
+ # close_io nils the pipes, so a client whose respawn never completed would
87
+ # otherwise fail per-mutant forever (NoMethodError on nil) and let the backend
88
+ # score every remaining mutant against nothing. Deadness is a property of the
89
+ # client, not of whichever exception happened to escape.
90
+ raise DaemonBootError, "daemon is not running" if @stdin.nil?
91
+
92
+ # A crash can surface on the WRITE (daemon died idle between requests →
93
+ # Errno::EPIPE) as well as the read (EOF), so guard both: either way, respawn
94
+ # for future mutants and score THIS one error (re-running a crash-causing
95
+ # mutant could loop). Never let a dead pipe abort the whole run.
96
+ reply =
97
+ begin
98
+ send_line("id" => id, "worker" => worker, "payload" => payload, "tests" => tests, "timeout" => timeout)
99
+ read_line(timeout + REPLY_GRACE)
100
+ rescue Errno::EPIPE, IOError
101
+ nil
102
+ end
103
+ return reply["verdict"] if reply && reply["id"] == id
104
+
105
+ restart!
106
+ "error"
107
+ end
108
+
109
+ # Ask the daemon to build the coverage map app-side and return it. One-shot
110
+ # control message (no id). On success, returns
111
+ # `{"map"=>..., "failed_test_files"=>..., "failed_clean_tests"=>...}`.
112
+ # On coverage-build failure, returns
113
+ # `{"map"=>{}, "failed_test_files"=>[], "error"=>...}`.
114
+ # Returns nil if the daemon vanished. The caller then falls back to running
115
+ # the full test set (no narrowing) rather than mis-scoring, except a red
116
+ # unmutated suite which aborts.
117
+ #
118
+ # @return [Hash, nil] the coverage payload, or nil on a dead pipe.
119
+ def coverage
120
+ send_line("cmd" => "coverage")
121
+ read_line
122
+ rescue Errno::EPIPE, IOError
123
+ nil
124
+ end
125
+
126
+ # Graceful shutdown; leaves no orphaned daemon/child.
127
+ #
128
+ # @return [void]
129
+ def quit
130
+ return unless @stdin
131
+
132
+ send_line("cmd" => "quit") rescue nil # rubocop:disable Style/RescueModifier
133
+ @wait_thr&.join(REPLY_GRACE) # a wedged daemon is killed by close_io
134
+ ensure
135
+ close_io
136
+ end
137
+
138
+ private
139
+
140
+ # Full environment for the daemon child. Spawn with +unsetenv_others+ so a
141
+ # key deleted here does not leak back from the parent. Undo only what
142
+ # Bundler saved before it activated, then point the child at the target app.
143
+ #
144
+ # @api private
145
+ # @return [Hash{String => String}]
146
+ def app_env
147
+ env = restored_user_env
148
+ scrub_managed_ruby_bins!(env)
149
+ env.delete("BUNDLER_SETUP")
150
+ env["BUNDLE_GEMFILE"] = @gemfile
151
+ env["RBENV_VERSION"] = @ruby_version if @ruby_version
152
+ env["RAILS_ENV"] = "test" if rails_boot? && !env.key?("RAILS_ENV")
153
+ env
154
+ end
155
+
156
+ # Environment with every <tt>BUNDLER_ORIG_*</tt> value put back. Bundler
157
+ # writes one saved key per variable it replaced. Restoring all of them
158
+ # undoes the tool bundle without a copied key list.
159
+ #
160
+ # @api private
161
+ # @return [Hash{String => String}]
162
+ def restored_user_env
163
+ env = ENV.to_h
164
+ restored = []
165
+ env.keys.each do |saved_key|
166
+ next unless saved_key.start_with?("BUNDLER_ORIG_")
167
+
168
+ key = saved_key.delete_prefix("BUNDLER_ORIG_")
169
+ saved = env.delete(saved_key)
170
+ restored << key
171
+ if saved == BUNDLER_UNSET
172
+ env.delete(key)
173
+ else
174
+ env[key] = saved
175
+ end
176
+ end
177
+ # A parent require must not run in the app, even when Bundler saved it.
178
+ %w[RUBYOPT RUBYLIB].each { |key| env.delete(key) }
179
+ # Inside Bundler, an unsaved value belongs to the tool. Standalone
180
+ # invocation has no saved keys: preserve the caller's application paths.
181
+ unless restored.empty?
182
+ %w[GEM_HOME GEM_PATH BUNDLE_PATH BUNDLE_WITHOUT BUNDLER_VERSION].each do |key|
183
+ env.delete(key) unless restored.include?(key)
184
+ end
185
+ end
186
+ env
187
+ end
188
+
189
+ # Drop rbenv and asdf version bins so shims can select the app Ruby.
190
+ # Leave those bins in place when no shim directory exists, so +bundle+
191
+ # stays on PATH. Leave chruby bins in place.
192
+ #
193
+ # @api private
194
+ # @param env [Hash{String => String}]
195
+ # @return [void]
196
+ def scrub_managed_ruby_bins!(env)
197
+ parts = env["PATH"].to_s.split(File::PATH_SEPARATOR)
198
+ rbenv = rbenv_shim_dirs(env)
199
+ asdf = asdf_shim_dirs(env)
200
+ dropped_rbenv = false
201
+ dropped_asdf = false
202
+ kept = parts.reject do |part|
203
+ if rbenv_version_bin?(part, env) && rbenv.any?
204
+ dropped_rbenv = true
205
+ elsif asdf_version_bin?(part, env) && asdf.any?
206
+ dropped_asdf = true
207
+ else
208
+ false
209
+ end
210
+ end
211
+ return if kept.size == parts.size
212
+
213
+ shims = []
214
+ shims.concat(rbenv) if dropped_rbenv
215
+ shims.concat(asdf) if dropped_asdf
216
+ env["PATH"] = (shims + kept).uniq.join(File::PATH_SEPARATOR)
217
+ end
218
+
219
+ # Existing rbenv shim directory. +RBENV_ROOT+ wins. Otherwise the home
220
+ # directory's <tt>.rbenv</tt> is used.
221
+ #
222
+ # @api private
223
+ # @param env [Hash{String => String}]
224
+ # @return [Array<String>]
225
+ def rbenv_shim_dirs(env)
226
+ root = rbenv_root(env)
227
+ return [] if root.nil?
228
+
229
+ dir = File.join(root, "shims")
230
+ File.directory?(dir) ? [dir] : []
231
+ end
232
+
233
+ # rbenv install root. +RBENV_ROOT+ wins over <tt>~/.rbenv</tt>.
234
+ #
235
+ # @api private
236
+ # @param env [Hash{String => String}]
237
+ # @return [String, nil]
238
+ def rbenv_root(env)
239
+ root = env["RBENV_ROOT"]
240
+ return File.expand_path(root) if root && !root.empty?
241
+
242
+ home = env["HOME"]
243
+ return nil if home.nil? || home.empty?
244
+
245
+ File.join(home, ".rbenv")
246
+ end
247
+
248
+ # Existing asdf shim directories. A custom install uses +ASDF_DATA_DIR+.
249
+ #
250
+ # @api private
251
+ # @param env [Hash{String => String}]
252
+ # @return [Array<String>]
253
+ def asdf_shim_dirs(env)
254
+ dirs = []
255
+ data = env["ASDF_DATA_DIR"]
256
+ dirs << File.join(data, "shims") if data && !data.empty?
257
+ home = env["HOME"]
258
+ dirs << File.join(home, ".asdf", "shims") if home && !home.empty?
259
+ dirs.select { |dir| File.directory?(dir) }.uniq
260
+ end
261
+
262
+ # True when `part` is a version bin under the active rbenv root.
263
+ #
264
+ # @api private
265
+ # @param part [String] one PATH entry.
266
+ # @param env [Hash{String => String}]
267
+ # @return [Boolean]
268
+ def rbenv_version_bin?(part, env)
269
+ root = rbenv_root(env)
270
+ return false if root.nil?
271
+
272
+ prefix = File.join(root, "versions")
273
+ File.expand_path(part).match?(%r{\A#{Regexp.escape(prefix)}/[^/]+/bin/?\z})
274
+ end
275
+
276
+ # True when `part` is an asdf Ruby version bin under ~/.asdf or +ASDF_DATA_DIR+.
277
+ #
278
+ # @api private
279
+ # @param part [String] one PATH entry.
280
+ # @param env [Hash{String => String}]
281
+ # @return [Boolean]
282
+ def asdf_version_bin?(part, env)
283
+ return true if part.match?(%r{/\.asdf/installs/ruby/[^/]+/bin/?\z})
284
+
285
+ data = env["ASDF_DATA_DIR"]
286
+ return false if data.nil? || data.empty?
287
+
288
+ prefix = File.join(File.expand_path(data), "installs", "ruby")
289
+ File.expand_path(part).match?(%r{\A#{Regexp.escape(prefix)}/[^/]+/bin/?\z})
290
+ end
291
+
292
+ # True when this daemon boots a Rails app.
293
+ #
294
+ # @api private
295
+ # @return [Boolean]
296
+ def rails_boot?
297
+ @boot[:rails] || @boot["rails"]
298
+ end
299
+
300
+ # Spawn the daemon under the app bundle and complete the ready handshake.
301
+ #
302
+ # @return [void]
303
+ # @raise [Mutineer::DaemonBootError] when the daemon fails to boot.
304
+ def spawn_daemon
305
+ # Plain `bundle exec ruby`, NOT `rbenv exec`, which would break CI and any
306
+ # non-rbenv setup. An explicit ruby_version replaces RBENV_VERSION.
307
+ # With no argument, a pin already in the environment stays. That pin is
308
+ # often the Ruby that started the tool. `.ruby-version` applies only
309
+ # when no pin is set. rbenv and asdf version bins leave PATH when a shim
310
+ # directory exists. chruby bins stay.
311
+ # Everything up to the handshake is terminal, not one mutant's problem: a spawn
312
+ # the OS refuses (EMFILE/ENOMEM under --jobs N, ENOENT when `bundle` does not
313
+ # resolve) and a daemon that dies before accepting the boot payload (EPIPE on
314
+ # the write) both leave a client that cannot recover. Raise the class that ends
315
+ # the run — a SystemCallError would reach the CLI as a usage error (exit 2).
316
+ ready =
317
+ begin
318
+ @stdin, @stdout, @stderr, @wait_thr = Open3.popen3(
319
+ app_env, "bundle", "exec", "ruby",
320
+ "-r", DAEMON_PATH, "-e", "Mutineer::DaemonServer.run",
321
+ unsetenv_others: true, chdir: @app_root
322
+ )
323
+ # Drain daemon stderr to the tool's stderr so child/boot errors are visible.
324
+ # Tracked (not fire-and-forget) so close_io can reclaim it on quit/respawn;
325
+ # the rescue swallows the benign EBADF/IOError raised when close_io closes
326
+ # the pipe out from under an in-flight copy_stream.
327
+ @drain = Thread.new do # rubocop:disable ThreadSafety/NewThread
328
+ IO.copy_stream(@stderr, @errio)
329
+ rescue IOError, Errno::EBADF
330
+ nil
331
+ end
332
+
333
+ send_line(@boot)
334
+ read_line(BOOT_TIMEOUT)
335
+ rescue SystemCallError, IOError => e
336
+ close_io
337
+ raise DaemonBootError, "daemon could not be started: #{e.class}: #{e.message}"
338
+ end
339
+
340
+ unless ready && ready["ready"]
341
+ detail =
342
+ if ready && ready["error"] then ready["error"]
343
+ elsif @timed_out then "the daemon did not finish booting within #{BOOT_TIMEOUT}s"
344
+ else "daemon exited before the handshake"
345
+ end
346
+ timed_out = @timed_out
347
+ close_io
348
+ raise (timed_out ? DaemonBootTimeout : DaemonBootError), "daemon failed to boot under the app bundle: #{detail}"
349
+ end
350
+ end
351
+
352
+ # Respawn after a crash, up to MAX_RESTARTS, then hard-fail loudly.
353
+ def restart!
354
+ close_io
355
+ @restarts += 1
356
+ if @restarts > MAX_RESTARTS
357
+ raise DaemonBootError, "daemon crashed or stopped answering #{@restarts} times; aborting the run"
358
+ end
359
+
360
+ cause = @timed_out ? "stopped answering" : "crashed"
361
+ @errio.puts("[mutineer] daemon #{cause} — respawning (#{@restarts}/#{MAX_RESTARTS})")
362
+ spawn_daemon
363
+ end
364
+
365
+ # Write one JSON object as a line to the daemon.
366
+ #
367
+ # @param obj [Hash] the message to encode.
368
+ # @return [void]
369
+ def send_line(obj)
370
+ @stdin.puts(JSON.generate(obj))
371
+ @stdin.flush
372
+ end
373
+
374
+ # Read one JSON reply line; nil on EOF/dead pipe, or when no line arrives
375
+ # within `timeout` seconds (caller treats either as a crash).
376
+ #
377
+ # @param timeout [Numeric, nil] seconds to wait; nil waits for the reply.
378
+ # @return [Hash, nil]
379
+ def read_line(timeout = nil)
380
+ @timed_out = timeout && !@stdout.wait_readable(timeout)
381
+ return nil if @timed_out
382
+
383
+ line = @stdout.gets
384
+ line && JSON.parse(line.strip)
385
+ rescue IOError, Errno::EPIPE, JSON::ParserError
386
+ nil
387
+ end
388
+
389
+ # Close the IPC pipes, stop the stderr-drain thread, and reap the daemon so a
390
+ # respawn or quit leaves no leaked fd, thread, or zombie.
391
+ #
392
+ # @return [void]
393
+ def close_io
394
+ # A wedged daemon may never read the closed stdin, so the reap below would
395
+ # wait forever: kill it first. An exited daemon makes this a no-op.
396
+ begin
397
+ Process.kill(:KILL, @wait_thr.pid) if @wait_thr&.alive?
398
+ rescue Errno::ESRCH
399
+ nil # it exited between the check and the kill
400
+ end
401
+ @drain&.kill # stop the drain BEFORE closing its fd (avoids a copy_stream EBADF)
402
+ [@stdin, @stdout, @stderr].each { |io| io&.close rescue nil } # rubocop:disable Style/RescueModifier
403
+ @wait_thr&.join # reap the exited daemon so respawn/quit leaves no zombie
404
+ @stdin = @stdout = @stderr = @drain = @wait_thr = nil
405
+ end
406
+ end
407
+ end