mutineer 0.11.1 → 0.11.2
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 +10 -0
- data/README.md +39 -4
- data/lib/mutineer/cli.rb +2 -2
- data/lib/mutineer/external_backend.rb +119 -11
- data/lib/mutineer/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 457a9cdee5d5291635fb56dd01caf17293bc500443ca2e43e298a81c0c1f73bf
|
|
4
|
+
data.tar.gz: ffe7d126be066573d5c9c42969f994e7a9be71ecb8d301643a6deab3de5d7af3
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 63e9df1191cdc62802dcd9dd674eb2954c416b8016017aa3bb9e689f777859f547450416e4a62f23b34f9c9171b063ff12a6639e3cda1ceb9180c48bbea673df
|
|
7
|
+
data.tar.gz: 0ace3fd9df09a67b535d55ee25f0e30d60f1dd3f03ca1027600cc216d11d9dd468f320e9b11190d595e204d6310bfe05f61ec80e6eeb33f36c123e0981ed1810
|
data/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,15 @@ All notable changes to this project are documented here. The format is based on
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.11.2] - 2026-07-24
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
- **`--test-command` under version managers** — scrub Mutineer's rbenv/asdf
|
|
13
|
+
version bins and bundler/gem env from the child so the suite can resolve the
|
|
14
|
+
app's Ruby via shims / `.ruby-version`. Smoke check prints a targeted hint on
|
|
15
|
+
`Bundler::RubyVersionMismatch` instead of only blaming DB/migrations. Docs
|
|
16
|
+
cover a wrapper recipe for stubborn setups (#32).
|
|
17
|
+
|
|
9
18
|
## [0.11.1] - 2026-07-23
|
|
10
19
|
|
|
11
20
|
### Fixed
|
|
@@ -244,6 +253,7 @@ Rails hardening + CI batch (issues #8–#13), all verified Rails-free.
|
|
|
244
253
|
- `.mutineer.yml` configuration (CLI > config > default precedence).
|
|
245
254
|
- Byte-correct source handling for multibyte (UTF-8) sources.
|
|
246
255
|
|
|
256
|
+
[0.11.2]: https://github.com/davidteren/mutineer/releases/tag/v0.11.2
|
|
247
257
|
[0.11.1]: https://github.com/davidteren/mutineer/releases/tag/v0.11.1
|
|
248
258
|
[0.11.0]: https://github.com/davidteren/mutineer/releases/tag/v0.11.0
|
|
249
259
|
[0.10.0]: https://github.com/davidteren/mutineer/releases/tag/v0.10.0
|
data/README.md
CHANGED
|
@@ -151,11 +151,46 @@ RAILS_ENV=test mutineer run app/models/order.rb \
|
|
|
151
151
|
|
|
152
152
|
- **`%{files}`** is required; it expands to the `--test` paths as separate
|
|
153
153
|
arguments (a path with a space stays one argument — there is no shell).
|
|
154
|
-
- **Environment
|
|
155
|
-
command
|
|
156
|
-
|
|
154
|
+
- **Environment:** vars like `RAILS_ENV` / `DATABASE_URL` set on the Mutineer
|
|
155
|
+
command are inherited. Mutineer **unsets** `BUNDLE_*`, `GEM_*`, `RUBY*`,
|
|
156
|
+
`RBENV_VERSION`, `ASDF_RUBY_VERSION`, and `RBENV_DIR` in the child (so
|
|
157
|
+
Mutineer's own Ruby cannot pin the suite), and drops version-manager
|
|
158
|
+
**version bins** (e.g. `~/.rbenv/versions/3.4.x/bin`) from `PATH`, then
|
|
159
|
+
prepends rbenv/asdf shims when a pin was scrubbed. Do not rely on
|
|
160
|
+
`RBENV_VERSION=…` on the Mutineer command for the suite; use `.ruby-version`
|
|
161
|
+
or a wrapper. Don't put `KEY=val` prefixes *inside* `--test-command` (no shell;
|
|
162
|
+
that would be treated as the program name).
|
|
163
|
+
|
|
164
|
+
#### Under a version manager (rbenv / asdf / chruby)
|
|
165
|
+
|
|
166
|
+
Automatic scrub targets **rbenv** and **asdf** (shims + version bins). **chruby**
|
|
167
|
+
has no shims: Mutineer still strips `…/rubies/…/bin` so it cannot leave Mutineer's
|
|
168
|
+
Ruby pinned, but you need a wrapper that sources chruby and selects the app
|
|
169
|
+
version. If the smoke check still reports a **Ruby version mismatch**, wrap the
|
|
170
|
+
suite. Example rbenv wrapper (`bin/mutineer-test` in the app):
|
|
157
171
|
|
|
158
|
-
|
|
172
|
+
```sh
|
|
173
|
+
#!/usr/bin/env bash
|
|
174
|
+
set -euo pipefail
|
|
175
|
+
cd "$(dirname "$0")/.."
|
|
176
|
+
unset GEM_HOME GEM_PATH RUBYLIB RUBYOPT BUNDLE_GEMFILE BUNDLE_BIN_PATH BUNDLER_VERSION
|
|
177
|
+
export RBENV_VERSION="$(cat .ruby-version 2>/dev/null || true)"
|
|
178
|
+
export RAILS_ENV="${RAILS_ENV:-test}"
|
|
179
|
+
export PATH="${HOME}/.rbenv/shims:${PATH}"
|
|
180
|
+
exec bundle exec rails test "$@"
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
```sh
|
|
184
|
+
# Mutineer on 3.4+; suite on the app's Ruby via the wrapper
|
|
185
|
+
mutineer run app/models/order.rb \
|
|
186
|
+
--test test/models/order_test.rb \
|
|
187
|
+
--test-command "bin/mutineer-test %{files}"
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
Mutineer also surfaces a targeted smoke-check message when Bundler prints
|
|
191
|
+
`RubyVersionMismatch`, instead of only blaming DB/migrations.
|
|
192
|
+
|
|
193
|
+
Tradeoffs — this path is correct but not free:
|
|
159
194
|
|
|
160
195
|
- **Slower:** your app re-boots for every mutant (no shared boot yet).
|
|
161
196
|
- **No coverage narrowing:** every mutant runs the *full* `--test` set, so the
|
data/lib/mutineer/cli.rb
CHANGED
|
@@ -49,8 +49,8 @@ module Mutineer
|
|
|
49
49
|
--rails Sugar for --boot config/environment --strategy redefine
|
|
50
50
|
--test-command CMD Run the target suite in the app's own runtime as a
|
|
51
51
|
subprocess (for apps on Ruby < 3.4). CMD must contain
|
|
52
|
-
%{files}
|
|
53
|
-
|
|
52
|
+
%{files}. Scrubs Mutineer Ruby PATH pins; set RAILS_ENV
|
|
53
|
+
on the mutineer command (not as KEY=val inside CMD)
|
|
54
54
|
--daemon Boot the app ONCE in a persistent daemon and fork per
|
|
55
55
|
mutant, with per-worker DB isolation so --jobs N is safe
|
|
56
56
|
under Rails (needs --rails/--boot; not with --test-command)
|
|
@@ -10,17 +10,21 @@ module Mutineer
|
|
|
10
10
|
# tests. The CLI maps this to a runtime error (exit 1), not a usage error.
|
|
11
11
|
class SmokeCheckError < StandardError; end
|
|
12
12
|
|
|
13
|
-
#
|
|
14
|
-
#
|
|
15
|
-
#
|
|
13
|
+
# External execution backend. Runs the user's `--test-command` as a subprocess
|
|
14
|
+
# in the app's own runtime (whatever Ruby its bundle resolves to), so mutineer
|
|
15
|
+
# (Ruby ≥ 3.4) can mutation-test apps pinned to an older Ruby.
|
|
16
16
|
#
|
|
17
17
|
# This is deliberately NOT a `TestRunners` framework adapter: those return an
|
|
18
18
|
# Integer 0/1 from inside a fork and are dispatched by framework name. This is a
|
|
19
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
|
|
20
|
+
# the exit status to a Result. The mapping is the same direction as in-process
|
|
21
21
|
# (suite passes => survived, suite fails => killed) but coarser: it cannot tell an
|
|
22
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
|
|
23
|
+
# persistent case and the score is disclosed as an upper bound.
|
|
24
|
+
#
|
|
25
|
+
# Child environment: bundler/gem env injected by Mutineer's Ruby is stripped, and
|
|
26
|
+
# version-manager concrete version bins (e.g. `~/.rbenv/versions/3.4.9/bin`) are
|
|
27
|
+
# removed from PATH so shims / `.ruby-version` can select the app's Ruby (#32).
|
|
24
28
|
module ExternalBackend
|
|
25
29
|
# Generous ceiling for the one-off smoke/calibration run (a cold app boot plus
|
|
26
30
|
# the full suite). The per-mutant timeout is derived from how long this took.
|
|
@@ -29,6 +33,24 @@ module Mutineer
|
|
|
29
33
|
# this backend waits on an external process TREE, not an in-process fork.
|
|
30
34
|
POLL = 0.02
|
|
31
35
|
|
|
36
|
+
# PATH entries that pin a concrete Ruby under a version manager (ahead of
|
|
37
|
+
# shims). Optional trailing slash; normal bins are left alone.
|
|
38
|
+
VERSION_BIN_PATH = %r{
|
|
39
|
+
(?:
|
|
40
|
+
/\.rbenv/versions/[^/]+/bin
|
|
41
|
+
|/\.asdf/installs/ruby/[^/]+/bin
|
|
42
|
+
|/\.rubies/[^/]+/bin
|
|
43
|
+
|/rubies/[^/]+/bin
|
|
44
|
+
)/?\z
|
|
45
|
+
}x
|
|
46
|
+
|
|
47
|
+
# Env keys Process.spawn must unset (nil value) so Mutineer's Ruby/bundler
|
|
48
|
+
# context does not leak. Spawn merges env onto the parent; omitting a key
|
|
49
|
+
# does NOT clear it.
|
|
50
|
+
CLEAR_ENV_KEYS = %w[
|
|
51
|
+
BUNDLER_VERSION RBENV_VERSION ASDF_RUBY_VERSION RBENV_DIR
|
|
52
|
+
].freeze
|
|
53
|
+
|
|
32
54
|
# Turn a command template into an argv array (no shell → no eval, no
|
|
33
55
|
# injection). The `%{files}` token expands IN PLACE to N separate argv
|
|
34
56
|
# elements — one per path, unescaped — so a path containing a space stays a
|
|
@@ -41,10 +63,63 @@ module Mutineer
|
|
|
41
63
|
Shellwords.split(command).flat_map { |tok| tok == "%{files}" ? files : [tok] }
|
|
42
64
|
end
|
|
43
65
|
|
|
66
|
+
# Environment delta for the test-command child. Process.spawn merges this
|
|
67
|
+
# onto the parent env: set a key to +nil+ to unset it. Scrubs Mutineer/bundler
|
|
68
|
+
# injection and version-manager PATH pins so shims / `.ruby-version` can win.
|
|
69
|
+
# Keeps other vars (e.g. `RAILS_ENV`) so setting them on the Mutineer command
|
|
70
|
+
# still reaches the suite.
|
|
71
|
+
#
|
|
72
|
+
# @api private
|
|
73
|
+
# @return [Hash{String => String, nil}] env for Process.spawn.
|
|
74
|
+
def self.child_env
|
|
75
|
+
env = {}
|
|
76
|
+
cleared_pin = false
|
|
77
|
+
ENV.each_key do |k|
|
|
78
|
+
next unless clear_env_key?(k)
|
|
79
|
+
|
|
80
|
+
env[k] = nil
|
|
81
|
+
cleared_pin = true
|
|
82
|
+
end
|
|
83
|
+
path = ENV["PATH"].to_s.split(File::PATH_SEPARATOR)
|
|
84
|
+
kept = path.reject { |p| p.match?(VERSION_BIN_PATH) }
|
|
85
|
+
scrubbed_bin = kept.size != path.size
|
|
86
|
+
# Only reorder PATH when we scrubbed a pin; otherwise leave the user's PATH alone.
|
|
87
|
+
path = if cleared_pin || scrubbed_bin
|
|
88
|
+
(version_manager_shim_dirs + kept).uniq
|
|
89
|
+
else
|
|
90
|
+
kept
|
|
91
|
+
end
|
|
92
|
+
env["PATH"] = path.join(File::PATH_SEPARATOR)
|
|
93
|
+
env
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# True when the key must be unset in the child (bundler/gem/version-manager).
|
|
97
|
+
#
|
|
98
|
+
# @api private
|
|
99
|
+
# @param key [String] environment variable name.
|
|
100
|
+
# @return [Boolean]
|
|
101
|
+
def self.clear_env_key?(key)
|
|
102
|
+
key.start_with?("BUNDLE_", "RUBY", "GEM_") || CLEAR_ENV_KEYS.include?(key)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Existing shim directories for rbenv / asdf (chruby uses PATH without shims).
|
|
106
|
+
#
|
|
107
|
+
# @api private
|
|
108
|
+
# @return [Array<String>] absolute shim paths that exist.
|
|
109
|
+
def self.version_manager_shim_dirs
|
|
110
|
+
home = ENV["HOME"]
|
|
111
|
+
return [] if home.nil? || home.empty?
|
|
112
|
+
|
|
113
|
+
[
|
|
114
|
+
File.join(home, ".rbenv", "shims"),
|
|
115
|
+
File.join(home, ".asdf", "shims")
|
|
116
|
+
].select { |d| File.directory?(d) }
|
|
117
|
+
end
|
|
118
|
+
|
|
44
119
|
# Runs the command for ONE mutant against whatever is currently on disk (the
|
|
45
120
|
# caller has already swapped the mutant in via FileSwap). Maps the outcome to a
|
|
46
|
-
# Result.
|
|
47
|
-
#
|
|
121
|
+
# Result. Uses {child_env} so version-manager PATH pins from Mutineer's Ruby do
|
|
122
|
+
# not force the suite onto the wrong interpreter.
|
|
48
123
|
#
|
|
49
124
|
# @param command [String] the --test-command template.
|
|
50
125
|
# @param files [Array<String>] test file paths.
|
|
@@ -93,10 +168,42 @@ module Mutineer
|
|
|
93
168
|
else "exited #{code}"
|
|
94
169
|
end
|
|
95
170
|
detail = output.empty? ? "" : "\n--- last output ---\n#{tail(output)}"
|
|
171
|
+
hint = ruby_version_mismatch_hint(output)
|
|
96
172
|
raise SmokeCheckError,
|
|
97
|
-
"the test command #{reason} against the UNMUTATED source —
|
|
98
|
-
"
|
|
99
|
-
|
|
173
|
+
"the test command #{reason} against the UNMUTATED source — " \
|
|
174
|
+
"#{hint || generic_env_hint}.#{detail}"
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# Generic smoke-failure framing when no Ruby-version mismatch is detected.
|
|
178
|
+
#
|
|
179
|
+
# @api private
|
|
180
|
+
# @return [String]
|
|
181
|
+
def self.generic_env_hint
|
|
182
|
+
"the unmutated suite is not green (failing tests, or a broken environment: " \
|
|
183
|
+
"DB, RAILS_ENV, migrations) — fix that before scoring"
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# Targeted hint when Bundler reports a RubyVersionMismatch (common under
|
|
187
|
+
# rbenv/asdf/chruby when Mutineer's version bin sits ahead of shims on PATH).
|
|
188
|
+
#
|
|
189
|
+
# @api private
|
|
190
|
+
# @param output [String] captured child stdout+stderr.
|
|
191
|
+
# @return [String, nil] hint text, or nil when not a version mismatch.
|
|
192
|
+
def self.ruby_version_mismatch_hint(output)
|
|
193
|
+
return nil if output.nil? || output.empty?
|
|
194
|
+
return nil unless output.match?(/RubyVersionMismatch|Your Ruby version is .* but your Gemfile specified/i)
|
|
195
|
+
|
|
196
|
+
ran = output[/Your Ruby version is ([0-9.]+)/i, 1]
|
|
197
|
+
want = output[/Gemfile specified ([0-9.]+)/i, 1]
|
|
198
|
+
parts = +"Detected a Ruby version mismatch"
|
|
199
|
+
parts << ": the test command ran under #{ran}" if ran
|
|
200
|
+
parts << " but the app expects #{want}" if want
|
|
201
|
+
parts << ". Mutineer already scrubbed version-manager bins and RBENV_VERSION/" \
|
|
202
|
+
"ASDF_RUBY_VERSION from the child env; the suite still picked the wrong " \
|
|
203
|
+
"Ruby. Use a wrapper that re-selects the app Ruby and ensure " \
|
|
204
|
+
".ruby-version / .tool-versions is present " \
|
|
205
|
+
"(see README: Apps on Ruby < 3.4 → Under a version manager)"
|
|
206
|
+
parts
|
|
100
207
|
end
|
|
101
208
|
|
|
102
209
|
# Spawns the command to a captured combined-output tempfile, enforces a
|
|
@@ -122,7 +229,8 @@ module Mutineer
|
|
|
122
229
|
# explicit [program, argv0] form guarantees the no-shell exec path even for a
|
|
123
230
|
# degenerate single-element argv (Process.spawn(*argv) would route a lone
|
|
124
231
|
# metachar-bearing string through /bin/sh, breaking the argv-only invariant).
|
|
125
|
-
pid = Process.spawn([argv.first, argv.first], *argv[1..],
|
|
232
|
+
pid = Process.spawn(child_env, [argv.first, argv.first], *argv[1..],
|
|
233
|
+
out: out, err: %i[child out], pgroup: true)
|
|
126
234
|
kind, code = wait_with_timeout(pid, timeout)
|
|
127
235
|
elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start
|
|
128
236
|
out.rewind
|
data/lib/mutineer/version.rb
CHANGED