mutineer 1.6.0 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,7 +3,10 @@
3
3
  require "json"
4
4
  require "tempfile"
5
5
  require_relative "child_stdout"
6
+ require_relative "child_coverage"
6
7
  require_relative "orphan_guard"
8
+ require_relative "concern_blocks"
9
+ require_relative "redaction"
7
10
 
8
11
  module Mutineer
9
12
  # App-side daemon (persistent worker).
@@ -26,17 +29,19 @@ module Mutineer
26
29
  # Protocol (one JSON object per line, both directions):
27
30
  # boot in : {"cmd":"boot","project_root":"...","boot":"config/environment","require_paths":[...],
28
31
  # "load_paths":["test"],"framework":"minitest","rails":true,"schema":"db/schema.rb"}
29
- # ready out: {"ready":true,"ruby":"3.3.6"} (or {"ready":false,"error":"..."} then exit)
32
+ # ready out: {"ready":true,"ruby":"3.3.6","database_count":1}
33
+ # (or {"ready":false,"error":"..."} then exit)
34
+ # provision: {"cmd":"provision","slots":N} -> {"ok":true} or {"ok":false,"error":"..."}
30
35
  # run in : {"id":N,"worker":I,"payload":{"code":"<ruby>","source_file":"app/models/order.rb"},
31
36
  # "tests":["test/models/order_test.rb"],"timeout":30}
32
37
  # verdict : {"id":N,"verdict":"survived"|"killed"|"error"|"timeout"}
33
38
  # quit in : {"cmd":"quit"}
34
39
  #
35
40
  # Worker isolation: when the app is Rails, each fork is routed to its own
36
- # database `<db>-<worker>` via {RailsWorkerDb} BEFORE any test loads, so
37
- # concurrent workers cannot clobber each other's transactional fixtures.
38
- # `worker` defaults to 0 (serial). SQLite this pass; Postgres provisioning is
39
- # not yet implemented.
41
+ # database via {RailsWorkerDb} BEFORE any test loads, so concurrent workers
42
+ # cannot clobber each other's transactional fixtures. `worker` defaults to 0
43
+ # (serial). SQLite copies a file per slot. Postgres and MySQL use
44
+ # `<base>-mutineer-<w>`, created once before the worker daemons start.
40
45
  #
41
46
  # Verdict mapping: child exit 0=survived (suite passed), 1=killed (suite
42
47
  # failed), 2=error (child raised AROUND the test: load, boot, or worker-DB
@@ -47,6 +52,26 @@ module Mutineer
47
52
  module DaemonServer
48
53
  # Poll interval (seconds) for the per-fork deadline wait loop.
49
54
  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.
65
+ module DaemonTempIgnore
66
+ # @return [void]
67
+ def setup
68
+ dirs = Array(Mutineer::DaemonServer.instance_variable_get(:@source_dirs))
69
+ dirs.each do |dir|
70
+ ignore(File.join(dir, Mutineer::DaemonServer::DAEMON_TEMP_BASENAME))
71
+ end
72
+ super
73
+ end
74
+ end
50
75
 
51
76
  class << self
52
77
  # Serve the protocol on the given IO pair (defaults to stdio). Returns on quit.
@@ -66,7 +91,7 @@ module Mutineer
66
91
  return if boot_line.nil? # client vanished before boot
67
92
 
68
93
  boot!(JSON.parse(boot_line.strip))
69
- output.puts(JSON.generate("ready" => true, "ruby" => RUBY_VERSION))
94
+ output.puts(JSON.generate("ready" => true, "ruby" => RUBY_VERSION, "database_count" => database_count))
70
95
  output.flush
71
96
 
72
97
  input.each_line do |line|
@@ -84,6 +109,14 @@ module Mutineer
84
109
  end
85
110
  break if req["cmd"] == "quit"
86
111
 
112
+ # Create worker databases before coverage forks or mutant forks.
113
+ # The first daemon does this. A restarted worker daemon does not.
114
+ if req["cmd"] == "provision"
115
+ output.puts(JSON.generate(provision_slots(req["slots"])))
116
+ output.flush
117
+ next
118
+ end
119
+
87
120
  # Build the coverage map app-side and ship it to the tool, which then
