mutineer 1.6.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.
@@ -11,6 +11,7 @@ require_relative "mutator_registry"
11
11
  require_relative "mutant_id"
12
12
  require_relative "project_path"
13
13
  require_relative "external_backend"
14
+ require_relative "orphan_guard"
14
15
 
15
16
  module Mutineer
16
17
  # The job vocabulary every backend shares: which mutants run, which are
@@ -55,6 +56,7 @@ module Mutineer
55
56
  # its ordinal instead of minting a second id for the same mutant.
56
57
  name_decls = Hash.new { |h, k| h[k] = {} }
57
58
  ignore_set = config.ignore.to_set
59
+ ignore_reasons = config.ignore_reasons || {}
58
60
  jobs = []
59
61
  ignored_results = []
60
62
  legacy_ignore_matches = {}
@@ -87,7 +89,8 @@ module Mutineer
87
89
  (legacy_ignore_matches[legacy] ||= []) << { id: id, file: id_path, subject: subject.qualified_name }
88
90
  end
89
91
  if ignored
90
- ignored_results << Result.ignored.with(subject: subject, mutation: mutation, id: id)
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)
91
94
  else
92
95
  jobs << [subject, mutation, id]
93
96
  end
@@ -149,8 +152,11 @@ module Mutineer
149
152
  # order of {CoverageMap#order_tests} (paired files, then cheapest), or
150
153
  # `[:verdict, Result]` (no_coverage / uncapturable) when none do. A line
151
154
  # Ruby does not count (a later line of a multi-line statement) uses the
152
- # tests that ran the statement that holds it ({StatementLines}), unless the
153
- # mutant sits in code of that statement that runs only sometimes.
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).
154
160
  #
155
161
  # An empty selection is `:ran_at_load` when the mutant's line ran while the
156
162
  # app booted or its class loaded ({ran_at_load?}): no test ran it, but the
@@ -167,13 +173,19 @@ module Mutineer
167
173
  # @param subject [Mutineer::Subject, nil] the subject (for its method body range).
168
174
  # @param source [String] the original source text.
169
175
  # @param coverage_map [Mutineer::CoverageMap] the built/loaded coverage map.
170
- # @return [Array(Symbol, Object)] `[:run, Array<String>]` or `[:verdict, Result]`.
176
+ # @return [Array(Symbol, Object)] `[:run, Array<String>]`, `[:maybe, Array<String>]`
177
+ # or `[:verdict, Result]`.
171
178
  def self.coverage_selection(source_file, mutation, subject, source, coverage_map)
172
179
  line = source.byteslice(0, mutation.start_offset).count("\n") + 1
173
180
  chosen = coverage_map.tests_for(source_file, line)
181
+ kind = :run
174
182
  if chosen.empty? && subject
175
183
  # A multi-line statement has a count on one of its lines only.
176
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
177
189
  chosen = lines.flat_map { |l| coverage_map.tests_for(source_file, l) }.uniq
178
190
  end
179
191
  if chosen.empty?
@@ -187,7 +199,25 @@ module Mutineer
187
199
  return [:verdict, Result.no_coverage]
188
200
  end
189
201
 
190
- [:run, coverage_map.order_tests(source_file, chosen).map { |t| File.expand_path(t, coverage_map.project_root) }]
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)
191
221
  end
192
222
 
193
223
  # True when the mutant's line ran while the app booted or its class loaded,
@@ -242,13 +272,19 @@ module Mutineer
242
272
  result.with(status: :ran_at_load)
243
273
  end
244
274
 
245
- # Map each line number to :all or a set of operator symbols, using
246
- # inline `# mutineer:disable-line [ops]` markers (RuboCop semantics: the marker
247
- # sits on the same physical line as the code it silences). A bare marker
248
- # disables every operator on that line; `disable-line a, b` only the listed
249
- # operators. Block-form disable/enable ranges are intentionally not supported.
250
- # Only a real `#` comment counts: Prism lists the comments, so the marker
251
- # text inside a string, heredoc or regex silences nothing (#158).
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: }`.
252
288
  def self.suppress_map(source, file)
253
289
  map = {}
254
290
  Parser.comments(source).grep(Prism::InlineComment).each do |comment|
@@ -261,14 +297,34 @@ module Mutineer
261
297
  ops = nil if ops&.empty?
262
298
  unknown = ops.to_a.reject { |o| MutatorRegistry::ALL.key?(o) }
263
299
  unknown.each do |o|
264
- warn "mutineer: unknown operator #{o.inspect} in #{file}:#{line} " \
300
+ warn "mutineer: unknown operator #{o.inspect} in #{file}:#{line}#{Mutineer.did_you_mean(o, MutatorRegistry::ALL.keys)} " \
265
301
  "(known: #{MutatorRegistry::ALL.keys.join(', ')}); write a reason after --"
266
302
  end
267
- map[line] = ops ? ops.map(&:to_sym).to_set : :all
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 }
268
306
  end
269
307
  map
270
308
  end
271
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
+
272
328
  # True when this mutant is suppressed: its line bears a disable-line marker
273
329
  # (bare, or scoped to its operator), OR its new or old-format id is in the
274
330
  # config ignore list. Checked at job-build time so a suppressed mutant is
@@ -279,13 +335,58 @@ module Mutineer
279
335
  def self.suppressed?(operator, line, ids, disabled, ignore_set)
280
336
  return true if Array(ids).any? { |id| ignore_set.include?(id) }
281
337
 
282
- case (entry = disabled[line])
338
+ case line_ops(disabled[line])
283
339
  when :all then true
284
- when Set then entry.include?(operator)
340
+ when Set then line_ops(disabled[line]).include?(operator)
285
341
  else false
286
342
  end
287
343
  end
288
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
+
289
390
  # --since: keep only jobs whose mutation lands on a line changed since the git
290
391
  # ref. Composes with coverage selection (it only narrows the job list; each
291
392
  # surviving mutant still goes through Runner.run's coverage check). A file with
@@ -335,16 +436,21 @@ module Mutineer
335
436
  end
336
437
 
337
438
  # Removes stale mutant tempfiles from the given directories. The daemon writes a
338
- # differently-named temp, so {DaemonBackend} passes its glob when it has to sweep
339
- # tool-side (nothing boots on an empty run, so the daemon's own sweep never runs).
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.
340
443
  #
341
444
  # @param dirs [Array<String>] directories to sweep.
342
445
  # @param glob [String] filename pattern to remove.
343
- # @return [void]
344
- def self.sweep_orphans(dirs, glob = "mutineer_mutant*.rb")
345
- dirs.each do |dir|
346
- Dir.glob(File.join(dir, glob)).each do |f|
347
- File.unlink(f) rescue nil # rubocop:disable Style/RescueModifier
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
348
454
  end
349
455
  end
350
456
  end