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.
@@ -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
@@ -10,10 +10,14 @@ require_relative "minitest_integration"
10
10
  require_relative "test_runners"
11
11
  require_relative "child_stdout"
12
12
  require_relative "orphan_guard"
13
+ require_relative "redaction"
13
14
  require_relative "project_path"
14
15
  require_relative "pairing"
15
16
 
16
17
  module Mutineer
18
+ # A `--coverage-from` capture that cannot be read, or does not fit this run.
19
+ class CoverageFromError < StandardError; end
20
+
17
21
  # Maps `(source_file, line) -> [test_files]` so each mutant runs only against
18
22
  # the tests that actually exercise its line. Built once, then queried per
19
23
  # mutant via #tests_for. Persisted to .mutineer/coverage.json with a
@@ -90,11 +94,12 @@ module Mutineer
90
94
  def initialize(source_paths:, test_paths:, cache_dir: ".mutineer",
91
95
  load_paths: ["lib"], project_root: Dir.pwd,
92
96
  capture_timeout: DEFAULT_CAPTURE_TIMEOUT, boot_path: nil,
93
- framework: "minitest", verbose: false, require_paths: [])
97
+ framework: "minitest", verbose: false, require_paths: [], coverage_from: nil)
94
98
  @source_paths = Array(source_paths)
95
99
  @require_paths = Array(require_paths)
96
100
  @test_paths = Array(test_paths)
97
101
  @cache_dir = cache_dir
102
+ @coverage_from = coverage_from
98
103
  @load_paths = Array(load_paths)
99
104
  @project_root = project_root
100
105
  @capture_timeout = capture_timeout
@@ -367,13 +372,20 @@ module Mutineer
367
372
  # @return [Mutineer::CoverageMap] self.
368
373
  def cached_or(after_fork: nil)
369
374
  @digest = compute_digest
375
+ return reuse_capture(after_fork) if @coverage_from
376
+
370
377
  cached = read_cache
371
378
  # A cache from before load methods (#209) or test timings (#203) were
372
- # saved rebuilds once. Boot mode reads its load lines and methods from
373
- # the live boot instead (#build_via_fork), but its map from before #209
374
- # lacks the `def` lines of the methods each test called.
379
+ # saved rebuilds once. A cache that does not record this process's
380
+ # PARALLEL_WORKERS value rebuilds too. Rails reads that value inside
381
+ # parallelize, so a map from another worker count selects the wrong
382
+ # tests. The daemon writes this same file. Boot mode reads its load
383
+ # lines and methods from the live boot instead (#build_via_fork), but
384
+ # its map from before #209 lacks the `def` lines of the methods each
385
+ # test called.
375
386
  if cached && cached["digest"] == @digest && dependencies_match?(cached) &&
376
- cached.key?("load_methods") && cached.key?("timings")
387
+ cached.key?("load_methods") && cached.key?("timings") &&
388
+ parallel_workers_match?(cached)
377
389
  @map = cached["map"] || {}
378
390
  @timings = cached["timings"] || {}
379
391
  unless @boot_path
@@ -397,6 +409,137 @@ module Mutineer
397
409
  self
398
410
  end
399
411
 
