mutineer 1.5.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.
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Mutineer
4
- # Immutable outcome of running one mutant. Eight distinct states:
4
+ # Immutable outcome of running one mutant. Ten distinct states:
5
5
  # killed - a test failed/errored, so the mutation was caught.
6
6
  # survived - every test passed, so the mutation went undetected.
7
7
  # error - the child crashed (unhandled exception): exit status 2.
@@ -13,6 +13,19 @@ module Mutineer
13
13
  # like no_coverage, but reported separately: it signals a
14
14
  # broken harness (a test that failed to run), not a genuine
15
15
  # coverage gap.
16
+ # unplaceable - under `--strategy redefine`, the method belongs to a class
17
+ # or module that cannot be named statically, so it has no
18
+ # owner to be loaded onto and is not run. Excluded from the
19
+ # denominator and, unlike uncapturable, from the
20
+ # no-verdict gate: nothing is broken. `--strategy reload`
21
+ # runs these mutants.
22
+ # ran_at_load - the mutated line ran while the app booted or its class
23
+ # loaded. That run happened before the mutant was applied,
24
+ # so a test can check a value the original code computed.
25
+ # A survivor on such a line, and a line that ran only at
26
+ # load, get this status; a kill stays killed. Excluded from
27
+ # the denominator and from the no-verdict gate, like
28
+ # unplaceable. `--test-command` verifies these mutants.
16
29
  # ignored - a known-equivalent mutant the user suppressed, via an
17
30
  # inline `# mutineer:disable-line` comment or a
18
31
  # `.mutineer.yml` `ignore:` id. A pre-fork classification
@@ -21,8 +34,9 @@ module Mutineer
21
34
  #
22
35
  # `error` and `skipped` are deliberately distinct: skipped is a pre-fork
23
36
  # validity failure (counted separately by the reporter), error is a runtime
24
- # crash. Never conflate them via `details` string parsing. `no_coverage` and
25
- # `uncapturable` are pre-fork selection results: both excluded from the score
37
+ # crash. Never conflate them via `details` string parsing. `no_coverage`,
38
+ # `uncapturable` and `unplaceable` are pre-fork results, and `ran_at_load` is
39
+ # pre-fork or a reclassified survivor: all excluded from the score
26
40
  # denominator.
27
41
  #
28
42
  # `subject`, `mutation`, and `id` are nil when the Result is built by
@@ -89,6 +103,16 @@ module Mutineer
89
103
  # @return [Mutineer::Result] uncapturable result.
90
104
  def self.uncapturable = new(status: :uncapturable, details: nil, subject: nil, mutation: nil, id: nil)
91
105
 
106
+ # Builds an unplaceable result.
107
+ #
108
+ # @return [Mutineer::Result] unplaceable result.
109
+ def self.unplaceable = new(status: :unplaceable, details: nil, subject: nil, mutation: nil, id: nil)
110
+
111
+ # Builds a ran_at_load result.
112
+ #
113
+ # @return [Mutineer::Result] ran-at-load result.
114
+ def self.ran_at_load = new(status: :ran_at_load, details: nil, subject: nil, mutation: nil, id: nil)
115
+
92
116
  # Builds an ignored result.
93
117
  #
94
118
  # @return [Mutineer::Result] ignored result.
@@ -108,6 +132,10 @@ module Mutineer
108
132
  def no_coverage? = status == :no_coverage
109
133
  # @return [Boolean] true when the status is uncapturable.
110
134
  def uncapturable? = status == :uncapturable
135
+ # @return [Boolean] true when the status is unplaceable.
136
+ def unplaceable? = status == :unplaceable
137
+ # @return [Boolean] true when the status is ran_at_load.
138
+ def ran_at_load? = status == :ran_at_load
111
139
  # @return [Boolean] true when the status is ignored.
112
140
  def ignored? = status == :ignored
113
141
 
@@ -130,7 +158,7 @@ module Mutineer
130
158
 
131
159
  # Aggregates a flat list of Results into counts, the mutation score, and the
132
160
  # surviving-mutant list. The score denominator is killed + survived ONLY:
133
- # no-coverage, uncapturable, skipped (invalid), errored, timeout, and ignored
161
+ # no-coverage, uncapturable, unplaceable, ran-at-load, skipped (invalid), errored, timeout, and ignored
134
162
  # (equivalent-mutant suppression) are each excluded and surfaced separately,
