mutineer 1.3.0 → 1.5.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.
Files changed (44) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +317 -0
  3. data/README.md +200 -28
  4. data/lib/mutineer/changed_lines.rb +57 -2
  5. data/lib/mutineer/cli.rb +142 -92
  6. data/lib/mutineer/config.rb +215 -56
  7. data/lib/mutineer/coverage_map.rb +161 -14
  8. data/lib/mutineer/daemon_backend.rb +3 -0
  9. data/lib/mutineer/daemon_client.rb +170 -13
  10. data/lib/mutineer/daemon_server.rb +2 -1
  11. data/lib/mutineer/external_backend.rb +2 -2
  12. data/lib/mutineer/isolation.rb +91 -6
  13. data/lib/mutineer/kill_channel.rb +284 -0
  14. data/lib/mutineer/kill_matrix.rb +88 -0
  15. data/lib/mutineer/minitest_integration/kill_recorder.rb +301 -0
  16. data/lib/mutineer/minitest_integration/outer_reporter.rb +126 -0
  17. data/lib/mutineer/minitest_integration/stop_at_first_failure.rb +14 -68
  18. data/lib/mutineer/minitest_integration.rb +27 -10
  19. data/lib/mutineer/mutant_id.rb +5 -4
  20. data/lib/mutineer/mutator_registry.rb +12 -3
  21. data/lib/mutineer/mutators/array_literal.rb +0 -7
  22. data/lib/mutineer/mutators/base.rb +63 -4
  23. data/lib/mutineer/mutators/chain_link.rb +0 -7
  24. data/lib/mutineer/mutators/collection_method.rb +1 -1
  25. data/lib/mutineer/mutators/condition_forcing.rb +134 -0
  26. data/lib/mutineer/mutators/operand_removal.rb +0 -7
  27. data/lib/mutineer/mutators/operator_assignment.rb +113 -0
  28. data/lib/mutineer/mutators/return_nil.rb +5 -11
  29. data/lib/mutineer/mutators/string_literal.rb +1 -1
  30. data/lib/mutineer/pairing.rb +222 -15
  31. data/lib/mutineer/parser.rb +12 -3
  32. data/lib/mutineer/project.rb +7 -0
  33. data/lib/mutineer/rails_worker_db.rb +1 -1
  34. data/lib/mutineer/reporter.rb +288 -52
  35. data/lib/mutineer/result.rb +37 -1
  36. data/lib/mutineer/runner.rb +133 -16
  37. data/lib/mutineer/statement_lines.rb +143 -0
  38. data/lib/mutineer/test_runners/minitest.rb +4 -2
  39. data/lib/mutineer/test_runners/rspec.rb +243 -3
  40. data/lib/mutineer/test_runners.rb +5 -3
  41. data/lib/mutineer/version.rb +1 -1
  42. data/lib/mutineer/worker_pool.rb +4 -2
  43. data/lib/mutineer.rb +3 -0
  44. metadata +10 -3
@@ -8,18 +8,68 @@ module Mutineer
8
8
  # never kill the host process. The CLI rescues this and maps it to exit 2.
9
9
  class ConfigError < StandardError; end
10
10
 
