mutineer 1.4.0 → 1.6.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.
Files changed (47) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +475 -0
  3. data/README.md +224 -28
  4. data/lib/mutineer/changed_lines.rb +24 -1
  5. data/lib/mutineer/cli.rb +117 -20
  6. data/lib/mutineer/config.rb +64 -13
  7. data/lib/mutineer/coverage_map.rb +533 -100
  8. data/lib/mutineer/daemon_backend.rb +51 -33
  9. data/lib/mutineer/daemon_client.rb +212 -22
  10. data/lib/mutineer/daemon_server.rb +74 -27
  11. data/lib/mutineer/external_backend.rb +2 -2
  12. data/lib/mutineer/file_swap.rb +1 -1
  13. data/lib/mutineer/isolation.rb +92 -6
  14. data/lib/mutineer/job_plan.rb +352 -0
  15. data/lib/mutineer/kill_channel.rb +284 -0
  16. data/lib/mutineer/kill_matrix.rb +88 -0
  17. data/lib/mutineer/minitest_integration/kill_recorder.rb +301 -0
  18. data/lib/mutineer/minitest_integration/outer_reporter.rb +126 -0
  19. data/lib/mutineer/minitest_integration/stop_at_first_failure.rb +14 -68
  20. data/lib/mutineer/minitest_integration.rb +75 -11
  21. data/lib/mutineer/mutant_id.rb +5 -4
  22. data/lib/mutineer/mutators/array_literal.rb +0 -7
  23. data/lib/mutineer/mutators/base.rb +63 -4
  24. data/lib/mutineer/mutators/chain_link.rb +0 -7
  25. data/lib/mutineer/mutators/collection_method.rb +1 -1
  26. data/lib/mutineer/mutators/condition_forcing.rb +0 -7
  27. data/lib/mutineer/mutators/operand_removal.rb +0 -7
  28. data/lib/mutineer/mutators/operator_assignment.rb +0 -7
  29. data/lib/mutineer/mutators/return_nil.rb +5 -11
  30. data/lib/mutineer/mutators/string_literal.rb +1 -1
  31. data/lib/mutineer/orphan_guard.rb +28 -0
  32. data/lib/mutineer/pairing.rb +222 -15
  33. data/lib/mutineer/parser.rb +12 -3
  34. data/lib/mutineer/project.rb +344 -21
  35. data/lib/mutineer/rails_worker_db.rb +83 -9
  36. data/lib/mutineer/reporter.rb +306 -53
  37. data/lib/mutineer/result.rb +73 -5
  38. data/lib/mutineer/runner.rb +123 -242
  39. data/lib/mutineer/statement_lines.rb +183 -0
  40. data/lib/mutineer/subject.rb +6 -2
  41. data/lib/mutineer/test_runners/minitest.rb +4 -2
  42. data/lib/mutineer/test_runners/rspec.rb +282 -3
  43. data/lib/mutineer/test_runners.rb +5 -3
  44. data/lib/mutineer/version.rb +1 -1
  45. data/lib/mutineer/worker_pool.rb +4 -2
  46. data/lib/mutineer.rb +2 -0
  47. metadata +10 -3
@@ -8,16 +8,14 @@ require_relative "isolation"
8
8
  require_relative "minitest_integration"
9
9
  require_relative "test_runners"
10
10
  require_relative "coverage_map"
11
- require_relative "changed_lines"
12
11
  require_relative "mutator_registry"
13
12
  require_relative "worker_pool"
14
13
  require_relative "progress"
15
- require_relative "mutant_id"
16
14
  require_relative "project_path"
17
15
  require_relative "file_swap"
18
16
  require_relative "external_backend"
19
17
  require_relative "daemon_backend"
20
- require "set"
18
+ require_relative "job_plan"
21
19
 
22
20
  module Mutineer
23
21
  # Orchestrates one mutation end-to-end: apply it textually, validate the
@@ -33,7 +31,7 @@ module Mutineer
33
31
  # Full orchestration: resolve operators, discover subjects, build the
