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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 42d278a96730cdf9616492a720c701ba9119195299f64fa74b339b3737e4e545
4
- data.tar.gz: ef2148e5752ea9790403ca256e4bc6672b5e8c2656289d74b9cd3ffcd067f475
3
+ metadata.gz: 457a9cdee5d5291635fb56dd01caf17293bc500443ca2e43e298a81c0c1f73bf
4
+ data.tar.gz: ffe7d126be066573d5c9c42969f994e7a9be71ecb8d301643a6deab3de5d7af3
5
5
  SHA512:
6
- metadata.gz: 82879be78e820ef184dca76bf52ad883298f3c0e8becdb6344061b32321f44945464025c6f7d8064e9b649486b6b5f258b60b43baf0ec6f0d38356b314af5793
7
- data.tar.gz: 67399daaa12b9b312a4ad0fcad144ae763783d2f5994e474b1303ad7ed0ddd5c5d42fba5b39c750115a0614356b4e4a2593238b91e39eee38edd96981c4afa5a
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** is inherited by the subprocess, so set it on the Mutineer
155
- command (e.g. the leading `RAILS_ENV=test` above). Don't put `KEY=val` inside
156
- `--test-command`.
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
- Tradeoffs (Phase 1) — this path is correct but not free:
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} (expands to the --test paths). Env is inherited,
53
- e.g. RAILS_ENV=test mutineer run ... --test-command "..."
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
- # #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.
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 SAME direction as in-process
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 (KTD-3/KTD-6).
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. Env is inherited by the subprocess, so `RAILS_ENV=test mutineer …`
47
- # reaches the child with no parsing here.
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 — the " \
98
- "environment looks broken (check DB, RAILS_ENV, migrations), not the " \
99
- "tests weak.#{detail}"
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..], out: out, err: %i[child out], pgroup: true)
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
@@ -2,5 +2,5 @@
2
2
 
3
3
  module Mutineer
4
4
  # Current Mutineer release version.
5
- VERSION = "0.11.1"
5
+ VERSION = "0.11.2"
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.11.1
4
+ version: 0.11.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - David Teren