135
163
  # so suppressing every survivor reaches 100%. An empty denominator yields a
136
164
  # nil score (rendered "N/A"), never 0.0, distinguishing "no testable mutants"
@@ -154,6 +182,10 @@ module Mutineer
154
182
  def no_coverage_count = count(:no_coverage)
155
183
  # @return [Integer] uncapturable count.
156
184
  def uncapturable_count = count(:uncapturable)
185
+ # @return [Integer] unplaceable count.
186
+ def unplaceable_count = count(:unplaceable)
187
+ # @return [Integer] ran-at-load count.
188
+ def ran_at_load_count = count(:ran_at_load)
157
189
  # @return [Integer] skipped-invalid count.
158
190
  def skipped_invalid_count = count(:skipped)
159
191
  # @return [Integer] errored count.
@@ -1,25 +1,21 @@
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"
23
19
 
24
20
  module Mutineer
25
21
  # Orchestrates one mutation end-to-end: apply it textually, validate the
@@ -35,7 +31,7 @@ module Mutineer
35
31
  # Full orchestration: resolve operators, discover subjects, build the
36
32
  # coverage map, run every mutation, and aggregate. Returns
37
33
  # [AggregateResult, source_map, extras], where extras is the hash
38
- # {.collect_jobs} returns (`:legacy_ignore_matches`, `:id_map`), unchanged.
34
+ # {JobPlan.collect_jobs} returns (`:legacy_ignore_matches`, `:id_map`), unchanged.
39
35
  # The CLI then reports + applies the exit code; the integration test asserts
40
36
  # directly on the AggregateResult.
41
37
  #
@@ -74,13 +70,20 @@ module Mutineer
74
70
  # Coverage instruments only files loaded AFTER it starts. Start it BEFORE
75
71
  # the boot require so the entire app loaded during boot is instrumented;
76
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.
77
75
  require "coverage"
78
- 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
79
81
  require File.expand_path(config.boot, config.project_root)
80
82
  else
81
83
  config.sources.each { |f| require File.expand_path(f, config.project_root) }
82
84
  end
83
85
  config.require_paths.each { |f| require File.expand_path(f, config.project_root) }
86
+ preload_owners(config) if config.boot && config.strategy == "redefine"
84
87
 
85
88
  if config.boot
86
89
  # Rails/Minitest test files do `require "test_helper"`, which needs the
@@ -88,7 +91,7 @@ module Mutineer
88
91
  # file's helper root here in the parent so loading them in the fork
89
92
  # children (both coverage capture and per-mutant) resolves.
90
93
  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) }
94
+ JobPlan.test_load_roots(boot_tests).each { |d| $LOAD_PATH.unshift(d) unless $LOAD_PATH.include?(d) }
92
95
 
93
96
  # Boot mode now uses coverage selection too: capture each test's coverage
94
97
  # by forking the booted parent, then select covering tests per mutant.
@@ -97,12 +100,16 @@ module Mutineer
97
100
  cache_dir: File.expand_path(config.cache_dir, config.project_root), project_root: config.project_root,
98
101
  load_paths: config.load_paths, framework: config.framework,
99
102
  boot_path: File.expand_path(config.boot, config.project_root),
103
+ require_paths: config.require_paths, # loaded above; here only for the cache digest
100
104
  verbose: config.verbose,
101
105
  capture_timeout: config.capture_timeout || CoverageMap::DEFAULT_CAPTURE_TIMEOUT
102
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?
103
110
  else
104
111
  # 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) })
112
+ test_roots = JobPlan.test_load_roots(config.tests.map { |t| File.expand_path(t, config.project_root) })
106
113
  libs = config.load_paths.map { |p| File.expand_path(p, config.project_root) }
107
114
  $LOAD_PATH.unshift(*(libs + test_roots).uniq.reject { |d| $LOAD_PATH.include?(d) })
108
115
  # Relative, so the cache digest does not depend on the checkout path.
@@ -111,23 +118,24 @@ module Mutineer
111
118
  source_paths: config.sources, test_paths: config.tests,
112
119
  cache_dir: File.expand_path(config.cache_dir, config.project_root), project_root: config.project_root,
113
120
  load_paths: config.load_paths + rel_roots, framework: config.framework,
