evilution 0.33.0 → 0.34.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.
@@ -0,0 +1,259 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require_relative "version"
5
+ require_relative "temp_dir_tracker"
6
+
7
+ # Single owner of the process-lifecycle invariant: every pid spawned here is
8
+ # group-isolated, tracked in a signal-safe registry, group-signalled through a
9
+ # TERM/KILL ladder, and reaped -- with its fds closed and sandbox dir removed.
10
+ #
11
+ # EV-9f3b / EV-5rrh, Track A step 1. Generalizes the lock-free COW
12
+ # WorkerRegistry (EV-jwao) and absorbs ProcessCleanup.safe_kill/safe_wait
13
+ # semantics. Pure unit: no call sites are migrated here -- Isolation::Fork
14
+ # (inner path) and WorkQueue::Worker (outer path) are routed through it in
15
+ # later steps (EV-3aw3, EV-dg69, EV-7a91).
16
+ #
17
+ # Shape: instances own the lifecycle of the children they spawn, but every
18
+ # handle is also recorded in ONE process-global registry so the Runner signal
19
+ # trap can `.signal_all` across every fork-site through a single owner.
20
+ #
21
+ # Signal-safety: under MRI a trap handler runs on the main thread between VM
22
+ # instructions, so it must not acquire a Mutex (the main thread may hold it ->
23
+ # deadlock). register/unregister swap @registry for a freshly built frozen
24
+ # array via a single atomic reference assignment (copy-on-write). The trap
25
+ # reads the current reference once and iterates that complete, immutable
26
+ # snapshot -- no torn reads, no lock.
27
+ class Evilution::ProcessSupervisor
28
+ GRACE_PERIOD = 2
29
+
30
+ # One tracked child: leader pid, its process-group id (== pid for a group
31
+ # leader), the parent-side fds to close on reap, and an optional sandbox dir
32
+ # to remove on reap.
33
+ Handle = Struct.new(:pid, :pgid, :fds, :sandbox_dir, keyword_init: true)
34
+
35
+ @registry = [].freeze
36
+
37
+ class << self
38
+ # Frozen snapshot. Safe to read from a signal handler.
39
+ attr_reader :registry
40
+
41
+ def register(handle)
42
+ @registry = (@registry + [handle]).freeze
43
+ end
44
+
45
+ def unregister(handle)
46
+ @registry = @registry.reject { |existing| existing.pid == handle.pid }.freeze
47
+ end
48
+
49
+ def signal_all(sig)
50
+ @registry.each do |handle|
51
+ Process.kill(sig, -handle.pgid)
52
+ rescue Errno::ESRCH
53
+ # Group already gone (leader + subtree reaped) -- nothing to signal.
54
+ nil
55
+ end
56
+ end
57
+
58
+ # Drop every inherited entry so a freshly forked child starts owning
59
+ # nothing. A child inherits a COW copy of this registry, but the handles in
60
+ # it belong to the PARENT (e.g. sibling workers); if the child later
61
+ # signalled or reaped them -- via signal_all / kill_and_reap_all in its own
62
+ # signal handler -- it would tear down processes it never spawned. The child
63
+ # re-registers only what it spawns itself.
64
+ def reset_for_child!
65
+ @registry = [].freeze
66
+ end
67
+
68
+ # Trap-safe teardown of every registered child: SIGKILL each process group
69
+ # (sweeping grandchildren) and the bare leader pid, then reap the leaders so
70
+ # they cannot zombie, and clear the registry. Reads the COW snapshot once --
71
+ # no Mutex, safe from a signal handler.
72
+ #
73
+ # EV-7a91: a process about to die on a fatal signal must not leave the
74
+ # children it OWNS behind. The Runner's group-kill reaches only the worker
75
+ # groups; the inner per-mutation children left those groups (setpgid, EV-2sh8)
76
+ # and live in the worker's own registry, so only the worker -- their parent --
77
+ # can kill AND reap them before it dies. Without the reap they survive as
78
+ # zombies until some ancestor exits and init collects them, which never comes
79
+ # when evilution runs embedded in a long-lived host process.
80
+ def kill_and_reap_all
81
+ snapshot = @registry
82
+ snapshot.each do |handle|
83
+ kill_tolerant("KILL", -handle.pgid)
84
+ kill_tolerant("KILL", handle.pid)
85
+ end
86
+ # Reap only after every group has been signalled, so a slow-to-die child
87
+ # never delays killing the others' subtrees.
88
+ snapshot.each { |handle| reap_tolerant(handle.pid) } # rubocop:disable Style/CombinableLoops
89
+ @registry = (@registry - snapshot).freeze
90
+ end
91
+
92
+ private
93
+
94
+ def kill_tolerant(sig, target)
95
+ Process.kill(sig, target)
96
+ rescue Errno::ESRCH
97
+ nil
98
+ end
99
+
100
+ def reap_tolerant(pid)
101
+ Process.waitpid(pid)
102
+ rescue Errno::ECHILD
103
+ nil
104
+ end
105
+ end
106
+
107
+ # Fork a child that becomes its own process-group leader and runs the block,
108
+ # returning a Handle. By default the child calls setpgid(0, 0) before
109
+ # yielding so any grandchildren it forks join its group and can be swept by a
110
+ # group signal; the parent repeats setpgid(pid, pid) to close the race where
111
+ # it signals before the child has isolated itself. The handle is registered
112
+ # BEFORE the parent-side setpgid so the trap can never observe a child that is
113
+ # already a group leader yet missing from the registry (EV-jwao race).
114
+ #
115
+ # isolate_in_child: false suppresses the child-side setpgid for long-lived
116
+ # workers (the outer path): the child must NOT become its own group leader
117
+ # until the parent has registered it, otherwise a trap firing between fork and
118
+ # register would see a leader it cannot signal. With only the parent-side,
119
+ # post-register setpgid, the child stays in the parent group (reachable by the
120
+ # terminal signal directly) until the registry already lists it.
121
+ def spawn(sandbox_dir: nil, fds: [], isolate_in_child: true)
122
+ pid = ::Process.fork do
123
+ self.class.reset_for_child!
124
+ isolate_self if isolate_in_child
125
+ yield
126
+ end
127
+
128
+ # Track the sandbox first thing after fork: if the parent takes a fatal
129
+ # signal before isolate_child returns, Runner's trap (TempDirTracker
130
+ # .cleanup_all) can still see and remove it, narrowing the leak window.
131
+ Evilution::TempDirTracker.register(sandbox_dir) if sandbox_dir
132
+ handle = Handle.new(pid: pid, pgid: pid, fds: fds, sandbox_dir: sandbox_dir)
133
+ self.class.register(handle)
134
+ isolate_child(pid)
135
+ handle
136
+ end
137
+
138
+ # Signal the child's whole process group (-pgid) to sweep any grandchildren,
139
+ # then the bare pid as a fallback for the case where setpgid failed (no group
140
+ # exists, so the group signal is a harmless Errno::ESRCH).
141
+ def signal_group(sig, handle)
142
+ safe_kill(sig, -handle.pgid)
143
+ safe_kill(sig, handle.pid)
144
+ end
145
+
146
+ # Bounded TERM -> grace -> KILL ladder, then reap. Always ends with the child
147
+ # reaped and its resources released, whichever rung it dies on.
148
+ def terminate(handle, grace: GRACE_PERIOD)
149
+ signal_group("TERM", handle)
150
+ unless exited?(handle.pid)
151
+ sleep(grace)
152
+ signal_group("KILL", handle) unless exited?(handle.pid)
153
+ end
154
+ reap(handle)
155
+ end
156
+
157
+ # Reap the leader (ECHILD-tolerant if already reaped), then unconditionally
158
+ # release the resources the handle owns: close parent-side fds, remove the
159
+ # sandbox dir, and drop the handle from the registry.
160
+ def reap(handle)
161
+ safe_wait(handle.pid)
162
+ ensure
163
+ release(handle)
164
+ end
165
+
166
+ # Non-blocking reap for callers that poll a child's liveness as part of a
167
+ # read protocol (e.g. Isolation::Fork's marshal-pipe loop). Returns false
168
+ # while the child is still running -- the handle stays registered so a signal
169
+ # trap can still reach it. Once the child has exited (or was already reaped),
170
+ # it releases the handle in the same step it reaps, so the process-global
171
+ # registry never holds a stale, already-reaped pgid.
172
+ def reap_nonblock(handle)
173
+ return false unless nonblocking_wait(handle.pid)
174
+
175
+ release(handle)
176
+ true
177
+ end
178
+
179
+ private
180
+
181
+ # WNOHANG wait: returns the pid once the child has exited, nil while it is
182
+ # still running, and -- treating an already-reaped child as exited -- the pid
183
+ # again on ECHILD so the caller still releases the handle.
184
+ def nonblocking_wait(pid)
185
+ ::Process.waitpid(pid, ::Process::WNOHANG)
186
+ rescue Errno::ECHILD
187
+ pid
188
+ end
189
+
190
+ def release(handle)
191
+ close_fds(handle)
192
+ cleanup_sandbox(handle)
193
+ self.class.unregister(handle)
194
+ end
195
+
196
+ def isolate_self
197
+ ::Process.setpgid(0, 0)
198
+ rescue SystemCallError
199
+ nil
200
+ end
201
+
202
+ def isolate_child(pid)
203
+ ::Process.setpgid(pid, pid)
204
+ rescue Errno::EACCES, Errno::ESRCH
205
+ # EACCES: child already exec'd/changed group; ESRCH: child already exited.
206
+ # Both are benign -- reaping handles the child either way.
207
+ nil
208
+ rescue SystemCallError => e
209
+ # Any other setpgid failure (e.g. EPERM) leaves the child in the parent
210
+ # group: a later group-kill won't sweep its subtree. Don't raise (spawn
211
+ # must still return a usable handle), but surface it so the leak is
212
+ # debuggable rather than silent.
213
+ warn "evilution: could not isolate process #{pid} into its own process " \
214
+ "group (#{e.class}: #{e.message}); grandchildren may survive a kill."
215
+ end
216
+
217
+ # True once the child has been reaped (now or earlier). WNOHANG returns the
218
+ # pid for a freshly exited child, nil while it still runs, and raises ECHILD
219
+ # if it was already reaped -- all of which we treat as "no longer running".
220
+ def exited?(pid)
221
+ !::Process.waitpid(pid, ::Process::WNOHANG).nil?
222
+ rescue Errno::ECHILD
223
+ true
224
+ end
225
+
226
+ def safe_kill(signal, target)
227
+ ::Process.kill(signal, target)
228
+ rescue Errno::ESRCH
229
+ nil
230
+ end
231
+
232
+ def safe_wait(pid)
233
+ ::Process.wait(pid)
234
+ rescue Errno::ECHILD
235
+ nil
236
+ end
237
+
238
+ def close_fds(handle)
239
+ handle.fds.each do |io|
240
+ io.close unless io.closed?
241
+ rescue IOError
242
+ nil
243
+ end
244
+ end
245
+
246
+ # Remove the sandbox first, then drop it from TempDirTracker only on success.
247
+ # If removal raises, leave the dir tracked so TempDirTracker.cleanup_all /
248
+ # at_exit can retry it, and swallow the error so reap's ensure-path still
249
+ # unregisters the handle (no stale entry in the process-global registry).
250
+ def cleanup_sandbox(handle)
251
+ dir = handle.sandbox_dir
252
+ return unless dir
253
+
254
+ FileUtils.rm_rf(dir)
255
+ Evilution::TempDirTracker.unregister(dir)
256
+ rescue StandardError
257
+ nil
258
+ end
259
+ end
@@ -5,6 +5,9 @@ require_relative "../baseline"
5
5
  require_relative "../spec_resolver"