11
+ # One row of the option schema: how a Config field is parsed and where the
12
+ # user can set it. `yaml_key` is nil for CLI-only options; `flag` is nil for
13
+ # YAML-only ones. `values`/`aliases` apply to the :enum type.
14
+ #
15
+ # @api private
16
+ ConfigOption = Struct.new(:field, :type, :yaml_key, :flag, :values, :aliases, keyword_init: true)
17
+
18
+ # Deprecated internal strategy names, mapped to their canonical equivalents.
19
+ STRATEGY_ALIASES = { "7a" => "reload", "7b" => "redefine" }.freeze
20
+
21
+ # The option schema. Config.parse is the only place that turns a raw value
22
+ # (a CLI string or a YAML scalar) into a typed one, so a value is checked once,
23
+ # at the boundary, whichever layer wrote it. Row order fixes the order of
24
+ # KNOWN_KEYS in warnings.
25
+ #
26
+ # @api private
27
+ CONFIG_OPTIONS = [
28
+ ConfigOption.new(field: :operators, type: :string_list, yaml_key: "operators", flag: "--operators"),
29
+ ConfigOption.new(field: :jobs, type: :positive_int, yaml_key: "jobs", flag: "--jobs"),
30
+ ConfigOption.new(field: :threshold, type: :percent, yaml_key: "threshold", flag: "--threshold"),
31
+ ConfigOption.new(field: :only, type: :string, yaml_key: "only", flag: "--only"),
32
+ ConfigOption.new(field: :require_paths, type: :string_list, yaml_key: "require"),
33
+ ConfigOption.new(field: :boot, type: :string, yaml_key: "boot", flag: "--boot"),
34
+ ConfigOption.new(field: :rails, type: :bool, yaml_key: "rails", flag: "--rails"),
35
+ ConfigOption.new(field: :since, type: :since, yaml_key: "since", flag: "--since"),
36
+ ConfigOption.new(field: :framework, type: :enum, yaml_key: "framework", flag: "--framework",
37
+ values: %w[minitest rspec]),
38
+ ConfigOption.new(field: :verbose, type: :bool, yaml_key: "verbose", flag: "--verbose"),
39
+ ConfigOption.new(field: :ignore, type: :string_list, yaml_key: "ignore"),
40
+ ConfigOption.new(field: :baseline, type: :string, yaml_key: "baseline", flag: "--baseline"),
41
+ ConfigOption.new(field: :fail_fast, type: :bool, yaml_key: "fail_fast", flag: "--fail-fast"),
42
+ ConfigOption.new(field: :matrix, type: :bool, yaml_key: "matrix", flag: "--matrix"),
43
+ ConfigOption.new(field: :test_command, type: :string, yaml_key: "test_command", flag: "--test-command"),
44
+ ConfigOption.new(field: :daemon, type: :bool, yaml_key: "daemon", flag: "--daemon"),
45
+ ConfigOption.new(field: :timeout, type: :positive_int, yaml_key: "timeout", flag: "--timeout"),
46
+ ConfigOption.new(field: :capture_timeout, type: :positive_int, yaml_key: "capture_timeout",
47
+ flag: "--capture-timeout"),
48
+ ConfigOption.new(field: :cache_dir, type: :string, yaml_key: "cache_dir", flag: "--cache-dir"),
49
+ ConfigOption.new(field: :format, type: :enum, flag: "--format", values: %w[human json html]),
50
+ ConfigOption.new(field: :strategy, type: :enum, flag: "--strategy", values: %w[reload redefine],
51
+ aliases: STRATEGY_ALIASES),
52
+ ConfigOption.new(field: :output, type: :string, flag: "--output"),
53
+ ConfigOption.new(field: :baseline_epsilon, type: :nonneg_float, flag: "--baseline-epsilon"),
54
+ ConfigOption.new(field: :dry_run, type: :bool, flag: "--dry-run")
55
+ ].freeze
56
+
11
57
  # Plain run configuration, populated by the CLI (or directly by the
12
58
  # integration test). `operators` nil means "all default operators";
13
59
  # `threshold` 0.0 means the CI gate is off (spec §10).
14
60
  #
15
61
  # Also holds: jobs (parallel workers), format (human|json), output (report
16
62
  # file), strategy (reload|redefine), require_paths (extra files to load).
17
- # Config loading and the CLI > file > default precedence merge live here.
63
+ # Config loading and the CLI > file > default precedence merge live here; each
64
+ # layer holds only the keys the user wrote, and Config#explicit? reports them.
65
+ #
66
+ # `matrix` (--matrix) runs every covering test for each mutant and reports
67
+ # which tests kill it (see KillMatrix); it never changes a verdict.
18
68
  #
19
69
  # Boot mode adds: boot (a file to require ONCE in the parent so the app env,
20
70
  # e.g. Rails, is booted before forking; sources are then NOT manually required)
21
- # and rails (sugar: defaults boot to config/environment and strategy to redefine,
22
- # and reconnects ActiveRecord per fork).
71
+ # and rails (sugar: defaults boot to config/environment, prefers redefine without
72
+ # a daemon, and reconnects ActiveRecord per fork).
23
73
  Config = Struct.new(
24
74
  :sources, :tests, :operators, :threshold, :only, :dry_run,
25
75
  :cache_dir, :project_root, :load_paths,
@@ -28,16 +78,23 @@ module Mutineer
28
78
  # :daemon is user-facing (--daemon flag + KNOWN_KEYS + boolean coerce).
29
79
  # :daemon_timeout stays programmatic (set by tests/Runner; no flag yet).
30
80
  :baseline, :baseline_epsilon, :fail_fast, :test_command,
31
- :daemon, :daemon_timeout,
81
+ :daemon, :daemon_timeout, :timeout, :capture_timeout, :matrix,
32
82
  keyword_init: true
33
83
  ) do
