mutineer 1.7.0 → 1.8.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 (45) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +432 -0
  3. data/README.md +157 -747
  4. data/lib/mutineer/baseline.rb +1 -1
  5. data/lib/mutineer/changed_lines.rb +2 -1
  6. data/lib/mutineer/cli.rb +119 -27
  7. data/lib/mutineer/concern_blocks.rb +168 -5
  8. data/lib/mutineer/config.rb +6 -0
  9. data/lib/mutineer/coverage_map.rb +92 -17
  10. data/lib/mutineer/daemon_backend.rb +75 -32
  11. data/lib/mutineer/daemon_client.rb +50 -17
  12. data/lib/mutineer/daemon_server.rb +92 -40
  13. data/lib/mutineer/external_backend.rb +150 -17
  14. data/lib/mutineer/isolation.rb +67 -25
  15. data/lib/mutineer/job_plan.rb +117 -33
  16. data/lib/mutineer/kill_channel.rb +24 -14
  17. data/lib/mutineer/migrate.rb +10 -8
  18. data/lib/mutineer/minitest_integration/in_test_database_error.rb +184 -0
  19. data/lib/mutineer/minitest_integration/kill_recorder.rb +25 -17
  20. data/lib/mutineer/minitest_integration.rb +44 -14
  21. data/lib/mutineer/mutators/array_literal.rb +4 -2
  22. data/lib/mutineer/mutators/base.rb +22 -0
  23. data/lib/mutineer/mutators/call_to_receiver.rb +20 -7
  24. data/lib/mutineer/mutators/chain_link.rb +6 -6
  25. data/lib/mutineer/mutators/condition_forcing.rb +6 -3
  26. data/lib/mutineer/mutators/operand_removal.rb +4 -2
  27. data/lib/mutineer/mutators/range_literal.rb +105 -4
  28. data/lib/mutineer/mutators/return_nil.rb +1 -0
  29. data/lib/mutineer/orphan_guard.rb +18 -6
  30. data/lib/mutineer/pairing.rb +62 -1
  31. data/lib/mutineer/project.rb +226 -12
  32. data/lib/mutineer/rails_worker_db.rb +200 -16
  33. data/lib/mutineer/redaction.rb +112 -11
  34. data/lib/mutineer/reporter.rb +109 -21
  35. data/lib/mutineer/result.rb +33 -5
  36. data/lib/mutineer/runner.rb +126 -18
  37. data/lib/mutineer/statement_lines.rb +14 -8
  38. data/lib/mutineer/subject.rb +13 -5
  39. data/lib/mutineer/test_runners/minitest.rb +2 -1
  40. data/lib/mutineer/test_runners.rb +4 -2
  41. data/lib/mutineer/triage.rb +1 -1
  42. data/lib/mutineer/version.rb +1 -1
  43. data/lib/mutineer/worker_pool.rb +2 -1
  44. data/skills/mutineer/SKILL.md +84 -0
  45. metadata +5 -1
@@ -28,12 +28,15 @@ module Mutineer
28
28
  #
29
29
  # Protocol (one JSON object per line, both directions):
30
30
  # boot in : {"cmd":"boot","project_root":"...","boot":"config/environment","require_paths":[...],
31
- # "load_paths":["test"],"framework":"minitest","rails":true,"schema":"db/schema.rb"}
32
- # ready out: {"ready":true,"ruby":"3.3.6","database_count":1}
31
+ # "load_paths":["test"],"framework":"minitest","rails":true}
32
+ # ready out: {"ready":true,"ruby":"3.3.6","databases":{"names":["primary"],"primary":"primary"}}
33
+ # ("databases" is null with no worker-DB adapter, {"error":"...","primary":"primary"|null} when the lookup fails)
33
34
  # (or {"ready":false,"error":"..."} then exit)
34
35
  # provision: {"cmd":"provision","slots":N} -> {"ok":true} or {"ok":false,"error":"..."}
35
36
  # run in : {"id":N,"worker":I,"payload":{"code":"<ruby>","source_file":"app/models/order.rb"},
36
37
  # "tests":["test/models/order_test.rb"],"timeout":30}
38
+ # (a mutant of a def in a concern's hook block adds to the payload
39
+ # "hook_def":{"line":12,"name":"total","singleton":false}, #297)
37
40
  # verdict : {"id":N,"verdict":"survived"|"killed"|"error"|"timeout"}
38
41
  # quit in : {"cmd":"quit"}
39
42
  #
@@ -45,29 +48,23 @@ module Mutineer
45
48
  #
46
49
  # Verdict mapping: child exit 0=survived (suite passed), 1=killed (suite
47
50
  # failed), 2=error (child raised AROUND the test: load, boot, or worker-DB
48
- # routing failure); parent-detected timeout. Tagging an in-test DB error (one
49
- # fired inside a test body, vs at routing time) as `error` rather than
50
- # `killed` is only observable under the concurrent gate and is not yet
51
- # implemented.
51
+ # routing failure); parent-detected timeout. Exit 2 also covers a run whose
52
+ # first failing test failed with an Active Record connection or lock error
53
+ # inside the test (#296): {MinitestIntegration::InTestDatabaseError} decides.
52
54
  module DaemonServer
