mutineer 1.5.0 → 1.7.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.
@@ -1,25 +1,22 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "digest"
4
3
  require "pathname"
5
4
  require_relative "parser"
6
5
  require_relative "project"
7
6
  require_relative "result"
8
- require_relative "statement_lines"
9
7
  require_relative "isolation"
10
8
  require_relative "minitest_integration"
11
9
  require_relative "test_runners"
12
10
  require_relative "coverage_map"
13
- require_relative "changed_lines"
14
11
  require_relative "mutator_registry"
15
12
  require_relative "worker_pool"
16
13
  require_relative "progress"
17
- require_relative "mutant_id"
18
14
  require_relative "project_path"
19
15
  require_relative "file_swap"
20
16
  require_relative "external_backend"
21
17
  require_relative "daemon_backend"
22
- require "set"
18
+ require_relative "job_plan"
19
+ require_relative "rails_run_env"
23
20
 
24
21
  module Mutineer
25
22
  # Orchestrates one mutation end-to-end: apply it textually, validate the
@@ -35,7 +32,7 @@ module Mutineer
35
32
  # Full orchestration: resolve operators, discover subjects, build the
36
33
  # coverage map, run every mutation, and aggregate. Returns
37
34
  # [AggregateResult, source_map, extras], where extras is the hash
38
- # {.collect_jobs} returns (`:legacy_ignore_matches`, `:id_map`), unchanged.
35
+ # {JobPlan.collect_jobs} returns (`:legacy_ignore_matches`, `:id_map`), unchanged.
39
36
  # The CLI then reports + applies the exit code; the integration test asserts
40
37
  # directly on the AggregateResult.
41
38
  #
@@ -47,6 +44,10 @@ module Mutineer
47
44
  # @return [Array(Mutineer::AggregateResult, Hash<String, String>, Hash)] aggregate,
48
45
  # source map, and run extras.
49
46
  def self.execute(config)
47
+ # Rails reads PARALLEL_WORKERS when a test helper calls parallelize.
48
+ # Set it before any boot or child spawn, then put the caller's value back.
49
+ previous_parallel_workers = ENV["PARALLEL_WORKERS"]
50
+ RailsRunEnv.pin_process!
50
51
  operator_classes = MutatorRegistry.resolve(config.operators || MutatorRegistry::DEFAULT_NAMES)
51
52
 
52
53
  # External backend: run the suite as a subprocess in the app's own runtime.
@@ -59,6 +60,15 @@ module Mutineer
59
60
  # payloads (Prism), so branch before any in-process boot.
60
61
  return DaemonBackend.execute(config, operator_classes) if config.daemon
61
62
 
63
+ # Whole-file reload writes .mutineer_mutant*.rb into each source dir (so
64
+ # require_relative resolves). A SIGKILL'd child skips the tempfile's
65
+ # ensure-unlink, orphaning it. `ensure` is unreliable vs SIGKILL, so the
66
+ # PARENT sweeps each source dir before and after the run. Orphans are
67
+ # impossible after a normal run. The first sweep runs before the boot: a
68
+ # file an older version left without the dot would stop Zeitwerk (#253).
69
+ dirs = JobPlan.source_dirs(config)
70
+ JobPlan.sweep_orphans(dirs)
71
+
62
72
  # Boot mode: require the boot file ONCE so the app env (e.g. Rails) is booted
63
73
  # in the parent and inherited by every fork. Do NOT manually require the
64
74
  # sources. Under Zeitwerk a manual require of an autoloadable file raises;
@@ -74,13 +84,20 @@ module Mutineer
74
84
  # Coverage instruments only files loaded AFTER it starts. Start it BEFORE
75
85
  # the boot require so the entire app loaded during boot is instrumented;
76
86
  # forked children then measure each test's coverage delta against it.
87
+ # An earlier run in this process leaves Coverage suspended (see below).
88
+ # Coverage a host started is the host's: it is left running.
77
89
  require "coverage"
78
- Coverage.start(lines: true) unless Coverage.running?
90
+ own_coverage = !Coverage.running?
91
+ case Coverage.state
92
+ when :idle then Coverage.start(lines: true, methods: true)
93
+ when :suspended then Coverage.resume
94
+ end
79
95
  require File.expand_path(config.boot, config.project_root)
80
96
  else
81
97
  config.sources.each { |f| require File.expand_path(f, config.project_root) }
82
98
  end
83
99
  config.require_paths.each { |f| require File.expand_path(f, config.project_root) }
100
+ preload_owners(config) if config.boot && config.strategy == "redefine"
84
101
 