34
84
  # Config file name.
35
85
  CONFIG_FILE = ".mutineer.yml"
36
- # Keys accepted in .mutineer.yml. `require` maps to the :require_paths field.
37
- KNOWN_KEYS = %w[operators jobs threshold only require boot rails since framework verbose ignore baseline fail_fast test_command daemon].freeze
86
+ # Keys accepted in .mutineer.yml, derived from the schema. `require` maps to the
87
+ # :require_paths field.
88
+ KNOWN_KEYS = CONFIG_OPTIONS.filter_map(&:yaml_key).freeze
38
89
 
39
- def initialize(**kwargs)
40
- super
90
+ # @param explicit [Array<Symbol>] fields the user wrote (CLI or file). Derived
91
+ # values fill only the others; a programmatic Config.new writes none.
92
+ # @param from_file [Array<Symbol>] the explicit fields whose value came from
93
+ # the config file (the command line did not override them).
94
+ def initialize(explicit: [], from_file: [], **kwargs)
95
+ super(**kwargs)
96
+ @explicit = explicit.to_a.dup.freeze
97
+ @from_file = from_file.to_a.dup.freeze
41
98
  self.sources ||= []
42
99
  self.tests ||= []
43
100
  self.threshold ||= 0.0
@@ -55,6 +112,29 @@ module Mutineer
55
112
  self.baseline_epsilon ||= 0.0
56
113
  self.fail_fast = false if fail_fast.nil?
57
114
  self.daemon = false if daemon.nil?
115
+ self.matrix = false if matrix.nil?
116
+ end
117
+
118
+ # True when the user wrote `key`, on the command line or in the config
119
+ # file, whatever the value (`false` and `nil` count). The answer comes from
120
+ # which keys the layers held, so a new option needs no bookkeeping to be
121
+ # covered.
122
+ #
123
+ # @param key [Symbol] Config field name.
124
+ # @return [Boolean]
125
+ def explicit?(key)
126
+ @explicit.include?(key)
127
+ end
128
+
129
+ # Where the user set `key`, for messages: the config-file key (as
130
+ # `name in .mutineer.yml`) when its value came from the file, else the
131
+ # command-line flag.
132
+ #
133
+ # @param key [Symbol] Config field name (a row of the option schema).
134
+ # @return [String] e.g. `"--fail-fast"` or `"fail_fast in .mutineer.yml"`.
135
+ def origin(key)
136
+ opt = CONFIG_OPTIONS.find { |o| o.field == key } or raise ArgumentError, "unknown option #{key.inspect}"
137
+ @from_file.include?(key) && opt.yaml_key ? "#{opt.yaml_key} in #{CONFIG_FILE}" : (opt.flag || opt.yaml_key)
58
138
  end
59
139
 
60
140
  # Walk from `start` toward `home`, returning the first .mutineer.yml path found
@@ -77,10 +157,18 @@ module Mutineer
77
157
  end
78
158
 
79
159
  # Parse a .mutineer.yml into a symbol-keyed hash of recognized keys. Unknown
80
- # keys / unknown operator names emit a one-line stderr warning and are ignored.
81
- # A YAML syntax error raises ConfigError: never a silent fallback to defaults,
82
- # and never an exit from the lib layer.
83
- def self.from_file(path)
160
+ # keys emit a one-line stderr warning and are ignored. Unknown operator names
161
+ # warn and are dropped. If that leaves no names, the file is an error: an
162
+ # empty operator list would run nothing and exit 0. Pass
163
+ # +defer_operators: true+ only when the command line replaces that list, so
164
+ # a blank or all-unknown file list does not block +--operators+. A YAML
165
+ # syntax error raises ConfigError: never a silent fallback to defaults, and
166
+ # never an exit from the lib layer.
167
+ #
168
+ # @param path [String] config file path.
169
+ # @param defer_operators [Boolean] keep an empty operator list for a CLI override.
170
+ # @return [Hash{Symbol => Object}]
171
+ def self.from_file(path, defer_operators: false)
84
172
  raw = YAML.safe_load(File.read(path)) || {}
85
173
  name = File.basename(path)
86
174
  unless raw.is_a?(Hash)