34
32
  # coverage map, run every mutation, and aggregate. Returns
35
33
  # [AggregateResult, source_map, extras], where extras is the hash
36
- # {.collect_jobs} returns (`:legacy_ignore_matches`, `:id_map`), unchanged.
34
+ # {JobPlan.collect_jobs} returns (`:legacy_ignore_matches`, `:id_map`), unchanged.
37
35
  # The CLI then reports + applies the exit code; the integration test asserts
38
36
  # directly on the AggregateResult.
39
37
  #
@@ -72,13 +70,20 @@ module Mutineer
72
70
  # Coverage instruments only files loaded AFTER it starts. Start it BEFORE
73
71
  # the boot require so the entire app loaded during boot is instrumented;
74
72
  # forked children then measure each test's coverage delta against it.
73
+ # An earlier run in this process leaves Coverage suspended (see below).
74
+ # Coverage a host started is the host's: it is left running.
75
75
  require "coverage"
76
- Coverage.start(lines: true) unless Coverage.running?
76
+ own_coverage = !Coverage.running?
77
+ case Coverage.state
78
+ when :idle then Coverage.start(lines: true, methods: true)
79
+ when :suspended then Coverage.resume
80
+ end
77
81
  require File.expand_path(config.boot, config.project_root)
78
82
  else
79
83
  config.sources.each { |f| require File.expand_path(f, config.project_root) }
80
84
  end
81
85
  config.require_paths.each { |f| require File.expand_path(f, config.project_root) }
86
+ preload_owners(config) if config.boot && config.strategy == "redefine"
82
87
 
83
88
  if config.boot
84
89
  # Rails/Minitest test files do `require "test_helper"`, which needs the
@@ -86,44 +91,51 @@ module Mutineer
86
91
  # file's helper root here in the parent so loading them in the fork
87
92
  # children (both coverage capture and per-mutant) resolves.
88
93
  boot_tests = config.tests.map { |t| File.expand_path(t, config.project_root) }
89
- test_load_roots(boot_tests).each { |d| $LOAD_PATH.unshift(d) unless $LOAD_PATH.include?(d) }
94
+ JobPlan.test_load_roots(boot_tests).each { |d| $LOAD_PATH.unshift(d) unless $LOAD_PATH.include?(d) }
90
95
 
91
96
  # Boot mode now uses coverage selection too: capture each test's coverage
92
97
  # by forking the booted parent, then select covering tests per mutant.
93
98
  coverage_map = CoverageMap.new(
94
99
  source_paths: config.sources, test_paths: config.tests,
95
- cache_dir: config.cache_dir, project_root: config.project_root,
100
+ cache_dir: File.expand_path(config.cache_dir, config.project_root), project_root: config.project_root,
96
101
  load_paths: config.load_paths, framework: config.framework,
97
102
  boot_path: File.expand_path(config.boot, config.project_root),
98
- verbose: config.verbose
103
+ require_paths: config.require_paths, # loaded above; here only for the cache digest
104
+ verbose: config.verbose,
105
+ capture_timeout: config.capture_timeout || CoverageMap::DEFAULT_CAPTURE_TIMEOUT
99
106
  ).build_via_fork(after_fork: (config.rails ? -> { reconnect_active_record } : nil))
107
+ # Nothing reads Coverage once the map is built (cache hit or not), so
108
+ # stop paying for it in every mutant fork (#228).
109
+ Coverage.suspend if own_coverage && Coverage.running?
100
110
  else
101
111
  # As in boot mode, and with lib first as `rake test` does.
102
- test_roots = test_load_roots(config.tests.map { |t| File.expand_path(t, config.project_root) })
112
+ test_roots = JobPlan.test_load_roots(config.tests.map { |t| File.expand_path(t, config.project_root) })
103
113
  libs = config.load_paths.map { |p| File.expand_path(p, config.project_root) }
104
114
  $LOAD_PATH.unshift(*(libs + test_roots).uniq.reject { |d| $LOAD_PATH.include?(d) })
105
115
  # Relative, so the cache digest does not depend on the checkout path.