85
102
  if config.boot
86
103
  # Rails/Minitest test files do `require "test_helper"`, which needs the
@@ -88,7 +105,7 @@ module Mutineer
88
105
  # file's helper root here in the parent so loading them in the fork
89
106
  # children (both coverage capture and per-mutant) resolves.
90
107
  boot_tests = config.tests.map { |t| File.expand_path(t, config.project_root) }
91
- test_load_roots(boot_tests).each { |d| $LOAD_PATH.unshift(d) unless $LOAD_PATH.include?(d) }
108
+ JobPlan.test_load_roots(boot_tests).each { |d| $LOAD_PATH.unshift(d) unless $LOAD_PATH.include?(d) }
92
109
 
93
110
  # Boot mode now uses coverage selection too: capture each test's coverage
94
111
  # by forking the booted parent, then select covering tests per mutant.
@@ -97,12 +114,17 @@ module Mutineer
97
114
  cache_dir: File.expand_path(config.cache_dir, config.project_root), project_root: config.project_root,
98
115
  load_paths: config.load_paths, framework: config.framework,
99
116
  boot_path: File.expand_path(config.boot, config.project_root),
117
+ require_paths: config.require_paths, # loaded above; here only for the cache digest
100
118
  verbose: config.verbose,
119
+ coverage_from: config.coverage_from && File.expand_path(config.coverage_from, config.project_root),
101
120
  capture_timeout: config.capture_timeout || CoverageMap::DEFAULT_CAPTURE_TIMEOUT
102
121
  ).build_via_fork(after_fork: (config.rails ? -> { reconnect_active_record } : nil))
122
+ # Nothing reads Coverage once the map is built (cache hit or not), so
123
+ # stop paying for it in every mutant fork (#228).
124
+ Coverage.suspend if own_coverage && Coverage.running?
103
125
  else
104
126
  # As in boot mode, and with lib first as `rake test` does.
105
- test_roots = test_load_roots(config.tests.map { |t| File.expand_path(t, config.project_root) })
127
+ test_roots = JobPlan.test_load_roots(config.tests.map { |t| File.expand_path(t, config.project_root) })
106
128
  libs = config.load_paths.map { |p| File.expand_path(p, config.project_root) }
107
129
  $LOAD_PATH.unshift(*(libs + test_roots).uniq.reject { |d| $LOAD_PATH.include?(d) })
108
130
  # Relative, so the cache digest does not depend on the checkout path.
@@ -111,23 +133,16 @@ module Mutineer
111
133
  source_paths: config.sources, test_paths: config.tests,
112
134
  cache_dir: File.expand_path(config.cache_dir, config.project_root), project_root: config.project_root,
113
135
  load_paths: config.load_paths + rel_roots, framework: config.framework,
136
+ require_paths: config.require_paths,
114
137
  capture_timeout: config.capture_timeout || CoverageMap::DEFAULT_CAPTURE_TIMEOUT
115
138
  ).build_or_load
116
139
  end
117
- abort_if_unclean!(coverage_map)
140
+ JobPlan.abort_if_unclean!(coverage_map)
118
141
 
119
142
  # Collect every (subject, mutation) up front so the pool can fan them out.
120
- jobs, ignored_results, source_map, extras = collect_jobs(config, operator_classes)
121
-
122
- jobs = filter_since(jobs, source_map, config) if config.since
143
+ jobs, ignored_results, source_map, extras = JobPlan.collect_jobs(config, operator_classes)
123
144
 
124
- # Whole-file reload writes mutineer_mutant*.rb into each source dir (so
125
- # require_relative resolves). A SIGKILL'd child skips the tempfile's
126
- # ensure-unlink, orphaning it. `ensure` is unreliable vs SIGKILL, so the
127
- # PARENT sweeps each source dir before and after the run. Orphans are
128
- # impossible after a normal run.
129
- dirs = source_dirs(config)
130
- sweep_orphans(dirs)
145
+ jobs, ignored_results, extras[:unscoped_mutants] = JobPlan.scope_since(jobs, ignored_results, source_map, config)
131
146
 
132
147
  strategy = config.strategy
133
148
  # Fail-fast must be serial: a parallel stop_when fires on the first survivor
@@ -153,10 +168,13 @@ module Mutineer
153
168
  r&.with(subject: jobs[i][0], mutation: jobs[i][1], id: jobs[i][2])
154
169
  end)
155
170
  ensure
156
- sweep_orphans(dirs)
171
+ JobPlan.sweep_orphans(dirs)
157
172
  end
158
173
 
