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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +248 -0
- data/README.md +324 -23
- data/lib/mutineer/child_coverage.rb +53 -0
- data/lib/mutineer/cli.rb +182 -23
- data/lib/mutineer/concern_blocks.rb +80 -0
- data/lib/mutineer/config.rb +148 -8
- data/lib/mutineer/coverage_map.rb +175 -12
- data/lib/mutineer/daemon_backend.rb +182 -56
- data/lib/mutineer/daemon_client.rb +30 -0
- data/lib/mutineer/daemon_server.rb +157 -25
- data/lib/mutineer/external_backend.rb +12 -1
- data/lib/mutineer/init.rb +124 -0
- data/lib/mutineer/isolation.rb +14 -2
- data/lib/mutineer/job_plan.rb +129 -23
- data/lib/mutineer/migrate.rb +705 -0
- data/lib/mutineer/mutator_registry.rb +6 -2
- data/lib/mutineer/mutators/base.rb +14 -7
- data/lib/mutineer/mutators/call_to_receiver.rb +135 -0
- data/lib/mutineer/mutators/chain_link.rb +1 -1
- data/lib/mutineer/orphan_guard.rb +72 -0
- data/lib/mutineer/project.rb +80 -6
- data/lib/mutineer/rails_run_env.rb +73 -0
- data/lib/mutineer/rails_worker_db.rb +1408 -53
- data/lib/mutineer/redaction.rb +98 -0
- data/lib/mutineer/reporter.rb +66 -12
- data/lib/mutineer/result.rb +22 -13
- data/lib/mutineer/runner.rb +24 -12
- data/lib/mutineer/statement_lines.rb +15 -8
- data/lib/mutineer/subject.rb +22 -4
- data/lib/mutineer/test_runners/rspec.rb +15 -3
- data/lib/mutineer/test_runners.rb +3 -2
- data/lib/mutineer/triage.rb +111 -0
- data/lib/mutineer/version.rb +1 -1
- data/lib/mutineer.rb +4 -0
- metadata +9 -1
|
@@ -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"
|
|
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
|
|
37
|
-
#
|
|
38
|
-
#
|
|
39
|
-
#
|
|
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
|
|
133
|
-
#
|
|
134
|
-
#
|
|
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" =>
|
|
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.
|
|
153
|
-
#
|
|
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
|
|
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
|
-
|
|
189
|
-
|
|
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
|
|
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
|
|
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,
|
|
256
|
-
|
|
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
|
-
#
|
|
304
|
-
#
|
|
305
|
-
#
|
|
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
|
data/lib/mutineer/isolation.rb
CHANGED
|
@@ -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
|
|