121
+ require_paths: config.require_paths,
114
122
  capture_timeout: config.capture_timeout || CoverageMap::DEFAULT_CAPTURE_TIMEOUT
115
123
  ).build_or_load
116
124
  end
117
- abort_if_unclean!(coverage_map)
125
+ JobPlan.abort_if_unclean!(coverage_map)
118
126
 
119
127
  # 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)
128
+ jobs, ignored_results, source_map, extras = JobPlan.collect_jobs(config, operator_classes)
121
129
 
122
- jobs = filter_since(jobs, source_map, config) if config.since
130
+ jobs = JobPlan.filter_since(jobs, source_map, config) if config.since
123
131
 
124
132
  # Whole-file reload writes mutineer_mutant*.rb into each source dir (so
125
133
  # require_relative resolves). A SIGKILL'd child skips the tempfile's
126
134
  # ensure-unlink, orphaning it. `ensure` is unreliable vs SIGKILL, so the
127
135
  # PARENT sweeps each source dir before and after the run. Orphans are
128
136
  # impossible after a normal run.
129
- dirs = source_dirs(config)
130
- sweep_orphans(dirs)
137
+ dirs = JobPlan.source_dirs(config)
138
+ JobPlan.sweep_orphans(dirs)
131
139
 
132
140
  strategy = config.strategy
133
141
  # Fail-fast must be serial: a parallel stop_when fires on the first survivor
@@ -153,7 +161,7 @@ module Mutineer
153
161
  r&.with(subject: jobs[i][0], mutation: jobs[i][1], id: jobs[i][2])
154
162
  end)
155
163
  ensure
156
- sweep_orphans(dirs)
164
+ JobPlan.sweep_orphans(dirs)
157
165
  end
158
166
 
159
167
  [AggregateResult.new(results + ignored_results), source_map, extras]
@@ -171,109 +179,6 @@ module Mutineer
171
179
  result.with(kills: Kills.new(killed_by: [], ran: [], complete: false))
172
180
  end
173
181
 
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
182
  # External backend orchestration. Runs each mutant's whole-file mutation on
278
183
  # disk (crash-safe swap) and executes the user's --test-command as a subprocess
279
184
  # in the app's own runtime. Serial by construction (one shared DB, no
@@ -284,7 +189,7 @@ module Mutineer
284
189
  # @param config [Mutineer::Config] run configuration (test_command set).
285
190
  # @param operator_classes [Array<Class>] resolved operators.
286
191
  # @return [Array(Mutineer::AggregateResult, Hash<String,String>, Hash)] aggregate,
287
- # source map, and the {.collect_jobs} extras.
192
+ # source map, and the {JobPlan.collect_jobs} extras.
288
193
  def self.execute_external(config, operator_classes)
289
194
  abs_tests = config.tests.map { |t| File.expand_path(t, config.project_root) }
290
195
  sources = config.sources.map { |s| FileSwap.canonical_path(File.expand_path(s, config.project_root)) }
@@ -300,8 +205,8 @@ module Mutineer
300
205
  # source. Heal first, then discover jobs from the clean tree.
301
206
  FileSwap.restore_orphans(dirs)
302
207
 
303
- jobs, ignored_results, source_map, extras = collect_jobs(config, operator_classes)
304
- 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
305
210
 
306
211
  # Nothing to mutate: return before the smoke check, which runs the whole
307
212
  # --test set to calibrate a timeout no mutant would use (#76).
@@ -350,146 +255,29 @@ module Mutineer
350
255
  end
351
256
  end
352
257
 
353
- # Aborts the run when coverage capture saw a red unmutated suite. Scoring
354
- # 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.
355
265
  #
356
- # @param coverage_map [Mutineer::CoverageMap] the built or loaded map.
266
+ # @param config [Mutineer::Config] run configuration.
357
267
  # @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)
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
469
278
  end
470
279
  end
471
280
 
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
281
  # When --rails is on and RAILS_ENV is unset, default it to "test" (and say so)
494
282
  # before the app boots. Otherwise it boots development and nothing is measured.
495
283
  # An explicitly-set RAILS_ENV is always respected.
@@ -501,32 +289,6 @@ module Mutineer
501
289
  warn "[mutineer] RAILS_ENV was unset; defaulting to 'test' for --rails."
502
290
  end
503
291
 
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
292
  # Runs a single mutation through isolation.
531
293
  #