@@ -96,28 +184,39 @@ module Mutineer
96
184
  "(known: #{KNOWN_KEYS.join(', ')}); ignored"
97
185
  next
98
186
  end
99
- out[field_for(ks)] = coerce(ks, value, name)
187
+ field = field_for(ks)
188
+ parsed = parse(field, value, file: name, defer_operators: defer_operators)
189
+ if field == :operators
190
+ parsed = filter_operators(parsed, name)
191
+ if parsed.empty? && !defer_operators
192
+ raise ConfigError, "#{name}: operators must name at least one known operator"
193
+ end
194
+ end
195
+ out[field] = parsed
100
196
  end
101
197
  out
102
198
  rescue Psych::SyntaxError => e
103
199
  raise ConfigError, "#{File.basename(path)} parse error: #{e.message}"
104
200
  end
105
201
 
106
- # Apply precedence: start from the CLI-provided values, then fill in a
107
- # config-file value only for keys the user did NOT type on the command line.
108
- # `explicit` is a Set of field symbols the CLI saw with a value; a Set (not
109
- # nil-sentinels) is used because some valid values are zero/false.
110
- def self.resolve(cli_opts, file_hash, explicit)
111
- merged = cli_opts.dup
112
- file_hash.each { |k, v| merged[k] = v unless explicit.include?(k) }
113
- config = new(**merged)
202
+ # Merges the two user layers, CLI over file, then derives what neither wrote.
203
+ # A layer is a Hash of only the keys the user set, so precedence is `merge`
204
+ # and "did the user write this" is whether the key exists. Nothing tracks
205
+ # provenance by hand, and `false`/`nil` are ordinary values.
206
+ #
207
+ # @param cli_opts [Hash{Symbol => Object}] parsed command-line fields.
208
+ # @param file_hash [Hash{Symbol => Object}] parsed .mutineer.yml fields.
209
+ # @return [Mutineer::Config]
210
+ def self.resolve(cli_opts, file_hash)
211
+ user = file_hash.merge(cli_opts)
212
+ config = new(**user, explicit: user.keys, from_file: file_hash.keys - cli_opts.keys)
114
213
 
115
214
  # --rails sugar: boot config/environment. Prefer redefine only for the
116
215
  # in-process path (daemon is whole-file reload only). In-process --rails
117
216
  # shares one test database, so force serial unless --daemon.
118
217
  if config.rails
119
218
  config.boot ||= "config/environment"
120
- unless config.daemon || explicit.include?(:strategy)
219
+ unless config.daemon || config.explicit?(:strategy)
121
220
  config.strategy = "redefine"
122
221
  end
123
222
  unless config.daemon
@@ -129,9 +228,9 @@ module Mutineer
129
228
  end
130
229
  end
131
230
 
132
- # Auto-detect the framework only when neither CLI nor config file set it
133
- # (explicit value, from either source, is already on config.framework and
134
- # always wins). Default minitest unless the test files clearly look RSpec.
231
+ # Auto-detect the framework only when the user wrote none: a value from
232
+ # either layer is already on config.framework and always wins. Default
233
+ # minitest unless the test files clearly look RSpec.
135
234
  config.framework ||= detect_framework(config.tests)
136
235
  config
137
236
  end
@@ -155,42 +254,102 @@ module Mutineer
155
254
  known_key == "require" ? :require_paths : known_key.to_sym
156
255
  end
157
256
 
158
- # Coerces a config value to its target type.
257
+ # Parses one raw value into the typed value for `field`. A value that does
258
+ # not fit its type raises ConfigError naming where it came from, so the CLI
259
+ # and .mutineer.yml report the same mistake in the same way. `nil` and
260
+ # `false` are valid results for some fields (`since: false` means "no scoping").
159
261
  #
