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 +4 -4
- data/CHANGELOG.md +16 -0
- data/README.md +38 -1
- data/lib/mutineer/cli.rb +60 -0
- data/lib/mutineer/config.rb +8 -2
- data/lib/mutineer/daemon_client.rb +172 -0
- data/lib/mutineer/daemon_server.rb +190 -0
- data/lib/mutineer/external_backend.rb +168 -0
- data/lib/mutineer/file_swap.rb +95 -0
- data/lib/mutineer/runner.rb +199 -29
- data/lib/mutineer/version.rb +1 -1
- metadata +5 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 92b74299812649a42303c4387412bd3bd67014e7e91c656df36489836773ae08
|
|
4
|
+
data.tar.gz: 20c25fed6e5a490deb8d5f9068cd47187efe16fdfa0625a18e9e1fe7296b17f6
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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),
|
|
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))
|
data/lib/mutineer/config.rb
CHANGED
|
@@ -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
|
-
:
|
|
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
|
data/lib/mutineer/runner.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
123
|
-
|
|
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(
|
|
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
|
data/lib/mutineer/version.rb
CHANGED
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.
|
|
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
|