106
116
  rel_roots = test_roots.map { |d| Pathname(d).relative_path_from(File.expand_path(config.project_root)).to_s }
107
117
  coverage_map = CoverageMap.new(
108
118
  source_paths: config.sources, test_paths: config.tests,
109
- cache_dir: config.cache_dir, project_root: config.project_root,
110
- load_paths: config.load_paths + rel_roots, framework: config.framework
119
+ cache_dir: File.expand_path(config.cache_dir, config.project_root), project_root: config.project_root,
120
+ load_paths: config.load_paths + rel_roots, framework: config.framework,
121
+ require_paths: config.require_paths,
122
+ capture_timeout: config.capture_timeout || CoverageMap::DEFAULT_CAPTURE_TIMEOUT
111
123
  ).build_or_load
112
124
  end
113
- abort_if_unclean!(coverage_map)
125
+ JobPlan.abort_if_unclean!(coverage_map)
114
126
 
115
127
  # Collect every (subject, mutation) up front so the pool can fan them out.
116
- jobs, ignored_results, source_map, extras = collect_jobs(config, operator_classes)
128
+ jobs, ignored_results, source_map, extras = JobPlan.collect_jobs(config, operator_classes)
117
129
 
118
- jobs = filter_since(jobs, source_map, config) if config.since
130
+ jobs = JobPlan.filter_since(jobs, source_map, config) if config.since
119
131
 
120
132
  # Whole-file reload writes mutineer_mutant*.rb into each source dir (so
121
133
  # require_relative resolves). A SIGKILL'd child skips the tempfile's
122
134
  # ensure-unlink, orphaning it. `ensure` is unreliable vs SIGKILL, so the
123
135
  # PARENT sweeps each source dir before and after the run. Orphans are
124
136
  # impossible after a normal run.
125
- dirs = source_dirs(config)
126
- sweep_orphans(dirs)
137
+ dirs = JobPlan.source_dirs(config)
138
+ JobPlan.sweep_orphans(dirs)
127
139
 
128
140
  strategy = config.strategy
129
141
  # Fail-fast must be serial: a parallel stop_when fires on the first survivor
@@ -138,85 +150,33 @@ module Mutineer
138
150
  bare = WorkerPool.new(jobs_n).run(jobs, stop_when: stop_when,
139
151
  on_result: ->(_r) { progress.tick }) do |subject, mutation|
140
152
  run(mutation, source_file: subject.file, coverage_map: coverage_map,
141
- subject: subject, strategy: strategy, rails: config.rails, framework: framework)
153
+ subject: subject, strategy: strategy, rails: config.rails, framework: framework,
154
+ timeout: config.timeout || Isolation::DEFAULT_TIMEOUT, matrix: config.matrix)
142
155
  end
143
156
  # The bare Results carry only status (Subjects hold live AST nodes that
144
157
  # do not marshal); reattach subject+mutation+id in the parent, in order.
145
158
  # filter_map drops nils for jobs --fail-fast left unscheduled.
146
- bare.each_with_index.filter_map { |r, i| r&.with(subject: jobs[i][0], mutation: jobs[i][1], id: jobs[i][2]) }
159
+ share_tests(bare.each_with_index.filter_map do |r, i|
160
+ r = unreported_row(r) if r && config.matrix
161
+ r&.with(subject: jobs[i][0], mutation: jobs[i][1], id: jobs[i][2])
162
+ end)
147
163
  ensure
148
- sweep_orphans(dirs)
164
+ JobPlan.sweep_orphans(dirs)
149
165
  end
150
166
 
151
167
  [AggregateResult.new(results + ignored_results), source_map, extras]
152
168
  end
153
169
 
