mutation_tester 1.4.2 → 1.5.1
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 +9 -0
- data/Gemfile.lock +1 -1
- data/docs/execution-runners.md +12 -0
- data/examples/github_actions/ai_mutation_gate.yml +1 -1
- data/examples/github_actions/mutation_test.yml +1 -1
- data/exe/mutation_test +6 -1
- data/lib/mutation_tester/configuration.rb +8 -1
- data/lib/mutation_tester/fork_runner/worker.rb +12 -0
- data/lib/mutation_tester/fork_runner.rb +15 -8
- data/lib/mutation_tester/mutation_runner.rb +38 -6
- data/lib/mutation_tester/test_command.rb +2 -1
- data/lib/mutation_tester/version.rb +1 -1
- data/readme.md +383 -541
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e32ba3afbad23fb7f2d04c12f197c8e7d7af50d0eb537266f76e3bd957165110
|
|
4
|
+
data.tar.gz: b27a1b44965b42cb4498daaba8d221dcf820b71fd3e61f3b94c47a71c7cad41d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 4a05a309477ff66005be2371bedd4b5ce4b3622a064f2013b3b62336dbe5d43af7c0e27526eee2ab186c07d9faef5fc8e3a568fe1bdda5e428c3142af19b0165
|
|
7
|
+
data.tar.gz: 3fd04d05930cf47e0a4fb15680531586dfd9183ecd77ec3040aae3e5397ca1cd0cef2ac68ea087acf87c0acdd43dd8f3a18a40a46f05d8c5fac9a545511988f0
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [1.5.1] - 2026-08-13
|
|
4
|
+
|
|
5
|
+
- Fixed load-time mutants losing per-worker database isolation in a parallel in-memory run with `--worker-env` and `--after-fork`. Mutants on load-time code (constants, class macros, `included do`) are decided file-based on purpose, but that file-based run acquires a fork-runner worker, and a worker booted inside a `Parallel` worker process inherited the parent process's value of the worker-env variable instead of the per-worker assignment: with 8 workers, every load-time mutant's test run hit the parent's database concurrently, races on shared rows failed tests unrelated to the mutant, and those failures were scored as kills. The same file could report 75% and 100% on consecutive runs with no error and no timeout in the output, while the spawn path and the pooled fork runner (which already carried the per-worker value) stayed stable. The fork execution path now passes the same per-worker env override the spawn path always used, and the worker applies it inside the forked test child before the spec loads, so every execution path resolves the same per-worker database. Reported in the field with exactly this signature: only class-macro mutants unstable, serial and fork-runner scores identical and stable.
|
|
6
|
+
|
|
7
|
+
## [1.5.0] - 2026-08-08
|
|
8
|
+
|
|
9
|
+
- Added `--after-fork FILE` (and `config.after_fork_file` / the `MUTATION_TESTER_AFTER_FORK` environment variable): a Ruby file loaded inside each preloaded in-memory clone right after it forks and receives its per-worker environment, so the app can re-establish per-worker state such as its ActiveRecord connection. With `--worker-env` set, this keeps the `in_memory` runner available in parallel runs instead of falling back to `fork`: each clone now receives its own per-worker value of the variable through the existing per-clone environment plumbing and runs the after-fork file once, giving in-memory execution and per-worker database isolation at the same time. Failure modes stay loud: a clone whose after-fork file raises reports the error on stderr and is dropped, its worker deciding its share of mutants file-based, and a missing after-fork file makes the whole run fall back to file-based execution with a warning. A serial in-memory run (`-p 1`) has only one worker and now stays in memory with `--worker-env` even without the hook.
|
|
10
|
+
- A serial file-based run (`-p 1`) with `--worker-env` now decides each mutant in a shadow workspace instead of writing mutants into the real source file with a `.mutation_backup` alongside, so a process killed mid-run can no longer leave a mutated source file in the checkout. This combination occurs naturally when a caller uses one code path for both a parallel run and a serial re-run. Without `--worker-env` serial file-based runs keep the in-place strategy (the next run still restores a leftover backup automatically).
|
|
11
|
+
|
|
3
12
|
## [1.4.2] - 2026-08-05
|
|
4
13
|
|
|
5
14
|
- Added `--spec-map 'PATTERN=>REPLACEMENT'`, a repeatable rule that builds the whole spec path from the whole source path with a regular expression, for the layouts the `{name}` template cannot express. `{name}` is the source path minus a leading `lib/`, so a template can only wrap it with a prefix and a suffix; every mapping that substitutes *inside* the path, after a variable-length prefix, was out of reach: `packs/identity/app/models/party.rb -> packs/identity/test/models/party_test.rb` (Packwerk / packs-rails), the same shape with Rails engines, and the plain Rails `app/ -> test/` rule. Because `--since` requires `--glob` and `--glob` maps through `--spec-glob`, such a layout previously lost the entire batch tier at once and had to be reimplemented in a wrapper script. The first matching rule wins and a source matching no rule falls back to `--spec-glob`, so one run can cover `app/` through a rule and `lib/` through the template. The flag works with `--glob`, a positional `FILE` list and `--staged`; a malformed rule is a usage error (exit code `2`) reported before any mutation runs.
|
data/Gemfile.lock
CHANGED
data/docs/execution-runners.md
CHANGED
|
@@ -134,6 +134,18 @@ no fallback is possible.
|
|
|
134
134
|
in a fresh fork of its own clone. If a pooled worker dies mid-run, that
|
|
135
135
|
worker finishes its share of mutants through the file-based path with a
|
|
136
136
|
warning; the other workers stay in memory.
|
|
137
|
+
- With `--worker-env` the preloaded clones would all share the primary
|
|
138
|
+
worker's already-established database connection, so a parallel run
|
|
139
|
+
(`-p N`, N > 1) skips the in-memory runner with a warning unless
|
|
140
|
+
`--after-fork FILE` is also given. With `--after-fork`, each clone
|
|
141
|
+
receives its per-worker value of the `--worker-env` variable and loads
|
|
142
|
+
`FILE` right after forking, and that file re-establishes the per-worker
|
|
143
|
+
state (typically the ActiveRecord connection), which keeps the whole run
|
|
144
|
+
in memory with per-worker database isolation. A serial run (`-p 1`) has
|
|
145
|
+
only one worker and stays in memory without any hook. A clone whose
|
|
146
|
+
after-fork file raises reports the error on stderr and is dropped, and
|
|
147
|
+
its worker falls back to the file-based path; a missing after-fork file
|
|
148
|
+
makes the whole run fall back with a warning.
|
|
137
149
|
- Before any mutant runs, the runner re-applies the **unmutated** source in a
|
|
138
150
|
probe child and runs the suite. If that probe fails (for example the file has
|
|
139
151
|
top-level side effects that break on a second execution, or the class is
|
data/exe/mutation_test
CHANGED
|
@@ -88,10 +88,14 @@ OptionParser.new do |opts|
|
|
|
88
88
|
options[:strict_equality] = true
|
|
89
89
|
end
|
|
90
90
|
|
|
91
|
-
opts.on('--worker-env NAME', 'Set environment variable NAME to a distinct per-worker value before each parallel worker boots (parallel_tests TEST_ENV_NUMBER convention: worker 0 -> "", worker N -> N+1), so a parallel_tests-style database.yml selects a per-worker database. Provision the databases yourself (e.g. rake parallel:prepare).
|
|
91
|
+
opts.on('--worker-env NAME', 'Set environment variable NAME to a distinct per-worker value before each parallel worker boots (parallel_tests TEST_ENV_NUMBER convention: worker 0 -> "", worker N -> N+1), so a parallel_tests-style database.yml selects a per-worker database. Provision the databases yourself (e.g. rake parallel:prepare). Without --after-fork a parallel in_memory run falls back to fork (preloaded clones share one database connection); pair it with --after-fork to keep the in_memory runner. A serial run (-p 1) decides mutants in a shadow workspace instead of mutating the checkout in place.') do |name|
|
|
92
92
|
options[:worker_env] = name
|
|
93
93
|
end
|
|
94
94
|
|
|
95
|
+
opts.on('--after-fork FILE', 'Ruby file loaded inside each preloaded in-memory clone right after it forks and receives its per-worker environment (see --worker-env), so the app can re-establish per-worker state such as its database connection (e.g. a file calling ActiveRecord::Base.establish_connection). With --worker-env set, this keeps the in_memory runner available in parallel runs.') do |file|
|
|
96
|
+
options[:after_fork] = file
|
|
97
|
+
end
|
|
98
|
+
|
|
95
99
|
opts.on('-h', '--help', 'Show this help message') do
|
|
96
100
|
puts opts
|
|
97
101
|
exit
|
|
@@ -178,6 +182,7 @@ apply_configuration = lambda do
|
|
|
178
182
|
config.timeout_factor = options[:timeout_factor] if options[:timeout_factor]
|
|
179
183
|
config.timeout_policy = options[:timeout_policy] if options[:timeout_policy]
|
|
180
184
|
config.worker_env_var = options[:worker_env] if options.key?(:worker_env)
|
|
185
|
+
config.after_fork_file = options[:after_fork] if options.key?(:after_fork)
|
|
181
186
|
config.fail_fast = options.fetch(:fail_fast, false)
|
|
182
187
|
config.mutation_types[:strict_equality] = true if options[:strict_equality]
|
|
183
188
|
|
|
@@ -19,7 +19,8 @@ module MutationTester
|
|
|
19
19
|
number <= 0 ? '' : (number + 1).to_s
|
|
20
20
|
end
|
|
21
21
|
|
|
22
|
-
attr_reader :parallel_processes, :runner, :worker_env_var, :timeout, :timeout_factor,
|
|
22
|
+
attr_reader :parallel_processes, :runner, :worker_env_var, :after_fork_file, :timeout, :timeout_factor,
|
|
23
|
+
:timeout_policy
|
|
23
24
|
|
|
24
25
|
attr_accessor :baseline_duration,
|
|
25
26
|
:baseline_timeout,
|
|
@@ -38,6 +39,7 @@ module MutationTester
|
|
|
38
39
|
self.parallel_processes = ENV['MUTATION_TESTER_PARALLEL_PROCESSES'] || self.class.auto_parallel_processes
|
|
39
40
|
self.runner = ENV['MUTATION_TESTER_RUNNER'] || :auto
|
|
40
41
|
self.worker_env_var = ENV['MUTATION_TESTER_WORKER_ENV']
|
|
42
|
+
self.after_fork_file = ENV['MUTATION_TESTER_AFTER_FORK']
|
|
41
43
|
@timeout = DEFAULT_TIMEOUT
|
|
42
44
|
@timeout_factor = DEFAULT_TIMEOUT_FACTOR
|
|
43
45
|
@timeout_policy = :killed
|
|
@@ -136,6 +138,11 @@ module MutationTester
|
|
|
136
138
|
@worker_env_var = normalized.empty? ? nil : normalized
|
|
137
139
|
end
|
|
138
140
|
|
|
141
|
+
def after_fork_file=(value)
|
|
142
|
+
normalized = value.to_s.strip
|
|
143
|
+
@after_fork_file = normalized.empty? ? nil : File.expand_path(normalized)
|
|
144
|
+
end
|
|
145
|
+
|
|
139
146
|
def worker_env_assignment(index)
|
|
140
147
|
return nil unless @worker_env_var
|
|
141
148
|
|
|
@@ -70,6 +70,7 @@ supervise_child = lambda do |job, out, child_body|
|
|
|
70
70
|
rescue Errno::EACCES, Errno::EPERM
|
|
71
71
|
end
|
|
72
72
|
Dir.chdir(job['chdir']) if job['chdir']
|
|
73
|
+
job['env']&.each { |name, value| ENV[name] = value }
|
|
73
74
|
mirror_load_path.call(job['mirror_of'], job['chdir'])
|
|
74
75
|
sink = File.open(job['log'] || File::NULL, 'w')
|
|
75
76
|
sink.sync = true
|
|
@@ -238,6 +239,17 @@ spawn_clone = lambda do |request, out|
|
|
|
238
239
|
out.close
|
|
239
240
|
STDIN.reopen(File::NULL)
|
|
240
241
|
request['env']&.each { |name, value| ENV[name] = value }
|
|
242
|
+
if request['after_fork']
|
|
243
|
+
begin
|
|
244
|
+
load(request['after_fork'])
|
|
245
|
+
rescue ScriptError, StandardError => e
|
|
246
|
+
clone_out.puts(JSON.generate(
|
|
247
|
+
'event' => 'clone_error',
|
|
248
|
+
'message' => "the after-fork file #{request['after_fork']} raised #{e.class}: #{e.message}; this clone is unavailable"
|
|
249
|
+
))
|
|
250
|
+
raise
|
|
251
|
+
end
|
|
252
|
+
end
|
|
241
253
|
serve.call(input, clone_out)
|
|
242
254
|
end
|
|
243
255
|
Process.detach(child)
|
|
@@ -39,10 +39,10 @@ module MutationTester
|
|
|
39
39
|
refill_pool(pool_key(use_bundle_exec, framework), count, primary, env_for: env_for)
|
|
40
40
|
end
|
|
41
41
|
|
|
42
|
-
def prepare_in_memory_pool(count, primary)
|
|
42
|
+
def prepare_in_memory_pool(count, primary, env_for: nil, after_fork: nil)
|
|
43
43
|
return [] unless available? && primary&.ready?
|
|
44
44
|
|
|
45
|
-
refill_pool(IN_MEMORY_POOL_KEY, count, primary)
|
|
45
|
+
refill_pool(IN_MEMORY_POOL_KEY, count, primary, env_for: env_for, after_fork: after_fork)
|
|
46
46
|
end
|
|
47
47
|
|
|
48
48
|
def in_memory_pool_prepared?
|
|
@@ -116,13 +116,13 @@ module MutationTester
|
|
|
116
116
|
Parallel.worker_number if defined?(Parallel) && Parallel.respond_to?(:worker_number)
|
|
117
117
|
end
|
|
118
118
|
|
|
119
|
-
def refill_pool(key, count, primary, env_for: nil)
|
|
119
|
+
def refill_pool(key, count, primary, env_for: nil, after_fork: nil)
|
|
120
120
|
entry = (pool[key] ||= { owner: Process.pid, runners: [] })
|
|
121
121
|
entry[:runners] = entry[:runners].each_with_index.map do |runner, index|
|
|
122
|
-
runner&.ready? ? runner : primary.fork_clone(env: env_for&.call(index))
|
|
122
|
+
runner&.ready? ? runner : primary.fork_clone(env: env_for&.call(index), after_fork: after_fork)
|
|
123
123
|
end
|
|
124
124
|
entry[:runners].size.upto(count - 1) do |index|
|
|
125
|
-
entry[:runners] << primary.fork_clone(env: env_for&.call(index))
|
|
125
|
+
entry[:runners] << primary.fork_clone(env: env_for&.call(index), after_fork: after_fork)
|
|
126
126
|
end
|
|
127
127
|
entry[:runners]
|
|
128
128
|
end
|
|
@@ -154,7 +154,7 @@ module MutationTester
|
|
|
154
154
|
end
|
|
155
155
|
|
|
156
156
|
def execute(spec_file, timeout: nil, chdir: nil, capture: false, args: [], stop_on_first_failure: false,
|
|
157
|
-
mirror_of: nil)
|
|
157
|
+
mirror_of: nil, env: nil)
|
|
158
158
|
log = capture ? Tempfile.new(['mutation_tester_fork', '.log']) : nil
|
|
159
159
|
job = {
|
|
160
160
|
spec: spec_file,
|
|
@@ -163,7 +163,8 @@ module MutationTester
|
|
|
163
163
|
log: log&.path,
|
|
164
164
|
args: args,
|
|
165
165
|
stop_on_first_failure: stop_on_first_failure,
|
|
166
|
-
mirror_of: mirror_of
|
|
166
|
+
mirror_of: mirror_of,
|
|
167
|
+
env: env
|
|
167
168
|
}
|
|
168
169
|
@job_writer.puts(JSON.generate(job))
|
|
169
170
|
status = await_result(timeout)['status']
|
|
@@ -198,7 +199,7 @@ module MutationTester
|
|
|
198
199
|
fail_worker
|
|
199
200
|
end
|
|
200
201
|
|
|
201
|
-
def fork_clone(env: nil)
|
|
202
|
+
def fork_clone(env: nil, after_fork: nil)
|
|
202
203
|
return nil unless ready?
|
|
203
204
|
return nil unless File.respond_to?(:mkfifo)
|
|
204
205
|
|
|
@@ -210,6 +211,7 @@ module MutationTester
|
|
|
210
211
|
|
|
211
212
|
clone_request = { 'job' => job_path, 'events' => events_path }
|
|
212
213
|
clone_request['env'] = env if env
|
|
214
|
+
clone_request['after_fork'] = after_fork if after_fork
|
|
213
215
|
@job_writer.puts(JSON.generate('clone' => clone_request))
|
|
214
216
|
event = read_event(monotonic_time + CLONE_TIMEOUT)
|
|
215
217
|
return nil unless event.is_a?(Hash) && event['event'] == 'cloned'
|
|
@@ -264,6 +266,11 @@ module MutationTester
|
|
|
264
266
|
next
|
|
265
267
|
end
|
|
266
268
|
|
|
269
|
+
if event['event'] == 'clone_error'
|
|
270
|
+
warn("[MutationTester] #{event['message']}")
|
|
271
|
+
return false
|
|
272
|
+
end
|
|
273
|
+
|
|
267
274
|
return event['event'] == 'ready'
|
|
268
275
|
end
|
|
269
276
|
end
|
|
@@ -56,16 +56,17 @@ module MutationTester
|
|
|
56
56
|
elsif in_memory_first?
|
|
57
57
|
run_in_memory_series(mutations, &progress_callback)
|
|
58
58
|
elsif @config.parallel_processes == 1
|
|
59
|
-
|
|
59
|
+
run_file_based_series(mutations, &progress_callback)
|
|
60
60
|
else
|
|
61
61
|
run_in_shadow_parallel(mutations, &progress_callback)
|
|
62
62
|
end
|
|
63
63
|
end
|
|
64
64
|
|
|
65
65
|
def in_memory_first?
|
|
66
|
-
return false
|
|
66
|
+
return false unless %i[in_memory auto].include?(@config.runner)
|
|
67
|
+
return true unless @config.worker_env_var
|
|
67
68
|
|
|
68
|
-
|
|
69
|
+
@config.parallel_processes == 1 || !@config.after_fork_file.nil?
|
|
69
70
|
end
|
|
70
71
|
|
|
71
72
|
def run_in_memory_series(mutations, &progress_callback)
|
|
@@ -100,7 +101,12 @@ module MutationTester
|
|
|
100
101
|
blocker = prepare_in_memory_execution
|
|
101
102
|
return fall_back_to_parallel_file_based(blocker, mutations, &progress_callback) if blocker
|
|
102
103
|
|
|
103
|
-
pool = ForkRunner.prepare_in_memory_pool(
|
|
104
|
+
pool = ForkRunner.prepare_in_memory_pool(
|
|
105
|
+
[@config.parallel_processes, mutations.size].min,
|
|
106
|
+
@in_memory_runner,
|
|
107
|
+
env_for: worker_env_for,
|
|
108
|
+
after_fork: @config.after_fork_file
|
|
109
|
+
)
|
|
104
110
|
if pool.compact.empty?
|
|
105
111
|
return fall_back_to_parallel_file_based('the preloaded worker pool could not be cloned', mutations, &progress_callback)
|
|
106
112
|
end
|
|
@@ -161,6 +167,28 @@ module MutationTester
|
|
|
161
167
|
cleanup_shadow_workspaces
|
|
162
168
|
end
|
|
163
169
|
|
|
170
|
+
def run_file_based_series(mutations, &progress_callback)
|
|
171
|
+
project_root = @config.worker_env_var ? discoverable_project_root : nil
|
|
172
|
+
return run_in_place_series(mutations, &progress_callback) unless project_root
|
|
173
|
+
|
|
174
|
+
run_in_shadow_series(mutations, project_root, &progress_callback)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def run_in_shadow_series(mutations, project_root, &progress_callback)
|
|
178
|
+
warn "[MutationTester] --worker-env #{@config.worker_env_var} is set, so the serial run decides each mutant in a shadow workspace and never mutates the checkout in place."
|
|
179
|
+
shadow_run_root
|
|
180
|
+
results = []
|
|
181
|
+
mutations.each_with_index do |mutation, index|
|
|
182
|
+
result = run_single_mutation(mutation, :shadow, project_root)
|
|
183
|
+
progress_callback.call(mutation, index + 1) if progress_callback
|
|
184
|
+
results << result
|
|
185
|
+
break if stop_early?(result)
|
|
186
|
+
end
|
|
187
|
+
results
|
|
188
|
+
ensure
|
|
189
|
+
cleanup_shadow_workspaces
|
|
190
|
+
end
|
|
191
|
+
|
|
164
192
|
def run_in_place_series(mutations, &progress_callback)
|
|
165
193
|
write_in_place_backup
|
|
166
194
|
results = []
|
|
@@ -401,6 +429,9 @@ module MutationTester
|
|
|
401
429
|
|
|
402
430
|
def prepare_in_memory_execution
|
|
403
431
|
return 'Process.fork is not supported on this platform' unless ForkRunner.available?
|
|
432
|
+
if @config.after_fork_file && !File.exist?(@config.after_fork_file)
|
|
433
|
+
return "the after-fork file #{@config.after_fork_file} does not exist"
|
|
434
|
+
end
|
|
404
435
|
if InMemoryLoader.load_time_defined_guard?(@original_content)
|
|
405
436
|
return 'the source file uses defined? at load time, so redefinition would silently skip the guarded code'
|
|
406
437
|
end
|
|
@@ -451,7 +482,7 @@ module MutationTester
|
|
|
451
482
|
offset_callback = progress_callback && lambda do |mutation, index|
|
|
452
483
|
progress_callback.call(mutation, completed + index)
|
|
453
484
|
end
|
|
454
|
-
|
|
485
|
+
run_file_based_series(mutations, &offset_callback)
|
|
455
486
|
end
|
|
456
487
|
|
|
457
488
|
def fall_back_to_parallel_file_based(reason, mutations, &progress_callback)
|
|
@@ -470,8 +501,9 @@ module MutationTester
|
|
|
470
501
|
def announce_worker_env_in_memory_opt_out
|
|
471
502
|
return unless @config.worker_env_var
|
|
472
503
|
return unless %i[in_memory auto].include?(@config.runner)
|
|
504
|
+
return if @config.parallel_processes == 1 || @config.after_fork_file
|
|
473
505
|
|
|
474
|
-
warn "[MutationTester] --worker-env #{@config.worker_env_var} is set, so the in-memory runner is skipped (its clones share one preloaded database connection); using the fork runner for per-worker database isolation."
|
|
506
|
+
warn "[MutationTester] --worker-env #{@config.worker_env_var} is set without --after-fork, so the in-memory runner is skipped (its clones share one preloaded database connection); using the fork runner for per-worker database isolation. Pass --after-fork FILE to keep the in-memory runner and re-establish per-worker connections inside each clone."
|
|
475
507
|
end
|
|
476
508
|
|
|
477
509
|
def run_mutation_load_time(mutation, result, project_root)
|