160
- # @param known_key [String] config key.
161
- # @param value [Object] raw YAML value.
162
- # @param file_name [String] config file name for warnings.
163
- # @return [Object] coerced value.
164
- def self.coerce(known_key, value, file_name)
165
- case known_key
166
- when "operators" then filter_operators(Array(value).map(&:to_s), file_name)
167
- when "jobs" then value.to_i
168
- when "threshold"
169
- f = Float(value, exception: false)
170
- if f.nil?
171
- raise ConfigError, "#{file_name}: threshold must be a number between 0 and 100 " \
172
- "(got: #{value.inspect})"
173
- end
262
+ # @param field [Symbol] Config field name (a row of the option schema).
263
+ # @param value [Object] raw CLI string or YAML value.
264
+ # @param file [String, nil] config file name when the value came from it;
265
+ # nil when it came from the command line.
266
+ # @param defer_operators [Boolean] when true, a blank operator list is
267
+ # returned instead of raising, so +--operators+ can replace it. The flag
268
+ # itself still rejects a blank list.
269
+ # @return [Object] the typed value.
270
+ # @raise [Mutineer::ConfigError] when the value does not fit the field's type.
271
+ def self.parse(field, value, file: nil, defer_operators: false)
272
+ opt = CONFIG_OPTIONS.find { |o| o.field == field } or raise ArgumentError, "unknown option #{field.inspect}"
273
+ origin = file ? "#{file}: #{opt.yaml_key}" : opt.flag
274
+ got = "(got: #{value.inspect})"
275
+ case opt.type
276
+ when :positive_int
277
+ n = value.is_a?(Integer) ? value : (value.to_i if value.is_a?(String) && value.match?(/\A\d+\z/))
278
+ raise ConfigError, "#{origin} must be a positive integer, digits only #{got}" if n.nil? || n < 1
279
+
280
+ n
281
+ when :percent
282
+ f = finite_float(value)
283
+ raise ConfigError, "#{origin} must be a number between 0 and 100 #{got}" unless f && (0.0..100.0).cover?(f)
284
+
174
285
  f
175
- when "require" then Array(value).map(&:to_s)
176
- when "boot" then value.to_s
177
- when "framework" then value.to_s
178
- when "rails" then value == true || value.to_s == "true"
179
- when "daemon" then value == true || value.to_s == "true"
180
- when "verbose" then value == true || value.to_s == "true"
181
- when "fail_fast" then value == true || value.to_s == "true"
182
- when "ignore" then Array(value).map(&:to_s)
183
- when "baseline" then value.to_s
184
- when "test_command" then value.to_s
185
- when "since"
186
- # false / empty normalize to nil ("no scoping"), so every consumer's
187
- # nil-check (runner scoping, the report's scoped marker) agrees. A
188
- # false left raw would skip scoping but still mark the report scoped.
189
- value == false || value.to_s.empty? ? nil : value.to_s
190
- else value
286
+ when :nonneg_float
287
+ f = finite_float(value)
288
+ raise ConfigError, "#{origin} must be a finite number, 0 or greater #{got}" unless f && f >= 0.0
289
+
290
+ f
291
+ when :bool
292
+ return value if [true, false].include?(value)
293
+ return value == "true" if %w[true false].include?(value)
294
+
295
+ raise ConfigError, "#{origin} must be true or false #{got}"
296
+ when :enum
297
+ name = opt.aliases&.fetch(value, nil) || value
298
+ return name if opt.values.include?(name)
299
+
300
+ prefix = file ? "#{file}: " : ""
301
+ raise ConfigError, "#{prefix}unknown #{field} #{value.to_s.inspect}. Expected: #{opt.values.join(', ')}"
302
+ when :string_list
303
+ items = Array(value).map(&:to_s)
304
+ # Only `operators` treats [] as "run these" rather than "use the
305
+ # default". A blank key then makes no mutants and exits 0. An empty
306
+ # `require` or `ignore` matches the default, so those stay valid.
307
+ if field == :operators && !defer_operators && ([nil, true, false].include?(value) || items.empty? || items.all? { |item| item.strip.empty? })
308
+ raise ConfigError, "#{origin} must name at least one operator, not blank #{got}"
309
+ end
310
+
311
+ items
312
+ when :string
313
+ # A key written with no value (`baseline:`) parses as nil. Keeping nil
314
+ # would switch the feature off without a word; main failed here, so the
315
+ # typo stays loud. An absent key never reaches parse, so it stays unset.
316
+ # `only: false` must not become the subject name "false" either: it
317
+ # matches nothing, so the run has no mutants and still exits 0.
318
+ raise ConfigError, "#{origin} must be a string #{got}" if [nil, true, false].include?(value)
319
+ # A blank cache directory (an unset CI variable) would put coverage.json
320
+ # in the project root, so it is an error, not the default.
321
+ raise ConfigError, "#{origin} must be a directory, not blank #{got}" if field == :cache_dir && value.to_s.strip.empty?
322
+
323
+ value.to_s
324
+ when :since
325
+ # `false` is the one way to say "no scoping" in the file. It becomes nil
326
+ # so every consumer's nil-check (runner scoping, the report's scoped
327
+ # marker) agrees; a false left raw would skip scoping but still mark
328
+ # the report scoped. A blank value is an error, not "no scoping": a
329
+ # `since: "$REF"` whose variable is unset must not turn scoping off.
330
+ return nil if value == false
331
+ raise ConfigError, "#{origin} must be a git ref, not blank #{got}" if value.to_s.strip.empty?
332
+
333
+ value.to_s
191
334
  end