159
174
  [AggregateResult.new(results + ignored_results), source_map, extras]
175
+ ensure
176
+ # A caller that set PARALLEL_WORKERS keeps that value after the run.
177
+ RailsRunEnv.restore_process!(previous_parallel_workers)
160
178
  end
161
179
 
162
180
  # A `--matrix` error that carries no {Kills} row (the worker crashed, or its
@@ -171,109 +189,6 @@ module Mutineer
171
189
  result.with(kills: Kills.new(killed_by: [], ran: [], complete: false))
172
190
  end
173
191
 
174
- # Collect every (subject, mutation, id) up front so a backend can run them.
175
- # A mutant the user marked known-equivalent (inline disable-line comment or
176
- # .mutineer.yml ignore id) is classified :ignored here and NEVER run. It is
177
- # removed from the killed+survived denominator so a strong file reaches 100%.
178
- # The id is computed per subject (occurrence needs the full list), keyed on
179
- # the file path relative to config.project_root, and carried on every job so
180
- # the parent can reattach it after the run. Shared by the in-process,
181
- # external, and daemon backends so job selection can never drift.
182
- #
183
- # Each mutant also gets its old-format id ({MutantId.legacy_for}), so an ignore
184
- # entry stored before ids carried the path still suppresses it. Prints nothing:
185
- # the extras hash returns, as data, `legacy_ignore_matches` (each old-format
186
- # ignore entry that matched a mutant through its old-format id => one
187
- # `{id:, file:, subject:}` hash per matched mutant, in collection order: its
188
- # new id, its project-relative file and its subject's qualified name;
189
- # recorded even when a new id is also listed, since the old entry still
190
- # over-matches other files) and `id_map` (every new id => its old-format id).
191
- #
192
- # Subjects sharing a qualified name in one file (two owner-less `def index`
193
- # in two DSL blocks) get a per-file ordinal in discovery order, so their ids
194
- # differ; the first one's ordinal is 0 and leaves its id unchanged.
195
- #
196
- # @param config [Mutineer::Config] run configuration.
197
- # @param operator_classes [Array<Class>] resolved operators.
198
- # @return [Array(Array, Array<Result>, Hash<String,String>, Hash{Symbol => Hash})]
199
- # jobs, ignored, source_map, and extras (`:legacy_ignore_matches`, `:id_map`).
200
- def self.collect_jobs(config, operator_classes)
201
- source_map = {}
202
- disabled_map = {}
203
- id_paths = {}
204
- # [file, qualified_name] => { declaration offset => ordinal }: keyed by the
205
- # declaration, so the same file discovered twice (two path spellings) reuses
206
- # its ordinal instead of minting a second id for the same mutant.
207
- name_decls = Hash.new { |h, k| h[k] = {} }
208
- ignore_set = config.ignore.to_set
209
- jobs = []
210
- ignored_results = []
211
- legacy_ignore_matches = {}
212
- id_map = {}
213
- Project.discover(config.sources, only: config.only).each do |subject|
214
- source = (source_map[subject.file] ||= File.read(subject.file))
215
- disabled = (disabled_map[subject.file] ||= suppress_map(source, subject.file))
216
- mutations = operator_classes.flat_map { |klass| klass.new.mutations_for(subject, source) }
217
- id_path = (id_paths[subject.file] ||= ProjectPath.relative(subject.file, config.project_root))
218
- decls = name_decls[[id_path, subject.qualified_name]]
219
- ordinal = (decls[subject.def_node.location.start_offset] ||= decls.size)
220
- ids = MutantId.for_subject(subject, source, mutations, path: id_path, subject_ordinal: ordinal)
221
- legacy_ids = MutantId.legacy_for_subject(subject, source, mutations)
222
- lines = mutations.map { |m| source.byteslice(0, m.start_offset).count("\n") + 1 }
223
- keys = result_keys(mutations, source, lines)
224
- # A repeat of an earlier edit on the same line is dropped (#159),
225
- # separately among the run and the ignored mutants, so an ignored copy
226
- # never hides a copy that should run. A dropped copy records nothing.
227
- seen = { run: Set.new, ignored: Set.new }
228
- mutations.each_with_index do |mutation, i|
229
- id = ids[i]
230
- legacy = legacy_ids[i]
231
- ignored = suppressed?(mutation.operator, lines[i], [id, legacy], disabled, ignore_set)
232
- next unless seen[ignored ? :ignored : :run].add?(keys[i])
233
-
234
- id_map[id] = legacy
235
- # An old entry still over-matches other files even when the new id is
236
- # listed too, so every old-entry match is reported for migration.
237
- if ignore_set.include?(legacy)
238
- (legacy_ignore_matches[legacy] ||= []) << { id: id, file: id_path, subject: subject.qualified_name }
239
- end
240
- if ignored
241
- ignored_results << Result.ignored.with(subject: subject, mutation: mutation, id: id)
242
- else
243
- jobs << [subject, mutation, id]
244
- end
245
- end
246
- end
247
- [jobs, ignored_results, source_map, { legacy_ignore_matches: legacy_ignore_matches, id_map: id_map }]
248
- end
249
-
250
- # One key per mutation; two mutations share a key exactly when they are
251
- # the same operator on the same line and give the same mutated source (one
252
- # edit, #159). The caller drops a repeat after ids are assigned. Only a
253
- # group of same-operator, same-line mutations can repeat, so only those
254
- # build a key from their text: the span from the group's earliest start to
255
- # its latest end, digested. Every other mutation gets a key of its own.
256
- #
257
- # @param mutations [Array<Mutineer::Mutation>] one subject's mutations.
258
- # @param source [String] the full, unmutated source.
259
- # @param lines [Array<Integer>] each mutation's line.
260
- # @return [Array<Object>] one key per mutation, in order.
261
- def self.result_keys(mutations, source, lines)
262
- keys = Array.new(mutations.size) { |i| i }
263
- mutations.each_index.group_by { |i| [mutations[i].operator, lines[i]] }.each_value do |group|
264
- next if group.size < 2
265
-
266
- from = group.map { |i| mutations[i].start_offset }.min
267
- to = group.map { |i| mutations[i].end_offset }.max
268
- group.each do |i|
269
- m = mutations[i]
270
- span = "#{source.byteslice(from...m.start_offset)}#{m.replacement}#{source.byteslice(m.end_offset...to)}"
271
- keys[i] = [m.operator, lines[i], Digest::SHA256.digest(span)]
272
- end
273
- end
274
- keys
275
- end
276
-
277
192
  # External backend orchestration. Runs each mutant's whole-file mutation on