88
121
  # selects covering tests per mutant. One-shot control message.
89
122
  if req["cmd"] == "coverage"
@@ -129,11 +162,17 @@ module Mutineer
129
162
  require "coverage"
130
163
  Coverage.start(lines: true, methods: true)
131
164
  end
132
- # Clear any mutant tempfile a prior SIGKILLed timeout child orphaned in a
133
- # source dir BEFORE the app boots. Zeitwerk would otherwise choke on the
134
- # tempfile's non-constant name during autoload setup.
165
+ # Clear a mutant tempfile a prior SIGKILLed child orphaned in a source
166
+ # dir BEFORE the app boots. Zeitwerk skips a hidden one, but it would
167
+ # choke on the non-constant name of one an older version wrote without
168
+ # the dot (#253). A file another
169
+ # live process still owns stays: this runs before the database lock,
170
+ # so a refused boot must not delete that run's mutant. The kept file
171
+ # is hidden from Zeitwerk instead.
135
172
  sweep_temps
173
+ install_zeitwerk_ignore! if cfg["rails"]
136
174
  require File.expand_path(cfg["boot"]) if cfg["boot"]
175
+ @zeitwerk_trace&.disable
137
176
  # The --require files, after the boot as in-process (Runner.execute), so
138
177
  # the coverage peek sees what they ran at load.
139
178
  Array(cfg["require_paths"]).each { |f| require File.expand_path(f) }
@@ -141,7 +180,7 @@ module Mutineer
141
180
  rescue Exception => e # rubocop:disable Lint/RescueException
142
181
  # Boot failed (bad boot path, app error). Tell the client and exit so it
143
182
  # can surface a clean error rather than hang on the handshake.
144
- @output.puts(JSON.generate("ready" => false, "error" => "#{e.class}: #{e.message}"))
183
+ @output.puts(JSON.generate("ready" => false, "error" => describe_error(e)))
145
184
  @output.flush
146
185
  exit!(1)
147
186
  end
@@ -149,20 +188,59 @@ module Mutineer
149
188
  # Load the per-worker DB adapter app-side (sibling gem file, by relative path
150
189
  # so it bypasses the app bundle, like this daemon itself). No-op unless the
151
190
  # app has ActiveRecord. Records the adapter + schema path so each fork can
152
- # route to its own database. SQLite-only this pass; a non-SQLite config
153
- # raises in the fork and reads as `error`, never a mis-routed verdict.
191
+ # route to its own database.
192
+ #
193
+ # `db_role` "prepare" waits for a provision command (the first daemon).
194
+ # "worker" only takes the shared lock (a worker daemon, including a
195
+ # respawn). Anything else provisions now, which is what a direct client
196
+ # that never sends the command needs. SQLite provisioning does nothing.
154
197
  def setup_worker_db(cfg)
155
198
  require_relative "rails_worker_db"
156
199
  @worker_db = RailsWorkerDb.available? ? RailsWorkerDb : nil
157
200
  schema = cfg["schema"] && File.expand_path(cfg["schema"])
158
201
  @schema_path = schema if schema && File.exist?(schema)
159
- # Each worker slot is seeded once, on first use (not every mutant fork).
202
+ # Each SQLite slot is seeded once, on first use (not every mutant fork).
203
+ # Postgres and MySQL slots are copied at provision time, so this flag is unused there.
160
204
  @slot_ready = {}
205
+ return unless @worker_db
206
+
207
+ case cfg["db_role"]
208
+ when "prepare"
209
+ nil
210
+ when "worker"
211
+ @worker_db.prepare_worker!
212
+ else
213
+ @worker_db.provision(cfg.fetch("slots", 2).to_i)
214
+ end
161
215
  rescue LoadError => e
162
- @errio.puts("[daemon] worker-DB routing unavailable: #{e.message}")
216
+ @errio.puts("[daemon] worker-DB routing unavailable: #{redact(e.message)}")
163
217
  @worker_db = nil
164
218
  end