6
6
  require_relative "../integration/rspec"
7
7
  require_relative "../integration/minitest"
8
+ require_relative "../coverage_example_filter"
9
+ require_relative "../coverage/map_store"
10
+ require_relative "../coverage/map_builder"
8
11
  require_relative "../integration/test_unit"
9
12
  require_relative "../example_filter"
10
13
  require_relative "../spec_ast_cache"
@@ -82,6 +85,13 @@ class Evilution::Runner::BaselineRunner
82
85
  def build_example_filter
83
86
  return nil unless config.example_targeting?
84
87
 
88
+ lexical = build_lexical_filter
89
+ return lexical unless config.coverage_targeting?
90
+
91
+ build_coverage_filter(lexical)
92
+ end
93
+
94
+ def build_lexical_filter
85
95
  Evilution::ExampleFilter.new(
86
96
  cache: Evilution::SpecAstCache.new(**config.example_targeting_cache),
87
97
  fallback: config.example_targeting_fallback,
@@ -89,6 +99,48 @@ class Evilution::Runner::BaselineRunner
89
99
  )
90
100
  end
91
101
 
102
+ # The coverage map is built (or loaded from cache) once here, in the parent,
103
+ # before any mutation fork. Any failure -- unsupported Ruby, suite error,
104
+ # corrupt cache that cannot rebuild -- degrades to lexical targeting rather
105
+ # than aborting the run (design: never abort, never silently mis-skip).
106
+ def build_coverage_filter(lexical)
107
+ map = resolve_coverage_map
108
+ return lexical unless map
109
+
110
+ Evilution::CoverageExampleFilter.new(map: map, lexical: lexical)
111
+ rescue StandardError => e
112
+ warn "evilution: coverage targeting unavailable (#{e.class}: #{e.message}); using lexical targeting"
113
+ lexical
114
+ end
115
+
116
+ def resolve_coverage_map
117
+ targets = config.target_files.map { |file| File.expand_path(file, Evilution::PROJECT_ROOT) }
118
+ return nil if targets.empty?
119
+
120
+ specs = resolved_spec_files
121
+ return nil if specs.empty? # nothing resolves -> no coverage to capture, lexical handles it
122
+
123
+ store = Evilution::Coverage::MapStore.new
124
+ cache_inputs = targets + specs
125
+ return store.load(targets) if store.stale_files(cache_inputs).empty?
126
+
127
+ map = Evilution::Coverage::MapBuilder.new(spec_files: specs, target_files: targets).call
128
+ store.save(map, cache_inputs)
129
+ map
130
+ end
131
+
132
+ # The RESOLVED spec files for the target sources -- the same lib-mirrored specs
133
+ # full-file targeting already runs cleanly -- NOT the whole suite. EV-7uui:
134
+ # capturing and replaying coverage only within these guarantees the covering
135
+ # examples load in the per-mutation run (cross-file integration specs, which a
136
+ # whole-suite map would surface, fail to bootstrap in isolation and lose kills).
137
+ def resolved_spec_files
138
+ config.target_files
139
+ .flat_map { |file| Array(config.spec_selector.call(file)) }
140
+ .map { |spec| File.expand_path(spec, Evilution::PROJECT_ROOT) }
141
+ .uniq
142
+ end
143
+
92
144
  def log_start
