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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +248 -0
- data/README.md +324 -23
- data/lib/mutineer/child_coverage.rb +53 -0
- data/lib/mutineer/cli.rb +182 -23
- data/lib/mutineer/concern_blocks.rb +80 -0
- data/lib/mutineer/config.rb +148 -8
- data/lib/mutineer/coverage_map.rb +175 -12
- data/lib/mutineer/daemon_backend.rb +182 -56
- data/lib/mutineer/daemon_client.rb +30 -0
- data/lib/mutineer/daemon_server.rb +157 -25
- data/lib/mutineer/external_backend.rb +12 -1
- data/lib/mutineer/init.rb +124 -0
- data/lib/mutineer/isolation.rb +14 -2
- data/lib/mutineer/job_plan.rb +129 -23
- data/lib/mutineer/migrate.rb +705 -0
- data/lib/mutineer/mutator_registry.rb +6 -2
- data/lib/mutineer/mutators/base.rb +14 -7
- data/lib/mutineer/mutators/call_to_receiver.rb +135 -0
- data/lib/mutineer/mutators/chain_link.rb +1 -1
- data/lib/mutineer/orphan_guard.rb +72 -0
- data/lib/mutineer/project.rb +80 -6
- data/lib/mutineer/rails_run_env.rb +73 -0
- data/lib/mutineer/rails_worker_db.rb +1408 -53
- data/lib/mutineer/redaction.rb +98 -0
- data/lib/mutineer/reporter.rb +66 -12
- data/lib/mutineer/result.rb +22 -13
- data/lib/mutineer/runner.rb +24 -12
- data/lib/mutineer/statement_lines.rb +15 -8
- data/lib/mutineer/subject.rb +22 -4
- data/lib/mutineer/test_runners/rspec.rb +15 -3
- data/lib/mutineer/test_runners.rb +3 -2
- data/lib/mutineer/triage.rb +111 -0
- data/lib/mutineer/version.rb +1 -1
- data/lib/mutineer.rb +4 -0
- metadata +9 -1
data/lib/mutineer/job_plan.rb
CHANGED
|
@@ -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
|
-
|
|
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})
|
|
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>]
|
|
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
|
-
[
|
|
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
|
|
246
|
-
#
|
|
247
|
-
# sits on the same physical line as the code it silences).
|
|
248
|
-
#
|
|
249
|
-
#
|
|
250
|
-
#
|
|
251
|
-
#
|
|
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
|
-
|
|
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 (
|
|
338
|
+
case line_ops(disabled[line])
|
|
283
339
|
when :all then true
|
|
284
|
-
when Set then
|
|
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
|
|
339
|
-
# tool-side
|
|
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 [
|
|
344
|
-
def self.sweep_orphans(dirs, glob = "mutineer_mutant*.rb")
|
|
345
|
-
dirs.
|
|
346
|
-
Dir.glob(File.join(dir, glob)).
|
|
347
|
-
|
|
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
|