154
- # Collect every (subject, mutation, id) up front so a backend can run them.
155
- # A mutant the user marked known-equivalent (inline disable-line comment or
156
- # .mutineer.yml ignore id) is classified :ignored here and NEVER run. It is
157
- # removed from the killed+survived denominator so a strong file reaches 100%.
158
- # The id is computed per subject (occurrence needs the full list), keyed on
159
- # the file path relative to config.project_root, and carried on every job so
160
- # the parent can reattach it after the run. Shared by the in-process,
161
- # external, and daemon backends so job selection can never drift.
162
- #
163
- # Each mutant also gets its old-format id ({MutantId.legacy_for}), so an ignore
164
- # entry stored before ids carried the path still suppresses it. Prints nothing:
165
- # the extras hash returns, as data, `legacy_ignore_matches` (each old-format
166
- # ignore entry that matched a mutant through its old-format id => one
167
- # `{id:, file:, subject:}` hash per matched mutant, in collection order: its
168
- # new id, its project-relative file and its subject's qualified name;
169
- # recorded even when a new id is also listed, since the old entry still
170
- # over-matches other files) and `id_map` (every new id => its old-format id).
170
+ # A `--matrix` error that carries no {Kills} row (the worker crashed, or its
171
+ # result was lost) gets an empty, incomplete row: its tests never reported,
172
+ # so a blind test may have killed it, and the report must say so.
171
173
  #
172
- # Subjects sharing a qualified name in one file (two owner-less `def index`
173
- # in two DSL blocks) get a per-file ordinal in discovery order, so their ids
174
- # differ; the first one's ordinal is 0 and leaves its id unchanged.
175
- #
176
- # @param config [Mutineer::Config] run configuration.
177
- # @param operator_classes [Array<Class>] resolved operators.
178
- # @return [Array(Array, Array<Result>, Hash<String,String>, Hash{Symbol => Hash})]
179
- # jobs, ignored, source_map, and extras (`:legacy_ignore_matches`, `:id_map`).
180
- def self.collect_jobs(config, operator_classes)
181
- source_map = {}
182
- disabled_map = {}
183
- id_paths = {}
184
- # [file, qualified_name] => { declaration offset => ordinal }: keyed by the
185
- # declaration, so the same file discovered twice (two path spellings) reuses
186
- # its ordinal instead of minting a second id for the same mutant.
187
- name_decls = Hash.new { |h, k| h[k] = {} }
188
- ignore_set = config.ignore.to_set
189
- jobs = []
190
- ignored_results = []
191
- legacy_ignore_matches = {}
192
- id_map = {}
193
- Project.discover(config.sources, only: config.only).each do |subject|
194
- source = (source_map[subject.file] ||= File.read(subject.file))
195
- disabled = (disabled_map[subject.file] ||= suppress_map(source, subject.file))
196
- mutations = operator_classes.flat_map { |klass| klass.new.mutations_for(subject, source) }
197
- id_path = (id_paths[subject.file] ||= ProjectPath.relative(subject.file, config.project_root))
198
- decls = name_decls[[id_path, subject.qualified_name]]
199
- ordinal = (decls[subject.def_node.location.start_offset] ||= decls.size)
200
- ids = MutantId.for_subject(subject, source, mutations, path: id_path, subject_ordinal: ordinal)
201
- legacy_ids = MutantId.legacy_for_subject(subject, source, mutations)
202
- mutations.each_with_index do |mutation, i|
203
- id = ids[i]
204
- legacy = legacy_ids[i]
205
- id_map[id] = legacy
206
- # An old entry still over-matches other files even when the new id is
207
- # listed too, so every old-entry match is reported for migration.
208
- if ignore_set.include?(legacy)
209
- (legacy_ignore_matches[legacy] ||= []) << { id: id, file: id_path, subject: subject.qualified_name }
210
- end
211
- line = source.byteslice(0, mutation.start_offset).count("\n") + 1
212
- if suppressed?(mutation.operator, line, [id, legacy], disabled, ignore_set)
213
- ignored_results << Result.ignored.with(subject: subject, mutation: mutation, id: id)
214
- else
215
- jobs << [subject, mutation, id]
216
- end
217
- end
218
- end
219
- [jobs, ignored_results, source_map, { legacy_ignore_matches: legacy_ignore_matches, id_map: id_map }]
174
+ # @param result [Mutineer::Result] a worker's result.
175
+ # @return [Mutineer::Result]
176
+ def self.unreported_row(result)
177
+ return result unless result.error? && result.kills.nil?
178
+
179
+ result.with(kills: Kills.new(killed_by: [], ran: [], complete: false))
220
180
  end