93
145
  return if config.quiet || !config.text? || !$stderr.tty?
94
146
 
@@ -5,6 +5,7 @@ require_relative "../isolation/fork"
5
5
  require_relative "../isolation/in_process"
6
6
  require_relative "../rails_detector"
7
7
  require_relative "../gem_detector"
8
+ require_relative "../integration/loading/test_load_path"
8
9
 
9
10
  class Evilution::Runner::IsolationResolver
10
11
  PRELOAD_CANDIDATES = [
@@ -12,6 +13,14 @@ class Evilution::Runner::IsolationResolver
12
13
  File.join("spec", "spec_helper.rb"),
13
14
  File.join("test", "test_helper.rb")
14
15
  ].freeze
16
+ # Conventional helpers for a non-Rails gem (no rails_helper). Ordered rspec
17
+ # then minitest/test-unit; test/helper.rb covers the flat-layout convention
18
+ # (rack, connection_pool, rake).
19
+ GEM_PRELOAD_CANDIDATES = [
20
+ File.join("spec", "spec_helper.rb"),
21
+ File.join("test", "test_helper.rb"),
22
+ File.join("test", "helper.rb")
23
+ ].freeze
15
24
 
16
25
  def initialize(config, target_files:, hooks:)
17
26
  @config = config
@@ -36,7 +45,7 @@ class Evilution::Runner::IsolationResolver
36
45
  path = resolve_preload_path