192
335
  end
193
336
 
337
+ # Reads a finite Float from a number or a plain decimal string. Rejects booleans,
338
+ # NaN and Infinity: `Float(true)` is an error, but a YAML `.nan` is a Float.
339
+ # A string must be digits with an optional fraction, the same digits-only rule
340
+ # as `jobs`: `Float()` alone would also read `0x10`, `1_0`, `+2` and `1e2`.
341
+ #
342
+ # @api private
343
+ # @param value [Object] raw value.
344
+ # @return [Float, nil] the number, or nil when it is not a finite number.
345
+ def self.finite_float(value)
346
+ f = case value
347
+ when Integer, Float then value.to_f
348
+ when String then Float(value) if value.match?(/\A\d+(\.\d+)?\z/)
349
+ end
350
+ f if f&.finite?
351
+ end
352
+
194
353
  # Drop (with a warning) operator names the registry does not know.
195
354
  # Referenced lazily so config.rb carries no load-order dependency on the
196
355
  # registry; by the time a config is parsed at runtime, it is loaded.
@@ -10,6 +10,7 @@ require_relative "minitest_integration"
10
10
  require_relative "test_runners"
11
11
  require_relative "child_stdout"
12
12
  require_relative "project_path"
13
+ require_relative "pairing"
13
14
 
14
15
  module Mutineer
15
16
  # Maps `(source_file, line) -> [test_files]` so each mutant runs only against
@@ -98,8 +99,11 @@ module Mutineer
98
99
  # rather than a genuine coverage gap? True iff some capture failed this run
99
100
  # AND this file got zero coverage from any successful capture AND a failed
100
101
  # test file maps to it by the _test/_spec/test_ naming convention. Derived
101
- # purely from already-persisted state (@map keys + @failed_test_files); no
102
- # rerun, no new cached field, no digest change.
102
+ # from already-persisted state (@map keys + @failed_test_files). A split
103
+ # name checks the longer source files that existed when that check first
104
+ # ran for this map. Adding or deleting one of those files changes the
105
+ # coverage digest, so the next run captures again instead of retargeting
106
+ # a stored failure.
103
107
  #
104
108
  # File-level, convention-based attribution. A line covered only by a failed
105
109
  # test in an otherwise-covered file stays no_coverage (condition 2), and a
@@ -112,7 +116,7 @@ module Mutineer
112
116
  rel = relativize(absolute(file))
113
117
  return false if covered_source_files.include?(rel)
114
118
 
115
- failed_test_targets.include?(File.basename(rel, ".rb"))
119
+ failed_test_blames?(rel)
116
120
  end
117
121
 
118
122
  # Per-method taint. A mutant on a line whose enclosing method got zero
@@ -131,7 +135,7 @@ module Mutineer
131
135
  return false if @failed_test_files.empty?
132
136
 
133
137
  rel = relativize(absolute(file))
134
- return false unless failed_test_targets.include?(File.basename(rel, ".rb"))
138
+ return false unless failed_test_blames?(rel)
135
139
 
136
140
  line_range.none? { |ln| @map.key?("#{rel}:#{ln}") }
137
141
  end
@@ -143,17 +147,119 @@ module Mutineer
143
147
  @map.keys.map { |k| k.rpartition(":").first }.to_set
144
148
  end
145
149
 