278
193
  # disk (crash-safe swap) and executes the user's --test-command as a subprocess
279
194
  # in the app's own runtime. Serial by construction (one shared DB, no
@@ -284,7 +199,7 @@ module Mutineer
284
199
  # @param config [Mutineer::Config] run configuration (test_command set).
285
200
  # @param operator_classes [Array<Class>] resolved operators.
286
201
  # @return [Array(Mutineer::AggregateResult, Hash<String,String>, Hash)] aggregate,
287
- # source map, and the {.collect_jobs} extras.
202
+ # source map, and the {JobPlan.collect_jobs} extras.
288
203
  def self.execute_external(config, operator_classes)
289
204
  abs_tests = config.tests.map { |t| File.expand_path(t, config.project_root) }
290
205
  sources = config.sources.map { |s| FileSwap.canonical_path(File.expand_path(s, config.project_root)) }
@@ -300,8 +215,8 @@ module Mutineer
300
215
  # source. Heal first, then discover jobs from the clean tree.
301
216
  FileSwap.restore_orphans(dirs)
302
217
 
303
- jobs, ignored_results, source_map, extras = collect_jobs(config, operator_classes)
304
- jobs = filter_since(jobs, source_map, config) if config.since
218
+ jobs, ignored_results, source_map, extras = JobPlan.collect_jobs(config, operator_classes)
219
+ jobs, ignored_results, extras[:unscoped_mutants] = JobPlan.scope_since(jobs, ignored_results, source_map, config)
305
220
 
306
221
  # Nothing to mutate: return before the smoke check, which runs the whole
307
222
  # --test set to calibrate a timeout no mutant would use (#76).
@@ -350,146 +265,29 @@ module Mutineer
350
265
  end
351
266
  end
352
267
 
353
- # Aborts the run when coverage capture saw a red unmutated suite. Scoring
354
- # those results would treat existing assertion failures as killed mutants.
268
+ # Loads the class or module of every subject in the booted parent, before
269
+ # the boot coverage is read. Under redefine a child resolves the owner by
270
+ # name ({Isolation.nesting_keywords}), so a lazily loaded class (Zeitwerk,
271
+ # `autoload`) would run its class body there, with the original method,
272
+ # and that run would count as no load at all. Loading it here makes its
273
+ # class-body calls load lines, the same as an eager load. A constant that
274
+ # fails to load stays lazy.
355
275
  #
356
- # @param coverage_map [Mutineer::CoverageMap] the built or loaded map.
276
+ # @param config [Mutineer::Config] run configuration.
357
277
  # @return [void]