37
46
  return unless path
38
47
 
39
- prepare_load_path_for_preload
48
+ prepare_load_path_for_preload(path)
40
49
  prepare_integration_for_preload
41
50
  require File.expand_path(path)
42
51
  rescue Evilution::ConfigError
@@ -82,24 +91,64 @@ class Evilution::Runner::IsolationResolver
82
91
  warn_in_process_under_rails if rails_root_detected?
83
92
  :in_process
84
93
  else # :auto
85
- rails_root_detected? ? :fork : :in_process
94
+ fork_isolation_default? ? :fork : :in_process
86
95
  end
87
96
  end
88
97
 
98
+ # Auto-isolation picks :fork for both Rails apps and packaged gems. A gem has
99
+ # a spec/test suite whose helper (and the gem's own deps) must be preloaded in
100
+ # the parent before forking; in_process can't preload without polluting the
101
+ # host, so a plain non-Rails gem run with the in_process default scored 0.0
102
+ # out-of-box (every mutation errored with 0 examples / NameError). Detecting
103
+ # the gemspec and defaulting to :fork lets auto-preload fire.
104
+ def fork_isolation_default?
105
+ rails_root_detected? || gem_root_detected?
106
+ end
107
+
108
+ def gem_root_detected?
109
+ !detected_gem_root.nil?
110
+ end
111
+
89
112
  def detected_rails_root