53
55
  # Poll interval (seconds) for the per-fork deadline wait loop.
54
56
  POLL = 0.02
55
- # Basename glob for a mutant file written beside a source file.
56
- # Tempfile puts hyphens in the name, so it is not a constant name. The
57
- # leading dot hides a new file from Zeitwerk (#253); a file an older
58
- # version left behind has none, so the glob matches both.
59
- DAEMON_TEMP_BASENAME = "{.,}mutineer_daemon*.rb"
60
-
61
- # Skips daemon mutant files when Zeitwerk scans an autoload directory.
62
- # The boot sweep keeps a file another process still holds. Zeitwerk skips a
63
- # hidden one (#253), but one an older version wrote has no dot, and it would
64
- # stop the next boot, because its name is not a constant.
57
+ # Skips mutant files of both modes ({OrphanGuard::TEMP_BASENAME}) when
58
+ # Zeitwerk scans an autoload directory. The boot sweep keeps a file another
59
+ # process still holds. Zeitwerk skips a hidden one (#253), but one an older
60
+ # version wrote has no dot, and it would stop the next boot, because its
61
+ # name is not a constant.
65
62
  module DaemonTempIgnore
66
63
  # @return [void]
67
64
  def setup
68
65
  dirs = Array(Mutineer::DaemonServer.instance_variable_get(:@source_dirs))
69
66
  dirs.each do |dir|
70
- ignore(File.join(dir, Mutineer::DaemonServer::DAEMON_TEMP_BASENAME))
67
+ ignore(File.join(dir, Mutineer::OrphanGuard::TEMP_BASENAME))
71
68
  end
72
69
  super
73
70
  end
@@ -87,11 +84,13 @@ module Mutineer
87
84
  output ||= @protocol
88
85
  @errio = errio
89
86
  @output = output
87
+ # The client sends UTF-8 JSON; with LANG unset the pipe would read as US-ASCII (#303).
88
+ input.set_encoding(Encoding::UTF_8)
90
89
  boot_line = input.gets
91
90
  return if boot_line.nil? # client vanished before boot
92
91
 
93
92
  boot!(JSON.parse(boot_line.strip))
94
- output.puts(JSON.generate("ready" => true, "ruby" => RUBY_VERSION, "database_count" => database_count))
93
+ output.puts(JSON.generate("ready" => true, "ruby" => RUBY_VERSION, "databases" => databases))
95
94
  output.flush
96
95
 
97
96
  input.each_line do |line|
@@ -197,8 +196,10 @@ module Mutineer
197
196
  def setup_worker_db(cfg)
198
197
  require_relative "rails_worker_db"
199
198
  @worker_db = RailsWorkerDb.available? ? RailsWorkerDb : nil
200
- schema = cfg["schema"] && File.expand_path(cfg["schema"])
201
- @schema_path = schema if schema && File.exist?(schema)
199
+ # Only the booted app knows its schema file (`schema_dump:` can rename it).
200
+ @schema_path = @worker_db&.ruby_schema_path
201
+ # Only a parallel worker daemon gets these names (DaemonBackend.boot_config).
202
+ @shared_databases = Array(cfg["shared_databases"]).map(&:to_s)
202
203
  # Each SQLite slot is seeded once, on first use (not every mutant fork).
203
204
  # Postgres and MySQL slots are copied at provision time, so this flag is unused there.
204
205
  @slot_ready = {}
@@ -217,16 +218,44 @@ module Mutineer
217
218
  @worker_db = nil
218
219
  end
219
220
 
220
- # Database configs in the booted test environment. 1 when the app has no
221
- # worker-DB adapter. The tool uses this to force a single worker.
221
+ # The booted test environment's database config names and the one each
222
+ # worker copies, for the tool's worker count. nil when the app has no
223
+ # worker-DB adapter. A failed lookup is reported, not hidden: the tool
224
+ # then keeps one worker. That report still names the primary when it
225
+ # resolves, so the tool still rejects a `shared_databases` that lists it.
222
226
  #
223
- # @return [Integer]
224
- def database_count
225
- return 1 unless @worker_db
227
+ # @return [Hash, nil]
228
+ def databases
229
+ return nil unless @worker_db
226
230
 
227
- @worker_db.database_count
231
+ @worker_db.database_names
232
+ rescue StandardError => e
233
+ { "error" => describe_error(e), "primary" => primary_database_name }
234
+ end
235
+
236
+ # The primary config name alone, or nil when it does not resolve either.
237
+ #
238
+ # @return [String, nil]
239
+ def primary_database_name
240
+ @worker_db.primary_database_name
228
241
  rescue StandardError
229
- 1
242
+ nil
243
+ end
244
+
245
+ # Raise when a test in this fork used a database that parallel workers
246
+ # share (`shared_databases`). Its verdict may rest on another worker's
247
+ # rows, so the mutant is scored `error`. A serial run sends no names.
248
+ #
249
+ # @return [void]
250
+ # @raise [RuntimeError] when a shared database holds a connection.
251
+ def check_shared_databases!
252
+ return if @worker_db.nil? || @shared_databases.nil? || @shared_databases.empty?
253
+
254
+ used = @worker_db.touched_shared_databases(@shared_databases)
255
+ return if used.empty?
256
+
257
+ raise "shared database used during a parallel run: #{used.join(", ")}. " \
258
+ "Remove it from shared_databases, or see https://github.com/davidteren/mutineer/blob/main/docs/rails-setup.md#more-than-one-database"
230
259
  end