165
219
 
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.
222
+ #
223
+ # @return [Integer]
224
+ def database_count
225
+ return 1 unless @worker_db
226
+
227
+ @worker_db.database_count
228
+ rescue StandardError
229
+ 1
230
+ end
231
+
232
+ # Run {RailsWorkerDb.provision} and report success or the cause. A failure
233
+ # stays on this reply. It does not become a mutant verdict.
234
+ #
235
+ # @param slots [Integer, String]
236
+ # @return [Hash]
237
+ def provision_slots(slots)
238
+ @worker_db.provision(Integer(slots)) if @worker_db
239
+ { "ok" => true }
240
+ rescue Exception => e # rubocop:disable Lint/RescueException
241
+ { "ok" => false, "error" => describe_error(e) }
242
+ end
243
+
166
244
  # Build the coverage map app-side (Coverage was started at boot) and return
167
245
  # it as `{map, failed_test_files, load_lines, load_methods}` for the tool to select covering tests.
168
246
  # Capture forks route to worker 0's DB (isolated, serial). On any failure
@@ -185,8 +263,26 @@ module Mutineer
185
263
  "failed_clean_tests" => cmap.failed_clean_tests, "load_lines" => cmap.load_lines.to_a,
186
264
  "load_methods" => cmap.load_methods.to_a }
187
265
  rescue Exception => e # rubocop:disable Lint/RescueException
188
- @errio.puts("[daemon] coverage build failed: #{e.class}: #{e.message}")
189
- { "map" => {}, "failed_test_files" => [], "error" => "#{e.class}: #{e.message}" }
266
+ error = describe_error(e)
267
+ @errio.puts("[daemon] coverage build failed: #{error}")
268
+ { "map" => {}, "failed_test_files" => [], "error" => error }
269
+ end
270
+
271
+ # `Class: message` for an error the daemon reports, with credentials hidden.
272
+ # Rails parses DATABASE_URL itself, so its URI error can carry the password.
273
+ #
274
+ # @param error [Exception]
275
+ # @return [String]
276
+ def describe_error(error)
277
+ redact("#{error.class}: #{error.message}")
278
+ end
279
+
280
+ # Hide database credentials in text bound for the client or stderr.
281
+ #
282
+ # @param text [String]
283
+ # @return [String]
284
+ def redact(text)
285
+ Redaction.text(text)
190
286
  end
191
287
 
192
288
  # Fork-safety hook for coverage capture. Each capture fork drops its copy
@@ -225,6 +321,7 @@ module Mutineer
225
321
  code =
226
322
  begin
227
323
  ChildStdout.silence
324
+ ChildCoverage.allow_start
228
325
  close_protocol
229
326
  # Route THIS fork at its own worker database before any test loads.
230
327
  # A routing failure raises here and is scored `error`, never a false verdict.
@@ -232,7 +329,7 @@ module Mutineer
232
329
  apply_payload(req["payload"])
233
330
  run_tests(Array(req["tests"]))
234
331
  rescue Exception => e # rubocop:disable Lint/RescueException
235
- @errio.puts("[daemon-child] #{e.class}: #{e.message}")
332
+ @errio.puts("[daemon-child] #{describe_error(e)}")
236
333
  2
237
334
  end
238
335
  exit!(code)
@@ -243,17 +340,46 @@ module Mutineer
243
340
  # failure. Both leave the slot unready so the next fork seeds again.
244
341
  @slot_ready[worker] = true if first_use && %w[killed survived].include?(verdict)
245
342
  # A SIGKILLed timeout child skipped its Tempfile unlink. Sweep the orphan
246
- # so it cannot outlive the run or trip Zeitwerk on a later fork.
343
+ # so it cannot outlive the run.
247
344
  sweep_temps if verdict == "timeout"
248
345
  { "id" => req["id"], "verdict" => verdict }
249
346
  end
250
347
 
348
+ # Hide kept mutant files from Zeitwerk before the app builds its loaders.
349
+ # The loader class may not exist yet. Watch for it, then stop watching.
350
+ #
351
+ # @return [void]
352
+ def install_zeitwerk_ignore!
353
+ if defined?(Zeitwerk::Loader)
354
+ prepend_daemon_temp_ignore
355
+ return
356
+ end
357
+
358
+ @zeitwerk_trace = TracePoint.new(:end) do |event|
359
+ next unless event.self.is_a?(Class) && event.self.name == "Zeitwerk::Loader"
360
+
361
+ @zeitwerk_trace.disable
362
+ prepend_daemon_temp_ignore
363
+ end
364
+ @zeitwerk_trace.enable
365
+ end
366
+
367
+ # @return [void]
368
+ def prepend_daemon_temp_ignore
369
+ return if Zeitwerk::Loader.ancestors.include?(DaemonTempIgnore)
370
+
371
+ Zeitwerk::Loader.prepend(DaemonTempIgnore)
372
+ end
373
+
251
374
  # Remove orphaned mutant tempfiles from the source dirs (parent-side; the