90
113
  return @detected_rails_root if defined?(@detected_rails_root)
91
114
 
92
115
  @detected_rails_root = Evilution::RailsDetector.rails_root_for_any(target_files)
93
116
  end
94
117
 
95
- # Preload files (e.g. spec/rails_helper.rb) typically `require 'spec_helper'`
96
- # which needs spec/ on $LOAD_PATH, and use `RSpec.configure` which needs
97
- # rspec/core loaded. The RSpec CLI normally sets this up, but evilution
98
- # calls Runner.run directly.
99
- def prepare_load_path_for_preload
118
+ def detected_gem_root
119
+ return @detected_gem_root if defined?(@detected_gem_root)
120
+
121
+ @detected_gem_root = Evilution::GemDetector.gem_root_for_any(target_files)
122
+ end
123
+
124
+ # Preload files `require` a sibling helper relative to the test root, which
125
+ # the suite's own runner satisfies via -Ispec/-Itest; evilution calls
126
+ # Runner.run directly, so it must mirror that on $LOAD_PATH. The policy is
127
+ # integration-specific so it does not over-widen the path:
128
+ # - rspec: spec/rails_helper.rb / spec/spec_helper.rb need spec/ only (and
129
+ # rspec/core for RSpec.configure). Kept spec-only to match the RSpec
130
+ # FrameworkLoader and avoid prepending test/ ahead of spec/ in apps that
131
+ # have both (a bare `require "support/foo"` must still resolve from spec/).
132
+ # - minitest/test-unit: a test/test_helper.rb doing a non-relative
133
+ # `require "support/..."` needs test/ on $LOAD_PATH. Route through
134
+ # TestLoadPath -- the same policy the per-mutation test load uses -- so preload and mutation paths agree.
135
+ def prepare_load_path_for_preload(preload_path)
136
+ if config.integration == :rspec
137
+ prepare_rspec_preload_load_path
138
+ else
139
+ prepare_test_preload_load_path(preload_path)
140
+ end
141
+ end
142
+
143
+ def prepare_rspec_preload_load_path
100
144
  spec_dir = File.expand_path(resolve_spec_dir)