358
- # @raise [Mutineer::SmokeCheckError] when any captured test failed clean,
359
- # or when no test recorded coverage and a capture failed.
360
- def self.abort_if_unclean!(coverage_map)
361
- if coverage_map.map.empty? && coverage_map.failed_test_files.any?
362
- raise SmokeCheckError, "no test recorded coverage, and capture failed for #{coverage_map.failed_test_files.join(', ')}"
363
- end
364
-
365
- files = coverage_map.failed_clean_tests
366
- return if files.empty?
367
-
368
- raise SmokeCheckError,
369
- "the unmutated suite is not green (#{files.join(', ')}) — " \
370
- "#{ExternalBackend.generic_env_hint}."
371
- end
372
-
373
- # Coverage-based test selection, shared by the in-process ({run}) and daemon
374
- # paths so both narrow identically (score parity). Returns
375
- # `[:run, abs_test_paths]` when some test covers the mutant's line, or
376
- # `[:verdict, Result]` (no_coverage / uncapturable) when none do. A line
377
- # Ruby does not count (a later line of a multi-line statement) uses the
378
- # tests that ran the statement that holds it ({StatementLines}), unless the
379
- # mutant sits in code of that statement that runs only sometimes.
380
- #
381
- # An empty selection is `:uncapturable` (not `:no_coverage`) when the
382
- # mutant's enclosing method body got coverage from no *successful* capture but
383
- # a sibling test failed to capture: the coverage was lost, not absent. Both are
384
- # excluded from the score denominator, so this distinction is reporting-only
385
- # and never changes the daemon-vs-in-process score.
386
- #
387
- # @param source_file [String] the mutated source file path.
388
- # @param mutation [Mutineer::Mutation] the mutation (for its line offset).
389
- # @param subject [Mutineer::Subject, nil] the subject (for its method body range).
390
- # @param source [String] the original source text.
391
- # @param coverage_map [Mutineer::CoverageMap] the built/loaded coverage map.
392
- # @return [Array(Symbol, Object)] `[:run, Array<String>]` or `[:verdict, Result]`.
393
- def self.coverage_selection(source_file, mutation, subject, source, coverage_map)
394
- line = source.byteslice(0, mutation.start_offset).count("\n") + 1
395
- chosen = coverage_map.tests_for(source_file, line)
396
- if chosen.empty? && subject
397
- # A multi-line statement has a count on one of its lines only.
398
- lines = StatementLines.for(subject.def_node, source, mutation.start_offset)
399
- chosen = lines.flat_map { |l| coverage_map.tests_for(source_file, l) }.uniq
400
- end
401
- if chosen.empty?
402
- # Method BODY range, not the whole def: the def/end lines are "covered" at
403
- # class-load even when the body never runs (body_loc is the statements' span).
404
- loc = subject&.body_loc
405
- range = loc ? (loc.start_line..loc.end_line) : (line..line)
406
- return [:verdict, coverage_map.method_uncapturable?(source_file, range) ? Result.uncapturable : Result.no_coverage]
407
- end
408
-
409
- [:run, chosen.map { |t| File.expand_path(t, coverage_map.project_root) }]
410
- end
411
-
412
- # Map each line number to :all or a set of operator symbols, using
413
- # inline `# mutineer:disable-line [ops]` markers (RuboCop semantics: the marker
414
- # sits on the same physical line as the code it silences). A bare marker
415
- # disables every operator on that line; `disable-line a, b` only the listed
416
- # operators. Block-form disable/enable ranges are intentionally not supported.
417
- # Only a real `#` comment counts: Prism lists the comments, so the marker
418
- # text inside a string, heredoc or regex silences nothing (#158).
419
- def self.suppress_map(source, file)
420
- map = {}
421
- Parser.comments(source).grep(Prism::InlineComment).each do |comment|
422
- line = comment.location.start_line
423
- next unless (m = comment.slice.match(/#\s*mutineer:disable-line(?:\s+([\w,\s]+))?/))
424
-
425
- ops = m[1]&.split(",")&.map(&:strip)&.reject(&:empty?)
426
- # Only spaces or commas after the marker (e.g. `disable-line -- why`)
427
- # is a bare marker, not an empty list that silences nothing.
428
- ops = nil if ops&.empty?
429
- unknown = ops.to_a.reject { |o| MutatorRegistry::ALL.key?(o) }
430
- unknown.each do |o|
431
- warn "mutineer: unknown operator #{o.inspect} in #{file}:#{line} " \
432
- "(known: #{MutatorRegistry::ALL.keys.join(', ')}); write a reason after --"
433
- end
434
- map[line] = ops ? ops.map(&:to_sym).to_set : :all
435
- end
436
- map
437
- end
438
-
439
- # True when this mutant is suppressed: its line bears a disable-line marker
440
- # (bare, or scoped to its operator), OR its new or old-format id is in the
441
- # config ignore list. Checked at job-build time so a suppressed mutant is
442
- # never forked.
443
- #
444
- # @param ids [Array<String>, String] the mutant's new id and its old-format
445
- # id, or a single id (the pre-#126 call shape).
446
- def self.suppressed?(operator, line, ids, disabled, ignore_set)
447
- return true if Array(ids).any? { |id| ignore_set.include?(id) }
448
-
449
- case (entry = disabled[line])
450
- when :all then true
451
- when Set then entry.include?(operator)
452
- else false
453
- end
454
- end
455
-
456
- # --since: keep only jobs whose mutation lands on a line changed since the git
457
- # ref. Composes with coverage selection (it only narrows the job list; each
458
- # surviving mutant still goes through Runner.run's coverage check). A file with
459
- # no changed lines (absent from the diff) contributes no jobs. Line is computed
460
- # exactly as Runner.run does, from the already-read source in source_map.
461
- def self.filter_since(jobs, source_map, config)
462
- changed = ChangedLines.for(ref: config.since, files: config.sources,
463
- project_root: config.project_root)
464
- jobs.select do |subject, mutation|
465
- source = source_map[subject.file]
466
- line = source.byteslice(0, mutation.start_offset).count("\n") + 1
467
- abs = File.expand_path(subject.file, config.project_root)
468
- changed.fetch(abs, []).include?(line)
278
+ def self.preload_owners(config)
279
+ Project.discover(config.sources, only: config.only).each do |subject|
280
+ next if subject.owner_unknown
281
+
282
+ Isolation.nesting_keywords(subject.lexical_namespace)
283
+ # The owner itself, as the child resolves it: a Class.new block owner
284
+ # has no lexical namespace.
285
+ Object.const_get(subject.namespace.join("::")) unless subject.namespace.empty?
286
+ rescue StandardError, ScriptError
287
+ nil
469
288
  end
470
289
  end
471
290
 
472
- # For each test file, the directory to add to $LOAD_PATH so its
473
- # `require "test_helper"` (or spec_helper) resolves: the nearest ancestor
474
- # holding that helper, plus the file's own dir as a fallback.
475
- def self.test_load_roots(test_files)
476
- test_files.flat_map do |f|
477
- dir = File.dirname(f)
478
- root = nil
479
- loop do
480
- if File.exist?(File.join(dir, "test_helper.rb")) || File.exist?(File.join(dir, "spec_helper.rb"))
481
- root = dir
482
- break
483
- end
484
- parent = File.dirname(dir)
485
- break if parent == dir
486
-
487
- dir = parent
488
- end
489
- [root, File.dirname(f)].compact
490
- end.uniq
491
- end
492
-
493
291
  # When --rails is on and RAILS_ENV is unset, default it to "test" (and say so)
494
292
  # before the app boots. Otherwise it boots development and nothing is measured.
495
293
  # An explicitly-set RAILS_ENV is always respected.
@@ -501,32 +299,6 @@ module Mutineer
501
299
  warn "[mutineer] RAILS_ENV was unset; defaulting to 'test' for --rails."
502
300
  end
503
301
 
504
- # The unique absolute directories holding the sources. Sweep target for both
505
- # orphan mechanisms (in-process mutant tempfiles and external backup files),
506
- # and shipped to the daemon via {DaemonBackend.boot_config} so it can sweep too.
507
- # Shared so the path-expansion rule cannot drift between the paths.
508
- #
509
- # @param config [Mutineer::Config] run configuration.
510
- # @return [Array<String>] unique absolute source directories.
511
- def self.source_dirs(config)
512
- config.sources.map { |f| File.dirname(File.expand_path(f, config.project_root)) }.uniq
513
- end
514
-
515
- # Removes stale mutant tempfiles from the given directories. The daemon writes a
516
- # differently-named temp, so {DaemonBackend} passes its glob when it has to sweep
517
- # tool-side (nothing boots on an empty run, so the daemon's own sweep never runs).
518
- #
519
- # @param dirs [Array<String>] directories to sweep.
520
- # @param glob [String] filename pattern to remove.
521
- # @return [void]
522
- def self.sweep_orphans(dirs, glob = "mutineer_mutant*.rb")
523
- dirs.each do |dir|
524
- Dir.glob(File.join(dir, glob)).each do |f|
525
- File.unlink(f) rescue nil # rubocop:disable Style/RescueModifier
526
- end
527
- end
528
- end
529
-
530
302
  # Runs a single mutation through isolation.
531
303
  #
532
304
  # @param mutation [Mutineer::Mutation] mutation to run.
@@ -552,8 +324,10 @@ module Mutineer
552
324
  # no test exercises is :no_coverage (no fork); otherwise exactly the
553
325
  # covering test files run in the child. Shared with the daemon path so both
554
326
  # narrow identically (score parity).
555
- kind, payload = coverage_selection(source_file, mutation, subject, source, coverage_map)
327
+ kind, payload = JobPlan.coverage_selection(source_file, mutation, subject, source, coverage_map)
556
328
  return payload if kind == :verdict
329
+ # A :maybe mutant that cannot be placed stays no_coverage, as before #254.
330
+ return kind == :maybe ? Result.no_coverage : Result.unplaceable if subject&.unplaceable?(strategy)
557
331
 
558
332
  abs_tests = payload
559
333
 
@@ -574,7 +348,9 @@ module Mutineer
574
348
  TestRunners.for(framework).run(abs_tests, stop_at_first_failure: true)
575
349
  end
576
350
  end
577
- relative_kills(result, coverage_map.project_root)
351
+ result = relative_kills(result, coverage_map.project_root)
352
+ result = JobPlan.load_verdict(result, source_file, mutation, subject, source, coverage_map)
353
+ JobPlan.selection_verdict(kind, result)
578
354
  end
579
355
 
580
356
  # Rewrites the test files of a result's {Kills} row relative to the project
@@ -12,12 +12,17 @@ module Mutineer
12
12
  # @param def_node [Prism::DefNode] the method that holds the position.
13
13
  # @param source [String] the source text of the file.
14
14
  # @param offset [Integer] byte offset of the position.
15
+ # @param loose [Boolean] also return the lines when the position is in
16
+ # code that runs only sometimes when the statement runs (the right side of
17
+ # `a && b`, a branch, the body of `x if c`). The tests that ran the
18
+ # statement then include every test that ran the position, but may not
19
+ # have run it (#254).
15
20
  # @return [Array<Integer>] the lines of the statement, with the body of each
16
21
  # heredoc in it. Empty when no statement in the body holds the position.
17
22
  # A first or last line that holds other code is left out, because Ruby
18
23
  # can count that line without the statement: the `def` line counts when
19
24
  # the file loads, and `x if done; y(` counts when `y` does not run.
20
- def self.for(def_node, source, offset)
25
+ def self.for(def_node, source, offset, loose: false)
21
26
  return [] unless def_node.body
22
27
 
23
28
  path = path_to(def_node.body, offset) || []
@@ -25,13 +30,15 @@ module Mutineer
25
30
  return [] unless index
26
31
 
27
32
  statement = path[index]
28
- # Code that runs only sometimes inside the statement: the tests that ran
29
- # the statement need not have run it.
30
- return [] if path[index..].each_cons(2).any? { |parent, child| sometimes?(parent, child) }
33
+ unless loose
34
+ # Code that runs only sometimes inside the statement: the tests that ran
35
+ # the statement need not have run it.
36
+ return [] if path[index..].each_cons(2).any? { |parent, child| sometimes?(parent, child) }
31
37
 
32
- # Ruby counts the line of `x while c` or `x if c` when it checks `c`,
33
- # also when `x` never runs. The path is the modifier, its body, statement.
34
- return [] if index >= 2 && modifier?(path[index - 2])
38
+ # Ruby counts the line of `x while c` or `x if c` when it checks `c`,
39
+ # also when `x` never runs. The path is the modifier, its body, statement.
40
+ return [] if index >= 2 && modifier?(path[index - 2])
41
+ end
35
42
 
36
43
  location = statement.location
37
44
  lines = (location.start_line..last_line(statement)).to_a
@@ -41,6 +48,46 @@ module Mutineer
41
48
  lines
42
49
  end
43
50
 
51
+ # Whether the code at `offset` runs each time the method runs: it is in
52
+ # the first statement of the body, not in a statement nested in it or in
53
+ # a {NESTED} node (a block or loop body), and not in code that runs only
54
+ # sometimes ({sometimes?}, the body of `x if c`). The first statement in
55
+ # parentheses or `begin ... end` runs with them, so it counts
56
+ # ({grouped_first?}). Used for a method whose body shares the `def` line,
57
+ # where only the method's call count tells that the code ran (#209).
58
+ #
59
+ # @param def_node [Prism::DefNode] the method that holds the position.
60
+ # @param offset [Integer] byte offset of the position.
61
+ # @return [Boolean]
62
+ def self.runs_with_method?(def_node, offset)
63
+ first = def_node.body.is_a?(Prism::StatementsNode) && def_node.body.body.first
64
+ path = first && path_to(first, offset)
65
+ return false unless path
66
+
67
+ return false if path.drop(1).any? { |node| NESTED.any? { |klass| node.is_a?(klass) } }
68
+ return false if path[1]&.newline?
69
+
70
+ path.each_cons(3).none? { |grand, parent, node| node.newline? && !grouped_first?(grand, parent, node) } &&
71
+ path.each_cons(2).none? { |parent, child| sometimes?(parent, child) }
72
+ end
73
+
74
+ # Whether `node` is the first statement in parentheses or `begin ... end`,
75
+ # which runs each time they do.
76
+ #
77
+ # @param grand [Prism::Node] the parent of `parent`.
78
+ # @param parent [Prism::Node] the parent of `node`.
79
+ # @param node [Prism::Node]
80
+ # @return [Boolean]
81
+ def self.grouped_first?(grand, parent, node)
82
+ (grand.is_a?(Prism::ParenthesesNode) || grand.is_a?(Prism::BeginNode)) &&
83
+ parent.is_a?(Prism::StatementsNode) && parent.body.first.equal?(node)
84
+ end
85
+
86
+ # Nodes whose code can run any number of times, or not at all, when the
87
+ # statement that holds them runs.
88
+ NESTED = [Prism::BlockNode, Prism::LambdaNode, Prism::WhileNode, Prism::UntilNode, Prism::ForNode,
89
+ Prism::DefNode].freeze
90
+
44
91
  # Nodes whose code runs only when a branch, a match or an exception picks
45
92
  # it: a `when` or `in` clause (its condition or pattern), a `rescue` clause
46
93
  # (its class list), an `else` branch, parameters (their defaults) and
@@ -7,8 +7,18 @@ module Mutineer
7
7
  # because mutators walk the def node directly. `namespace` names the owner
8
8
  # (`module ::X` inside `Outer` owns into `X`); `lexical` is the class/module
9
9
  # chain as written (`["Outer", "::X"]`), which the redefine strategy needs to
10
- # rebuild the same Module.nesting as a whole-file reload (#145).
11
- Subject = Struct.new(:file, :namespace, :name, :singleton, :def_node, :lexical, keyword_init: true) do
10
+ # rebuild the same Module.nesting as a whole-file reload (#145). `block_owner`
11
+ # is the constant a `Data.define`-style block is assigned to, as written, or
12
+ # the `ClassMethods` of a concern's `class_methods` block (#252);
13
+ # `owner_unknown` is true when the class that owns the method cannot be named
14
+ # (an unassigned builder block, or a concern block whose class or module no
15
+ # constant names, such as an `included` block that runs in each includer),
16
+ # so the redefine strategy cannot load a mutant onto it. `hook_block` is true
17
+ # for a def anywhere inside a concern's `included` or `prepended` block, which
18
+ # ActiveSupport runs only when a class first includes the concern, so the
19
+ # reload strategy cannot load a mutant onto it either (#256).
20
+ Subject = Struct.new(:file, :namespace, :name, :singleton, :def_node, :lexical, :block_owner, :owner_unknown,
21
+ :hook_block, keyword_init: true) do
12
22
  # Returns the fully-qualified subject name.
13
23
  #
14
24
  # @return [String] namespaced method name like `Billing::Invoice#total`.
@@ -24,6 +34,18 @@ module Mutineer
24
34
  lexical || namespace
25
35
  end
26
36
 
37
+ # True when the strategy cannot load a mutant of this method where the tests
38
+ # call it, so the mutant is not run. Redefine needs a named owner. Reload
39
+ # loads the whole file again, which redefines everything except what a
40
+ # concern's `included` or `prepended` block defines: that block does not run
41
+ # again in a class that already includes the concern.
42
+ #
43
+ # @param strategy [String] `"reload"` or `"redefine"`.
44
+ # @return [Boolean]
45
+ def unplaceable?(strategy)
46
+ strategy == "redefine" ? owner_unknown == true : hook_block == true
47
+ end
48
+
27
49
  # Returns the body location for the subject, if any.
28
50
  #
29
51
  # @return [Prism::Location, nil] body location or nil for empty methods.