221
181
 
222
182
  # External backend orchestration. Runs each mutant's whole-file mutation on
@@ -229,7 +189,7 @@ module Mutineer
229
189
  # @param config [Mutineer::Config] run configuration (test_command set).
230
190
  # @param operator_classes [Array<Class>] resolved operators.
231
191
  # @return [Array(Mutineer::AggregateResult, Hash<String,String>, Hash)] aggregate,
232
- # source map, and the {.collect_jobs} extras.
192
+ # source map, and the {JobPlan.collect_jobs} extras.
233
193
  def self.execute_external(config, operator_classes)
234
194
  abs_tests = config.tests.map { |t| File.expand_path(t, config.project_root) }
235
195
  sources = config.sources.map { |s| FileSwap.canonical_path(File.expand_path(s, config.project_root)) }
@@ -245,8 +205,8 @@ module Mutineer
245
205
  # source. Heal first, then discover jobs from the clean tree.
246
206
  FileSwap.restore_orphans(dirs)
247
207
 
248
- jobs, ignored_results, source_map, extras = collect_jobs(config, operator_classes)
249
- jobs = filter_since(jobs, source_map, config) if config.since
208
+ jobs, ignored_results, source_map, extras = JobPlan.collect_jobs(config, operator_classes)
209
+ jobs = JobPlan.filter_since(jobs, source_map, config) if config.since
250
210
 
251
211
  # Nothing to mutate: return before the smoke check, which runs the whole
252
212
  # --test set to calibrate a timeout no mutant would use (#76).
@@ -295,135 +255,29 @@ module Mutineer
295
255
  end
296
256
  end
297
257
 
298
- # Aborts the run when coverage capture saw a red unmutated suite. Scoring
299
- # those results would treat existing assertion failures as killed mutants.
258
+ # Loads the class or module of every subject in the booted parent, before
259
+ # the boot coverage is read. Under redefine a child resolves the owner by
260
+ # name ({Isolation.nesting_keywords}), so a lazily loaded class (Zeitwerk,
261
+ # `autoload`) would run its class body there, with the original method,
262
+ # and that run would count as no load at all. Loading it here makes its
263
+ # class-body calls load lines, the same as an eager load. A constant that
264
+ # fails to load stays lazy.
300
265
  #
301
- # @param coverage_map [Mutineer::CoverageMap] the built or loaded map.
266
+ # @param config [Mutineer::Config] run configuration.
302
267
  # @return [void]