146
- # Basenames of the sources that failed test files pair with by convention:
147
- # a trailing _test/_spec is stripped first, as pairing tries that form first.
148
- def failed_test_targets
149
- @failed_test_files.map do |t|
150
- name = File.basename(t, ".rb")
151
- case name
152
- when /_(test|spec)\z/ then name.sub(/_(test|spec)\z/, "")
153
- when "test_helper" then name # Minitest's support file pairs with no source
154
- else name.delete_prefix("test_")
150
+ # True when a failed test file pairs with `source_rel` by convention.
151
+ # Exact `_test` / `_spec` / `test_` names match by basename, as before.
152
+ # A split `<name>_*_test.rb` matches only in the mirrored test directory,
153
+ # and only when no longer source file owns that name (#87). The answer
154
+ # is kept for this map, so a later delete does not move the failure.
155
+ #
156
+ # @param source_rel [String] project-relative source path.
157
+ # @return [Boolean]
158
+ def failed_test_blames?(source_rel)
159
+ @blame_for ||= {}
160
+ return @blame_for[source_rel] if @blame_for.key?(source_rel)
161
+
162
+ @blame_for[source_rel] = exact_failed_test?(source_rel) || split_failed_test?(source_rel)
163
+ end
164
+
165
+ # True when a failed exact `_test` / `_spec` / `test_` file pairs with
166
+ # this source. An `app/` or `lib/` source only accepts a `test/` or
167
+ # `spec/` file from its mirrored directory, so
168
+ # `test/foo/bar_upsert_test.rb` does not taint `app/other/bar_upsert.rb`.
169
+ # Any other source still matches by basename. A fixture under
170
+ # `test/fixtures/` and an explicit test both name their source that way.
171
+ #
172
+ # @param source_rel [String] project-relative source path.
173
+ # @return [Boolean]
174
+ def exact_failed_test?(source_rel)
175
+ name = File.basename(source_rel, ".rb")
176
+ base, lib = Pairing.logical_path(source_rel)
177
+ @failed_test_files.any? do |test_path|
178
+ next false unless failed_test_target_name(test_path) == name
179
+
180
+ rel = relativize(test_path)
181
+ dir = File.dirname(rel)
182
+ if app_or_lib_source?(source_rel) && conventional_test_dir?(dir)
183
+ mirrored_test_dir?(rel, base, lib) || spec_mirror?(rel, base, lib)
184
+ else
185
+ true
155
186
  end
156
- end.to_set
187
+ end
188
+ end
189
+
190
+ # True when pairing gives this source a mirrored test directory.
191
+ #
192
+ # @param source_rel [String] project-relative source path.
193
+ # @return [Boolean]
194
+ def app_or_lib_source?(source_rel)
195
+ source_rel.start_with?("app/", "lib/")
196
+ end
197
+
198
+ # Basename a failed test file would pair with, before the directory check.
199
+ #
200
+ # @param test_path [String]
201
+ # @return [String]
202
+ def failed_test_target_name(test_path)
203
+ name = File.basename(test_path, ".rb")
204
+ case name
205
+ when /_(test|spec)\z/ then name.sub(/_(test|spec)\z/, "")
206
+ when "test_helper" then name
207
+ else name.delete_prefix("test_")
208
+ end
209
+ end
210
+
211
+ # True when `dir` is `test`, `spec`, or a directory under one of them.
212
+ #
213
+ # @param dir [String] project-relative directory.
214
+ # @return [Boolean]
215
+ def conventional_test_dir?(dir)
216
+ dir == "test" || dir == "spec" || dir.start_with?("test/", "spec/")
217
+ end
218
+
219
+ # True when a failed split test file pairs with this source. Directory and
220
+ # the longer-source check match {Pairing}, so a test outside the mirror, or
221
+ # one owned by `user_session.rb`, does not taint `user.rb`.
222
+ #
223
+ # @param source_rel [String] project-relative source path.
224
+ # @return [Boolean]
225
+ def split_failed_test?(source_rel)
226
+ base, lib = Pairing.logical_path(source_rel)
227
+ name = File.basename(base)
228
+ @failed_test_files.any? do |t|
229
+ test_rel = relativize(t)
230
+ entry = File.basename(test_rel)
231
+ next false unless Pairing.split_entry?(entry, name)
232
+ next false unless mirrored_test_dir?(test_rel, base, lib)
233
+ next false if Pairing.claimed_by_longer_source?(@project_root, base, entry, File.dirname(test_rel))
234
+
235
+ true
236
+ end
237
+ end
238
+
239
+ # True when `test_rel` sits in a mirrored test directory for `base`.
240
+ #
241
+ # @param test_rel [String] project-relative test path.
242
+ # @param base [String] logical source path without extension.
243
+ # @param lib [Boolean] whether the source originated from lib/.
244
+ # @return [Boolean]
245
+ def mirrored_test_dir?(test_rel, base, lib)
246
+ dirs = [Pairing.mirror_dir("test", base)]
247
+ dirs << Pairing.mirror_dir("test/lib", base) if lib
248
+ dirs.include?(File.dirname(test_rel))
249
+ end
250
+
251
+ # True when `test_rel` sits in a mirrored spec directory for `base`.
252
+ # Split Minitest names stay in {#mirrored_test_dir?}. An exact `_spec`
253
+ # file uses this directory.
254
+ #
255
+ # @param test_rel [String] project-relative test path.
256
+ # @param base [String] logical source path without extension.
257
+ # @param lib [Boolean] whether the source originated from lib/.
258
+ # @return [Boolean]
259
+ def spec_mirror?(test_rel, base, lib)
260
+ dirs = [Pairing.mirror_dir("spec", base)]
261
+ dirs << Pairing.mirror_dir("spec/lib", base) if lib
262
+ dirs.include?(File.dirname(test_rel))
157
263
  end
