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.
@@ -4,7 +4,9 @@ require "tempfile"
4
4
  require_relative "result"
5
5
  require_relative "parser"
6
6
  require_relative "child_stdout"
7
+ require_relative "child_coverage"
7
8
  require_relative "kill_channel"
9
+ require_relative "concern_blocks"
8
10
 
9
11
  module Mutineer
10
12
  # Fork-based isolation for running one mutant. The block runs in a child
@@ -56,6 +58,7 @@ module Mutineer
56
58
  code = 0
57
59
  begin
58
60
  ChildStdout.silence
61
+ ChildCoverage.allow_start
59
62
  # A block that takes no channel (a zero-arity lambda) still runs.
60
63
  result = channel ? yield(wr) : yield
61
64
  code = result.is_a?(Integer) ? result : 0
@@ -179,15 +182,24 @@ module Mutineer
179
182
  # "base"`). Writing it elsewhere makes those requires resolve to the temp
180
183
  # dir and raise LoadError.
181
184
  #
185
+ # Loading the file reopens its namespace. With lazy loading, that can be the
186
+ # first time Zeitwerk scans the namespace's directory. Zeitwerk skips hidden
187
+ # files, so the leading dot keeps it from rejecting the tempfile's name as a
188
+ # constant, in this child and in any other running at the same time (#253).
189
+ #
190
+ # A concern's stored `included` and `prepended` blocks from the source file
191
+ # are forgotten around the load, so ActiveSupport accepts the blocks the
192
+ # temp file stores (#256).
193
+ #
182
194
  # @api private
183
195
  # @param mutated [String] mutated source text.
184
196
  # @param source_file [String] original source file path.
185
197
  # @return [Object] whatever `load` returns.
186
198
  def self.apply_whole_file(mutated, source_file)
187
- Tempfile.create(["mutineer_mutant", ".rb"], File.dirname(File.expand_path(source_file))) do |f|
199
+ Tempfile.create([".mutineer_mutant", ".rb"], File.dirname(File.expand_path(source_file))) do |f|
188
200
  f.write(mutated)
189
201
  f.flush
190
- load f.path
202
+ ConcernBlocks.replace(source_file) { load f.path }
191
203
  end
192
204
  end
193
205
 
@@ -234,6 +246,7 @@ module Mutineer
234
246
  else
235
247
  mutated_def
236
248
  end
249
+ inner = "#{subject.block_owner}.class_eval do\n#{inner}\nend" if subject.block_owner
237
250
  wrapped = "#{prefix}#{inner}#{"\nend" * keywords.size}"
238
251
 
239
252
  # A snippet that fails to reparse must NOT silently fall through to