231
260
 
232
261
  # Run {RailsWorkerDb.provision} and report success or the cause. A failure
@@ -270,11 +299,13 @@ module Mutineer
270
299
 
271
300
  # `Class: message` for an error the daemon reports, with credentials hidden.
272
301
  # Rails parses DATABASE_URL itself, so its URI error can carry the password.
302
+ # {Redaction.describe} never raises, so a rescue that calls this cannot
303
+ # make the raw error the cause of a new one.
273
304
  #
274
305
  # @param error [Exception]
275
306
  # @return [String]
276
307
  def describe_error(error)
277
- redact("#{error.class}: #{error.message}")
308
+ Redaction.describe(error)
278
309
  end
279
310
 
280
311
  # Hide database credentials in text bound for the client or stderr.
@@ -327,7 +358,12 @@ module Mutineer
327
358
  # A routing failure raises here and is scored `error`, never a false verdict.
328
359
  @worker_db&.after_fork(worker, first_use ? @schema_path : nil, seed: first_use)
329
360
  apply_payload(req["payload"])
330
- run_tests(Array(req["tests"]))
361
+ # A parallel worker records each pool that runs SQL, so a test
362
+ # that closes its shared connection is still caught below.
363
+ @worker_db.watch_sql if @worker_db && @shared_databases&.any?
364
+ result = run_tests(Array(req["tests"]))
365
+ check_shared_databases!
366
+ result
331
367
  rescue Exception => e # rubocop:disable Lint/RescueException
332
368
  @errio.puts("[daemon-child] #{describe_error(e)}")
333
369
  2
@@ -336,8 +372,12 @@ module Mutineer
336
372
  end
337
373
  verdict = wait_verdict(pid, timeout)
338
374
  # Mark ready only when the child finished cleanly after seeding
339
- # (killed/survived). Timeout can interrupt mid-copy; error is a routing
340
- # failure. Both leave the slot unready so the next fork seeds again.
375
+ # (killed/survived). Timeout can interrupt mid-copy; error can be a
376
+ # routing failure or a database error inside a test (#296). Both leave
377
+ # the slot unready so the next fork seeds again. A shared-database
378
+ # error (check_shared_databases!) also exits 2, so it seeds again too:
379
+ # the exit code cannot tell it from a routing failure. That costs only
380
+ # time, in a run where that check then fails every mutant.
341
381
  @slot_ready[worker] = true if first_use && %w[killed survived].include?(verdict)
342
382
  # A SIGKILLed timeout child skipped its Tempfile unlink. Sweep the orphan
343
383
  # so it cannot outlive the run.
@@ -371,12 +411,13 @@ module Mutineer
371
411
  Zeitwerk::Loader.prepend(DaemonTempIgnore)
372
412
  end
373
413
 
374
- # Remove orphaned mutant tempfiles from the source dirs (parent-side; the
375
- # SIGKILL path cannot run the child's ensure). Mirrors JobPlan.sweep_orphans.
376
- # Skips a file another process still owns ({OrphanGuard.mutant_file_in_use?}).
414
+ # Remove orphaned mutant tempfiles of both modes from the source dirs
415
+ # (parent-side; the SIGKILL path cannot run the child's ensure). Mirrors
416
+ # JobPlan.sweep_orphans. Skips a file another process still owns
417
+ # ({OrphanGuard.mutant_file_in_use?}).
377
418
  def sweep_temps
378
419
  @source_dirs.to_a.each do |dir|
379
- Dir.glob(File.join(dir, DAEMON_TEMP_BASENAME)).each do |path|
420
+ Dir.glob(File.join(dir, OrphanGuard::TEMP_BASENAME)).each do |path|
380
421
  next if OrphanGuard.mutant_file_in_use?(path)
381
422
 
382
423
  File.unlink(path) rescue nil # rubocop:disable Style/RescueModifier
@@ -432,6 +473,9 @@ module Mutineer
432
473
  # removes orphans and never touches a live file. Same path for reload
433
474
  # (whole file) and redefine (wrapped snippet). The backend sends the whole
434
475
  # file, so the concern blocks it stores replace the forgotten ones (#256).
476
+ # With a `"hook_def"`, the loaded file stored a mutated hook-block def, and
477
+ # {ConcernBlocks.place_hook_def} loads it into each class that has the
478
+ # method; it raises when none was stored, so the mutant scores `error` (#297).
435
479
  def apply_payload(payload)
436
480
  dir = File.dirname(File.expand_path(payload.fetch("source_file")))
437
481
  Tempfile.create([".mutineer_daemon", ".rb"], dir) do |f|
@@ -442,17 +486,25 @@ module Mutineer
442
486
  f.flush
443
487
  ConcernBlocks.replace(payload.fetch("source_file")) { load f.path }
444
488
  end