158
264
 
159
265
  # Shared cache dance for both build paths: hit the digest-keyed cache, else
@@ -809,11 +915,52 @@ module Mutineer
809
915
  digest_group(d, "source", @source_paths)
810
916
  digest_group(d, "test", @test_paths)
811
917
  digest_group(d, "boot", [boot_digest_path]) if @boot_path
918
+ ownership_paths.each do |rel|
919
+ d.update("owner\0")
920
+ d.update(rel)
921
+ d.update("\0")
922
+ end
812
923
  @load_paths.sort.each { |lp| d.update("loadpath\0#{lp}\0") }
813
924
  d.update("framework\0#{@framework}\0")
814
925
  d.hexdigest
815
926
  end
816
927
 
928
+ # Longer source files that can take a split test from a configured source.
929
+ # Only their presence is digested. A content edit of a sibling does not
930
+ # change pairing, and it does not rebuild coverage. Each directory is
931
+ # listed once, even when many sources share it.
932
+ #
933
+ # @return [Array<String>] project-relative paths, sorted.
934
+ def ownership_paths
935
+ root = File.expand_path(@project_root)
936
+ listings = {}
937
+ paths = @source_paths.flat_map do |source|
938
+ rel = relativize(absolute(source))
939
+ next [] if rel.start_with?("/")
940
+
941
+ base, = Pairing.logical_path(rel)
942
+ name = File.basename(base)
943
+ dir = File.dirname(base)
944
+ folders = [dir == "." ? nil : dir]
945
+ %w[app lib].each { |prefix| folders << (dir == "." ? prefix : File.join(prefix, dir)) }
946
+ folders.uniq.flat_map do |folder|
947
+ abs = folder ? File.join(root, folder) : root
948
+ entries = listings.fetch(abs) do
949
+ listings[abs] = File.directory?(abs) ? Dir.children(abs) : []
950
+ end
951
+ entries.filter_map do |entry|
952
+ next unless entry.end_with?(".rb")
953
+
954
+ stem = entry.delete_suffix(".rb")
955
+ next unless stem.start_with?("#{name}_") && stem.length > name.length
956
+
957
+ folder ? File.join(folder, entry) : entry
958
+ end
959
+ end
960
+ end
961
+ paths.uniq.sort
962
+ end
963
+
817
964
  # boot_path is a require-style path (e.g. "config/environment", no extension);
818
965
  # resolve it to the real file for reading, appending ".rb" when needed.
819
966
  def boot_digest_path
@@ -279,6 +279,7 @@ module Mutineer
279
279
  project_root: config.project_root,
280
280
  boot: File.expand_path(config.boot || "config/environment", config.project_root),
281
281
  load_paths: Runner.test_load_roots(abs_tests),
282
+ cache_dir: File.expand_path(config.cache_dir, config.project_root),
282
283
  source_dirs: Runner.source_dirs(config), # so the daemon can sweep orphan mutant temps
283
284
  framework: config.framework,
284
285
  rails: config.rails,
@@ -290,6 +291,8 @@ module Mutineer
290
291
  # instrumentation/memory across every mutant fork). `sources`/`tests` are the
291
292
  # map-build inputs.
292
293
  coverage: coverage,
294
+ # The map-building daemon's capture limit (--capture-timeout); nil = default.
295
+ capture_timeout: config.capture_timeout,
293
296
  sources: config.sources.map { |s| File.expand_path(s, config.project_root) },
294
297
  tests: abs_tests
295
298
  }