101
145
  $LOAD_PATH.unshift(spec_dir) unless $LOAD_PATH.include?(spec_dir)
102
- require "rspec/core" if config.integration == :rspec
146
+ require "rspec/core"
147
+ end
148
+
149
+ def prepare_test_preload_load_path(preload_path)
150
+ base = detected_rails_root || Evilution.project_base_dir
151
+ Evilution::Integration::Loading::TestLoadPath.add!([preload_path], base: base)
103
152
  end
104
153
 
105
154
  def resolve_spec_dir
@@ -145,21 +194,41 @@ class Evilution::Runner::IsolationResolver
145
194
  raise Evilution::ConfigError, autodetect_missing_message
146
195
  end
147
196
 
148
- detected_gem_entry
197
+ resolve_autodetected_gem_preload
198
+ end
199
+
200
+ # For a non-Rails gem, prefer the conventional test helper (which loads the
201
+ # gem's library AND the suite's framework/support setup) over the bare gem
202
+ # entry, so example groups actually register. Fall back to the gem entry, and
203
+ # flag a non-standard test layout so the user can pass --preload.
204
+ def resolve_autodetected_gem_preload
205
+ helper = find_first_existing_gem_helper
206
+ return helper if helper
207
+
208
+ entry = detected_gem_entry
209
+ warn_unconventional_test_layout(entry) if detected_gem_root
210
+ entry
149
211
  end
150
212
 
151
213
  def detected_gem_entry
152
214
  return @detected_gem_entry if defined?(@detected_gem_entry)
153
215
 
154
- root = Evilution::GemDetector.gem_root_for_any(target_files)
216
+ root = detected_gem_root
155
217
  @detected_gem_entry = root && Evilution::GemDetector.gem_entry_for(root, target_paths: target_files)
156
218
  end
157
219
 
158
220
  def find_first_existing_candidate
159
- root = detected_rails_root
221
+ find_first_existing_under(detected_rails_root, PRELOAD_CANDIDATES)
222
+ end
223
+
224
+ def find_first_existing_gem_helper
225
+ find_first_existing_under(detected_gem_root, GEM_PRELOAD_CANDIDATES)
226
+ end
227
+
228
+ def find_first_existing_under(root, candidates)
160
229
  return nil unless root
161
230
 
162
- PRELOAD_CANDIDATES.each do |rel|
231
+ candidates.each do |rel|
163
232
  abs = File.join(root, rel)
164
233
  return abs if File.file?(abs)
165
234
  end
@@ -209,6 +278,31 @@ class Evilution::Runner::IsolationResolver
209
278
  )
210
279
  end
211
280
 
281
+ # A gem was detected but none of the conventional test helpers exist, so the
282
+ # suite likely uses a non-standard layout. Point at the expected locations and
283
+ # the --preload escape hatch. The fallback wording reflects what will actually
284
+ # happen: preload the gem entry when one was found, otherwise nothing is
285
+ # preloaded (gem_entry can be nil when the gemspec name has no on-disk lib
286
+ # entry) — without a helper, the gem entry alone may not register example
287
+ # groups and mutations can error with 0 examples.
288
+ def warn_unconventional_test_layout(gem_entry)
289
+ return if config.quiet
290
+
291
+ fallback =
292
+ if gem_entry
293
+ "Falling back to the gem entry (#{gem_entry})"
294
+ else
295
+ "No gem entry found to fall back to, so nothing will be preloaded"
296
+ end
297
+
298
+ $stderr.write(
299
+ "[evilution] warning: no conventional test helper found under " \
300
+ "#{detected_gem_root.inspect} (looked for #{GEM_PRELOAD_CANDIDATES.join(", ")}). " \
301
+ "#{fallback}. If mutations error with '0 examples loaded' or NameError, " \
302
+ "your test layout is non-standard — pass --preload <your helper>.\n"
303
+ )
304
+ end
305
+
212
306
  # When the user explicitly requests InProcess on a Rails project, warn once