489
+ hook = payload["hook_def"] or return
490
+
491
+ ConcernBlocks.place_hook_def(source_file: payload.fetch("source_file"), line: hook.fetch("line"),
492
+ name: hook.fetch("name").to_sym, singleton: hook.fetch("singleton"))
445
493
  end
446
494
 
447
495
  # Load the covering test files and run them; 0 = all passed (survived),
448
- # 1 = a failure/error (killed). Minitest only; rspec is not yet on this path.
496
+ # 1 = a failure/error (killed), 2 = the first failure was an in-test
497
+ # database error (error, #296). Minitest only; rspec is not yet on this path.
498
+ # {MinitestIntegration.run} is the method the daemon's clean check uses,
499
+ # so each mutant fork gets the same seed and class list (#282). It loads
500
+ # only stdlib, so the daemon still needs no Prism. The fork requires
501
+ # nothing else first: the test files' `require "test_helper"` loads
502
+ # rails/test_help after the class list is reset, as in the clean check.
449
503
  def run_tests(tests)
450
504
  raise "unsupported framework #{@framework.inspect}" unless @framework == "minitest"
451
505
 
452
- require "minitest"
453
- require "rails/test_help" if defined?(Rails)
454
- tests.each { |t| load File.expand_path(t) }
455
- Minitest.run([]) ? 0 : 1
506
+ require_relative "minitest_integration"
507
+ MinitestIntegration.run(tests.map { |t| File.expand_path(t) })
456
508
  end
457
509
  end
458
510
  end
@@ -4,6 +4,7 @@ require "shellwords"
4
4
  require "tempfile"
5
5
  require_relative "result"
6
6
  require_relative "rails_run_env"
7
+ require_relative "redaction"
7
8
 
8
9
  module Mutineer
9
10
  # Raised when the smoke check (the unmutated suite) is not green, so the run
@@ -18,15 +19,18 @@ module Mutineer
18
19
  # This is deliberately NOT a `TestRunners` framework adapter: those return an
19
20
  # Integer 0/1 from inside a fork and are dispatched by framework name. This is a
20
21
  # whole backend — it spawns a process, enforces a wall-clock timeout, and maps
21
- # the exit status to a Result. The mapping is the same direction as in-process
22
- # (suite passes => survived, suite fails => killed) but coarser: it cannot tell an
23
- # infrastructure error from a genuine kill, so the smoke check (below) guards the
24
- # persistent case and the score is disclosed as an upper bound.
22
+ # the exit status to a Result. The mapping follows Isolation.decode: exit 0 is
23
+ # survived, exit 1 is killed, and any other exit is error (#292). When the clean
24
+ # smoke run printed a Minitest or RSpec summary line, exit 1 is a kill only when
25
+ # the output also shows a failing test, so a suite that crashed before its tests
26
+ # ran is not a kill. The smoke check (below) guards a persistently broken
27
+ # environment, and the score is disclosed as an upper bound.
25
28
  #
26
29
  # Child environment: bundler/gem env injected by Mutineer's Ruby is stripped, and
27
30
  # version-manager concrete version bins (e.g. `~/.rbenv/versions/3.4.9/bin`) are
28
31
  # removed from PATH so shims / `.ruby-version` can select the app's Ruby (#32).
29
- # PARALLEL_WORKERS and DISABLE_SPRING are set to 1 ({RailsRunEnv}).
32
+ # PARALLEL_WORKERS and DISABLE_SPRING are set to 1 ({RailsRunEnv}), and so is
33
+ # DISABLE_BOOTSNAP_COMPILE_CACHE ({child_env}).
30
34
  module ExternalBackend
31
35
  # Generous ceiling for the one-off smoke/calibration run (a cold app boot plus
32
36
  # the full suite). The per-mutant timeout is derived from how long this took.
@@ -37,6 +41,26 @@ module Mutineer
37
41
  # Exit codes a shell or wrapper uses when the command could not run at all
38
42
  # (126: not executable, 127: not found). Scored `error`, never `killed`.
39
43
  CANNOT_RUN_CODES = [126, 127].freeze