252
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?}).
253
377
  def sweep_temps
254
378
  @source_dirs.to_a.each do |dir|
255
- Dir.glob(File.join(dir, "mutineer_daemon*.rb")).each do |f|
256
- File.unlink(f) rescue nil # rubocop:disable Style/RescueModifier
379
+ Dir.glob(File.join(dir, DAEMON_TEMP_BASENAME)).each do |path|
380
+ next if OrphanGuard.mutant_file_in_use?(path)
381
+
382
+ File.unlink(path) rescue nil # rubocop:disable Style/RescueModifier
257
383
  end
258
384
  end
259
385
  end
@@ -300,15 +426,21 @@ module Mutineer
300
426
  # `require_relative` in the mutated source resolves against its real
301
427
  # neighbours. Writing it to the tmpdir would LoadError on such files and
302
428
  # score a spurious `error` that diverges from the in-process path. The
303
- # Zeitwerk hazard (a stray `.rb` in an autoload dir) is handled by the
304
- # boot/timeout `sweep_temps`, not by relocating the file. Same path for
305
- # reload (whole file) and redefine (wrapped snippet).
429
+ # leading dot keeps the file away from Zeitwerk, which skips hidden files:
430
+ # loading it can be the first time Zeitwerk scans a namespace directory,
431
+ # and a parallel worker's file may sit there too (#253). `sweep_temps` only
432
+ # removes orphans and never touches a live file. Same path for reload
433
+ # (whole file) and redefine (wrapped snippet). The backend sends the whole
434
+ # file, so the concern blocks it stores replace the forgotten ones (#256).
306
435
  def apply_payload(payload)
307
436
  dir = File.dirname(File.expand_path(payload.fetch("source_file")))
308
- Tempfile.create(["mutineer_daemon", ".rb"], dir) do |f|
437
+ Tempfile.create([".mutineer_daemon", ".rb"], dir) do |f|
438
+ # Held until this block ends, including SIGKILL (the kernel drops it).
439
+ # Another run's sweep sees the lock and does not delete the file.
440
+ f.flock(File::LOCK_EX) rescue nil # rubocop:disable Style/RescueModifier
309
441
  f.write(payload.fetch("code"))
310
442
  f.flush
311
- load f.path
443
+ ConcernBlocks.replace(payload.fetch("source_file")) { load f.path }
312
444
  end
313
445
  end
314
446
 
@@ -3,6 +3,7 @@
3
3
  require "shellwords"
4
4
  require "tempfile"
5
5
  require_relative "result"
6
+ require_relative "rails_run_env"
6
7
 
7
8
  module Mutineer
8
9
  # Raised when the smoke check (the unmutated suite) is not green, so the run
@@ -25,6 +26,7 @@ module Mutineer
25
26
  # Child environment: bundler/gem env injected by Mutineer's Ruby is stripped, and
26
27
  # version-manager concrete version bins (e.g. `~/.rbenv/versions/3.4.9/bin`) are
27
28
  # 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}).
28
30
  module ExternalBackend
29
31
  # Generous ceiling for the one-off smoke/calibration run (a cold app boot plus
30
32
  # the full suite). The per-mutant timeout is derived from how long this took.
@@ -32,6 +34,9 @@ module Mutineer
32
34
  # Poll interval for the deadline wait loop. Independent of Isolation's loop —
33
35
  # this backend waits on an external process TREE, not an in-process fork.
34
36
  POLL = 0.02
37
+ # Exit codes a shell or wrapper uses when the command could not run at all
38
+ # (126: not executable, 127: not found). Scored `error`, never `killed`.
39
+ CANNOT_RUN_CODES = [126, 127].freeze
35
40
 
36
41
  # PATH entries that pin a concrete Ruby under a version manager (ahead of