213
307
  # per run. Rails wraps ActiveRecord transactions in
214
308
  # Thread.handle_interrupt(Exception => :never), which defers Timeout's
@@ -182,7 +182,8 @@ class Evilution::Runner
182
182
  # terminal Ctrl-C reaches only the parent's group. Forward to each worker
183
183
  # group here -- the parent's fatal-signal death skips work_queue#map's
184
184
  # `ensure cleanup_workers`, so this trap is the reliable forwarding hook.
185
- Evilution::Parallel::WorkQueue::WorkerRegistry.signal_all(sig)
185
+ # EV-dg69: the signal-safe registry is now owned by ProcessSupervisor.
186
+ Evilution::ProcessSupervisor.signal_all(sig)
186
187
 
187
188
  case prev_handler
188
189
  when Proc, Method
@@ -231,7 +232,7 @@ require_relative "result/summary"
231
232
  require_relative "baseline"
232
233
  require_relative "cache"
233
234
  require_relative "parallel/pool"
234
- require_relative "parallel/work_queue/worker_registry"
235
+ require_relative "process_supervisor"
235
236
  require_relative "session/store"
236
237
  require_relative "temp_dir_tracker"
237
238
  require_relative "rails_detector"
@@ -28,6 +28,22 @@ class Evilution::SpecResolver
28
28
  Array(source_paths).filter_map { |path| call(path) }.uniq
29
29
  end
30
30
 
31
+ # Like #call, but returns an ARRAY of test files and additionally covers the
32
+ # dir-grouped layout (a source file's tests live in a directory named after
33
+ # the source basename, e.g. lib/x/branch.rb -> test/unit/branch/*_test.rb,
34
+ # rather than a single mirror file). The deterministic file mirror from #call
35
+ # always wins; only when no mirror file exists is the first matching grouped
36
+ # directory expanded into its test files (EV-bi41). Returns nil when nothing
37
+ # resolves.
38
+ def resolve_specs(source_path, spec_pattern: nil)
39
+ return nil if source_path.nil? || source_path.empty?
40
+
41
+ file = call(source_path, spec_pattern: spec_pattern)
42
+ return [file] if file
43
+
44
+ resolve_grouped_dir(source_path, spec_pattern: spec_pattern)
45
+ end
46
+
31
47
  # Best-guess candidate for an unresolved source, found by basename glob
32
48
  # rather than the deterministic path mirroring used by #call. Used only to
33
49
  # enrich the "no matching test" hint (EV-z7f5 / GH #1325) — never to pick a
@@ -75,6 +91,56 @@ class Evilution::SpecResolver
75
91
  candidates.select { |path| File.fnmatch?(pattern, path, File::FNM_PATHNAME | File::FNM_EXTGLOB) }
76
92
  end
77
93
 