44
+ # The exit code Minitest, `bin/rails test` and RSpec use for a failing test.
45
+ # Any other non-zero code is scored `error`, the same rule as Isolation.decode.
46
+ FAILURE_CODE = 1
47
+
48
+ # The end-of-run summary line of Minitest 5 and 6 ("N runs") and of
49
+ # minitest-reporters ("N tests"). Captures the failure and error counts.
50
+ MINITEST_SUMMARY = /\d+ (?:runs|tests), \d+ assertions, (\d+) failures, (\d+) errors/
51
+ # The end-of-run summary line of RSpec ("N examples, N failures").
52
+ # Captures the failure count.
53
+ RSPEC_SUMMARY = /\d+ examples?, (\d+) failures?/
54
+ # The line Minitest prints once every test file has loaded, before the first
55
+ # test runs. Exit 1 after it with no summary line means code under test
56
+ # called `exit 1` or `abort`, which the in-process backend also scores killed.
57
+ MINITEST_RUNNING = "# Running:"
58
+ # The line SimpleCov prints when its minimum_coverage gate fails the run.
59
+ SIMPLECOV_GATE = /SimpleCov failed with exit \d+ due to a coverage related error/
60
+ # RSpec's note that a file raised outside any example, such as at load.
61
+ RSPEC_OUTSIDE_ERROR = /errors? occurred outside of examples/
62
+ # ANSI colour codes, removed before matching so a coloured summary still counts.
63
+ ANSI_CODE = /\e\[[\d;]*m/
40
64
 
41
65
  # PATH entries that pin a concrete Ruby under a version manager (ahead of
42
66
  # shims). Optional trailing slash; normal bins are left alone.
@@ -72,8 +96,15 @@ module Mutineer
72
96
  # onto the parent env: set a key to +nil+ to unset it. Scrubs Mutineer/bundler
73
97
  # injection and version-manager PATH pins so shims / `.ruby-version` can win.
74
98
  # Keeps other vars (e.g. `RAILS_ENV`) so setting them on the Mutineer command
75
- # still reaches the suite. Also sets +PARALLEL_WORKERS+ to 1 and
76
- # +DISABLE_SPRING+ to 1. A wrapper that sets either variable again wins.
99
+ # still reaches the suite. Also sets +PARALLEL_WORKERS+, +DISABLE_SPRING+ and
100
+ # +DISABLE_BOOTSNAP_COMPILE_CACHE+ to 1. A wrapper can change them again.
101
+ # Bootsnap checks only that its variable is set, so only unsetting it turns
102
+ # the cache back on. The Bootsnap pin is here, not in
103
+ # {RailsRunEnv.pin_child!}: this backend rewrites each source file in place,
104
+ # and Bootsnap keys its compile cache on path, size and whole-second mtime,
105
+ # so a same-size mutant written in the same second can load the previous
106
+ # mutant's cached code (#285). The daemon loads each mutant from a new file
107
+ # and keeps the cache.
77
108
  #
78
109
  # @api private
79
110
  # @return [Hash{String => String, nil}] env for Process.spawn.
@@ -97,6 +128,7 @@ module Mutineer
97
128
  end
98
129
  env["PATH"] = path.join(File::PATH_SEPARATOR)
99
130
  RailsRunEnv.pin_child!(env, spring: true)
131
+ env["DISABLE_BOOTSNAP_COMPILE_CACHE"] = "1"
100
132
  env
101
133
  end
102
134
 
@@ -132,46 +164,142 @@ module Mutineer
132
164
  # @param files [Array<String>] test file paths.
133
165
  # @param timeout [Numeric] per-mutant wall-clock timeout in seconds.
134
166
  # @param verbose [Boolean] print the child's captured output on a non-pass.
167
+ # @param check_summary [Boolean] score exit 1 as killed only when the output
168
+ # has a summary line that reports a failing test (see {failure_reported?}).
169
+ # The runner turns this on when the clean smoke run printed a summary line.
135
170
  # @return [Mutineer::Result]
136
- def self.run(command, files, timeout:, verbose: false)
171
+ def self.run(command, files, timeout:, verbose: false, check_summary: false)
137
172
  kind, code, output, = spawn_capture(command, files, timeout)
138
173
  case kind
139
174
  when :timeout
140
175
  # A timeout is the one non-pass we flag by default — a normal kill is also
141
176
  # a non-zero exit, so notifying on every non-zero would spam every kill.
142
177
  warn "[mutineer] test-command exceeded #{timeout}s and was killed (scored timeout)."
143
- warn output if verbose && !output.empty?
178
+ warn Redaction.text(output) if verbose && !output.empty?
144
179
  Result.timeout
145
180
  else # :exited
146
181
  return Result.survived if code&.zero?
147
182
  # Signal death (nil exitstatus) is infrastructure, not a suite assertion
148
183
  # failure — match Isolation/daemon (error), do not inflate kill rate.
149
184
  if code.nil?
150
- warn output if verbose && !output.empty?
185
+ warn Redaction.text(output) if verbose && !output.empty?
151
186
  return Result.error("test-command terminated by signal")
152
187
  end
153
188
 
154
- warn output if verbose && !output.empty?
189
+ warn Redaction.text(output) if verbose && !output.empty?
155
190
  # 126 (cannot execute) and 127 (command not found) come from a wrapper
156
191
  # such as `bundle exec` that could not start the suite: no test ran.
157
192
  return Result.error("test-command could not start (exit #{code})") if CANNOT_RUN_CODES.include?(code)
158
193
 
194
+ text = summary_text(output)
195
+ # SimpleCov's minimum_coverage gate replaced the exit code. Under
196
+ # Minitest it runs in Minitest.after_run even after a failing test, so a
197
+ # failing summary plus its message is still a kill. A passing summary
198
+ # plus its message means no test caught the mutant: in process, the
199
+ # fork exits before that hook, and the mutant survives. Keyed on
200
+ # SimpleCov's own message, so code under test that calls `exit 2`
201
+ # still scores error. Ceiling: with `SimpleCov.print_error_status =
202
+ # false` the gate prints nothing, and its exit scores error too.
203
+ if text.match?(SIMPLECOV_GATE)
204
+ return Result.killed if failure_reported?(text)
205
+ return Result.survived if tests_passed?(text)
206
+ end
207
+
208
+ unless code == FAILURE_CODE
209
+ return Result.error("test-command exited #{code} (only exit 1 counts as a kill; " \
210
+ "check RSpec --failure-exit-code, a coverage gate, or your wrapper)")
211
+ end
212
+
213
+ if check_summary && !failure_reported?(text) && !exited_during_tests?(text)
214
+ return Result.error("test-command exited 1 but its output shows no failing test " \
215
+ "(the suite or the mutant crashed before tests ran; --verbose shows the output)")
216
+ end
217
+
159
218
  Result.killed
160
219
  end
161
220
  end
162
221
 
222
+ # True when the text has a Minitest or RSpec summary line.
223
+ #
224
+ # @api private
225
+ # @param text [String] output from {summary_text}.
226
+ # @return [Boolean]
227
+ def self.summary_line?(text)
228
+ text.match?(MINITEST_SUMMARY) || text.match?(RSPEC_SUMMARY)
229
+ end
230
+
231
+ # True when a summary line reports a failing test: a Minitest failure or
232
+ # error, or an RSpec failure. An RSpec error outside examples alone (a spec
233
+ # file that raised while loading) is not one, as in TestRunners::RSpec.
234
+ # Any matching line counts, so a wrapper that runs two suites still kills.
235
+ # A test that prints a fake summary line to stdout fools this check.
236
+ #
237
+ # @api private
238
+ # @param text [String] output from {summary_text}.
239
+ # @return [Boolean]
240
+ def self.failure_reported?(text)
241
+ text.scan(MINITEST_SUMMARY).any? { |failures, errors| (failures.to_i + errors.to_i).positive? } ||
242
+ text.scan(RSPEC_SUMMARY).any? { |(failures)| failures.to_i.positive? }
243
+ end
244
+
245
+ # True when Minitest started its tests and the process then ended with no
246
+ # summary line after that start: code under test called `exit 1` or
247
+ # `abort` mid-run. Only the last start counts, so a wrapper whose earlier
248
+ # suite printed a summary still kills. A load crash ends before Minitest
249
+ # prints {MINITEST_RUNNING}, so it stays false.
250
+ # Only the default Minitest reporter prints that line: under RSpec or
251
+ # minitest-reporters, such an exit scores error, not killed. Ceiling: an
252
+ # exception that escapes Minitest.run itself (a reporter plugin bug) also
253
+ # reads as a kill here, where the in-process backend scores error.
254
+ #
255
+ # @api private
256
+ # @param text [String] output from {summary_text}.
257
+ # @return [Boolean]
258
+ def self.exited_during_tests?(text)
259
+ running_at = text.rindex(MINITEST_RUNNING)
260
+ return false unless running_at
261
+
262
+ summary_at = [text.rindex(MINITEST_SUMMARY), text.rindex(RSPEC_SUMMARY)].compact.max
263
+ summary_at.nil? || summary_at < running_at
264
+ end
265
+
266
+ # True when a recognized summary line is present and none shows a failing
267
+ # test or an RSpec error outside examples. With no recognized summary line
268
+ # (a JUnit or custom reporter), it is false, so a SimpleCov gate exit there
269
+ # scores error rather than a survivor the output cannot prove.
270
+ #
271
+ # @api private
272
+ # @param text [String] output from {summary_text}.
273
+ # @return [Boolean]
274
+ def self.tests_passed?(text)
275
+ summary_line?(text) && !failure_reported?(text) && !text.match?(RSPEC_OUTSIDE_ERROR)
276
+ end
277
+
278
+ # The output as binary with ANSI colour codes removed. Binary, so bytes that
279
+ # are invalid in the default encoding (an unset locale) cannot raise.
280
+ # Computed once per run and passed to the predicates above.
281
+ #
282
+ # @api private
283
+ # @param output [String] captured child stdout+stderr.
284
+ # @return [String]
285
+ def self.summary_text(output)
286
+ output.b.gsub(ANSI_CODE, "")
287
+ end
288
+
163
289
  # Pre-flight: run the command once against the UNMUTATED tree. Green (exit 0)
164
- # returns the elapsed seconds (used to calibrate the per-mutant timeout);
165
- # anything else raises SmokeCheckError so the run aborts before scoring.
290
+ # returns the elapsed seconds (used to calibrate the per-mutant timeout) and
291
+ # whether the output has a Minitest or RSpec summary line (used to turn on
292
+ # the summary check in {run}); anything else raises SmokeCheckError so the
293
+ # run aborts before scoring.
166
294
  #
167
295
  # @param command [String] the --test-command template.
168
296
  # @param files [Array<String>] test file paths.
169
297
  # @param timeout [Numeric] ceiling for the calibration run.
170
- # @return [Float] elapsed seconds of the clean run.
298
+ # @return [Array(Float, Boolean)] `[elapsed_seconds, summary_seen]`.
171
299
  # @raise [Mutineer::SmokeCheckError] when the clean suite is not green.
172
300
  def self.smoke_check!(command, files, timeout: SMOKE_TIMEOUT)
173
301
  kind, code, output, elapsed = spawn_capture(command, files, timeout)
174
- return elapsed if kind == :exited && code&.zero?
302
+ return [elapsed, summary_line?(summary_text(output))] if kind == :exited && code&.zero?
175
303
 
176
304
  reason =
177
305
  if kind == :timeout then "did not finish within #{timeout}s"
@@ -213,7 +341,7 @@ module Mutineer
213
341
  "ASDF_RUBY_VERSION from the child env; the suite still picked the wrong " \
214
342
  "Ruby. Use a wrapper that re-selects the app Ruby and ensure " \
215
343
  ".ruby-version / .tool-versions is present " \
216
- "(see README: Apps on Ruby < 3.4 → Under a version manager)"
344
+ "(see https://github.com/davidteren/mutineer/blob/main/docs/rails-setup.md#apps-on-ruby--34, section Under a version manager)"
217
345
  parts
218
346
  end
219
347
 
@@ -245,7 +373,12 @@ module Mutineer
245
373
  kind, code = wait_with_timeout(pid, timeout)
246
374
  elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start
247
375
  out.rewind
248
- [kind, code, out.read, elapsed]
376
+ # Test output is arbitrary bytes. Read it as UTF-8 whatever the locale (#303).
377
+ # Each invalid byte becomes `\xNN`, as in Reporter#printable, so the
378
+ # smoke-failure checks can match on the text and nothing is lost.
379
+ output = out.read.force_encoding(Encoding::UTF_8)
380
+ .scrub { |bytes| bytes.unpack("C*").map { |b| format("\\x%02X", b) }.join }
381
+ [kind, code, output, elapsed]
249
382
  ensure
250
383
  if out
251
384
  out.close
@@ -7,6 +7,7 @@ require_relative "child_stdout"
7
7
  require_relative "child_coverage"
8
8
  require_relative "kill_channel"
9
9
  require_relative "concern_blocks"
10
+ require_relative "redaction"
10
11
 
11
12
  module Mutineer
12
13
  # Fork-based isolation for running one mutant. The block runs in a child
@@ -66,7 +67,7 @@ module Mutineer
66
67
  code = e.status
67
68
  rescue Exception => e # rubocop:disable Lint/RescueException
68
69
  # STDERR, not `warn`: a test may have left `$stderr` as a StringIO.
69
- STDERR.puts "[mutineer-child] #{e.class}: #{e.message}"
70
+ STDERR.puts "[mutineer-child] #{Redaction.describe(e)}"
70
71
  code = 2
71
72
  end
72
73
  STDERR.flush
@@ -138,9 +139,10 @@ module Mutineer
138
139
  #
139
140
  # The verdict matches a run without `--matrix`, which skips everything after
140
141
  # the first failing serial test except the code around it (see
141
- # {KillChannel}). So when a serial test has named a kill and the child then
142
+ # {KillChannel}). So when a serial test has named a failure and the child then
142
143
  # ended inside a region that run would skip (an exit, a crash or the
143
- # timeout in a later test, class or group), the mutant is `killed`: the
144
+ # timeout in a later test, class or group), its first failure decides:
145
+ # `error` for an unavailable database outcome, otherwise `killed`. The
144
146
  # plain run never reached that point. An end anywhere else keeps the exit
145
147
  # status, because the plain run reaches that code too: the rest of the
146
148
  # failing test's class wrapper or group hooks, or the suite hooks. A kill in
@@ -167,6 +169,7 @@ module Mutineer
167
169
  return result.with(kills: Kills.new(killed_by: [], ran: [], complete: false)) if report.invalid
168
170
 
169
171
  result = Result.killed if report.started && report.serial_kill && report.skipping.positive?
172
+ result = Result.error if report.started && report.serial_error && report.skipping.positive?
170
173
  agrees = result.killed? ? report.killed.any? : result.survived? && report.killed.empty?
171
174
  complete = finished && report.started && report.finished && report.lost.zero? && agrees
172
175
  result.with(kills: Kills.new(killed_by: report.killed, ran: report.ran, complete: complete))
@@ -189,17 +192,43 @@ module Mutineer
189
192
  #
190
193
  # A concern's stored `included` and `prepended` blocks from the source file
191
194
  # are forgotten around the load, so ActiveSupport accepts the blocks the
192
- # temp file stores (#256).
195
+ # temp file stores (#256). For a def in such a block, the caller passes the
196
+ # text from {JobPlan.hook_mutant} and then calls
197
+ # {ConcernBlocks.place_hook_def} (#297).
193
198
  #
194
199
  # @api private
195
200
  # @param mutated [String] mutated source text.
196
201
  # @param source_file [String] original source file path.
197
202
  # @return [Object] whatever `load` returns.
198
203
  def self.apply_whole_file(mutated, source_file)
199
- Tempfile.create([".mutineer_mutant", ".rb"], File.dirname(File.expand_path(source_file))) do |f|
200
- f.write(mutated)
204
+ write_beside(mutated, source_file) { |path| ConcernBlocks.replace(source_file) { load path } }
205
+ end
206
+
207
+ # Writes `code` to a hidden `.mutineer_mutant` file beside `source_file`,
208
+ # yields its path, and removes it after the block. Both strategies write
209
+ # through here. Only redefine enables the system-temp fallback when the
210
+ # source directory is not writable.
211
+ #
212
+ # The file is locked until the block ends, as the daemon locks its own file
213
+ # (#295). A SIGKILL drops the lock too. The pid in the name and the lock
214
+ # tell another run's sweep that the file is in use, so the sweep does not
215
+ # delete it between the write and the `load`.
216
+ #
217
+ # @api private
218
+ # @param code [String] the Ruby source to write.
219
+ # @param source_file [String] the source file the code replaces.
220
+ # @param fallback [Boolean] use the system temp directory when the source
221
+ # directory is not writable; redefine only.
222
+ # @yieldparam path [String] absolute path of the written file.
223
+ # @return [Object] whatever the block returns.
224
+ def self.write_beside(code, source_file, fallback: false)
225
+ dir = File.dirname(File.expand_path(source_file))
226
+ dir = Dir.tmpdir if fallback && !File.writable?(dir)
227
+ Tempfile.create([".mutineer_mutant", ".rb"], dir) do |f|
228
+ f.flock(File::LOCK_EX) rescue nil # rubocop:disable Style/RescueModifier
229
+ f.write(code)
201
230
  f.flush
202
- ConcernBlocks.replace(source_file) { load f.path }
231
+ yield f.path
203
232
  end
204
233
  end
205
234
 
@@ -218,13 +247,6 @@ module Mutineer
218
247
  # @param source [String] full source text.
219
248
  # @return [Object] whatever `load` returns.
220
249
  def self.apply_surgical(mutation, subject, source)
221
- loc = subject.def_node.location
222
- def_start = loc.start_offset
223
- snippet = source.byteslice(def_start...loc.end_offset)
224
- rel_s = mutation.start_offset - def_start
225
- rel_e = mutation.end_offset - def_start
226
- mutated_def = snippet.byteslice(0...rel_s) + mutation.replacement + snippet.byteslice(rel_e..)
227
-
228
250
  # Rebuild the FULL namespace nesting textually so unqualified enclosing-
229
251
  # namespace constants resolve exactly as the reload strategy would. A
230
252
  # bare redefinition on the owner would collapse Module.nesting to [owner]
@@ -240,12 +262,7 @@ module Mutineer
240
262
  # falsely survives. Re-open the singleton class so the redefinition lands on
241
263
  # the same method the test calls. `def self.foo` already carries its
242
264
  # receiver, so it is left as-is (wrapping it would mis-target).
243
- inner =
244
- if subject.singleton && subject.def_node.receiver.nil?
245
- "class << self\n#{mutated_def}\nend"
246
- else
247
- mutated_def
248
- end
265
+ inner = mutated_def(mutation, subject, source)
249
266
  inner = "#{subject.block_owner}.class_eval do\n#{inner}\nend" if subject.block_owner
250
267
  wrapped = "#{prefix}#{inner}#{"\nend" * keywords.size}"
251
268
 
@@ -267,15 +284,40 @@ module Mutineer
267
284
  # Module.nesting identically, with no dynamic string execution for
268
285
  # scanners to flag. The input is the project's OWN source (the enclosing
269
286
  # method, textually mutated), loaded only in this forked child.
270
- Tempfile.create(["mutineer_surgical", ".rb"]) do |f|
271
- f.write(wrapped)
272
- f.flush
273
- load f.path
274
- end
287
+ #
288
+ # The file sits beside the source, as in apply_whole_file, so `__dir__`
289
+ # and `require_relative` in the method resolve against its real
290
+ # neighbours (#279). In the temp dir they raise ENOENT or LoadError, and
291
+ # every mutant of the method dies whatever it changes. The hidden
292
+ # `.mutineer_mutant` name keeps Zeitwerk away (#253) and lets the parent
293
+ # sweep an orphan after a timeout kill.
294
+ #
295
+ # A read-only source dir keeps the 1.x system-temp location, so redefine
296
+ # still runs there (only path-relative methods mis-score, as before: #339).
297
+ write_beside(wrapped, subject.file, fallback: true) { |path| load path }
275
298
 
276
299
  target.send(vis, subject.name) if vis && vis != :public
277
300
  end
278
301
 
302
+ # The enclosing def with the mutation applied, wrapped in `class << self`
303
+ # for a singleton def with no receiver (#20). Pure text: it reads no live
304
+ # module, so the tool process can call it too ({JobPlan.hook_mutant}).
305
+ #
306
+ # @api private
307
+ # @param mutation [Mutineer::Mutation] mutation to apply.
308
+ # @param subject [Mutineer::Subject] subject being mutated.
309
+ # @param source [String] full source text.
310
+ # @return [String] the mutated def.
311
+ def self.mutated_def(mutation, subject, source)
312
+ loc = subject.def_node.location
313
+ def_start = loc.start_offset
314
+ snippet = source.byteslice(def_start...loc.end_offset)
315
+ rel_s = mutation.start_offset - def_start
316
+ rel_e = mutation.end_offset - def_start
317
+ mutated = snippet.byteslice(0...rel_s) + mutation.replacement + snippet.byteslice(rel_e..)
318
+ subject.singleton && subject.def_node.receiver.nil? ? "class << self\n#{mutated}\nend" : mutated
319
+ end
320
+
279
321
  # Resolve each namespace ELEMENT to its live Module and pick the correct
280
322
  # keyword (reopening a class with `module` — or vice versa — raises
281
323
  # TypeError), so the textual wrapper matches the real definitions.