412
+ # Takes the map from the `--coverage-from` capture instead of capturing,
413
+ # keeping only this run's sources, then runs the tests clean. A mutant runs
414
+ # only the tests that cover its line, often one file alone, so each test
415
+ # that covers one of this run's sources runs alone, as on a cache hit: a
416
+ # test that fails alone here would otherwise kill every mutant it runs. The
417
+ # other tests never run alone in this run, so only the combined check
418
+ # covers them.
419
+ #
420
+ # @api private
421
+ # @param after_fork [Proc, nil] boot-mode fork hook.
422
+ # @return [Mutineer::CoverageMap] self.
423
+ # @raise [Mutineer::CoverageFromError] when the capture does not fit.
424
+ def reuse_capture(after_fork)
425
+ cached = accepted_capture
426
+ sources = source_fingerprints.keys.to_set
427
+ @map = cached["map"].select { |key, _| sources.include?(key_source(key)) }
428
+ @timings = cached["timings"] || {}
429
+ @failed_test_files = []
430
+ @failed_clean_tests = []
431
+ @loaded_dependencies = cached["dependencies"]
432
+ covering = @map.values.flatten.to_set
433
+ verify_cached_clean(after_fork: after_fork,
434
+ test_paths: @test_paths.select { |t| covering.include?(relativize(t)) })
435
+ verify_combined_clean(after_fork: after_fork)
436
+ save
437
+ self
438
+ end
439
+
440
+ # The parsed `--coverage-from` capture, once it fits this run.
441
+ #
442
+ # @api private
443
+ # @return [Hash] the parsed coverage.json.
444
+ # @raise [Mutineer::CoverageFromError] when it cannot be read or does not fit.
445
+ def accepted_capture
446
+ if File.expand_path(@coverage_from) == File.expand_path(cache_path)
447
+ raise CoverageFromError, "--coverage-from #{@coverage_from} is this run's own cache; give the run another --cache-dir"
448
+ end
449
+
450
+ cached = JSON.parse(File.read(@coverage_from))
451
+ reason = capture_mismatch(cached)
452
+ raise CoverageFromError, "--coverage-from #{@coverage_from} cannot be reused: #{reason}" if reason
453
+
454
+ cached
455
+ rescue SystemCallError, JSON::ParserError => e
456
+ raise CoverageFromError, "--coverage-from #{@coverage_from} cannot be read: #{e.message}"
457
+ end
458
+
459
+ # Why a capture does not fit this run, or nil when it does.
460
+ #
461
+ # @api private
462
+ # @param cached [Object] the parsed coverage.json.
463
+ # @return [String, nil]
464
+ def capture_mismatch(cached)
465
+ return "it is not a coverage cache" unless cached.is_a?(Hash) && cached["map"].is_a?(Hash)
466
+ unless cached["sources"] && cached["owners"] && cached["inputs"]
467
+ return "it has no source fingerprints; capture it again with this version and --rails/--boot"
468
+ end
469
+ return "its entries are not the shape a coverage cache saves; capture it again" unless well_formed_capture?(cached)
470
+ return "its tests, --require files, boot file, load paths or framework differ" unless cached["inputs"] == compute_digest(inputs_only: true)
471
+
472
+ fingerprints = source_fingerprints
473
+ missing = fingerprints.keys - cached["sources"].keys
474
+ return "it was not captured over #{missing.join(', ')}" if missing.any?
475
+
476
+ changed = fingerprints.reject { |rel, fingerprint| cached["sources"][rel] == fingerprint }.keys
477
+ return "#{changed.join(', ')} changed since the capture" if changed.any?
478
+
479
+ owners = ownership_paths - cached["owners"]
480
+ return "#{owners.join(', ')} appeared since the capture and can own a split test" if owners.any?
481
+
482
+ # The capture can list owners of sources outside this run, so compare
483
+ # with the disk, not with this run's owners.
484
+ gone = cached["owners"].reject { |rel| File.file?(absolute(rel)) }
485
+ return "#{gone.join(', ')} disappeared since the capture and could own a split test" if gone.any?
486
+
487
+ return "a file its tests loaded changed since the capture" unless dependencies_match?(cached)
488
+ unless parallel_workers_match?(cached)
489
+ return "it was captured with PARALLEL_WORKERS=#{cached['parallel_workers'].inspect}, " \
490
+ "this run has #{ENV.fetch('PARALLEL_WORKERS', nil).inspect}"
491
+ end
492
+
493
+ failed = Array(cached["failed_test_files"])
494
+ return "capture failed for #{failed.join(', ')}" if failed.any?
495
+
496
+ moved = load_mismatch(cached, fingerprints.keys.to_set)
497
+ return nil if moved.empty?
498
+
499
+ "lines of #{moved.join(', ')} ran at load in only one of the capture and this run: loading the capture's " \
500
+ "other sources ran them. Under --strategy redefine, eager-load in both (config.eager_load = true)"
501
+ end
502
+
503
+ # True when every entry the reuse reads has the type {#save} writes, so a
504
+ # hand-edited or foreign file is rejected with a reason, not a backtrace.
505
+ #
506
+ # @api private
507
+ # @param cached [Hash] the parsed coverage.json.
508
+ # @return [Boolean]
509
+ def well_formed_capture?(cached)
510
+ strings = ->(value) { value.is_a?(Array) && value.all?(String) }
511
+ cached["inputs"].is_a?(String) && strings.(cached["owners"]) &&
512
+ cached["sources"].is_a?(Hash) && cached["sources"].all? { |k, v| k.is_a?(String) && v.is_a?(String) } &&
513
+ cached["map"].all? { |k, v| k.is_a?(String) && strings.(v) } &&
514
+ %w[load_lines load_methods].all? { |key| cached[key].nil? || strings.(cached[key]) } &&
515
+ (cached["timings"].nil? || cached["timings"].is_a?(Hash))
516
+ end
517
+
518
+ # This run's sources whose load lines or load methods differ in the capture.
519
+ #
520
+ # @api private
521
+ # @param cached [Hash] the parsed coverage.json.
522
+ # @param sources [Set<String>] project-relative source paths.
523
+ # @return [Array<String>] the sources that differ, sorted.
524
+ def load_mismatch(cached, sources)
525
+ owned = ->(keys) { Array(keys).select { |key| sources.include?(key_source(key)) }.to_set }
526
+ moved = (owned.(cached["load_lines"]) ^ owned.(@load_lines)) + (owned.(cached["load_methods"]) ^ owned.(@load_methods))
527
+ moved.map { |key| key_source(key) }.uniq.sort
528
+ end
529
+
530
+ # Fingerprint of each source, by project-relative path.
531
+ #
532
+ # @return [Hash{String => String}]
533
+ def source_fingerprints
534
+ @source_paths.to_h { |p| [relativize(absolute(p)), file_fingerprint(absolute(p))] }
535
+ end
536
+
537
+ # The source path of a "file:line" or "file:line:column" key.
538
+ #
539
+ # @param key [String] a map, load line or load method key.
540
+ # @return [String]
541
+ def key_source(key) = key.sub(/(?::\d+)+\z/, "")
542
+
400
543
  # Runs standalone coverage capture.