@@ -0,0 +1,458 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "set"
5
+ require_relative "parser"
6
+ require_relative "project"
7
+ require_relative "result"
8
+ require_relative "statement_lines"
9
+ require_relative "changed_lines"
10
+ require_relative "mutator_registry"
11
+ require_relative "mutant_id"
12
+ require_relative "project_path"
13
+ require_relative "external_backend"
14
+ require_relative "orphan_guard"
15
+
16
+ module Mutineer
17
+ # The job vocabulary every backend shares: which mutants run, which are
18
+ # ignored, which lines `--since` keeps, which tests cover a mutant, and the
19
+ # source and test directories a run touches. {Runner}, {DaemonBackend} and
20
+ # {CLI} each require this file, so job selection cannot drift between them
21
+ # (score parity), and the require graph has no cycles (#75). Never require
22
+ # runner.rb, daemon_backend.rb or cli.rb from here: each requires this file.
23
+ module JobPlan
24
+ # Collect every (subject, mutation, id) up front so a backend can run them.
25
+ # A mutant the user marked known-equivalent (inline disable-line comment or
26
+ # .mutineer.yml ignore id) is classified :ignored here and NEVER run. It is
27
+ # removed from the killed+survived denominator so a strong file reaches 100%.
28
+ # The id is computed per subject (occurrence needs the full list), keyed on
29
+ # the file path relative to config.project_root, and carried on every job so
30
+ # the parent can reattach it after the run. Shared by the in-process,
31
+ # external, and daemon backends so job selection can never drift.
32
+ #
33
+ # Each mutant also gets its old-format id ({MutantId.legacy_for}), so an ignore
34
+ # entry stored before ids carried the path still suppresses it. Prints nothing:
35
+ # the extras hash returns, as data, `legacy_ignore_matches` (each old-format
36
+ # ignore entry that matched a mutant through its old-format id => one
37
+ # `{id:, file:, subject:}` hash per matched mutant, in collection order: its
38
+ # new id, its project-relative file and its subject's qualified name;
39
+ # recorded even when a new id is also listed, since the old entry still
40
+ # over-matches other files) and `id_map` (every new id => its old-format id).
41
+ #
42
+ # Subjects sharing a qualified name in one file (two owner-less `def index`
43
+ # in two DSL blocks) get a per-file ordinal in discovery order, so their ids
44
+ # differ; the first one's ordinal is 0 and leaves its id unchanged.
45
+ #
46
+ # @param config [Mutineer::Config] run configuration.
47
+ # @param operator_classes [Array<Class>] resolved operators.
48
+ # @return [Array(Array, Array<Result>, Hash<String,String>, Hash{Symbol => Hash})]
49
+ # jobs, ignored, source_map, and extras (`:legacy_ignore_matches`, `:id_map`).
50
+ def self.collect_jobs(config, operator_classes)
51
+ source_map = {}
52
+ disabled_map = {}
53
+ id_paths = {}
54
+ # [file, qualified_name] => { declaration offset => ordinal }: keyed by the
55
+ # declaration, so the same file discovered twice (two path spellings) reuses
56
+ # its ordinal instead of minting a second id for the same mutant.
57
+ name_decls = Hash.new { |h, k| h[k] = {} }
58
+ ignore_set = config.ignore.to_set
59
+ ignore_reasons = config.ignore_reasons || {}
60
+ jobs = []
61
+ ignored_results = []
62
+ legacy_ignore_matches = {}
63
+ id_map = {}
64
+ Project.discover(config.sources, only: config.only).each do |subject|
65
+ source = (source_map[subject.file] ||= File.read(subject.file))
66
+ disabled = (disabled_map[subject.file] ||= suppress_map(source, subject.file))
67
+ mutations = operator_classes.flat_map { |klass| klass.new.mutations_for(subject, source) }
68
+ id_path = (id_paths[subject.file] ||= ProjectPath.relative(subject.file, config.project_root))
69
+ decls = name_decls[[id_path, subject.qualified_name]]
70
+ ordinal = (decls[subject.def_node.location.start_offset] ||= decls.size)
71
+ ids = MutantId.for_subject(subject, source, mutations, path: id_path, subject_ordinal: ordinal)
72
+ legacy_ids = MutantId.legacy_for_subject(subject, source, mutations)
73
+ lines = mutations.map { |m| source.byteslice(0, m.start_offset).count("\n") + 1 }
74
+ keys = result_keys(mutations, source, lines)
75
+ # A repeat of an earlier edit on the same line is dropped (#159),
76
+ # separately among the run and the ignored mutants, so an ignored copy
77
+ # never hides a copy that should run. A dropped copy records nothing.
78
+ seen = { run: Set.new, ignored: Set.new }
79
+ mutations.each_with_index do |mutation, i|
80
+ id = ids[i]
81
+ legacy = legacy_ids[i]
82
+ ignored = suppressed?(mutation.operator, lines[i], [id, legacy], disabled, ignore_set)
83
+ next unless seen[ignored ? :ignored : :run].add?(keys[i])
84
+
85
+ id_map[id] = legacy
86
+ # An old entry still over-matches other files even when the new id is
87
+ # listed too, so every old-entry match is reported for migration.
88
+ if ignore_set.include?(legacy)
89
+ (legacy_ignore_matches[legacy] ||= []) << { id: id, file: id_path, subject: subject.qualified_name }
90
+ end
91
+ if ignored
92
+ reason = suppression_reason(mutation.operator, lines[i], [id, legacy], disabled, ignore_reasons)
93
+ ignored_results << Result.ignored.with(subject: subject, mutation: mutation, id: id, reason: reason)
94
+ else
95
+ jobs << [subject, mutation, id]
96
+ end
97
+ end
98
+ end
99
+ [jobs, ignored_results, source_map, { legacy_ignore_matches: legacy_ignore_matches, id_map: id_map }]
100
+ end
101
+
102
+ # One key per mutation; two mutations share a key exactly when they are
103
+ # the same operator on the same line and give the same mutated source (one
104
+ # edit, #159). The caller drops a repeat after ids are assigned. Only a
105
+ # group of same-operator, same-line mutations can repeat, so only those
106
+ # build a key from their text: the span from the group's earliest start to
107
+ # its latest end, digested. Every other mutation gets a key of its own.
108
+ #
109
+ # @param mutations [Array<Mutineer::Mutation>] one subject's mutations.
110
+ # @param source [String] the full, unmutated source.
111
+ # @param lines [Array<Integer>] each mutation's line.
112
+ # @return [Array<Object>] one key per mutation, in order.
113
+ def self.result_keys(mutations, source, lines)
114
+ keys = Array.new(mutations.size) { |i| i }
115
+ mutations.each_index.group_by { |i| [mutations[i].operator, lines[i]] }.each_value do |group|
116
+ next if group.size < 2
117
+
118
+ from = group.map { |i| mutations[i].start_offset }.min
119
+ to = group.map { |i| mutations[i].end_offset }.max
120
+ group.each do |i|
121
+ m = mutations[i]
122
+ span = "#{source.byteslice(from...m.start_offset)}#{m.replacement}#{source.byteslice(m.end_offset...to)}"
123
+ keys[i] = [m.operator, lines[i], Digest::SHA256.digest(span)]
124
+ end
125
+ end
126
+ keys
127
+ end
128
+
129
+ # Aborts the run when coverage capture saw a red unmutated suite. Scoring
130
+ # those results would treat existing assertion failures as killed mutants.
131
+ #
132
+ # @param coverage_map [Mutineer::CoverageMap] the built or loaded map.
133
+ # @return [void]
134
+ # @raise [Mutineer::SmokeCheckError] when any captured test failed clean,
135
+ # or when no test recorded coverage and a capture failed.
136
+ def self.abort_if_unclean!(coverage_map)
137
+ if coverage_map.map.empty? && coverage_map.failed_test_files.any?
138
+ raise SmokeCheckError, "no test recorded coverage, and capture failed for #{coverage_map.failed_test_files.join(', ')}"
139
+ end
140
+
141
+ files = coverage_map.failed_clean_tests
142
+ return if files.empty?
143
+
144
+ raise SmokeCheckError,
145
+ "the unmutated suite is not green (#{files.join(', ')}) — " \
146
+ "#{ExternalBackend.generic_env_hint}."
147
+ end
148
+
149
+ # Coverage-based test selection, shared by the in-process ({Runner.run}) and daemon
150
+ # paths so both narrow identically (score parity). Returns
151
+ # `[:run, abs_test_paths]` when some test covers the mutant's line, in the
152
+ # order of {CoverageMap#order_tests} (paired files, then cheapest), or
153
+ # `[:verdict, Result]` (no_coverage / uncapturable) when none do. A line
154
+ # Ruby does not count (a later line of a multi-line statement) uses the
155
+ # tests that ran the statement that holds it ({StatementLines}). When the
156
+ # mutant sits in code of that statement that runs only sometimes (the right
157
+ # side of a multi-line `a && b`), those tests may not have run it, so the
158
+ # answer is `[:maybe, abs_test_paths]` and {selection_verdict} treats a
159
+ # survivor as no_coverage (#254).
160
+ #
161
+ # An empty selection is `:ran_at_load` when the mutant's line ran while the
162
+ # app booted or its class loaded ({ran_at_load?}): no test ran it, but the
163
+ # load did, so it is not a coverage gap.
164
+ #
165
+ # An empty selection is `:uncapturable` (not `:no_coverage`) when the
166
+ # mutant's enclosing method body got coverage from no *successful* capture but
167
+ # a sibling test failed to capture: the coverage was lost, not absent. Both are
168
+ # excluded from the score denominator, so this distinction is reporting-only
169
+ # and never changes the daemon-vs-in-process score.
170
+ #
171
+ # @param source_file [String] the mutated source file path.
172
+ # @param mutation [Mutineer::Mutation] the mutation (for its line offset).
173
+ # @param subject [Mutineer::Subject, nil] the subject (for its method body range).
174
+ # @param source [String] the original source text.
175
+ # @param coverage_map [Mutineer::CoverageMap] the built/loaded coverage map.
176
+ # @return [Array(Symbol, Object)] `[:run, Array<String>]`, `[:maybe, Array<String>]`
177
+ # or `[:verdict, Result]`.
178
+ def self.coverage_selection(source_file, mutation, subject, source, coverage_map)
179
+ line = source.byteslice(0, mutation.start_offset).count("\n") + 1
180
+ chosen = coverage_map.tests_for(source_file, line)
181
+ kind = :run
182
+ if chosen.empty? && subject
183
+ # A multi-line statement has a count on one of its lines only.
184
+ lines = StatementLines.for(subject.def_node, source, mutation.start_offset)
185
+ if lines.empty?
186
+ lines = StatementLines.for(subject.def_node, source, mutation.start_offset, loose: true)
187
+ kind = :maybe
188
+ end
189
+ chosen = lines.flat_map { |l| coverage_map.tests_for(source_file, l) }.uniq
190
+ end
191
+ if chosen.empty?
192
+ # Method BODY range, not the whole def: the def/end lines are "covered" at
193
+ # class-load even when the body never runs (body_loc is the statements' span).
194
+ loc = subject&.body_loc
195
+ range = loc ? (loc.start_line..loc.end_line) : (line..line)
196
+ return [:verdict, Result.uncapturable] if coverage_map.method_uncapturable?(source_file, range)
197
+ return [:verdict, Result.ran_at_load] if ran_at_load?(source_file, mutation, subject, source, coverage_map)
198
+
199
+ return [:verdict, Result.no_coverage]
200
+ end
201
+
202
+ [kind, coverage_map.order_tests(source_file, chosen).map { |t| File.expand_path(t, coverage_map.project_root) }]
203
+ end
204
+
205
+ # The verdict of a mutant whose tests {coverage_selection} chose as `:maybe`:
206
+ # they ran its statement but may not have run the mutated code. A kill is a
207
+ # real kill. A survivor is no_coverage, because the run cannot tell "never
208
+ # ran" from "ran unchecked" (#254); like any no_coverage mutant it has no
209
+ # `--matrix` row. An error or a timeout stays what it is: the mutant ran, and
210
+ # a failure is reported, not hidden. A kill keeps its whole `--matrix` row.
211
+ # A result of any other selection comes back unchanged.
212
+ #
213
+ # @param kind [Symbol, nil, false] the first element of {coverage_selection}'s
214
+ # answer, or a falsy value when no selection ran.
215
+ # @param result [Mutineer::Result] the mutant's verdict.
216
+ # @return [Mutineer::Result]
217
+ def self.selection_verdict(kind, result)
218
+ return result unless kind == :maybe && result.survived?
219
+
220
+ result.with(status: :no_coverage, kills: nil)
221
+ end
222
+
223
+ # True when the mutant's line ran while the app booted or its class loaded,
224
+ # so a test can check a value the original code computed before the mutant
225
+ # was applied. Shared by {coverage_selection}, {Runner.run} and the daemon backend.
226
+ #
227
+ # Only the lines of the statement that holds the mutant count
228
+ # ({StatementLines}), and only inside the method body. So code that runs
229
+ # only sometimes (`x if c`, a ternary branch) never counts: its line can
230
+ # count at load without it. The `def` line never counts: Ruby counts it
231
+ # when the method is defined.
232
+ #
233
+ # A one-line or endless def keeps its body on the `def` line, so it uses
234
+ # the method's own call count at load instead (#209), for code that runs
235
+ # each time the method does ({StatementLines.runs_with_method?}).
236
+ #
237
+ # @param source_file [String] the mutated source file path.
238
+ # @param mutation [Mutineer::Mutation] the mutation.
239
+ # @param subject [Mutineer::Subject, nil] the subject (for its method body range).
240
+ # @param source [String] the original source text.
241
+ # @param coverage_map [Mutineer::CoverageMap, nil] the coverage map.
242
+ # @return [Boolean]
243
+ def self.ran_at_load?(source_file, mutation, subject, source, coverage_map)
244
+ loc = subject&.body_loc
245
+ return false unless loc && coverage_map
246
+
247
+ def_loc = subject.def_node.location
248
+ def_line = def_loc.start_line
249
+ if loc.end_line == def_line
250
+ return coverage_map.method_ran_at_load?(source_file, def_line, def_loc.start_column) &&
251
+ StatementLines.runs_with_method?(subject.def_node, mutation.start_offset)
252
+ end
253
+
254
+ body = (loc.start_line..loc.end_line)
255
+ lines = StatementLines.for(subject.def_node, source, mutation.start_offset)
256
+ lines.any? { |l| l != def_line && body.cover?(l) && coverage_map.ran_at_load?(source_file, l) }
257
+ end
258
+
259
+ # A survivor whose line ran at load becomes `ran_at_load` ({ran_at_load?});
260
+ # any other result comes back unchanged. A `--matrix` row is kept.
261
+ #
262
+ # @param result [Mutineer::Result] the mutant's verdict.
263
+ # @param source_file [String] the mutated source file path.
264
+ # @param mutation [Mutineer::Mutation] the mutation.
265
+ # @param subject [Mutineer::Subject, nil] the subject.
266
+ # @param source [String] the original source text.
267
+ # @param coverage_map [Mutineer::CoverageMap, nil] the coverage map.
268
+ # @return [Mutineer::Result]
269
+ def self.load_verdict(result, source_file, mutation, subject, source, coverage_map)
270
+ return result unless result.survived? && ran_at_load?(source_file, mutation, subject, source, coverage_map)
271
+
272
+ result.with(status: :ran_at_load)
273
+ end
274
+
275
+ # Map each line number to `{ ops:, reason: }`, using inline
276
+ # `# mutineer:disable-line [ops]` markers (RuboCop semantics: the marker
277
+ # sits on the same physical line as the code it silences). `ops` is `:all`
278
+ # or a set of operator symbols. `reason` is the text after `--`, or nil.
279
+ # A bare marker disables every operator on that line; `disable-line a, b`
280
+ # only the listed operators. Block-form disable/enable ranges are
281
+ # intentionally not supported. Only a real `#` comment counts: Prism lists
282
+ # the comments, so the marker text inside a string, heredoc or regex
283
+ # silences nothing (#158).
284
+ #
285
+ # @param source [String] the source text.
286
+ # @param file [String] the file name, for warnings.
287
+ # @return [Hash{Integer => Hash}] line => `{ ops:, reason: }`.
288
+ def self.suppress_map(source, file)
289
+ map = {}
290
+ Parser.comments(source).grep(Prism::InlineComment).each do |comment|
291
+ line = comment.location.start_line
292
+ next unless (m = comment.slice.match(/#\s*mutineer:disable-line(?:\s+([\w,\s]+))?/))
293
+
294
+ ops = m[1]&.split(",")&.map(&:strip)&.reject(&:empty?)
295
+ # Only spaces or commas after the marker (e.g. `disable-line -- why`)
296
+ # is a bare marker, not an empty list that silences nothing.
297
+ ops = nil if ops&.empty?
298
+ unknown = ops.to_a.reject { |o| MutatorRegistry::ALL.key?(o) }
299
+ unknown.each do |o|
300
+ warn "mutineer: unknown operator #{o.inspect} in #{file}:#{line}#{Mutineer.did_you_mean(o, MutatorRegistry::ALL.keys)} " \
301
+ "(known: #{MutatorRegistry::ALL.keys.join(', ')}); write a reason after --"
302
+ end
303
+ reason = comment.slice[/\s--\s*(.*)\z/, 1]&.strip
304
+ reason = nil if reason.nil? || reason.empty?
305
+ map[line] = { ops: ops ? ops.map(&:to_sym).to_set : :all, reason: reason }
306
+ end
307
+ map
308
+ end
309
+
310
+ # The operator set for one suppress-map entry. A Hash is the current shape
311
+ # (`ops` plus `reason`). `:all` and a Set remain valid for callers that
312
+ # build the map by hand.
313
+ #
314
+ # @param entry [Hash, Symbol, Set, nil] one line's suppress-map value.
315
+ # @return [Symbol, Set, nil] `:all`, the operator set, or nil.
316
+ def self.line_ops(entry)
317
+ entry.is_a?(Hash) ? entry[:ops] : entry
318
+ end
319
+
320
+ # The reason text on one suppress-map entry, when the entry stores one.
321
+ #
322
+ # @param entry [Hash, Object] one line's suppress-map value.
323
+ # @return [String, nil]
324
+ def self.line_reason(entry)
325
+ entry.is_a?(Hash) ? entry[:reason] : nil
326
+ end
327
+
328
+ # True when this mutant is suppressed: its line bears a disable-line marker
329
+ # (bare, or scoped to its operator), OR its new or old-format id is in the
330
+ # config ignore list. Checked at job-build time so a suppressed mutant is
331
+ # never forked.
332
+ #
333
+ # @param ids [Array<String>, String] the mutant's new id and its old-format
334
+ # id, or a single id (the pre-#126 call shape).
335
+ def self.suppressed?(operator, line, ids, disabled, ignore_set)
336
+ return true if Array(ids).any? { |id| ignore_set.include?(id) }
337
+
338
+ case line_ops(disabled[line])
339
+ when :all then true
340
+ when Set then line_ops(disabled[line]).include?(operator)
341
+ else false
342
+ end
343
+ end
344
+
345
+ # Why an ignored mutant was suppressed. An inline reason wins. Otherwise
346
+ # the reason is the first matching ignore id that has one. Blank text is nil.
347
+ #
348
+ # @param operator [Symbol] the mutant's operator.
349
+ # @param line [Integer] the mutant's line.
350
+ # @param ids [Array<String>] the new id and the old-format id.
351
+ # @param disabled [Hash] the file's suppress map.
352
+ # @param ignore_reasons [Hash{String => String}] id => reason.
353
+ # @return [String, nil]
354
+ def self.suppression_reason(operator, line, ids, disabled, ignore_reasons)
355
+ entry = disabled[line]
356
+ ops = line_ops(entry)
357
+ line_hit = ops == :all || (ops.is_a?(Set) && ops.include?(operator))
358
+ inline = line_hit ? line_reason(entry) : nil
359
+ return inline if inline.is_a?(String) && !inline.strip.empty?
360
+
361
+ Array(ids).each do |candidate|
362
+ text = ignore_reasons[candidate]
363
+ return text if text.is_a?(String) && !text.strip.empty?
364
+ end
365
+ nil
366
+ end
367
+
368
+ # Narrows the jobs and the suppressed (ignored) results to --since when it
369
+ # is set, as the dry run does, so a scoped report lists only mutants on
370
+ # changed lines. Also returns how many mutants there were before narrowing
371
+ # (nil without --since), so the CLI can tell "the changes held nothing to
372
+ # test" from "nothing was mutable at all".
373
+ #
374
+ # @param jobs [Array] (subject, mutation, id) entries to run.
375
+ # @param ignored_results [Array<Mutineer::Result>] suppressed mutants.
376
+ # @param source_map [Hash{String => String}] source text by file.
377
+ # @param config [Mutineer::Config] run configuration.
378
+ # @return [Array(Array, Array<Mutineer::Result>, Integer), Array(Array, Array<Mutineer::Result>, nil)]
379
+ # the jobs to run, the ignored results to report, and the count before --since.
380
+ def self.scope_since(jobs, ignored_results, source_map, config)
381
+ return [jobs, ignored_results, nil] unless config.since
382
+
383
+ # One pass, so git computes the changed lines once.
384
+ ignored_entries = ignored_results.map { |r| [r.subject, r.mutation, r] }
385
+ kept = filter_since(jobs + ignored_entries, source_map, config)
386
+ kept_ignored, kept_jobs = kept.partition { |entry| entry.last.is_a?(Result) }
387
+ [kept_jobs, kept_ignored.map(&:last), jobs.size + ignored_results.size]
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)
403
+ end
404
+ end
405
+
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
+ # The unique absolute directories holding the sources. Sweep target for both
428
+ # orphan mechanisms (in-process mutant tempfiles and external backup files),
429
+ # and shipped to the daemon via {DaemonBackend.boot_config} so it can sweep too.
430
+ # Shared so the path-expansion rule cannot drift between the paths.
431
+ #
432
+ # @param config [Mutineer::Config] run configuration.
433
+ # @return [Array<String>] unique absolute source directories.
434
+ def self.source_dirs(config)
435
+ config.sources.map { |f| File.dirname(File.expand_path(f, config.project_root)) }.uniq
436
+ end
437
+
438
+ # Removes stale mutant tempfiles from the given directories. The daemon writes a
439
+ # differently-named temp, so {DaemonBackend} passes its glob when it sweeps
440
+ # tool-side at the end of every daemon run, empty or not.
441
+ # A daemon file another process still owns is left in place. In-process mutant
442
+ # files have no owner recorded in the name, so this still removes those orphans.
443
+ #
444
+ # @param dirs [Array<String>] directories to sweep.
445
+ # @param glob [String] filename pattern to remove.
446
+ # @return [Array<String>] the files left in place because a process owns them.
447
+ def self.sweep_orphans(dirs, glob = "{.,}mutineer_mutant*.rb")
448
+ dirs.flat_map do |dir|
449
+ Dir.glob(File.join(dir, glob)).select do |path|
450
+ next true if OrphanGuard.mutant_file_in_use?(path)
451
+
452
+ File.unlink(path) rescue nil # rubocop:disable Style/RescueModifier
453
+ false
454
+ end
455
+ end
456
+ end
457
+ end
458
+ end