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.
@@ -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: :string_list, yaml_key: "ignore"),
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(', ')}); ignored"
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
- if config.jobs.to_i > 1
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(', ')}); ignored"
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