532
294
  # @param mutation [Mutineer::Mutation] mutation to run.
@@ -552,8 +314,9 @@ module Mutineer
552
314
  # no test exercises is :no_coverage (no fork); otherwise exactly the
553
315
  # covering test files run in the child. Shared with the daemon path so both
554
316
  # narrow identically (score parity).
555
- kind, payload = coverage_selection(source_file, mutation, subject, source, coverage_map)
317
+ kind, payload = JobPlan.coverage_selection(source_file, mutation, subject, source, coverage_map)
556
318
  return payload if kind == :verdict
319
+ return Result.unplaceable if strategy == "redefine" && subject&.owner_unknown
557
320
 
558
321
  abs_tests = payload
559
322
 
@@ -574,7 +337,8 @@ module Mutineer
574
337
  TestRunners.for(framework).run(abs_tests, stop_at_first_failure: true)
575
338
  end
576
339
  end
577
- relative_kills(result, coverage_map.project_root)
340
+ result = relative_kills(result, coverage_map.project_root)
341
+ JobPlan.load_verdict(result, source_file, mutation, subject, source, coverage_map)
578
342
  end
579
343
 
580
344
  # Rewrites the test files of a result's {Kills} row relative to the project
@@ -41,6 +41,46 @@ module Mutineer
41
41
  lines
42
42
  end
43
43
 
44
+ # Whether the code at `offset` runs each time the method runs: it is in
45
+ # the first statement of the body, not in a statement nested in it or in
46
+ # a {NESTED} node (a block or loop body), and not in code that runs only
47
+ # sometimes ({sometimes?}, the body of `x if c`). The first statement in
48
+ # parentheses or `begin ... end` runs with them, so it counts
49
+ # ({grouped_first?}). Used for a method whose body shares the `def` line,
50
+ # where only the method's call count tells that the code ran (#209).
51
+ #
52
+ # @param def_node [Prism::DefNode] the method that holds the position.
53
+ # @param offset [Integer] byte offset of the position.
54
+ # @return [Boolean]
55
+ def self.runs_with_method?(def_node, offset)
56
+ first = def_node.body.is_a?(Prism::StatementsNode) && def_node.body.body.first
57
+ path = first && path_to(first, offset)
58
+ return false unless path
59
+
60
+ return false if path.drop(1).any? { |node| NESTED.any? { |klass| node.is_a?(klass) } }
61
+ return false if path[1]&.newline?
62
+
63
+ path.each_cons(3).none? { |grand, parent, node| node.newline? && !grouped_first?(grand, parent, node) } &&
64
+ path.each_cons(2).none? { |parent, child| sometimes?(parent, child) }
65
+ end
66
+
67
+ # Whether `node` is the first statement in parentheses or `begin ... end`,
68
+ # which runs each time they do.
69
+ #
70
+ # @param grand [Prism::Node] the parent of `parent`.
71
+ # @param parent [Prism::Node] the parent of `node`.
72
+ # @param node [Prism::Node]
73
+ # @return [Boolean]
74
+ def self.grouped_first?(grand, parent, node)
75
+ (grand.is_a?(Prism::ParenthesesNode) || grand.is_a?(Prism::BeginNode)) &&
76
+ parent.is_a?(Prism::StatementsNode) && parent.body.first.equal?(node)
77
+ end
78
+
79
+ # Nodes whose code can run any number of times, or not at all, when the
80
+ # statement that holds them runs.
81
+ NESTED = [Prism::BlockNode, Prism::LambdaNode, Prism::WhileNode, Prism::UntilNode, Prism::ForNode,
82
+ Prism::DefNode].freeze
83
+
44
84
  # Nodes whose code runs only when a branch, a match or an exception picks
45
85
  # it: a `when` or `in` clause (its condition or pattern), a `rescue` clause
46
86
  # (its class list), an `else` branch, parameters (their defaults) and
@@ -7,8 +7,12 @@ 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;
12
+ # `owner_unknown` is true when the class that block builds cannot be named,
13
+ # so the redefine strategy cannot load a mutant onto it.
14
+ Subject = Struct.new(:file, :namespace, :name, :singleton, :def_node, :lexical, :block_owner, :owner_unknown,
15
+ keyword_init: true) do
12
16
  # Returns the fully-qualified subject name.
13
17
  #
14
18
  # @return [String] namespaced method name like `Billing::Invoice#total`.