mutineer 0.9.1 → 0.10.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0d61c927b33961995a0d38b691cdf0b34a6e9041213980c0cd7a3a01f457f875
4
- data.tar.gz: 8e4f32e923344837ab664805a3099a9e67d405bbe7b63d83986d1af0b488ead5
3
+ metadata.gz: 92b74299812649a42303c4387412bd3bd67014e7e91c656df36489836773ae08
4
+ data.tar.gz: 20c25fed6e5a490deb8d5f9068cd47187efe16fdfa0625a18e9e1fe7296b17f6
5
5
  SHA512:
6
- metadata.gz: 5ea750d511b52ef0f9d320c39ef0b4a3b51189116c09811b3bf4f2d5752b026d1ddc62411387a261f6f60ba73e23e2b76af1e553a745bd63f9a3d56a0cd068cb
7
- data.tar.gz: 6dc10950ea34998e459e5007095bd43df87a633a4ca18e19afee1812a8fed154e086f3ae3a7b808bd84ba93336bb61208195d001efd846fea367502a23167ef2
6
+ metadata.gz: 0d838506311eff5c879ee9b23701a995c112a84ce4d7e0cfa9d1b43788ff39eb39cfafcbc4111db1547feb5f5874c0f5e07489f2c7597235c3cd37ef360d407f
7
+ data.tar.gz: 15ec124714bb63d42a2ac95e5ea8582f4329f7577e8fabfe7e544fe062e5667b60bcb9b3d07b167e731c3b7980a7c71ed18f368b7c8fe4d32d20f28021e0cdb0
data/CHANGELOG.md CHANGED
@@ -4,6 +4,22 @@ All notable changes to this project are documented here. The format is based on
4
4
  [Keep a Changelog](https://keepachangelog.com/), and this project adheres to
5
5
  [Semantic Versioning](https://semver.org/).
6
6
 
7
+ ## [Unreleased]
8
+
9
+ ## [0.10.0] - 2026-07-02
10
+
11
+ ### Added
12
+ - **`--test-command` external backend** (#27) — mutation-test apps pinned to Ruby
13
+ < 3.4. Mutineer stays on ≥ 3.4 but runs your suite as a subprocess in the app's
14
+ own runtime via `--test-command "bundle exec rails test %{files}"` (`%{files}`
15
+ expands to the `--test` paths; env is inherited). The mutant is applied on disk
16
+ with crash-safe backup/restore (self-heals a hard-killed run on next startup); a
17
+ smoke check aborts before scoring if the unmutated suite isn't green. This path
18
+ is reload-only, serial (`--jobs` forced to 1), and does no coverage narrowing —
19
+ so its score is an upper bound, not comparable to an in-process `--rails` score
20
+ (Mutineer prints this caveat). Also settable as `test_command:` in `.mutineer.yml`.
21
+ Safe parallelism for this path is tracked in #26.
22
+
7
23
  ## [0.9.1] - 2026-07-01
8
24
 
9
25
  ### Fixed
data/README.md CHANGED
@@ -56,6 +56,7 @@ mutineer run lib/calculator.rb --test test/calculator_test.rb --threshold 90
56
56
  | `--jobs N` | Parallel worker count (default: processor count; `1` under `--rails`) |
57
57
  | `--verbose` | Surface the real error when a fork capture fails (alias `--debug`) |
58
58
  | `--strategy NAME` | Mutation application: `reload` whole-file (default) or `redefine` surgical (`7a`/`7b` accepted as deprecated aliases) |
59
+ | `--test-command CMD` | Run the suite as a subprocess in the app's own runtime (for apps on Ruby < 3.4); `CMD` must contain `%{files}`. See [Apps on Ruby < 3.4](#apps-on-ruby--34) |
59
60
  | `--format human\|json\|html` | Report format (default: human; `html` is a self-contained file) |
60
61
  | `--output FILE` | Write the report to FILE instead of stdout |
61
62
  | `--dry-run` | List candidate mutations without executing (honors suppression) |
@@ -103,6 +104,40 @@ Add Mutineer to your Gemfile's test group:
103
104
  gem "mutineer", group: :test, require: false
104
105
  ```
105
106
 
107
+ ### Apps on Ruby < 3.4
108
+
109
+ Mutineer's own process needs Ruby ≥ 3.4 (it parses with stdlib Prism), and the
110
+ `--rails` path above boots your app *inside Mutineer's process* — so it can't run
111
+ against an app pinned to an older Ruby (`ruby "3.1.6"` in the Gemfile), where the
112
+ bundle rejects 3.4.
113
+
114
+ `--test-command` decouples the two: Mutineer stays on ≥ 3.4, but your suite runs
115
+ as a **subprocess in your app's own runtime** (whatever Ruby its bundle resolves
116
+ to). Run Mutineer with a 3.4+ Ruby and hand it the command that runs your tests:
117
+
118
+ ```sh
119
+ RAILS_ENV=test mutineer run app/models/order.rb \
120
+ --test test/models/order_test.rb \
121
+ --test-command "bundle exec rails test %{files}"
122
+ ```
123
+
124
+ - **`%{files}`** is required; it expands to the `--test` paths as separate
125
+ arguments (a path with a space stays one argument — there is no shell).
126
+ - **Environment** is inherited by the subprocess, so set it on the Mutineer
127
+ command (e.g. the leading `RAILS_ENV=test` above). Don't put `KEY=val` inside
128
+ `--test-command`.
129
+
130
+ Tradeoffs (Phase 1) — this path is correct but not free:
131
+
132
+ - **Slower:** your app re-boots for every mutant (no shared boot yet).
133
+ - **No coverage narrowing:** every mutant runs the *full* `--test` set, so the
134
+ score is an **upper bound and not comparable to an in-process (`--rails`)
135
+ score** — uncovered mutants count as survivors, and an infrastructure failure
136
+ is scored as a kill. Mutineer prints this caveat on every run and aborts up
137
+ front (a "smoke check") if your unmutated suite isn't green.
138
+ - **Reload strategy only** (`--strategy redefine` is rejected on this path) and
139
+ **serial** (`--jobs` is forced to 1 — safe parallelism is tracked in #26).
140
+
106
141
  ## Suppressing equivalent mutants
107
142
 
108
143
  Some mutants are equivalent (behaviour-identical) and survive forever — keeping a
@@ -166,7 +201,9 @@ walking up). CLI flags override config; config overrides defaults.
166
201
 
167
202
  Sources are positional CLI arguments and test files come from `--test`; the
168
203
  config file accepts these keys: `operators`, `threshold`, `jobs`, `only`,
169
- `require` (extra files to load before mutating), and `boot`/`rails`.
204
+ `require` (extra files to load before mutating), `boot`/`rails`, and
205
+ `test_command` (the external-runtime suite command — see
206
+ [Apps on Ruby < 3.4](#apps-on-ruby--34)).
170
207
 
171
208
  ```yaml
172
209
  # .mutineer.yml
data/lib/mutineer/cli.rb CHANGED
@@ -47,6 +47,10 @@ module Mutineer
47
47
  --boot FILE Require FILE once in the parent to boot the app env, then
48
48
  fork per mutant (Rails apps; requires --test)
49
49
  --rails Sugar for --boot config/environment --strategy redefine
50
+ --test-command CMD Run the target suite in the app's own runtime as a
51
+ subprocess (for apps on Ruby < 3.4). CMD must contain
52
+ %{files} (expands to the --test paths). Env is inherited,
53
+ e.g. RAILS_ENV=test mutineer run ... --test-command "..."
50
54
  --format human|json|html Report format (default: human)
51
55
  --output FILE Write the report to FILE instead of stdout
52
56
  --dry-run List mutations without executing
@@ -105,6 +109,9 @@ module Mutineer
105
109
  # typed (CLI wins over the file). --baseline-epsilon is CLI-only.
106
110
  o.on("--baseline FILE") { |v| opts[:baseline] = v; explicit << :baseline }
107
111
  o.on("--baseline-epsilon FLOAT") { |v| opts[:baseline_epsilon] = v.to_f }
112
+ # #27: run the target suite as a subprocess in the app's OWN runtime so
113
+ # mutineer (Ruby >= 3.4) can mutation-test apps pinned to an older Ruby.
114
+ o.on("--test-command CMD") { |v| opts[:test_command] = v; explicit << :test_command }
108
115
  end
109
116
 
110
117
  begin
@@ -188,6 +195,11 @@ module Mutineer
188
195
  rescue Mutineer::ParseError => e
189
196
  warn "mutineer: error reading: #{e.message}"
190
197
  exit 1
198
+ rescue Mutineer::SmokeCheckError => e
199
+ # #27: the unmutated suite isn't green under --test-command — a broken
200
+ # environment, not weak tests. Runtime error (exit 1), not usage (exit 2).
201
+ warn "mutineer: #{e.message}"
202
+ exit 1
191
203
  end
192
204
 
193
205
  # Flag validation: every flag/usage failure exits 2 (C7), consistent with the
@@ -229,6 +241,8 @@ module Mutineer
229
241
  exit 2
230
242
  end
231
243
 
244
+ validate_test_command!(config) if config.test_command
245
+
232
246
  validate_since!(config) if config.since
233
247
  preflight_output!(config.output) if config.output
234
248
  preflight_baseline!(config.baseline) if config.baseline
@@ -250,6 +264,42 @@ module Mutineer
250
264
  validate_paths!(config)
251
265
  end
252
266
 
267
+ # #27: --test-command runs the target suite in the app's own runtime. Validate
268
+ # its shape up front (usage errors → exit 2) and force serial execution: each
269
+ # subprocess boots the app and opens its own fixture transaction against the
270
+ # same DB, so --jobs > 1 would corrupt results (the #12 fixture-contention
271
+ # hazard). Unlike --rails, this path has NO per-worker DB isolation to opt into,
272
+ # so an explicit --jobs N is forced to 1 rather than honored (KTD-5).
273
+ # Validates the --test-command configuration.
274
+ #
275
+ # @api private
276
+ # @param config [Mutineer::Config] run configuration.
277
+ # @return [void]
278
+ def self.validate_test_command!(config)
279
+ if config.test_command.strip.empty?
280
+ warn "mutineer: --test-command must not be empty"
281
+ exit 2
282
+ end
283
+ unless config.test_command.include?("%{files}")
284
+ warn "mutineer: --test-command must contain %{files} (where the --test paths are substituted)"
285
+ exit 2
286
+ end
287
+ if config.boot
288
+ warn "mutineer: --test-command cannot be combined with --boot/--rails " \
289
+ "(the external subprocess boots the app itself)"
290
+ exit 2
291
+ end
292
+ if config.strategy == "redefine"
293
+ warn "mutineer: --test-command supports only --strategy reload " \
294
+ "(surgical redefine needs a shared VM; the subprocess has its own)"
295
+ exit 2
296
+ end
297
+ return unless config.jobs > 1
298
+
299
+ warn "[mutineer] --test-command runs serially (no per-worker DB isolation yet); forcing --jobs 1."
300
+ config.jobs = 1
301
+ end
302
+
253
303
  # --since needs a real git repo and a resolvable ref; either failure is a
254
304
  # usage error (exit 2) so CI sees "bad invocation," not "tests too weak."
255
305
  # Validates the --since ref.
@@ -377,6 +427,16 @@ module Mutineer
377
427
  reporter.report(out: $stdout, err: $stderr, threshold: config.threshold,
378
428
  format: config.format, output: config.output, baseline: delta)
379
429
 
430
+ # #27/KTD-6: warn (stderr, so it never pollutes json/html) that an external
431
+ # run's score is not comparable to an in-process run — it has no coverage
432
+ # narrowing, so uncovered mutants count as survivors, and an infra failure is
433
+ # scored as a kill (upper bound).
434
+ if config.test_command
435
+ warn "[mutineer] --test-command score is an upper bound, not comparable to an " \
436
+ "in-process run: no coverage narrowing (uncovered mutants count as survivors) " \
437
+ "and an infra failure is scored as a kill."
438
+ end
439
+
380
440
  # #14: nudge toward the opt-in tier-2 operators (human report only — never
381
441
  # pollute JSON output).
382
442
  if !%w[json html].include?(config.format) && (hint = tier2_hint(config.operators))
@@ -25,13 +25,17 @@ module Mutineer
25
25
  :cache_dir, :project_root, :load_paths,
26
26
  :jobs, :format, :output, :strategy, :require_paths,
27
27
  :boot, :rails, :since, :framework, :verbose, :ignore,
28
- :baseline, :baseline_epsilon, :fail_fast,
28
+ # :daemon / :daemon_timeout are NOT user-facing yet — no CLI flag or KNOWN_KEYS
29
+ # entry until the Phase 2c `--daemon` unit lands (which adds the flag + a `to_i`
30
+ # coerce + KNOWN_KEYS). For now they're set programmatically (tests/Runner).
31
+ :baseline, :baseline_epsilon, :fail_fast, :test_command,
32
+ :daemon, :daemon_timeout,
29
33
  keyword_init: true
30
34
  ) do
31
35
  # Config file name.
32
36
  CONFIG_FILE = ".mutineer.yml"
33
37
  # Keys accepted in .mutineer.yml (R7). `require` maps to the :require_paths field.
34
- KNOWN_KEYS = %w[operators jobs threshold only require boot rails since framework verbose ignore baseline fail_fast].freeze
38
+ KNOWN_KEYS = %w[operators jobs threshold only require boot rails since framework verbose ignore baseline fail_fast test_command].freeze
35
39
 
36
40
  def initialize(**kwargs)
37
41
  super
@@ -51,6 +55,7 @@ module Mutineer
51
55
  self.ignore ||= []
52
56
  self.baseline_epsilon ||= 0.0
53
57
  self.fail_fast = false if fail_fast.nil?
58
+ self.daemon = false if daemon.nil?
54
59
  end
55
60
 
56
61
  # Walk from `start` toward `home`, returning the first .mutineer.yml path found
@@ -166,6 +171,7 @@ module Mutineer
166
171
  when "verbose" then value == true || value.to_s == "true"
167
172
  when "ignore" then Array(value).map(&:to_s)
168
173
  when "baseline" then value.to_s
174
+ when "test_command" then value.to_s
169
175
  else value
170
176
  end
171
177
  end
@@ -0,0 +1,172 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "open3"
5
+
6
+ module Mutineer
7
+ # Raised when the daemon cannot be booted (bad boot path, app error, or it dies on
8
+ # the handshake). The CLI maps it to a runtime error.
9
+ class DaemonBootError < StandardError; end
10
+
11
+ # #26/#27 Phase 2a — the TOOL-side handle for the app-side daemon.
12
+ #
13
+ # Spawns `daemon_server.rb` UNDER THE APP'S BUNDLE/RUBY (cleaned env so the gem's
14
+ # bundler context never leaks; the daemon file is loaded by absolute path with
15
+ # `-r`, which bypasses the app bundle that has no mutineer), completes the ready
16
+ # handshake, then ships per-mutant payloads and reads structured verdicts. If the
17
+ # daemon dies mid-run it respawns (bounded) and marks the in-flight mutant `error`
18
+ # rather than corrupting the run. Reuses the cleaned-env spawn + stderr-drain proven
19
+ # in the spike driver and the spawn discipline of ExternalBackend.
20
+ class DaemonClient
21
+ # Absolute path to the daemon entry, loaded app-side by `-r` (bypasses the bundle).
22
+ DAEMON_PATH = File.expand_path("daemon_server.rb", __dir__)
23
+ # How many times to respawn a crashing daemon before aborting the run.
24
+ MAX_RESTARTS = 3
25
+
26
+ # @param boot [Hash] boot config sent to the daemon: project_root, boot,
27
+ # load_paths, framework, rails.
28
+ # @param app_root [String] directory to spawn the daemon in (the app root).
29
+ # @param ruby_version [String, nil] RBENV_VERSION for the app's Ruby (nil = inherit).
30
+ # @param gemfile [String, nil] BUNDLE_GEMFILE for the app's bundle (nil = app_root/Gemfile).
31
+ # @param errio [IO] where daemon stderr is drained.
32
+ def initialize(boot:, app_root:, ruby_version: nil, gemfile: nil, errio: $stderr)
33
+ @boot = boot
34
+ @app_root = app_root
35
+ @ruby_version = ruby_version
36
+ @gemfile = gemfile || File.join(app_root, "Gemfile")
37
+ @errio = errio
38
+ @restarts = 0
39
+ end
40
+
41
+ # Spawn the daemon and complete the ready handshake. Raises DaemonBootError on
42
+ # failure (surfaced by the CLI as a clean runtime error, not a hang).
43
+ #
44
+ # @return [self]
45
+ def start
46
+ spawn_daemon
47
+ self
48
+ end
49
+
50
+ # Run one mutant: ship the payload + covering tests, return the verdict string.
51
+ # On a daemon crash (EOF/dead pipe) respawn (bounded) and return `"error"` for
52
+ # this mutant — never a wrong verdict, never a wedged run.
53
+ #
54
+ # @param id [Integer] request id (echoed back for ordering safety).
55
+ # @param payload [Hash] {"code" => mutated ruby, "source_file" => path}.
56
+ # @param tests [Array<String>] covering test file paths.
57
+ # @param timeout [Numeric] per-mutant wall-clock timeout (seconds).
58
+ # @return [String] one of survived/killed/error/timeout.
59
+ def request(id:, payload:, tests:, timeout:)
60
+ # A crash can surface on the WRITE (daemon died idle between requests →
61
+ # Errno::EPIPE) as well as the read (EOF), so guard both: either way, respawn
62
+ # for future mutants and score THIS one error (re-running a crash-causing
63
+ # mutant could loop). Never let a dead pipe abort the whole run.
64
+ reply =
65
+ begin
66
+ send_line("id" => id, "payload" => payload, "tests" => tests, "timeout" => timeout)
67
+ read_line
68
+ rescue Errno::EPIPE, IOError
69
+ nil
70
+ end
71
+ return reply["verdict"] if reply && reply["id"] == id
72
+
73
+ restart!
74
+ "error"
75
+ end
76
+
77
+ # Graceful shutdown; leaves no orphaned daemon/child.
78
+ #
79
+ # @return [void]
80
+ def quit
81
+ return unless @stdin
82
+
83
+ send_line("cmd" => "quit") rescue nil # rubocop:disable Style/RescueModifier
84
+ @wait_thr&.join
85
+ ensure
86
+ close_io
87
+ end
88
+
89
+ private
90
+
91
+ # Cleaned environment for the app bundle: strip the gem's bundler/Ruby context so
92
+ # `bundle exec` resolves the APP's Gemfile under the requested Ruby.
93
+ def app_env
94
+ env = ENV.to_h.reject { |k, _| k.start_with?("BUNDLE_", "RUBY", "GEM_") }
95
+ env["BUNDLE_GEMFILE"] = @gemfile
96
+ env["RBENV_VERSION"] = @ruby_version if @ruby_version
97
+ env["RAILS_ENV"] ||= "test" if @boot[:rails] || @boot["rails"]
98
+ env
99
+ end
100
+
101
+ # Spawn the daemon under the app bundle and complete the ready handshake.
102
+ #
103
+ # @return [void]
104
+ # @raise [Mutineer::DaemonBootError] when the daemon fails to boot.
105
+ def spawn_daemon
106
+ # Plain `bundle exec ruby` — NOT `rbenv exec`, which would break CI and any
107
+ # non-rbenv setup. When bundler/ruby are rbenv shims, the RBENV_VERSION carried
108
+ # in app_env still selects the app's Ruby; otherwise the active Ruby is used.
109
+ @stdin, @stdout, @stderr, @wait_thr = Open3.popen3(
110
+ app_env, "bundle", "exec", "ruby",
111
+ "-r", DAEMON_PATH, "-e", "Mutineer::DaemonServer.run", chdir: @app_root
112
+ )
113
+ # Drain daemon stderr to the tool's stderr so child/boot errors are visible.
114
+ # Tracked (not fire-and-forget) so close_io can reclaim it on quit/respawn; the
115
+ # rescue swallows the benign EBADF/IOError raised when close_io closes the pipe
116
+ # out from under an in-flight copy_stream.
117
+ @drain = Thread.new do # rubocop:disable ThreadSafety/NewThread
118
+ IO.copy_stream(@stderr, @errio)
119
+ rescue IOError, Errno::EBADF
120
+ nil
121
+ end
122
+
123
+ send_line(@boot)
124
+ ready = read_line
125
+ unless ready && ready["ready"]
126
+ detail = ready && ready["error"] ? ready["error"] : "daemon exited before the handshake"
127
+ close_io
128
+ raise DaemonBootError, "daemon failed to boot under the app bundle: #{detail}"
129
+ end
130
+ end
131
+
132
+ # Respawn after a crash, up to MAX_RESTARTS, then hard-fail loudly.
133
+ def restart!
134
+ close_io
135
+ @restarts += 1
136
+ if @restarts > MAX_RESTARTS
137
+ raise DaemonBootError, "daemon crashed #{@restarts} times; aborting the run"
138
+ end
139
+
140
+ @errio.puts("[mutineer] daemon crashed — respawning (#{@restarts}/#{MAX_RESTARTS})")
141
+ spawn_daemon
142
+ end
143
+
144
+ # Write one JSON object as a line to the daemon.
145
+ #
146
+ # @param obj [Hash] the message to encode.
147
+ # @return [void]
148
+ def send_line(obj)
149
+ @stdin.puts(JSON.generate(obj))
150
+ @stdin.flush
151
+ end
152
+
153
+ # Read one JSON reply line; nil on EOF/dead pipe (caller treats as a crash).
154
+ def read_line
155
+ line = @stdout.gets
156
+ line && JSON.parse(line.strip)
157
+ rescue IOError, Errno::EPIPE, JSON::ParserError
158
+ nil
159
+ end
160
+
161
+ # Close the IPC pipes, stop the stderr-drain thread, and reap the daemon so a
162
+ # respawn or quit leaves no leaked fd, thread, or zombie.
163
+ #
164
+ # @return [void]
165
+ def close_io
166
+ @drain&.kill # stop the drain BEFORE closing its fd (avoids a copy_stream EBADF)
167
+ [@stdin, @stdout, @stderr].each { |io| io&.close rescue nil } # rubocop:disable Style/RescueModifier
168
+ @wait_thr&.join # reap the exited daemon so respawn/quit leaves no zombie
169
+ @stdin = @stdout = @stderr = @drain = @wait_thr = nil
170
+ end
171
+ end
172
+ end
@@ -0,0 +1,190 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "tempfile"
5
+
6
+ module Mutineer
7
+ # #26/#27 Phase 2a — the app-side daemon (persistent worker).
8
+ #
9
+ # Runs UNDER THE APP'S OWN BUNDLE/RUBY (the tool's DaemonClient spawns it via
10
+ # `bundle exec ruby`). It boots the app ONCE, then serves per-mutant test-run
11
+ # requests over stdin/stdout as newline-delimited JSON. For each request it FORKS
12
+ # a child that loads the mutated source text the tool sent, runs the covering
13
+ # tests, and exits with a status the parent decodes into a verdict.
14
+ #
15
+ # HARD CONSTRAINT (KTD-2/R4): this file must be loadable WITHOUT Prism or the rest
16
+ # of mutineer — the app's Ruby may be < 3.4 (no stdlib Prism) and its bundle has no
17
+ # mutineer. So it requires ONLY stdlib + the app's own boot file; it re-implements
18
+ # the fork/timeout/decode loop rather than requiring `isolation.rb` (which pulls in
19
+ # Prism). All parsing/mutation happened tool-side; the daemon only `load`s text.
20
+ #
21
+ # Protocol (one JSON object per line, both directions):
22
+ # boot in : {"cmd":"boot","project_root":"...","boot":"config/environment",
23
+ # "load_paths":["test"],"framework":"minitest","rails":true}
24
+ # ready out: {"ready":true,"ruby":"3.3.6"} (or {"ready":false,"error":"..."} then exit)
25
+ # run in : {"id":N,"payload":{"code":"<ruby>","source_file":"app/models/order.rb"},
26
+ # "tests":["test/models/order_test.rb"],"timeout":30}
27
+ # verdict : {"id":N,"verdict":"survived"|"killed"|"error"|"timeout"}
28
+ # quit in : {"cmd":"quit"}
29
+ #
30
+ # Verdict mapping (KTD-5, Phase-2a honest limit): child exit 0=survived (suite
31
+ # passed), 1=killed (suite failed), 2=error (child raised AROUND the test — load or
32
+ # boot failure); parent-detected timeout. Tagging an in-TEST DB error as `error`
33
+ # (vs killed) is a Phase-2b concern (needs the after_fork adapter's re-raise).
34
+ module DaemonServer
35
+ # Poll interval (seconds) for the per-fork deadline wait loop.
36
+ POLL = 0.02
37
+
38
+ class << self
39
+ # Serve the protocol on the given IO pair (defaults to stdio). Returns on quit.
40
+ #
41
+ # @param input [IO] request stream.
42
+ # @param output [IO] verdict stream.
43
+ # @param errio [IO] diagnostics stream (never the IPC channel).
44
+ # @return [void]
45
+ def run(input: $stdin, output: $stdout, errio: $stderr)
46
+ @errio = errio
47
+ @output = output
48
+ boot_line = input.gets
49
+ return if boot_line.nil? # client vanished before boot
50
+
51
+ boot!(JSON.parse(boot_line.strip))
52
+ output.puts(JSON.generate("ready" => true, "ruby" => RUBY_VERSION))
53
+ output.flush
54
+
55
+ input.each_line do |line|
56
+ line = line.strip
57
+ next if line.empty?
58
+
59
+ begin
60
+ req = JSON.parse(line)
61
+ rescue JSON::ParserError => e
62
+ # A corrupt line has no id to address a reply to (and the client only ever
63
+ # sends valid JSON, so it can't be a pending request) — log and read on
64
+ # rather than write an unaddressable verdict onto the channel.
65
+ @errio.puts("[daemon] dropped unparseable line: #{e.message}")
66
+ next
67
+ end
68
+ break if req["cmd"] == "quit"
69
+
70
+ output.puts(JSON.generate(run_mutant(req)))
71
+ output.flush
72
+ end
73
+ end
74
+
75
+ private
76
+
77
+ # BOOT ONCE. chdir + require the app's boot file so the whole app is loaded and
78
+ # inherited by every fork. Never requires mutineer.
79
+ def boot!(cfg)
80
+ @framework = cfg.fetch("framework", "minitest")
81
+ @source_dirs = Array(cfg["source_dirs"]).map { |d| File.expand_path(d) }
82
+ Dir.chdir(cfg["project_root"]) if cfg["project_root"]
83
+ ENV["RAILS_ENV"] ||= "test" if cfg["rails"]
84
+ Array(cfg["load_paths"]).each { |d| $LOAD_PATH.unshift(File.expand_path(d)) }
85
+ # Clear any mutant tempfile a prior SIGKILLed timeout child orphaned in a
86
+ # source dir BEFORE the app boots — Zeitwerk would otherwise choke on the
87
+ # tempfile's non-constant name during autoload setup.
88
+ sweep_temps
89
+ require File.expand_path(cfg["boot"]) if cfg["boot"]
90
+ rescue Exception => e # rubocop:disable Lint/RescueException
91
+ # Boot failed (bad boot path, app error) — tell the client and exit so it can
92
+ # surface a clean error rather than hang on the handshake.
93
+ @output.puts(JSON.generate("ready" => false, "error" => "#{e.class}: #{e.message}"))
94
+ @output.flush
95
+ exit!(1)
96
+ end
97
+
98
+ # Fork a child to run one mutant in isolation; decode its exit into a verdict.
99
+ def run_mutant(req)
100
+ timeout = req.fetch("timeout", 30)
101
+ pid = fork do
102
+ # New process group so a per-fork timeout can SIGKILL the whole subtree
103
+ # (carries the Phase-1 pgroup discipline), and silence the child's stdout so
104
+ # test-framework output can never corrupt the IPC pipe (KTD-6).
105
+ Process.setpgid(0, 0) rescue nil # rubocop:disable Style/RescueModifier
106
+ $stdout.reopen(File::NULL, "w")
107
+ code =
108
+ begin
109
+ apply_payload(req["payload"])
110
+ run_tests(Array(req["tests"]))
111
+ rescue Exception => e # rubocop:disable Lint/RescueException
112
+ @errio.puts("[daemon-child] #{e.class}: #{e.message}")
113
+ 2
114
+ end
115
+ exit!(code)
116
+ end
117
+ verdict = wait_verdict(pid, timeout)
118
+ # A SIGKILLed timeout child skipped its Tempfile unlink — sweep the orphan so
119
+ # it can't outlive the run or trip Zeitwerk on a later fork.
120
+ sweep_temps if verdict == "timeout"
121
+ { "id" => req["id"], "verdict" => verdict }
122
+ end
123
+
124
+ # Remove orphaned mutant tempfiles from the source dirs (parent-side; the
125
+ # SIGKILL path can't run the child's ensure). Mirrors Runner.sweep_orphans.
126
+ def sweep_temps
127
+ @source_dirs.to_a.each do |dir|
128
+ Dir.glob(File.join(dir, "mutineer_daemon*.rb")).each do |f|
129
+ File.unlink(f) rescue nil # rubocop:disable Style/RescueModifier
130
+ end
131
+ end
132
+ end
133
+
134
+ # Single-waiter deadline loop (mirrors Isolation.run and
135
+ # ExternalBackend.wait_with_timeout, re-implemented here because Isolation pulls
136
+ # in Prism which is forbidden app-side). NOTE: this is the 3rd copy of the
137
+ # waitpid2(WNOHANG)+deadline+pgroup-SIGKILL+decode discipline — a fix to the
138
+ # kill/reap/decode logic must be applied to all three in lockstep. SIGKILL the
139
+ # child's process group past the deadline; a signalled child (nil exitstatus) is
140
+ # `error`.
141
+ def wait_verdict(pid, timeout)
142
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
143
+ loop do
144
+ reaped, status = Process.waitpid2(pid, Process::WNOHANG)
145
+ if reaped
146
+ return { 0 => "survived", 1 => "killed" }.fetch(status.exitstatus, "error")
147
+ end
148
+ if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
149
+ begin
150
+ Process.kill(:KILL, -pid)
151
+ rescue Errno::ESRCH, Errno::EPERM
152
+ Process.kill(:KILL, pid) rescue nil # rubocop:disable Style/RescueModifier
153
+ end
154
+ Process.waitpid(pid) rescue nil # rubocop:disable Style/RescueModifier
155
+ return "timeout"
156
+ end
157
+ sleep POLL
158
+ end
159
+ end
160
+
161
+ # Write the tool-built mutated text beside the real source and `load` it —
162
+ # reopening the mutated class/method in THIS child only. It goes in the source
163
+ # file's directory (like Isolation.apply_whole_file) so a `require_relative` in
164
+ # the mutated source resolves against its real neighbours — writing it to the
165
+ # tmpdir would LoadError on such files and score a spurious `error` that
166
+ # diverges from the in-process path. The Zeitwerk hazard (a stray `.rb` in an
167
+ # autoload dir) is handled by the boot/timeout `sweep_temps`, not by relocating
168
+ # the file. Same path for reload (whole file) and redefine (wrapped snippet).
169
+ def apply_payload(payload)
170
+ dir = File.dirname(File.expand_path(payload.fetch("source_file")))
171
+ Tempfile.create(["mutineer_daemon", ".rb"], dir) do |f|
172
+ f.write(payload.fetch("code"))
173
+ f.flush
174
+ load f.path
175
+ end
176
+ end
177
+
178
+ # Load the covering test files and run them; 0 = all passed (survived),
179
+ # 1 = a failure/error (killed). Minitest only in 2a (rspec is a later unit).
180
+ def run_tests(tests)
181
+ raise "unsupported framework #{@framework.inspect}" unless @framework == "minitest"
182
+
183
+ require "minitest"
184
+ require "rails/test_help" if defined?(Rails)
185
+ tests.each { |t| load File.expand_path(t) }
186
+ Minitest.run([]) ? 0 : 1
187
+ end
188
+ end
189
+ end
190
+ end
@@ -0,0 +1,168 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "shellwords"
4
+ require "tempfile"
5
+ require_relative "result"
6
+
7
+ module Mutineer
8
+ # Raised when the smoke check (the unmutated suite) is not green, so the run
9
+ # aborts before scoring — a broken environment must never be reported as strong
10
+ # tests. The CLI maps this to a runtime error (exit 1), not a usage error.
11
+ class SmokeCheckError < StandardError; end
12
+
13
+ # #27 (U3): the external execution backend. Runs the user's `--test-command` as a
14
+ # subprocess in the app's OWN runtime (whatever Ruby its bundle resolves to), so
15
+ # mutineer (Ruby >= 3.4) can mutation-test apps pinned to an older Ruby.
16
+ #
17
+ # This is deliberately NOT a `TestRunners` framework adapter: those return an
18
+ # Integer 0/1 from inside a fork and are dispatched by framework name. This is a
19
+ # whole backend — it spawns a process, enforces a wall-clock timeout, and maps
20
+ # the exit status to a Result. The mapping is the SAME direction as in-process
21
+ # (suite passes => survived, suite fails => killed) but coarser: it cannot tell an
22
+ # infrastructure error from a genuine kill, so the smoke check (below) guards the
23
+ # persistent case and the score is disclosed as an upper bound (KTD-3/KTD-6).
24
+ module ExternalBackend
25
+ # Generous ceiling for the one-off smoke/calibration run (a cold app boot plus
26
+ # the full suite). The per-mutant timeout is derived from how long this took.
27
+ SMOKE_TIMEOUT = 900
28
+ # Poll interval for the deadline wait loop. Independent of Isolation's loop —
29
+ # this backend waits on an external process TREE, not an in-process fork.
30
+ POLL = 0.02
31
+
32
+ # Turn a command template into an argv array (no shell → no eval, no
33
+ # injection). The `%{files}` token expands IN PLACE to N separate argv
34
+ # elements — one per path, unescaped — so a path containing a space stays a
35
+ # single argument. It is not a space-joined string.
36
+ #
37
+ # @param command [String] the --test-command template (contains %{files}).
38
+ # @param files [Array<String>] test file paths to substitute.
39
+ # @return [Array<String>] argv.
40
+ def self.build_argv(command, files)
41
+ Shellwords.split(command).flat_map { |tok| tok == "%{files}" ? files : [tok] }
42
+ end
43
+
44
+ # Runs the command for ONE mutant against whatever is currently on disk (the
45
+ # caller has already swapped the mutant in via FileSwap). Maps the outcome to a
46
+ # Result. Env is inherited by the subprocess, so `RAILS_ENV=test mutineer …`
47
+ # reaches the child with no parsing here.
48
+ #
49
+ # @param command [String] the --test-command template.
50
+ # @param files [Array<String>] test file paths.
51
+ # @param timeout [Numeric] per-mutant wall-clock timeout in seconds.
52
+ # @param verbose [Boolean] print the child's captured output on a non-pass.
53
+ # @return [Mutineer::Result]
54
+ def self.run(command, files, timeout:, verbose: false)
55
+ kind, code, output, = spawn_capture(command, files, timeout)
56
+ case kind
57
+ when :timeout
58
+ # A timeout is the one non-pass we flag by default — a normal kill is also
59
+ # a non-zero exit, so notifying on every non-zero would spam every kill.
60
+ warn "[mutineer] test-command exceeded #{timeout}s and was killed (scored timeout)."
61
+ warn output if verbose && !output.empty?
62
+ Result.timeout
63
+ else # :exited
64
+ return Result.survived if code&.zero?
65
+
66
+ warn output if verbose && !output.empty?
67
+ Result.killed
68
+ end
69
+ end
70
+
71
+ # Pre-flight: run the command once against the UNMUTATED tree. Green (exit 0)
72
+ # returns the elapsed seconds (used to calibrate the per-mutant timeout);
73
+ # anything else raises SmokeCheckError so the run aborts before scoring.
74
+ #
75
+ # @param command [String] the --test-command template.
76
+ # @param files [Array<String>] test file paths.
77
+ # @param timeout [Numeric] ceiling for the calibration run.
78
+ # @return [Float] elapsed seconds of the clean run.
79
+ # @raise [Mutineer::SmokeCheckError] when the clean suite is not green.
80
+ def self.smoke_check!(command, files, timeout: SMOKE_TIMEOUT)
81
+ kind, code, output, elapsed = spawn_capture(command, files, timeout)
82
+ return elapsed if kind == :exited && code&.zero?
83
+
84
+ reason =
85
+ if kind == :timeout then "did not finish within #{timeout}s"
86
+ elsif code.nil? then "was terminated by a signal"
87
+ else "exited #{code}"
88
+ end
89
+ detail = output.empty? ? "" : "\n--- last output ---\n#{tail(output)}"
90
+ raise SmokeCheckError,
91
+ "the test command #{reason} against the UNMUTATED source — the " \
92
+ "environment looks broken (check DB, RAILS_ENV, migrations), not the " \
93
+ "tests weak.#{detail}"
94
+ end
95
+
96
+ # Spawns the command to a captured combined-output tempfile, enforces a
97
+ # wall-clock timeout (SIGKILL past the deadline), and returns
98
+ # [kind, exit_code, output, elapsed]. Mirrors Isolation's single-waiter loop:
99
+ # we are the only caller of waitpid on this pid, so the kill can never hit a
100
+ # reaped/recycled pid.
101
+ #
102
+ # @api private
103
+ def self.spawn_capture(command, files, timeout)
104
+ argv = build_argv(command, files)
105
+ # Unreachable in practice (validate_test_command! guarantees a non-empty
106
+ # command with %{files}); a neutral ArgumentError, not the smoke-specific
107
+ # SmokeCheckError, since this is not a smoke-check failure.
108
+ raise ArgumentError, "--test-command produced an empty command" if argv.empty?
109
+
110
+ out = Tempfile.create("mutineer_ext")
111
+ start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
112
+ # `pgroup: true` puts the child in its OWN process group so a timeout can
113
+ # kill the whole tree (`bundle exec rails test` forks parallel workers;
114
+ # spring/bundler add more) — killing only the leader would orphan workers
115
+ # that keep holding the shared DB, corrupting later serial mutants. The
116
+ # explicit [program, argv0] form guarantees the no-shell exec path even for a
117
+ # degenerate single-element argv (Process.spawn(*argv) would route a lone
118
+ # metachar-bearing string through /bin/sh, breaking the argv-only invariant).
119
+ pid = Process.spawn([argv.first, argv.first], *argv[1..], out: out, err: %i[child out], pgroup: true)
120
+ kind, code = wait_with_timeout(pid, timeout)
121
+ elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start
122
+ out.rewind
123
+ [kind, code, out.read, elapsed]
124
+ ensure
125
+ if out
126
+ out.close
127
+ File.unlink(out.path) rescue nil # rubocop:disable Style/RescueModifier
128
+ end
129
+ end
130
+
131
+ # Waits for the spawned pid, SIGKILLing its process group past the deadline.
132
+ # Single-waiter deadline loop (mirrors Isolation), so the kill can never hit a
133
+ # reaped/recycled pid.
134
+ #
135
+ # @api private
136
+ # @param pid [Integer] the spawned child pid.
137
+ # @param timeout [Numeric] wall-clock deadline in seconds.
138
+ # @return [Array(Symbol, Integer, nil)] `[:exited, code]` or `[:timeout, nil]`.
139
+ def self.wait_with_timeout(pid, timeout)
140
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
141
+ loop do
142
+ reaped, status = Process.waitpid2(pid, Process::WNOHANG)
143
+ return [:exited, status.exitstatus] if reaped
144
+
145
+ if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
146
+ # Kill the whole process GROUP (negative pid) so forked test workers die
147
+ # with the leader; fall back to the leader alone if the group is already
148
+ # gone. The child led its group (pgroup: true at spawn).
149
+ begin
150
+ Process.kill(:KILL, -pid)
151
+ rescue Errno::ESRCH, Errno::EPERM
152
+ Process.kill(:KILL, pid) rescue nil # rubocop:disable Style/RescueModifier
153
+ end
154
+ Process.waitpid(pid) rescue nil # rubocop:disable Style/RescueModifier
155
+ return [:timeout, nil]
156
+ end
157
+ sleep POLL
158
+ end
159
+ end
160
+
161
+ # Last ~40 lines of captured output, for a smoke-failure message.
162
+ #
163
+ # @api private
164
+ def self.tail(output, lines = 40)
165
+ output.lines.last(lines).join
166
+ end
167
+ end
168
+ end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mutineer
4
+ # Raised when a source file's backup already exists as FileSwap.with begins —
5
+ # a second mutineer run is racing on the same file (the backup path is shared
6
+ # and unlocked). Aborting beats silently leaving the tree mutated.
7
+ class ConcurrentRunError < StandardError
8
+ def initialize(backup)
9
+ super("a backup already exists at #{backup} — is another mutineer run active " \
10
+ "in this directory? Aborting to avoid corrupting the source file.")
11
+ end
12
+ end
13
+
14
+ # #27 (U2): apply one whole-file mutant to the REAL source path for the external
15
+ # (`--test-command`) backend, and guarantee the original is restored on every
16
+ # exit path. A separate `bundle exec` subprocess has its own VM and cannot see an
17
+ # in-process `load`, so the mutant must live on disk while its suite runs — which
18
+ # makes leaving the file mutated the one genuinely dangerous failure mode.
19
+ #
20
+ # Defense in depth, mirroring the tempfile-orphan discipline
21
+ # (`Runner.sweep_orphans`, `isolation.rb` tempfiles):
22
+ # - the original bytes are held in memory AND written to a sibling backup;
23
+ # - `ensure` restores from memory around every mutant;
24
+ # - the backup survives a SIGKILL (which skips `ensure`), so `restore_orphans`
25
+ # can self-heal a left-mutated tree on the next run's startup.
26
+ # Only one mutant is in flight per file at a time (the external path is serial),
27
+ # so backups never collide.
28
+ module FileSwap
29
+ # Suffix for the on-disk backup; fixed so `restore_orphans` finds it.
30
+ BACKUP_SUFFIX = ".mutineer-backup"
31
+
32
+ # Writes `mutated` to `source_file`, yields, then restores the original bytes
33
+ # on every exit path (normal return, exception, or `ensure`). Byte-exact:
34
+ # binary read/write preserves encoding, newlines, and trailing bytes.
35
+ #
36
+ # @param source_file [String] path to the real source file.
37
+ # @param mutated [String] mutated source text to write for the duration.
38
+ # @yield the block to run while the mutant is on disk.
39
+ # @return [Object] the block's return value.
40
+ def self.with(source_file, mutated)
41
+ backup = source_file + BACKUP_SUFFIX
42
+ # A backup already on disk means either a prior hard-killed run (restore_orphans
43
+ # should have healed it at startup) or a SECOND mutineer run racing us on the
44
+ # same file. The backup path is shared and unlocked, so proceeding would let
45
+ # us capture the other run's mutant AS the "original" and permanently mutate
46
+ # the tree. Refuse loudly rather than silently corrupt — and do it BEFORE
47
+ # `created` is set, so the ensure below never touches a backup we don't own.
48
+ raise ConcurrentRunError, backup if File.exist?(backup)
49
+
50
+ original = File.binread(source_file)
51
+ File.binwrite(backup, original)
52
+ created = true
53
+ File.binwrite(source_file, mutated)
54
+ yield
55
+ ensure
56
+ if created
57
+ File.binwrite(source_file, original)
58
+ File.unlink(backup) if File.exist?(backup)
59
+ end
60
+ end
61
+
62
+ # Startup/after-run self-heal: restore any source file left mutated by a prior
63
+ # interrupted run (a leftover `*.mutineer-backup`), then remove the backup.
64
+ # Prints one line to stderr when it actually heals something, so a developer
65
+ # knows their working tree was auto-restored (a file they did not touch).
66
+ #
67
+ # @param dirs [Array<String>] directories to sweep for orphaned backups.
68
+ # @return [void]
69
+ def self.restore_orphans(dirs)
70
+ healed = 0
71
+ dirs.uniq.each do |dir|
72
+ Dir.glob(File.join(dir, "*#{BACKUP_SUFFIX}")).each do |backup|
73
+ source_file = backup.delete_suffix(BACKUP_SUFFIX)
74
+ backup_bytes = File.binread(backup)
75
+ if !File.exist?(source_file)
76
+ # A real user file that merely ends in our suffix, with no sibling to
77
+ # restore — leave it untouched (never create a file from it).
78
+ next
79
+ elsif File.binread(source_file) == backup_bytes
80
+ # Redundant backup (e.g. a crash between restore and unlink): nothing to
81
+ # heal, just clear the orphan so the next run doesn't see a false race.
82
+ File.unlink(backup)
83
+ else
84
+ File.binwrite(source_file, backup_bytes)
85
+ File.unlink(backup)
86
+ healed += 1
87
+ end
88
+ end
89
+ end
90
+ return if healed.zero?
91
+
92
+ warn "[mutineer] restored #{healed} source file(s) left mutated by a previous interrupted run."
93
+ end
94
+ end
95
+ end
@@ -11,6 +11,9 @@ require_relative "changed_lines"
11
11
  require_relative "mutator_registry"
12
12
  require_relative "worker_pool"
13
13
  require_relative "mutant_id"
14
+ require_relative "file_swap"
15
+ require_relative "external_backend"
16
+ require_relative "daemon_client"
14
17
  require "set"
15
18
 
16
19
  module Mutineer
@@ -38,6 +41,16 @@ module Mutineer
38
41
  def self.execute(config)
39
42
  operator_classes = MutatorRegistry.resolve(config.operators || MutatorRegistry::DEFAULT_NAMES)
40
43
 
44
+ # #27: the external backend runs the suite as a subprocess in the app's own
45
+ # runtime — it does no in-process boot/require or coverage build, so branch
46
+ # before any of that. The in-process path below is untouched.
47
+ return execute_external(config, operator_classes) if config.test_command
48
+
49
+ # #26/#27 Phase 2a: the daemon backend boots the app ONCE in a persistent
50
+ # subprocess under the app's bundle and forks per mutant. Tool-side we only
51
+ # discover jobs + build payloads (Prism), so branch before any in-process boot.
52
+ return execute_daemon(config, operator_classes) if config.daemon
53
+
41
54
  # Boot mode: require the boot file ONCE so the app env (e.g. Rails) is booted
42
55
  # in the parent and inherited by every fork. Do NOT manually require the
43
56
  # sources — under Zeitwerk a manual require of an autoloadable file raises;
@@ -87,31 +100,7 @@ module Mutineer
87
100
  end
88
101
 
89
102
  # Collect every (subject, mutation) up front so the pool can fan them out.
90
- # #10: a mutant the user marked known-equivalent (inline disable-line comment
91
- # or .mutineer.yml ignore id) is classified :ignored here and NEVER forked —
92
- # it is removed from the killed+survived denominator so a strong file reaches
93
- # 100%. The stable id is computed per subject (occurrence needs the full list)
94
- # and carried on every job so the parent can reattach it after the run.
95
- source_map = {}
96
- disabled_map = {}
97
- ignore_set = config.ignore.to_set
98
- jobs = []
99
- ignored_results = []
100
- Project.discover(config.sources, only: config.only).each do |subject|
101
- source = (source_map[subject.file] ||= File.read(subject.file))
102
- disabled = (disabled_map[subject.file] ||= suppress_map(source))
103
- mutations = operator_classes.flat_map { |klass| klass.new.mutations_for(subject, source) }
104
- ids = MutantId.for_subject(subject, source, mutations)
105
- mutations.each_with_index do |mutation, i|
106
- id = ids[i]
107
- line = source.byteslice(0, mutation.start_offset).count("\n") + 1
108
- if suppressed?(mutation.operator, line, id, disabled, ignore_set)
109
- ignored_results << Result.ignored.with(subject: subject, mutation: mutation, id: id)
110
- else
111
- jobs << [subject, mutation, id]
112
- end
113
- end
114
- end
103
+ jobs, ignored_results, source_map = collect_jobs(config, operator_classes)
115
104
 
116
105
  jobs = filter_since(jobs, source_map, config) if config.since
117
106
 
@@ -119,9 +108,8 @@ module Mutineer
119
108
  # resolves). A SIGKILL'd child skips the tempfile's ensure-unlink, orphaning
120
109
  # it. `ensure` is unreliable vs SIGKILL, so the PARENT sweeps each source dir
121
110
  # before and after the run — orphans are impossible after a normal run.
122
- source_dirs = config.sources
123
- .map { |f| File.dirname(File.expand_path(f, config.project_root)) }.uniq
124
- sweep_orphans(source_dirs)
111
+ dirs = source_dirs(config)
112
+ sweep_orphans(dirs)
125
113
 
126
114
  strategy = config.strategy
127
115
  results =
@@ -139,12 +127,183 @@ module Mutineer
139
127
  # filter_map drops nils for jobs --fail-fast left unscheduled.
140
128
  bare.each_with_index.filter_map { |r, i| r&.with(subject: jobs[i][0], mutation: jobs[i][1], id: jobs[i][2]) }
141
129
  ensure
142
- sweep_orphans(source_dirs)
130
+ sweep_orphans(dirs)
143
131
  end
144
132
 
145
133
  [AggregateResult.new(results + ignored_results), source_map]
146
134
  end
147
135
 
136
+ # Collect every (subject, mutation, id) up front so a backend can run them.
137
+ # #10: a mutant the user marked known-equivalent (inline disable-line comment
138
+ # or .mutineer.yml ignore id) is classified :ignored here and NEVER run — it is
139
+ # removed from the killed+survived denominator so a strong file reaches 100%.
140
+ # The stable id is computed per subject (occurrence needs the full list) and
141
+ # carried on every job so the parent can reattach it after the run. Shared by
142
+ # the in-process and external (#27) backends so job selection can never drift.
143
+ #
144
+ # @return [Array(Array, Array<Result>, Hash<String,String>)] jobs, ignored, source_map.
145
+ def self.collect_jobs(config, operator_classes)
146
+ source_map = {}
147
+ disabled_map = {}
148
+ ignore_set = config.ignore.to_set
149
+ jobs = []
150
+ ignored_results = []
151
+ Project.discover(config.sources, only: config.only).each do |subject|
152
+ source = (source_map[subject.file] ||= File.read(subject.file))
153
+ disabled = (disabled_map[subject.file] ||= suppress_map(source))
154
+ mutations = operator_classes.flat_map { |klass| klass.new.mutations_for(subject, source) }
155
+ ids = MutantId.for_subject(subject, source, mutations)
156
+ mutations.each_with_index do |mutation, i|
157
+ id = ids[i]
158
+ line = source.byteslice(0, mutation.start_offset).count("\n") + 1
159
+ if suppressed?(mutation.operator, line, id, disabled, ignore_set)
160
+ ignored_results << Result.ignored.with(subject: subject, mutation: mutation, id: id)
161
+ else
162
+ jobs << [subject, mutation, id]
163
+ end
164
+ end
165
+ end
166
+ [jobs, ignored_results, source_map]
167
+ end
168
+
169
+ # #27: external backend orchestration. Runs each mutant's whole-file mutation on
170
+ # disk (crash-safe swap) and executes the user's --test-command as a subprocess
171
+ # in the app's own runtime. Serial by construction (KTD-5: one shared DB, no
172
+ # per-worker isolation yet). No coverage narrowing — every mutant runs the full
173
+ # --test set (KTD-6); the score is therefore an upper bound and not comparable
174
+ # to an in-process run (the CLI discloses this).
175
+ #
176
+ # @param config [Mutineer::Config] run configuration (test_command set).
177
+ # @param operator_classes [Array<Class>] resolved operators.
178
+ # @return [Array(Mutineer::AggregateResult, Hash<String,String>)] aggregate and source map.
179
+ def self.execute_external(config, operator_classes)
180
+ abs_tests = config.tests.map { |t| File.expand_path(t, config.project_root) }
181
+ dirs = source_dirs(config)
182
+
183
+ # Heal any file a prior hard-killed run left mutated BEFORE reading source —
184
+ # collect_jobs computes mutation offsets/ids from the on-disk bytes, so a
185
+ # still-mutated file would yield garbage offsets against the later-healed
186
+ # source. Heal first, then discover jobs from the clean tree.
187
+ FileSwap.restore_orphans(dirs)
188
+
189
+ jobs, ignored_results, source_map = collect_jobs(config, operator_classes)
190
+ jobs = filter_since(jobs, source_map, config) if config.since
191
+
192
+ # Calibrate the per-mutant timeout from the clean run (a real suite far
193
+ # outlasts the 10s in-process fork budget), and abort if it isn't green.
194
+ # ponytail: 3x the clean run, floor 30s, ceiling 300s — a heuristic. The
195
+ # floor covers a fast suite; the ceiling bounds a hung mutant (infinite loop)
196
+ # so a handful can't stall a serial run for ~45min on a slow suite.
197
+ smoke_elapsed = ExternalBackend.smoke_check!(config.test_command, abs_tests)
198
+ timeout = [[smoke_elapsed * 3, 30].max, 300].min.ceil
199
+
200
+ results = []
201
+ begin
202
+ jobs.each do |subject, mutation, id|
203
+ r = run_external(subject, mutation, config.test_command, abs_tests,
204
+ timeout: timeout, verbose: config.verbose)
205
+ results << r.with(subject: subject, mutation: mutation, id: id)
206
+ break if config.fail_fast && r.survived? # #21: stop at the first survivor
207
+ end
208
+ ensure
209
+ FileSwap.restore_orphans(dirs)
210
+ end
211
+
212
+ [AggregateResult.new(results + ignored_results), source_map]
213
+ end
214
+
215
+ # Runs one mutant through the external backend: apply the whole-file mutation on
216
+ # disk, run the command, restore. KTD-8: an invalid (non-reparsing) mutant would
217
+ # fail to load and score a false `killed`, so skip it tool-side (Prism, already
218
+ # cheap) and never write the file — preserving the `skipped` verdict the
219
+ # in-process path gives at runner.rb's pre-fork check.
220
+ #
221
+ # @return [Mutineer::Result] verdict for this mutant.
222
+ def self.run_external(subject, mutation, command, abs_tests, timeout:, verbose:)
223
+ source = File.read(subject.file)
224
+ mutated = mutation.apply(source)
225
+ return Result.skipped if Parser.parse_string(mutated).errors.any?
226
+
227
+ FileSwap.with(subject.file, mutated) do
228
+ ExternalBackend.run(command, abs_tests, timeout: timeout, verbose: verbose)
229
+ end
230
+ end
231
+
232
+ # #26/#27 Phase 2a — daemon backend orchestration (serial). Boots the app ONCE in
233
+ # a persistent subprocess and forks per mutant, restoring the one-boot speed the
234
+ # Phase 1 subprocess path gives up. Tool-side we build the ready-to-`load` payload
235
+ # (KTD-2/KTD-3: whole-file reload by default — the spike-proven path) and ship it;
236
+ # the daemon needs no Prism/mutineer. Serial in 2a (worker-DB isolation +
237
+ # parallelism is Phase 2b). No coverage narrowing yet (Phase 2c), so every mutant
238
+ # runs the full `--test` set.
239
+ #
240
+ # @return [Array(Mutineer::AggregateResult, Hash<String,String>)] aggregate and source map.
241
+ def self.execute_daemon(config, operator_classes)
242
+ jobs, ignored_results, source_map = collect_jobs(config, operator_classes)
243
+ jobs = filter_since(jobs, source_map, config) if config.since
244
+
245
+ abs_tests = config.tests.map { |t| File.expand_path(t, config.project_root) }
246
+ client = DaemonClient.new(boot: daemon_boot_config(config, abs_tests),
247
+ app_root: config.project_root).start
248
+
249
+ results = []
250
+ begin
251
+ jobs.each_with_index do |(subject, mutation, id), i|
252
+ source = source_map[subject.file]
253
+ mutated = mutation.apply(source)
254
+ # KTD-8 (carried): skip an invalid mutant tool-side — never ship a payload
255
+ # that would fail to load and read as a false `killed`.
256
+ r =
257
+ if Parser.parse_string(mutated).errors.any?
258
+ Result.skipped
259
+ else
260
+ verdict = client.request(
261
+ id: i, timeout: config.daemon_timeout || DAEMON_TIMEOUT,
262
+ payload: { "code" => mutated, "source_file" => File.expand_path(subject.file, config.project_root) },
263
+ tests: abs_tests
264
+ )
265
+ daemon_result(verdict)
266
+ end
267
+ results << r.with(subject: subject, mutation: mutation, id: id)
268
+ break if config.fail_fast && r.survived? # #21: stop at the first survivor
269
+ end
270
+ ensure
271
+ client.quit
272
+ end
273
+
274
+ [AggregateResult.new(results + ignored_results), source_map]
275
+ end
276
+
277
+ # Default per-mutant timeout on the daemon path. Generous because 2a runs the full
278
+ # `--test` set per mutant (no coverage narrowing until Phase 2c).
279
+ DAEMON_TIMEOUT = 60
280
+
281
+ # The boot config the daemon needs to boot the app once: where to boot, the test
282
+ # load roots (so `require "test_helper"` resolves in every fork), framework, and
283
+ # whether this is Rails.
284
+ def self.daemon_boot_config(config, abs_tests)
285
+ {
286
+ project_root: config.project_root,
287
+ boot: File.expand_path(config.boot || "config/environment", config.project_root),
288
+ load_paths: test_load_roots(abs_tests),
289
+ source_dirs: source_dirs(config), # so the daemon can sweep orphan mutant temps
290
+ framework: config.framework,
291
+ rails: config.rails
292
+ }
293
+ end
294
+
295
+ # Map a daemon verdict string to a Result. The daemon reports the four run-time
296
+ # states it can decide (KTD-5); pre-fork states (skipped/no_coverage/…) are
297
+ # resolved tool-side before a request is ever sent.
298
+ def self.daemon_result(verdict)
299
+ case verdict
300
+ when "survived" then Result.survived
301
+ when "killed" then Result.killed
302
+ when "timeout" then Result.timeout
303
+ else Result.error("daemon verdict: #{verdict}")
304
+ end
305
+ end
306
+
148
307
  # Scan a source once into { line_number => :all | Set[operator_syms] } from
149
308
  # inline `# mutineer:disable-line [ops]` markers (RuboCop semantics: the marker
150
309
  # sits on the same physical line as the code it silences). A bare marker
@@ -222,6 +381,17 @@ module Mutineer
222
381
  warn "[mutineer] RAILS_ENV was unset; defaulting to 'test' for --rails."
223
382
  end
224
383
 
384
+ # The unique absolute directories holding the sources — the sweep target for
385
+ # both orphan mechanisms (in-process mutant tempfiles and external backup
386
+ # files). Shared so the path-expansion rule can't drift between the two paths.
387
+ #
388
+ # @api private
389
+ # @param config [Mutineer::Config] run configuration.
390
+ # @return [Array<String>] unique absolute source directories.
391
+ def self.source_dirs(config)
392
+ config.sources.map { |f| File.dirname(File.expand_path(f, config.project_root)) }.uniq
393
+ end
394
+
225
395
  # Removes stale mutant tempfiles from the given directories.
226
396
  #
227
397
  # @api private
@@ -2,5 +2,5 @@
2
2
 
3
3
  module Mutineer
4
4
  # Current Mutineer release version.
5
- VERSION = "0.9.1"
5
+ VERSION = "0.10.0"
6
6
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: mutineer
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.9.1
4
+ version: 0.10.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - David Teren
@@ -71,6 +71,10 @@ files:
71
71
  - lib/mutineer/cli.rb
72
72
  - lib/mutineer/config.rb
73
73
  - lib/mutineer/coverage_map.rb
74
+ - lib/mutineer/daemon_client.rb
75
+ - lib/mutineer/daemon_server.rb
76
+ - lib/mutineer/external_backend.rb
77
+ - lib/mutineer/file_swap.rb
74
78
  - lib/mutineer/isolation.rb
75
79
  - lib/mutineer/minitest_integration.rb
76
80
  - lib/mutineer/mutant_id.rb