37
42
  # shims). Optional trailing slash; normal bins are left alone.
@@ -67,7 +72,8 @@ module Mutineer
67
72
  # onto the parent env: set a key to +nil+ to unset it. Scrubs Mutineer/bundler
68
73
  # injection and version-manager PATH pins so shims / `.ruby-version` can win.
69
74
  # Keeps other vars (e.g. `RAILS_ENV`) so setting them on the Mutineer command
70
- # still reaches the suite.
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.
71
77
  #
72
78
  # @api private
73
79
  # @return [Hash{String => String, nil}] env for Process.spawn.
@@ -90,6 +96,7 @@ module Mutineer
90
96
  kept
91
97
  end
92
98
  env["PATH"] = path.join(File::PATH_SEPARATOR)
99
+ RailsRunEnv.pin_child!(env, spring: true)
93
100
  env
94
101
  end
95
102
 
@@ -145,6 +152,10 @@ module Mutineer
145
152
  end
146
153
 
147
154
  warn output if verbose && !output.empty?
155
+ # 126 (cannot execute) and 127 (command not found) come from a wrapper
156
+ # such as `bundle exec` that could not start the suite: no test ran.
157
+ return Result.error("test-command could not start (exit #{code})") if CANNOT_RUN_CODES.include?(code)
158
+
148
159
  Result.killed
149
160
  end
150
161
  end
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+ require_relative "config"
5
+ require_relative "mutator_registry"
6
+
7
+ module Mutineer
8
+ # Writes a starter `.mutineer.yml` and prints the first run command.
9
+ # It does not replace an existing file unless `--force` is set.
10
+ module Init
11
+ # Usage line for a bad invocation.
12
+ USAGE = "Usage: mutineer init [--rails] [--force]"
13
+
14
+ # Writes the config and prints the first command.
15
+ # 0 is success. 2 is a usage error, a refused overwrite, or a missing directory.
16
+ #
17
+ # @param argv [Array<String>] arguments after `init`.
18
+ # @param out [IO] where the command goes.
19
+ # @param err [IO] where errors go.
20
+ # @param dir [String] directory that receives `.mutineer.yml`.
21
+ # @return [Integer] 0 or 2.
22
+ def self.run(argv, out: $stdout, err: $stderr, dir: Dir.pwd)
23
+ rails = false
24
+ force = false
25
+ help = false
26
+ parser = OptionParser.new do |o|
27
+ o.banner = USAGE
28
+ o.on("--rails") { rails = true }
29
+ o.on("--force") { force = true }
30
+ o.on("--help") { help = true }
31
+ end
32
+ begin
33
+ parser.parse!(argv)
34
+ rescue OptionParser::InvalidOption, OptionParser::MissingArgument => e
35
+ err.puts "mutineer: #{e.message}"
36
+ return 2
37
+ end
38
+ if help
39
+ out.puts USAGE
40
+ return 0
41
+ end
42
+ unless argv.empty?
43
+ err.puts "mutineer: init takes no source paths"
44
+ return 2
45
+ end
46
+
47
+ path = File.join(dir, CONFIG_FILE)
48
+ if File.directory?(path)
49
+ err.puts "mutineer: #{CONFIG_FILE} is a directory"
50
+ return 2
51
+ end
52
+ if File.exist?(path) && !force
53
+ err.puts "mutineer: #{CONFIG_FILE} already exists. Pass --force to replace it."
54
+ return 2
55
+ end
56
+ # The command writes one file. It does not create the project directory.
57
+ unless File.directory?(dir)
58
+ err.puts "mutineer: cannot write #{CONFIG_FILE}: no such directory"
59
+ return 2
60
+ end
61
+
62
+ begin
63
+ File.write(path, template(rails: rails))
64
+ rescue SystemCallError => e
65
+ err.puts "mutineer: cannot write #{CONFIG_FILE}: #{e.message}"
66
+ return 2
67
+ end
68
+ command = command_for(dir, rails: rails)
69
+ if command
70
+ out.puts command
71
+ else
72
+ err.puts "mutineer: no source folder found. Create app/models or lib, then run mutineer on that folder."
73
+ end
74
+ 0
75
+ end
76
+
77
+ # Starter config. `since` stays commented so the first run is a full scan.
78
+ # `--rails` also sets `rails: true` and leaves `daemon` commented.
79
+ #
80
+ # @param rails [Boolean] when true, set `rails: true`.
81
+ # @return [String] YAML text.
82
+ def self.template(rails:)
83
+ operators = MutatorRegistry::DEFAULT_NAMES.map { |name| " - #{name}" }.join("\n")
84
+ head = <<~YAML
85
+ # Mutineer config. Command-line flags override this file.
86
+ # These operators are the default set.
87
+ operators:
88
+ YAML
89
+ tail = <<~YAML
90
+ # since: origin/main
91
+ # Leave this commented so the first run is a full scan.
92
+ YAML
93
+ body = "#{head}#{operators}\n\n#{tail}"
94
+ return body unless rails
95
+
96
+ extra = <<~YAML
97
+ rails: true
98
+
99
+ # daemon: true
100
+ # Leave this commented until the support matrix says the daemon fits.
101
+ # The matrix is on https://davidteren.github.io/mutineer/rails.html#daemon
102
+ YAML
103
+ "#{body.rstrip}\n\n#{extra}"
104
+ end
105
+
106
+ # The first run command. Plain init names `lib`.
107
+ # `--rails` names each existing folder among `app/models`, `app/services`, and `lib`.
108
+ # A missing folder is left out: `mutineer run` rejects a path that is not there.
109
+ #
110
+ # @param dir [String] project directory.
111
+ # @param rails [Boolean] when true, print the Rails source folders.
112
+ # @return [String, nil] the command, or nil when no Rails source folder exists.
113
+ def self.command_for(dir, rails:)
114
+ return "mutineer run lib" unless rails
115
+
116
+ folders = ["app/models", "app/services", "lib"].select do |folder|
117
+ File.directory?(File.join(dir, folder))
118
+ end
119
+ return if folders.empty?
120
+
121
+ "mutineer run #{folders.join(" ")}"
122
+ end
123
+ end
124
+ end
@@ -4,7 +4,9 @@ require "tempfile"
4
4
  require_relative "result"