94
+ # Dir-grouped resolution: find the first candidate directory that exists and
95
+ # expand it into its test files. Ranked below #call's file mirrors (callers
96
+ # try #call first), so a 1:1 spec always wins when present.
97
+ def resolve_grouped_dir(source_path, spec_pattern: nil)
98
+ dir = directory_candidates(normalize_path(source_path)).find { |c| project_relative_dir?(c) }
99
+ return nil unless dir
100
+
101
+ files = test_files_in(dir)
102
+ files = filter_by_pattern(files, spec_pattern) if spec_pattern
103
+ files.empty? ? nil : files
104
+ end
105
+
106
+ # The grouped-directory analogue of #mirror_candidates: cross every
107
+ # conventional root with each layout variant, using the source basename
108
+ # (suffix dropped) as a DIRECTORY name rather than a test FILE name.
109
+ def directory_candidates(source_path)
110
+ stripped = strip_source_prefix(source_path)
111
+ mirror_variants(stripped).flat_map { |variant| grouped_dir_candidates(variant) }.uniq
112
+ end
113
+
114
+ def grouped_dir_candidates(variant)
115
+ dir, _, file = variant.rpartition("/")
116
+ name = file.delete_suffix(@test_suffix)
117
+ return [] if name.empty?
118
+
119
+ relative = dir.empty? ? name : "#{dir}/#{name}"
120
+ roots.map { |root| "#{root}/#{relative}" }
121
+ end
122
+
123
+ def strip_source_prefix(source_path)
124
+ base = source_path.sub(/\.rb\z/, @test_suffix)
125
+ prefix = STRIPPABLE_PREFIXES.find { |p| source_path.start_with?(p) }
126
+ prefix ? base.delete_prefix(prefix) : base
127
+ end
128
+
129
+ # Every test file under a grouped directory: the mirrored suffix plus, for
130
+ # minitest/test-unit, the `test_` prefix convention. Sorted for determinism.
131
+ def test_files_in(dir)
132
+ globs = ["#{dir}/**/*#{@test_suffix}", ("#{dir}/**/test_*.rb" if @test_suffix == MINITEST_SUFFIX)]
133
+ globs.compact.flat_map { |glob| glob_relative(glob) }.uniq.sort
134
+ end
135
+
136
+ # Directory analogue of #project_relative_exists?.
137
+ def project_relative_dir?(path)
138
+ return true if File.directory?(path)
139
+ return false unless Evilution.in_isolated_worker?
140
+
141
+ File.directory?(File.expand_path(path, Evilution::PROJECT_ROOT))
142
+ end
143
+
78
144
  def normalize_path(path)
79
145
  path = path.delete_prefix("./")
80
146
  if path.start_with?("/")
@@ -19,12 +19,23 @@ class Evilution::SpecSelector
19
19
  return existing unless existing.empty?
20
20
  end
21
21
 
22
- resolved = @spec_resolver.call(source_path, spec_pattern: @spec_pattern)
23
- resolved ? [resolved] : nil
22
+ resolved = resolve_via_resolver(source_path)
23
+ resolved && !resolved.empty? ? resolved : nil
24
24
  end
25
25
 
26
26
  private
27
27
 
28
+ # Prefer the array-returning #resolve_specs, but fall back to the older single-file #call contract so a custom
29
+ # resolver that only implements #call keeps working.
30
+ def resolve_via_resolver(source_path)
31
+ if @spec_resolver.respond_to?(:resolve_specs)
32
+ @spec_resolver.resolve_specs(source_path, spec_pattern: @spec_pattern)
33
+ else
34
+ file = @spec_resolver.call(source_path, spec_pattern: @spec_pattern)
35
+ file ? [file] : nil
36
+ end
37
+ end
38
+
28
39
  def mapping_for(source_path)
29
40
  @spec_mappings[normalize(source_path)]
30
41
  end
@@ -40,8 +51,7 @@ class Evilution::SpecSelector
40
51
  normalized.delete_prefix("./")
41
52
  end
42
53
 
43
- # Same semantics as Evilution::SpecResolver#project_relative_exists? — see
44
- # that method for the EV-wqxu / GH #1278 rationale.
54
+ # Same semantics as Evilution::SpecResolver#project_relative_exists?
45
55
  def project_relative_exists?(path)
46
56
  return true if File.exist?(path)
47
57
  return false unless Evilution.in_isolated_worker?
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Evilution
4
- VERSION = "0.33.0"
4
+ VERSION = "0.34.0"
5
5
  end
data/lib/evilution.rb CHANGED
@@ -100,6 +100,7 @@ require_relative "evilution/mutator/registry"
100
100
  require_relative "evilution/equivalent"
101
101
  require_relative "evilution/equivalent/heuristic"
102
102
  require_relative "evilution/equivalent/detector"
103
+ require_relative "evilution/process_supervisor"
103
104
  require_relative "evilution/isolation"
104
105
  require_relative "evilution/isolation/fork"
105
106
  require_relative "evilution/isolation/in_process"