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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +473 -0
- data/README.md +352 -26
- data/lib/mutineer/child_coverage.rb +53 -0
- data/lib/mutineer/cli.rb +184 -24
- data/lib/mutineer/concern_blocks.rb +80 -0
- data/lib/mutineer/config.rb +148 -8
- data/lib/mutineer/coverage_map.rb +541 -92
- data/lib/mutineer/daemon_backend.rb +227 -86
- data/lib/mutineer/daemon_client.rb +72 -9
- data/lib/mutineer/daemon_server.rb +229 -51
- data/lib/mutineer/external_backend.rb +12 -1
- data/lib/mutineer/file_swap.rb +1 -1
- data/lib/mutineer/init.rb +124 -0
- data/lib/mutineer/isolation.rb +15 -2
- data/lib/mutineer/job_plan.rb +458 -0
- data/lib/mutineer/migrate.rb +705 -0
- data/lib/mutineer/minitest_integration.rb +48 -1
- 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 +100 -0
- data/lib/mutineer/project.rb +411 -21
- data/lib/mutineer/rails_run_env.rb +73 -0
- data/lib/mutineer/rails_worker_db.rb +1477 -48
- data/lib/mutineer/redaction.rb +98 -0
- data/lib/mutineer/reporter.rb +83 -12
- data/lib/mutineer/result.rb +49 -8
- data/lib/mutineer/runner.rb +66 -290
- data/lib/mutineer/statement_lines.rb +54 -7
- data/lib/mutineer/subject.rb +24 -2
- data/lib/mutineer/test_runners/rspec.rb +54 -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 +5 -0
- metadata +11 -1
data/lib/mutineer/config.rb
CHANGED
|
@@ -1,9 +1,25 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "did_you_mean"
|
|
3
4
|
require "etc"
|
|
4
5
|
require "yaml"
|
|
5
6
|
|
|
6
7
|
module Mutineer
|
|
8
|
+
# The phrase every 1.x warning uses for behaviour that 2.0 turns into an
|
|
9
|
+
# error, so the 2.0 release can find each of those sites with one search.
|
|
10
|
+
BECOMES_ERROR_IN_2_0 = "this becomes an error in Mutineer 2.0"
|
|
11
|
+
|
|
12
|
+
# A "did you mean" hint for a misspelled name, using the stdlib spell
|
|
13
|
+
# checker that Ruby itself uses for NameError.
|
|
14
|
+
#
|
|
15
|
+
# @param word [String] the name the user wrote.
|
|
16
|
+
# @param candidates [Array<String>] the names that are valid here.
|
|
17
|
+
# @return [String] ` (did you mean "x"?)` for the closest candidate, or "" when none is close.
|
|
18
|
+
def self.did_you_mean(word, candidates)
|
|
19
|
+
match = DidYouMean::SpellChecker.new(dictionary: candidates).correct(word.to_s).first
|
|
20
|
+
match ? " (did you mean #{match.inspect}?)" : ""
|
|
21
|
+
end
|
|
22
|
+
|
|
7
23
|
# Raised by the config layer instead of calling exit/abort. A data class must
|
|
8
24
|
# never kill the host process. The CLI rescues this and maps it to exit 2.
|
|
9
25
|
class ConfigError < StandardError; end
|
|
@@ -36,7 +52,7 @@ module Mutineer
|
|
|
36
52
|
ConfigOption.new(field: :framework, type: :enum, yaml_key: "framework", flag: "--framework",
|
|
37
53
|
values: %w[minitest rspec]),
|
|
38
54
|
ConfigOption.new(field: :verbose, type: :bool, yaml_key: "verbose", flag: "--verbose"),
|
|
39
|
-
ConfigOption.new(field: :ignore, type: :
|
|
55
|
+
ConfigOption.new(field: :ignore, type: :ignore_list, yaml_key: "ignore"),
|
|
40
56
|
ConfigOption.new(field: :baseline, type: :string, yaml_key: "baseline", flag: "--baseline"),
|
|
41
57
|
ConfigOption.new(field: :fail_fast, type: :bool, yaml_key: "fail_fast", flag: "--fail-fast"),
|
|
42
58
|
ConfigOption.new(field: :matrix, type: :bool, yaml_key: "matrix", flag: "--matrix"),
|
|
@@ -46,6 +62,8 @@ module Mutineer
|
|
|
46
62
|
ConfigOption.new(field: :capture_timeout, type: :positive_int, yaml_key: "capture_timeout",
|
|
47
63
|
flag: "--capture-timeout"),
|
|
48
64
|
ConfigOption.new(field: :cache_dir, type: :string, yaml_key: "cache_dir", flag: "--cache-dir"),
|
|
65
|
+
ConfigOption.new(field: :allow_empty, type: :bool, yaml_key: "allow_empty", flag: "--allow-empty"),
|
|
66
|
+
ConfigOption.new(field: :coverage_from, type: :string, flag: "--coverage-from"),
|
|
49
67
|
ConfigOption.new(field: :format, type: :enum, flag: "--format", values: %w[human json html]),
|
|
50
68
|
ConfigOption.new(field: :strategy, type: :enum, flag: "--strategy", values: %w[reload redefine],
|
|
51
69
|
aliases: STRATEGY_ALIASES),
|
|
@@ -74,11 +92,11 @@ module Mutineer
|
|
|
74
92
|
:sources, :tests, :operators, :threshold, :only, :dry_run,
|
|
75
93
|
:cache_dir, :project_root, :load_paths,
|
|
76
94
|
:jobs, :format, :output, :strategy, :require_paths,
|
|
77
|
-
:boot, :rails, :since, :framework, :verbose, :ignore,
|
|
95
|
+
:boot, :rails, :since, :framework, :verbose, :ignore, :ignore_reasons,
|
|
78
96
|
# :daemon is user-facing (--daemon flag + KNOWN_KEYS + boolean coerce).
|
|
79
97
|
# :daemon_timeout stays programmatic (set by tests/Runner; no flag yet).
|
|
80
98
|
:baseline, :baseline_epsilon, :fail_fast, :test_command,
|
|
81
|
-
:daemon, :daemon_timeout, :timeout, :capture_timeout, :matrix,
|
|
99
|
+
:daemon, :daemon_timeout, :timeout, :capture_timeout, :matrix, :allow_empty, :coverage_from,
|
|
82
100
|
keyword_init: true
|
|
83
101
|
) do
|
|
84
102
|
# Config file name.
|
|
@@ -109,10 +127,12 @@ module Mutineer
|
|
|
109
127
|
self.rails = false if rails.nil?
|
|
110
128
|
self.verbose = false if verbose.nil?
|
|
111
129
|
self.ignore ||= []
|
|
130
|
+
self.ignore_reasons ||= {}
|
|
112
131
|
self.baseline_epsilon ||= 0.0
|
|
113
132
|
self.fail_fast = false if fail_fast.nil?
|
|
114
133
|
self.daemon = false if daemon.nil?
|
|
115
134
|
self.matrix = false if matrix.nil?
|
|
135
|
+
self.allow_empty = false if allow_empty.nil?
|
|
116
136
|
end
|
|
117
137
|
|
|
118
138
|
# True when the user wrote `key`, on the command line or in the config
|
|
@@ -180,12 +200,17 @@ module Mutineer
|
|
|
180
200
|
raw.each do |key, value|
|
|
181
201
|
ks = key.to_s
|
|
182
202
|
unless KNOWN_KEYS.include?(ks)
|
|
183
|
-
warn "mutineer: unknown config key #{ks.inspect} in #{name} " \
|
|
184
|
-
"(known: #{KNOWN_KEYS.join(', ')})
|
|
203
|
+
warn "mutineer: unknown config key #{ks.inspect} in #{name}#{Mutineer.did_you_mean(ks, KNOWN_KEYS)}; " \
|
|
204
|
+
"ignored, and #{BECOMES_ERROR_IN_2_0} (known: #{KNOWN_KEYS.join(', ')})"
|
|
185
205
|
next
|
|
186
206
|
end
|
|
187
207
|
field = field_for(ks)
|
|
188
208
|
parsed = parse(field, value, file: name, defer_operators: defer_operators)
|
|
209
|
+
if field == :ignore
|
|
210
|
+
out[:ignore] = parsed.fetch(:ids)
|
|
211
|
+
out[:ignore_reasons] = parsed.fetch(:reasons)
|
|
212
|
+
next
|
|
213
|
+
end
|
|
189
214
|
if field == :operators
|
|
190
215
|
parsed = filter_operators(parsed, name)
|
|
191
216
|
if parsed.empty? && !defer_operators
|
|
@@ -220,7 +245,9 @@ module Mutineer
|
|
|
220
245
|
config.strategy = "redefine"
|
|
221
246
|
end
|
|
222
247
|
unless config.daemon
|
|
223
|
-
|
|
248
|
+
# A typed --no-daemon already chose the serial run; only an explicit
|
|
249
|
+
# --jobs N still needs telling that --daemon is the way to get N.
|
|
250
|
+
if config.jobs.to_i > 1 && (!config.explicit?(:daemon) || config.explicit?(:jobs))
|
|
224
251
|
warn "[mutineer] --rails without --daemon runs serially (shared test DB); " \
|
|
225
252
|
"forcing --jobs 1. Use --daemon for safe --jobs N."
|
|
226
253
|
end
|
|
@@ -235,6 +262,24 @@ module Mutineer
|
|
|
235
262
|
config
|
|
236
263
|
end
|
|
237
264
|
|
|
265
|
+
# Mutineer 2.0 makes the daemon the default under --rails for Minitest, so a
|
|
266
|
+
# run that would change says so now. The CLI calls this after test pairing,
|
|
267
|
+
# which can still switch the framework to rspec. The runs that 2.0 keeps on this path
|
|
268
|
+
# stay quiet: RSpec, --test-command, --matrix and --fail-fast cannot use the
|
|
269
|
+
# daemon, a --dry-run runs no tests, and a written `daemon` value (true or
|
|
270
|
+
# false) is the user's choice.
|
|
271
|
+
#
|
|
272
|
+
# @api private
|
|
273
|
+
# @param config [Mutineer::Config] the resolved config.
|
|
274
|
+
# @return [void]
|
|
275
|
+
def self.warn_rails_default_change(config)
|
|
276
|
+
return unless config.rails && !config.explicit?(:daemon) && config.framework == "minitest"
|
|
277
|
+
return if config.test_command || config.matrix || config.fail_fast || config.dry_run
|
|
278
|
+
|
|
279
|
+
warn "[mutineer] in Mutineer 2.0, --rails uses --daemon by default: parallel workers (unless " \
|
|
280
|
+
"--jobs 1) and the reload strategy. Pass --no-daemon (or daemon: false) to keep this run as it is."
|
|
281
|
+
end
|
|
282
|
+
|
|
238
283
|
# Pick rspec when a MAJORITY of the given test files end with _spec.rb;
|
|
239
284
|
# otherwise minitest. Empty/ambiguous -> minitest (the safe default).
|
|
240
285
|
#
|
|
@@ -299,6 +344,8 @@ module Mutineer
|
|
|
299
344
|
|
|
300
345
|
prefix = file ? "#{file}: " : ""
|
|
301
346
|
raise ConfigError, "#{prefix}unknown #{field} #{value.to_s.inspect}. Expected: #{opt.values.join(', ')}"
|
|
347
|
+
when :ignore_list
|
|
348
|
+
parse_ignore_list(value, file)
|
|
302
349
|
when :string_list
|
|
303
350
|
items = Array(value).map(&:to_s)
|
|
304
351
|
# Only `operators` treats [] as "run these" rather than "use the
|
|
@@ -319,6 +366,7 @@ module Mutineer
|
|
|
319
366
|
# A blank cache directory (an unset CI variable) would put coverage.json
|
|
320
367
|
# in the project root, so it is an error, not the default.
|
|
321
368
|
raise ConfigError, "#{origin} must be a directory, not blank #{got}" if field == :cache_dir && value.to_s.strip.empty?
|
|
369
|
+
raise ConfigError, "#{origin} must be a file, not blank #{got}" if field == :coverage_from && value.to_s.strip.empty?
|
|
322
370
|
|
|
323
371
|
value.to_s
|
|
324
372
|
when :since
|
|
@@ -350,6 +398,98 @@ module Mutineer
|
|
|
350
398
|
f if f&.finite?
|
|
351
399
|
end
|
|
352
400
|
|
|
401
|
+
# Reads an `ignore:` value into ids plus a reason for each mapping.
|
|
402
|
+
# A bare string or integer is an id with no reason. YAML reads an unquoted
|
|
403
|
+
# all-digit id as an integer, and the id text is those decimal digits.
|
|
404
|
+
# A mapping may hold `id` and `reason` only. A mapping with no id, or with
|
|
405
|
+
# any other key, is dropped and warned: 2.0 will reject it. Callers that
|
|
406
|
+
# match ids keep the id list.
|
|
407
|
+
#
|
|
408
|
+
# @api private
|
|
409
|
+
# @param value [Object] the raw YAML value.
|
|
410
|
+
# @param file [String, nil] config file name.
|
|
411
|
+
# @return [Hash] `:ids` is an array of id strings. `:reasons` maps an id to its text.
|
|
412
|
+
def self.parse_ignore_list(value, file)
|
|
413
|
+
label = file || CONFIG_FILE
|
|
414
|
+
case value
|
|
415
|
+
when nil then return { ids: [], reasons: {} }
|
|
416
|
+
when String, Integer then return { ids: [ignore_id_text(value)], reasons: {} }
|
|
417
|
+
when Array then items = value
|
|
418
|
+
when Hash then items = [value]
|
|
419
|
+
else
|
|
420
|
+
warn "mutineer: ignore in #{label} must be a list; ignored, and #{BECOMES_ERROR_IN_2_0}"
|
|
421
|
+
return { ids: [], reasons: {} }
|
|
422
|
+
end
|
|
423
|
+
|
|
424
|
+
ids = []
|
|
425
|
+
reasons = {}
|
|
426
|
+
items.each_with_index do |item, index|
|
|
427
|
+
number = index + 1
|
|
428
|
+
id = ignore_id_text(item)
|
|
429
|
+
if id
|
|
430
|
+
ids << id unless id.empty? || ids.include?(id)
|
|
431
|
+
elsif item.is_a?(Hash)
|
|
432
|
+
mapped, reason = ignore_mapping(item, number, label)
|
|
433
|
+
next if mapped.nil?
|
|
434
|
+
|
|
435
|
+
ids << mapped unless ids.include?(mapped)
|
|
436
|
+
reasons[mapped] = reason if reason
|
|
437
|
+
else
|
|
438
|
+
warn "mutineer: ignore entry #{number} in #{label} is not an id or a mapping; " \
|
|
439
|
+
"ignored, and #{BECOMES_ERROR_IN_2_0}"
|
|
440
|
+
end
|
|
441
|
+
end
|
|
442
|
+
{ ids: ids, reasons: reasons }
|
|
443
|
+
end
|
|
444
|
+
|
|
445
|
+
# Text of one ignore id. An integer is the decimal digits YAML kept.
|
|
446
|
+
# A leading zero is already gone by then, so quote an id that needs one.
|
|
447
|
+
# Any other type is not an id.
|
|
448
|
+
#
|
|
449
|
+
# @api private
|
|
450
|
+
# @param value [Object] a list item, a scalar ignore value, or a mapping id.
|
|
451
|
+
# @return [String, nil] the id text, or nil when the value cannot be an id.
|
|
452
|
+
def self.ignore_id_text(value)
|
|
453
|
+
case value
|
|
454
|
+
when String then value
|
|
455
|
+
when Integer then value.to_s
|
|
456
|
+
end
|
|
457
|
+
end
|
|
458
|
+
|
|
459
|
+
# One `ignore:` mapping. Returns the id and reason, or nil when the entry
|
|
460
|
+
# is not usable.
|
|
461
|
+
#
|
|
462
|
+
# @api private
|
|
463
|
+
# @param item [Hash] the mapping.
|
|
464
|
+
# @param number [Integer] 1-based index in the list.
|
|
465
|
+
# @param label [String] config file name.
|
|
466
|
+
# @return [Array(String, String), Array(nil, nil)]
|
|
467
|
+
def self.ignore_mapping(item, number, label)
|
|
468
|
+
unknown = item.keys.map(&:to_s) - %w[id reason]
|
|
469
|
+
unless unknown.empty?
|
|
470
|
+
names = unknown.map(&:inspect).join(", ")
|
|
471
|
+
warn "mutineer: ignore entry #{number} in #{label} has unknown key #{names}; " \
|
|
472
|
+
"ignored, and #{BECOMES_ERROR_IN_2_0}"
|
|
473
|
+
return [nil, nil]
|
|
474
|
+
end
|
|
475
|
+
|
|
476
|
+
id = ignore_id_text(item["id"] || item[:id])
|
|
477
|
+
unless id.is_a?(String) && !id.strip.empty?
|
|
478
|
+
warn "mutineer: ignore entry #{number} in #{label} has no id; ignored, and #{BECOMES_ERROR_IN_2_0}"
|
|
479
|
+
return [nil, nil]
|
|
480
|
+
end
|
|
481
|
+
|
|
482
|
+
reason = item.key?("reason") ? item["reason"] : item[:reason]
|
|
483
|
+
if !reason.nil? && !reason.is_a?(String)
|
|
484
|
+
warn "mutineer: ignore entry #{number} in #{label} reason must be text; " \
|
|
485
|
+
"ignored, and #{BECOMES_ERROR_IN_2_0}"
|
|
486
|
+
return [nil, nil]
|
|
487
|
+
end
|
|
488
|
+
|
|
489
|
+
text = reason.to_s.strip
|
|
490
|
+
[id, text.empty? ? nil : text]
|
|
491
|
+
end
|
|
492
|
+
|
|
353
493
|
# Drop (with a warning) operator names the registry does not know.
|
|
354
494
|
# Referenced lazily so config.rb carries no load-order dependency on the
|
|
355
495
|
# registry; by the time a config is parsed at runtime, it is loaded.
|
|
@@ -363,8 +503,8 @@ module Mutineer
|
|
|
363
503
|
names.select do |n|
|
|
364
504
|
next true if known.include?(n)
|
|
365
505
|
|
|
366
|
-
warn "mutineer: unknown operator #{n.inspect} in #{file_name} " \
|
|
367
|
-
"(known: #{known.join(', ')})
|
|
506
|
+
warn "mutineer: unknown operator #{n.inspect} in #{file_name}#{Mutineer.did_you_mean(n, known)}; " \
|
|
507
|
+
"ignored, and #{BECOMES_ERROR_IN_2_0} (known: #{known.join(', ')})"
|
|
368
508
|
false
|
|
369
509
|
end
|
|
370
510
|
end
|