5
5
  require_relative "parser"
6
6
  require_relative "child_stdout"
7
+ require_relative "child_coverage"
7
8
  require_relative "kill_channel"
9
+ require_relative "concern_blocks"
8
10
 
9
11
  module Mutineer
10
12
  # Fork-based isolation for running one mutant. The block runs in a child
@@ -56,6 +58,7 @@ module Mutineer
56
58
  code = 0
57
59
  begin
58
60
  ChildStdout.silence
61
+ ChildCoverage.allow_start
59
62
  # A block that takes no channel (a zero-arity lambda) still runs.
60
63
  result = channel ? yield(wr) : yield
61
64
  code = result.is_a?(Integer) ? result : 0
@@ -179,15 +182,24 @@ module Mutineer
179
182
  # "base"`). Writing it elsewhere makes those requires resolve to the temp
180
183
  # dir and raise LoadError.
181
184
  #
185
+ # Loading the file reopens its namespace. With lazy loading, that can be the
186
+ # first time Zeitwerk scans the namespace's directory. Zeitwerk skips hidden
187
+ # files, so the leading dot keeps it from rejecting the tempfile's name as a
188
+ # constant, in this child and in any other running at the same time (#253).
189
+ #
190
+ # A concern's stored `included` and `prepended` blocks from the source file
191
+ # are forgotten around the load, so ActiveSupport accepts the blocks the
192
+ # temp file stores (#256).
193
+ #
182
194
  # @api private
183
195
  # @param mutated [String] mutated source text.
184
196
  # @param source_file [String] original source file path.
185
197
  # @return [Object] whatever `load` returns.
186
198
  def self.apply_whole_file(mutated, source_file)
187
- Tempfile.create(["mutineer_mutant", ".rb"], File.dirname(File.expand_path(source_file))) do |f|
199
+ Tempfile.create([".mutineer_mutant", ".rb"], File.dirname(File.expand_path(source_file))) do |f|
188
200
  f.write(mutated)
189
201
  f.flush
190
- load f.path
202
+ ConcernBlocks.replace(source_file) { load f.path }
191
203
  end
192
204
  end
193
205