401
544
  #
402
545
  # @api private
@@ -578,7 +721,8 @@ module Mutineer
578
721
  def fail_test(test_path, reason)
579
722
  rel = relativize(test_path)
580
723
  @failed_test_files << rel
581
- warn "[mutineer] coverage skipped for #{rel}: #{reason}"
724
+ # The reason can quote an app error, and so a credentialed DATABASE_URL.
725
+ warn "[mutineer] coverage skipped for #{rel}: #{Redaction.text(reason)}"
582
726
  nil
583
727
  end
584
728
 
@@ -623,9 +767,10 @@ module Mutineer
623
767
  #
624
768
  # @api private
625
769
  # @param after_fork [Proc, nil] boot-mode fork hook.
770
+ # @param test_paths [Array<String>] the tests to run, each alone.
626
771
  # @return [void]
627
- def verify_cached_clean(after_fork: nil)
628
- @test_paths.each do |test_path|
772
+ def verify_cached_clean(after_fork: nil, test_paths: @test_paths)
773
+ test_paths.each do |test_path|
629
774
  rel = relativize(test_path)
630
775
  next if @failed_test_files.include?(rel)
631
776
 
@@ -1178,18 +1323,32 @@ module Mutineer
1178
1323
  end
1179
1324
  end
1180
1325
 
1326
+ # True when `cached` records the same +PARALLEL_WORKERS+ value this process
1327
+ # has now. Rails reads that variable inside +parallelize+ and can run one
1328
+ # test file in several workers. A missing key is a map saved before this
1329
+ # value was stored. In-process runs and the daemon write this same file.
1330
+ #
1331
+ # @api private
1332
+ # @param cached [Hash] parsed coverage.json.
1333
+ # @return [Boolean]
1334
+ def parallel_workers_match?(cached)
1335
+ cached.key?("parallel_workers") && cached["parallel_workers"] == ENV.fetch("PARALLEL_WORKERS", nil)
1336
+ end
1337
+
1181
1338
  # Digest each file's ROLE + relative path + content length + content, plus
1182
1339
  # the load_paths. Without role/path/length delimiters the digest collides
1183
1340
  # (("ab","c") == ("a","bc")) and is blind to source/test role swaps, silently
1184
1341
  # accepting a stale cached map.
1185
- def compute_digest
1342
+ #
1343
+ # @param inputs_only [Boolean] leave out the sources and their owners.
1344
+ def compute_digest(inputs_only: false)
1186
1345
  d = Digest::SHA256.new
1187
- digest_group(d, "source", @source_paths)
1346
+ digest_group(d, "source", @source_paths) unless inputs_only
1188
1347
  digest_group(d, "test", @test_paths)
1189
1348
  # One group per file: digest_group sorts, and the load order matters.
1190
1349
  @require_paths.each { |p| digest_group(d, "require", [digest_path(p)]) }
1191
1350
  digest_group(d, "boot", [digest_path(@boot_path)]) if @boot_path
1192
- ownership_paths.each do |rel|
1351
+ (inputs_only ? [] : ownership_paths).each do |rel|
1193
1352
  d.update("owner\0")
1194
1353
  d.update(rel)
1195
1354
  d.update("\0")
@@ -1305,7 +1464,11 @@ module Mutineer
1305
1464
  data = { "digest" => @digest, "failed_test_files" => @failed_test_files,
1306
1465
  "dependencies" => @loaded_dependencies, "map" => @map,
1307
1466
  "load_lines" => @load_lines.to_a.sort, "load_methods" => @load_methods.to_a.sort,
1308
- "timings" => @timings }
1467
+ "timings" => @timings, "parallel_workers" => ENV.fetch("PARALLEL_WORKERS", nil) }
1468
+ if @boot_path
1469
+ data.merge!("inputs" => compute_digest(inputs_only: true), "sources" => source_fingerprints,
1470
+ "owners" => ownership_paths)
1471
+ end
1309
1472
  tmp = "#{cache_path}.tmp"
1310
1473
  File.write(tmp, JSON.generate(data))
1311
1474
  File.rename(tmp, cache_path) # atomic swap