303
- # @raise [Mutineer::SmokeCheckError] when any captured test failed clean,
304
- # or when no test recorded coverage and a capture failed.
305
- def self.abort_if_unclean!(coverage_map)
306
- if coverage_map.map.empty? && coverage_map.failed_test_files.any?
307
- raise SmokeCheckError, "no test recorded coverage, and capture failed for #{coverage_map.failed_test_files.join(', ')}"
308
- end
309
-
310
- files = coverage_map.failed_clean_tests
311
- return if files.empty?
312
-
313
- raise SmokeCheckError,
314
- "the unmutated suite is not green (#{files.join(', ')}) — " \
315
- "#{ExternalBackend.generic_env_hint}."
316
- end
317
-
318
- # Coverage-based test selection, shared by the in-process ({run}) and daemon
319
- # paths so both narrow identically (score parity). Returns
320
- # `[:run, abs_test_paths]` when some test covers the mutant's line, or
321
- # `[:verdict, Result]` (no_coverage / uncapturable) when none do.
322
- #
323
- # An empty selection is `:uncapturable` (not `:no_coverage`) when the
324
- # mutant's enclosing method body got coverage from no *successful* capture but
325
- # a sibling test failed to capture: the coverage was lost, not absent. Both are
326
- # excluded from the score denominator, so this distinction is reporting-only
327
- # and never changes the daemon-vs-in-process score.
328
- #
329
- # @param source_file [String] the mutated source file path.
330
- # @param mutation [Mutineer::Mutation] the mutation (for its line offset).
331
- # @param subject [Mutineer::Subject, nil] the subject (for its method body range).
332
- # @param source [String] the original source text.
333
- # @param coverage_map [Mutineer::CoverageMap] the built/loaded coverage map.
334
- # @return [Array(Symbol, Object)] `[:run, Array<String>]` or `[:verdict, Result]`.
335
- def self.coverage_selection(source_file, mutation, subject, source, coverage_map)
336
- line = source.byteslice(0, mutation.start_offset).count("\n") + 1
337
- chosen = coverage_map.tests_for(source_file, line)
338
- if chosen.empty?
339
- # Method BODY range, not the whole def: the def/end lines are "covered" at
340
- # class-load even when the body never runs (body_loc is the statements' span).
341
- loc = subject&.body_loc
342
- range = loc ? (loc.start_line..loc.end_line) : (line..line)
343
- return [:verdict, coverage_map.method_uncapturable?(source_file, range) ? Result.uncapturable : Result.no_coverage]
344
- end
345
-
346
- [:run, chosen.map { |t| File.expand_path(t, coverage_map.project_root) }]
347
- end
348
-
349
- # Scan a source once into { line_number => :all | Set[operator_syms] } from
350
- # inline `# mutineer:disable-line [ops]` markers (RuboCop semantics: the marker
351
- # sits on the same physical line as the code it silences). A bare marker
352
- # disables every operator on that line; `disable-line a, b` only the listed
353
- # operators. Block-form disable/enable ranges are intentionally not supported.
354
- def self.suppress_map(source, file)
355
- map = {}
356
- source.each_line.with_index(1) do |text, line|
357
- next unless (m = text.match(/#\s*mutineer:disable-line(?:\s+([\w,\s]+))?/))
358
-
359
- ops = m[1]&.split(",")&.map(&:strip)&.reject(&:empty?)
360
- # Only spaces or commas after the marker (e.g. `disable-line -- why`)
361
- # is a bare marker, not an empty list that silences nothing.
362
- ops = nil if ops&.empty?
363
- unknown = ops.to_a.reject { |o| MutatorRegistry::ALL.key?(o) }
364
- unknown.each do |o|
365
- warn "mutineer: unknown operator #{o.inspect} in #{file}:#{line} " \
366
- "(known: #{MutatorRegistry::ALL.keys.join(', ')}); write a reason after --"
367
- end
368
- map[line] = ops ? ops.map(&:to_sym).to_set : :all
369
- end
370
- map
371
- end
372
-
373
- # True when this mutant is suppressed: its line bears a disable-line marker
374
- # (bare, or scoped to its operator), OR its new or old-format id is in the
375
- # config ignore list. Checked at job-build time so a suppressed mutant is
376
- # never forked.
377
- #
378
- # @param ids [Array<String>, String] the mutant's new id and its old-format
379
- # id, or a single id (the pre-#126 call shape).
380
- def self.suppressed?(operator, line, ids, disabled, ignore_set)
381
- return true if Array(ids).any? { |id| ignore_set.include?(id) }
382
-
383
- case (entry = disabled[line])
384
- when :all then true
385
- when Set then entry.include?(operator)
386
- else false
387
- end
388
- end
389
-
390
- # --since: keep only jobs whose mutation lands on a line changed since the git
391
- # ref. Composes with coverage selection (it only narrows the job list; each
392
- # surviving mutant still goes through Runner.run's coverage check). A file with
393
- # no changed lines (absent from the diff) contributes no jobs. Line is computed
394
- # exactly as Runner.run does, from the already-read source in source_map.
395
- def self.filter_since(jobs, source_map, config)
396
- changed = ChangedLines.for(ref: config.since, files: config.sources,
397
- project_root: config.project_root)
398
- jobs.select do |subject, mutation|
399
- source = source_map[subject.file]
400
- line = source.byteslice(0, mutation.start_offset).count("\n") + 1
401
- abs = File.expand_path(subject.file, config.project_root)
402
- changed.fetch(abs, []).include?(line)
268
+ def self.preload_owners(config)
269
+ Project.discover(config.sources, only: config.only).each do |subject|
270
+ next if subject.owner_unknown
271
+
272
+ Isolation.nesting_keywords(subject.lexical_namespace)
273
+ # The owner itself, as the child resolves it: a Class.new block owner
274
+ # has no lexical namespace.
275
+ Object.const_get(subject.namespace.join("::")) unless subject.namespace.empty?
276
+ rescue StandardError, ScriptError
277
+ nil
403
278
  end
404
279
  end
405
280
 
406
- # For each test file, the directory to add to $LOAD_PATH so its
407
- # `require "test_helper"` (or spec_helper) resolves: the nearest ancestor
408
- # holding that helper, plus the file's own dir as a fallback.
409
- def self.test_load_roots(test_files)
410
- test_files.flat_map do |f|
411
- dir = File.dirname(f)
412
- root = nil
413
- loop do
414
- if File.exist?(File.join(dir, "test_helper.rb")) || File.exist?(File.join(dir, "spec_helper.rb"))
415
- root = dir
416
- break
417
- end
418
- parent = File.dirname(dir)
419
- break if parent == dir
420
-
421
- dir = parent
422
- end
423
- [root, File.dirname(f)].compact
424
- end.uniq
425
- end
426
-
427
281
  # When --rails is on and RAILS_ENV is unset, default it to "test" (and say so)
428
282
  # before the app boots. Otherwise it boots development and nothing is measured.
429
283
  # An explicitly-set RAILS_ENV is always respected.
@@ -435,32 +289,6 @@ module Mutineer
435
289
  warn "[mutineer] RAILS_ENV was unset; defaulting to 'test' for --rails."
436
290
  end
437
291
 
438
- # The unique absolute directories holding the sources. Sweep target for both
439
- # orphan mechanisms (in-process mutant tempfiles and external backup files),
440
- # and shipped to the daemon via {DaemonBackend.boot_config} so it can sweep too.
441
- # Shared so the path-expansion rule cannot drift between the paths.
442
- #
443
- # @param config [Mutineer::Config] run configuration.
444
- # @return [Array<String>] unique absolute source directories.
445
- def self.source_dirs(config)
446
- config.sources.map { |f| File.dirname(File.expand_path(f, config.project_root)) }.uniq
447
- end
448
-
449
- # Removes stale mutant tempfiles from the given directories. The daemon writes a
450
- # differently-named temp, so {DaemonBackend} passes its glob when it has to sweep
451
- # tool-side (nothing boots on an empty run, so the daemon's own sweep never runs).
452
- #
453
- # @param dirs [Array<String>] directories to sweep.
454
- # @param glob [String] filename pattern to remove.
455
- # @return [void]
456
- def self.sweep_orphans(dirs, glob = "mutineer_mutant*.rb")
457
- dirs.each do |dir|
458
- Dir.glob(File.join(dir, glob)).each do |f|
459
- File.unlink(f) rescue nil # rubocop:disable Style/RescueModifier
460
- end
461
- end
462
- end
463
-
464
292
  # Runs a single mutation through isolation.
465
293
  #
466
294
  # @param mutation [Mutineer::Mutation] mutation to run.
@@ -471,9 +299,11 @@ module Mutineer
471
299
  # @param timeout [Integer] child timeout in seconds.
472
300
  # @param rails [Boolean] whether Rails reconnect handling is enabled.
473
301
  # @param framework [String] test framework name.
302
+ # @param matrix [Boolean] a `--matrix` run: run every covering test and
303
+ # attach the {Kills} row, with project-relative test files.
474
304
  # @return [Mutineer::Result] mutant result.
475
305
  def self.run(mutation, source_file:, coverage_map: nil, subject: nil, strategy: "reload",
476
- timeout: Isolation::DEFAULT_TIMEOUT, rails: false, framework: "minitest")
306
+ timeout: Isolation::DEFAULT_TIMEOUT, rails: false, framework: "minitest", matrix: false)
477
307
  source = File.read(source_file)
478
308
  mutated = mutation.apply(source)
479
309
 
@@ -484,12 +314,13 @@ module Mutineer
484
314
  # no test exercises is :no_coverage (no fork); otherwise exactly the
485
315
  # covering test files run in the child. Shared with the daemon path so both
486
316
  # narrow identically (score parity).
487
- kind, payload = coverage_selection(source_file, mutation, subject, source, coverage_map)
317
+ kind, payload = JobPlan.coverage_selection(source_file, mutation, subject, source, coverage_map)
488
318
  return payload if kind == :verdict
319
+ return Result.unplaceable if strategy == "redefine" && subject&.owner_unknown
489
320
 
490
321
  abs_tests = payload
491
322
 
492
- Isolation.run(timeout: timeout) do
323
+ result = Isolation.run(timeout: timeout, channel: matrix) do |channel|
493
324
  # Forking inherits the parent's live DB connection; sharing one socket
494
325
  # across processes corrupts it. Drop it so AR reconnects per child.
495
326
  reconnect_active_record if rails
@@ -498,8 +329,58 @@ module Mutineer
498
329
  else
499
330
  Isolation.apply_whole_file(mutated, source_file)
500
331
  end
501
- # One failing test already kills the mutant, so the child stops there.
502
- TestRunners.for(framework).run(abs_tests, stop_at_first_failure: true)
332
+ if channel
333
+ # --matrix: every covering test runs, and each outcome goes to the parent.
334
+ TestRunners.for(framework).run(abs_tests, record_to: channel)
335
+ else
336
+ # One failing test already kills the mutant, so the child stops there.
337
+ TestRunners.for(framework).run(abs_tests, stop_at_first_failure: true)
338
+ end
339
+ end
340
+ result = relative_kills(result, coverage_map.project_root)
341
+ JobPlan.load_verdict(result, source_file, mutation, subject, source, coverage_map)
342
+ end
343
+
344
+ # Rewrites the test files of a result's {Kills} row relative to the project
345
+ # root, the form the coverage map and the report use. A result without a
346
+ # row comes back unchanged.
347
+ #
348
+ # @api private
349
+ # @param result [Mutineer::Result] a mutant result.
350
+ # @param root [String] project root.
351
+ # @return [Mutineer::Result]
352
+ def self.relative_kills(result, root)
353
+ return result unless (kills = result.kills)
354
+
355
+ paths = {}
356
+ rel = lambda do |tests|
357
+ tests.map { |file, name, id| [(paths[file] ||= ProjectPath.relative(file, root)), name, id] }.uniq.sort
358
+ end
359
+ result.with(kills: kills.with(killed_by: rel.call(kills.killed_by), ran: rel.call(kills.ran)))
360
+ end
361
+
362
+ # Makes every {Kills} row refer to one shared, frozen array per test. Each
363
+ # row arrives with its own copy of every test it ran, and a large matrix
364
+ # (thousands of mutants times hundreds of tests) would otherwise hold that
365
+ # many copies. Results without a row come back unchanged.
366
+ #
367
+ # A test is its file and its id. The name is display data and can differ
368
+ # between mutants (an RSpec example worded from its matcher reads
369
+ # differently under each mutant), so every row gets the name the first
370
+ # mutant reported, and one example stays one test.
371
+ #
372
+ # @api private
373
+ # @param results [Array<Mutineer::Result>] mutant results.
374
+ # @return [Array<Mutineer::Result>]
375
+ def self.share_tests(results)
376
+ shared = {}
377
+ share = lambda do |tests|
378
+ tests.map { |file, name, id| shared[[file, id]] ||= [file, name, id].map { |s| -s }.freeze }.uniq.sort.freeze
379
+ end
380
+ results.map do |r|
381
+ next r unless (kills = r.kills)
382
+
383
+ r.with(kills: kills.with(killed_by: share.call(kills.killed_by), ran: share.call(kills.ran)))
503
384
  end
504
385
  end
505
386