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
data/lib/mutineer/cli.rb CHANGED
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "optparse"
4
- require "set"
5
4
  require "open3"
6
5
  require_relative "version"
7
6
  require_relative "config"
@@ -11,6 +10,7 @@ require_relative "pairing"
11
10
  require_relative "changed_lines"
12
11
  require_relative "runner"
13
12
  require_relative "reporter"
13
+ require_relative "kill_matrix"
14
14
  require_relative "baseline"
15
15
  require_relative "mutator_registry"
16
16
 
@@ -29,11 +29,12 @@ module Mutineer
29
29
  Usage: mutineer [options] <command> [args]
30
30
 
31
31
  Commands:
32
- run [options] <source...> --test <test...> Mutate, run, and report
32
+ run [options] <source...> --test <test> [--test <test>...]
33
+ Mutate, run, and report
33
34
  run --dry-run [options] <source...> Print candidate mutations only
34
35
 
35
36
  Run options:
36
- --test FILE Test file covering the sources (repeatable)
37
+ --test FILE Test file covering the sources (one per flag; repeat it)
37
38
  --operators LIST Comma-separated operator names (default: Tier 1 set)
38
39
  --threshold FLOAT Fail (exit 1) when score < FLOAT (default: 0 = off)
39
40
  --baseline FILE Fail (exit 1) on NEW survivors / score drop vs a prior
@@ -42,12 +43,22 @@ module Mutineer
42
43
  --only NAME Restrict to one fully-qualified subject
43
44
  --since REF Only mutate lines changed since git REF (e.g. origin/main)
44
45
  --no-since Disable diff scoping (a typed no beats a .mutineer.yml since:)
45
- --jobs N Parallel worker count (default: processor count)
46
+ --jobs N Parallel worker count (default: processor count);
47
+ --test-command, --fail-fast, or --rails
48
+ without --daemon forces 1
46
49
  --strategy NAME reload (whole-file) or redefine (surgical); default: reload
50
+ --timeout SECONDS Per-mutant time limit for in-process runs (default: 10;
51
+ not with --daemon or --test-command); a mutant over it
52
+ is a timeout, excluded from the score
53
+ --capture-timeout SECONDS Time limit for each coverage-capture subprocess
54
+ and the clean check (default: 120; not with --test-command)
55
+ --cache-dir DIR Directory for the coverage cache (default: .mutineer;
56
+ not with --test-command)
47
57
  --framework NAME minitest or rspec (default: auto-detect from --test names)
48
58
  --boot FILE Require FILE once in the parent to boot the app env, then
49
59
  fork per mutant (Rails apps; requires --test)
50
- --rails Sugar for --boot config/environment --strategy redefine
60
+ --rails Boot config/environment; without --daemon, defaults to
61
+ redefine and serial execution
51
62
  --test-command CMD Run the target suite in the app's own runtime as a
52
63
  subprocess (for apps on Ruby < 3.4). CMD must contain
53
64
  %{files}. Scrubs Mutineer Ruby PATH pins; set RAILS_ENV
@@ -58,7 +69,13 @@ module Mutineer
58
69
  --format human|json|html Report format (default: human)
59
70
  --output FILE Write the report to FILE instead of stdout
60
71
  --dry-run List mutations without executing
61
- --fail-fast Stop at the first surviving mutant
72
+ --fail-fast Stop at the first surviving mutant (--no-fail-fast beats a
73
+ .mutineer.yml fail_fast:)
74
+ --matrix Run every covering test for each mutant and report blind and
75
+ redundant tests; the JSON report also lists each mutant's
76
+ killers (in-process only; not with --daemon, --test-command,
77
+ --fail-fast or --dry-run; --no-matrix beats a .mutineer.yml
78
+ matrix:)
62
79
  --verbose Surface the real error when a fork capture fails (alias: --debug)
63
80
 
64
81
  Options:
@@ -67,19 +84,14 @@ module Mutineer
67
84
  --help Print this help and exit
68
85
  USAGE
69
86
 
70
- # Field symbols whose config-file value is suppressed when the flag is typed.
71
- PRECEDENCE_FLAGS = %i[operators jobs threshold only].freeze
72
-
73
- # Deprecated internal strategy names, mapped to their canonical equivalents.
74
- STRATEGY_ALIASES = { "7a" => "reload", "7b" => "redefine" }.freeze
75
-
76
87
  # Parses arguments, executes the command, and exits.
77
88
  #
78
89
  # @param argv [Array<String>] raw command-line arguments.
79
90
  # @return [void]
80
91
  def self.start(argv)
81
- opts = {} # symbol => value, the CLI-provided Config fields
82
- explicit = Set.new # precedence keys the user typed
92
+ # The CLI layer holds only the fields the user typed, so Config.resolve
93
+ # can tell a typed `false`/`nil` from an absent flag by whether the key exists.
94
+ opts = {}
83
95
  show_operators = false
84
96
 
85
97
  parser = OptionParser.new do |o|
@@ -94,47 +106,46 @@ module Mutineer
94
106
  end
95
107
  o.on("--list-operators") { show_operators = true }
96
108
  o.on("--dry-run") { opts[:dry_run] = true }
97
- o.on("--fail-fast") { opts[:fail_fast] = true; explicit << :fail_fast }
98
- o.on("--only NAME") { |v| opts[:only] = v; explicit << :only }
99
- o.on("--since REF") { |v| opts[:since] = v; explicit << :since }
100
- # A typed "no" must beat a .mutineer.yml `since:` key (CLI-over-config
101
- # precedence): marking :since explicit with a nil value blocks the fill.
102
- o.on("--no-since") { opts[:since] = nil; explicit << :since }
109
+ # A typed --no-* beats the .mutineer.yml key, so a file key that
110
+ # conflicts with another flag can be turned off for one run.
111
+ o.on("--[no-]fail-fast") { |on| opts[:fail_fast] = on }
112
+ o.on("--[no-]matrix") { |on| opts[:matrix] = on }
113
+ o.on("--only NAME") { |v| opts[:only] = v }
114
+ o.on("--since REF") { |v| opts[:since] = Config.parse(:since, v) }
115
+ # A typed "no" must beat a .mutineer.yml `since:` key: the key is present
116
+ # with a nil value, and nil is a value.
117
+ o.on("--no-since") { opts[:since] = nil }
103
118
  o.on("--test FILE") { |v| (opts[:tests] ||= []) << v }
104
- o.on("--operators LIST") { |v| opts[:operators] = v.split(",").map(&:strip); explicit << :operators }
105
- o.on("--threshold FLOAT") do |v|
106
- f = Float(v, exception: false)
107
- if f.nil?
108
- warn "mutineer: --threshold requires a number between 0 and 100 (got: #{v.inspect})"
109
- exit 2
110
- end
111
- opts[:threshold] = f
112
- explicit << :threshold
119
+ o.on("--operators LIST") do |v|
120
+ opts[:operators] = Config.parse(:operators, v.split(",").map(&:strip))
113
121
  end
114
- o.on("--jobs N") { |v| opts[:jobs] = v; explicit << :jobs }
115
- o.on("--strategy STRAT") { |v| opts[:strategy] = v; explicit << :strategy }
116
- o.on("--framework NAME") { |v| opts[:framework] = v; explicit << :framework }
117
- o.on("--boot FILE") { |v| opts[:boot] = v; explicit << :boot }
122
+ o.on("--threshold FLOAT") { |v| opts[:threshold] = Config.parse(:threshold, v) }
123
+ o.on("--jobs N") { |v| opts[:jobs] = Config.parse(:jobs, v) }
124
+ o.on("--timeout SECONDS") { |v| opts[:timeout] = Config.parse(:timeout, v) }
125
+ o.on("--capture-timeout SECONDS") { |v| opts[:capture_timeout] = Config.parse(:capture_timeout, v) }
126
+ o.on("--cache-dir DIR") { |v| opts[:cache_dir] = Config.parse(:cache_dir, v) }
127
+ o.on("--strategy STRAT") { |v| opts[:strategy] = Config.parse(:strategy, v) }
128
+ o.on("--framework NAME") { |v| opts[:framework] = Config.parse(:framework, v) }
129
+ o.on("--boot FILE") { |v| opts[:boot] = v }
118
130
  o.on("--rails") { opts[:rails] = true }
119
131
  o.on("--verbose") { opts[:verbose] = true }
120
132
  o.on("--debug") { opts[:verbose] = true } # alias of --verbose
121
- o.on("--format FORMAT") { |v| opts[:format] = v }
133
+ o.on("--format FORMAT") { |v| opts[:format] = Config.parse(:format, v) }
122
134
  o.on("--output FILE") { |v| opts[:output] = v }
123
- # --baseline is also a .mutineer.yml key, so mark it explicit when typed
124
- # (CLI wins over the file). --baseline-epsilon is CLI-only.
125
- o.on("--baseline FILE") { |v| opts[:baseline] = v; explicit << :baseline }
126
- o.on("--baseline-epsilon FLOAT") { |v| opts[:baseline_epsilon] = v.to_f }
135
+ # --baseline-epsilon is CLI-only.
136
+ o.on("--baseline FILE") { |v| opts[:baseline] = v }
137
+ o.on("--baseline-epsilon FLOAT") { |v| opts[:baseline_epsilon] = Config.parse(:baseline_epsilon, v) }
127
138
  # Run the target suite as a subprocess in the app's OWN runtime so
128
139
  # mutineer (Ruby >= 3.4) can mutation-test apps pinned to an older Ruby.
129
- o.on("--test-command CMD") { |v| opts[:test_command] = v; explicit << :test_command }
140
+ o.on("--test-command CMD") { |v| opts[:test_command] = v }
130
141
  # Boot the app ONCE in a persistent daemon and fork per mutant, with
131
142
  # per-worker DB isolation so --jobs N is safe under Rails.
132
- o.on("--daemon") { opts[:daemon] = true; explicit << :daemon }
143
+ o.on("--daemon") { opts[:daemon] = true }
133
144
  end
134
145
 
135
146
  begin
136
147
  parser.parse!(argv)
137
- rescue OptionParser::InvalidOption, OptionParser::MissingArgument => e
148
+ rescue OptionParser::InvalidOption, OptionParser::MissingArgument, Mutineer::ConfigError => e
138
149
  warn "mutineer: #{e.message}"
139
150
  exit 2
140
151
  end
@@ -151,8 +162,8 @@ module Mutineer
151
162
 
152
163
  begin
153
164
  file_path = Config.find_file
154
- file_hash = file_path ? Config.from_file(file_path) : {}
155
- config = Config.resolve(opts, file_hash, explicit)
165
+ file_hash = file_path ? Config.from_file(file_path, defer_operators: opts.key?(:operators)) : {}
166
+ config = Config.resolve(opts, file_hash)
156
167
  rescue Mutineer::ConfigError => e
157
168
  # The lib layer raises instead of killing the host; the CLI maps a
158
169
  # config (usage) error to exit 2.
@@ -163,10 +174,16 @@ module Mutineer
163
174
  case argv.first
164
175
  when "run"
165
176
  warn_config_root_mismatch(file_path, config.project_root) if file_path
166
- # A directory source expands to its **/*.rb files; literal files pass
167
- # through. Test inference (when --test is omitted) happens in validate!.
177
+ tests_as_sources = argv[1..].grep(%r{(\A|/)(test|spec)/.*_(test|spec)\.rb\z})
178
+ if config.explicit?(:tests) && tests_as_sources.any?
179
+ warn "mutineer: #{tests_as_sources.join(', ')} looks like a test file, not a source. " \
180
+ "--test takes one file; repeat it for each test file (--test a_test.rb --test b_test.rb)"
181
+ exit 2
182
+ end
183
+ # A directory source expands to its **/*.rb files; a file inside the
184
+ # project becomes its root-relative path (#104). Test inference (when --test is omitted) happens in validate!.
168
185
  config.sources = Pairing.expand_sources(argv[1..], project_root: config.project_root)
169
- run(config, explicit)
186
+ run(config)
170
187
  else
171
188
  warn "mutineer: unknown command '#{argv.first}'"
172
189
  exit 2
@@ -187,14 +204,13 @@ module Mutineer
187
204
  # Runs the requested command after validation.
188
205
  #
189
206
  # @param config [Mutineer::Config] run configuration.
190
- # @param explicit [Set<Symbol>] explicit CLI fields.
191
207
  # @return [void]
192
- def self.run(config, explicit = Set.new)
208
+ def self.run(config)
193
209
  if config.sources.empty?
194
210
  warn "mutineer: run requires at least one source file"
195
211
  exit 2
196
212
  end
197
- validate!(config, explicit)
213
+ validate!(config)
198
214
 
199
215
  config.dry_run ? dry_run(config) : execute(config)
200
216
  rescue ArgumentError => e
@@ -236,39 +252,10 @@ module Mutineer
236
252
  #
237
253
  # @api private
238
254
  # @param config [Mutineer::Config] run configuration.
239
- # @param explicit [Set<Symbol>] explicit CLI fields.
240
255
  # @return [void]
241
- def self.validate!(config, explicit = Set.new)
242
- unless (0.0..100.0).cover?(config.threshold)
243
- warn "mutineer: --threshold must be between 0 and 100"
244
- exit 2
245
- end
246
-
247
- jobs = Integer(config.jobs.to_s, exception: false)
248
- if jobs.nil? || jobs < 1
249
- warn "mutineer: --jobs requires a positive integer (got: #{config.jobs})"
250
- exit 2
251
- end
252
- config.jobs = jobs
253
-
254
- unless %w[human json html].include?(config.format)
255
- warn %(mutineer: unknown format "#{config.format}". Expected: human, json, html)
256
- exit 2
257
- end
258
-
259
- # Canonical strategies are reload|redefine; 7a/7b are accepted as deprecated
260
- # aliases. Normalize to canonical so the rest of the pipeline sees one name.
261
- config.strategy = STRATEGY_ALIASES.fetch(config.strategy, config.strategy)
262
- unless %w[reload redefine].include?(config.strategy)
263
- warn %(mutineer: unknown strategy "#{config.strategy}". Expected: reload, redefine)
264
- exit 2
265
- end
266
-
267
- unless %w[minitest rspec].include?(config.framework)
268
- warn %(mutineer: unknown framework "#{config.framework}". Expected: minitest, rspec)
269
- exit 2
270
- end
271
-
256
+ def self.validate!(config)
257
+ # First, so a conflict is reported before another check rewrites the config.
258
+ validate_matrix!(config) if config.matrix
272
259
  validate_test_command!(config) if config.test_command
273
260
 
274
261
  validate_since!(config) if config.since
@@ -277,7 +264,7 @@ module Mutineer
277
264
 
278
265
  # When --test is omitted, infer each source's test by convention. Autopair
279
266
  # also re-detects framework from inferred tests when --framework was not set.
280
- autopair!(config, explicit) unless config.dry_run
267
+ autopair!(config) unless config.dry_run
281
268
 
282
269
  # Daemon validation runs AFTER autopair so auto-inferred *_spec.rb tests
283
270
  # cannot bypass the RSpec rejection (framework would still be minitest if we
@@ -322,6 +309,7 @@ module Mutineer
322
309
  "(surgical redefine needs a shared VM; the subprocess has its own)"
323
310
  exit 2
324
311
  end
312
+ warn_unused_settings(config, "--test-command", %i[timeout capture_timeout cache_dir])
325
313
  return unless config.jobs > 1
326
314
 
327
315
  warn "[mutineer] --test-command runs serially (no per-worker DB isolation yet); forcing --jobs 1."
@@ -350,6 +338,7 @@ module Mutineer
350
338
  "(rspec is not implemented on the daemon path yet)"
351
339
  exit 2
352
340
  end
341
+ warn_unused_settings(config, "--daemon", %i[timeout])
353
342
  return if config.strategy == "reload"
354
343
 
355
344
  # --rails defaults strategy to redefine; daemon always whole-file loads.
@@ -358,6 +347,66 @@ module Mutineer
358
347
  config.strategy = "reload"
359
348
  end
360
349
 
350
+ # Where each setting a backend may ignore applies, for the warning.
351
+ UNUSED_SETTING_SCOPE = {
352
+ timeout: "in-process runs only",
353
+ capture_timeout: "in-process and --daemon coverage capture",
354
+ cache_dir: "the in-process and --daemon coverage cache"
355
+ }.freeze
356
+
357
+ # A backend that never reads a setting the user set would silently do
358
+ # nothing with it, so say so. `--test-command` builds no coverage map and
359
+ # has its own runtime, so it reads none of {UNUSED_SETTING_SCOPE}; the
360
+ # daemon reads the capture limit and the cache directory but keeps its own
361
+ # per-mutant limit.
362
+ #
363
+ # @api private
364
+ # @param config [Mutineer::Config] run configuration.
365
+ # @param backend [String] the backend's flag.
366
+ # @param keys [Array<Symbol>] the settings that backend ignores.
367
+ # @return [void]
368
+ def self.warn_unused_settings(config, backend, keys)
369
+ keys.each do |key|
370
+ next unless config.explicit?(key)
371
+
372
+ applies = UNUSED_SETTING_SCOPE.fetch(key)
373
+ warn "[mutineer] #{config.origin(key)} has no effect with #{backend} (it applies to #{applies}); ignoring it."
374
+ end
375
+ end
376
+
377
+ # --matrix runs on the in-process backend and needs every mutant's whole
378
+ # covering run, so a backend that cannot name the failing test, or a run that
379
+ # stops early, is a usage error (exit 2), never a quietly partial matrix.
380
+ #
381
+ # @api private
382
+ # @param config [Mutineer::Config] run configuration.
383
+ # @return [void]
384
+ def self.validate_matrix!(config)
385
+ conflict, reason =
386
+ if config.dry_run
387
+ [:dry_run, "a dry run runs no tests, so it has no matrix"]
388
+ elsif config.daemon
389
+ [:daemon, "the kill matrix runs on the in-process backend only"]
390
+ elsif config.test_command
391
+ [:test_command, "the external suite reports pass or fail, not which test failed"]
392
+ elsif config.fail_fast
393
+ [:fail_fast, "a fail-fast run is partial, so blind and redundant tests would be wrong"]
394
+ end
395
+ return unless conflict
396
+
397
+ # Say how to turn off whichever side came from the file, for this run.
398
+ hint =
399
+ if config.origin(:matrix) != "--matrix"
400
+ "; pass --no-matrix to run without it"
401
+ elsif conflict == :fail_fast && config.origin(:fail_fast) != "--fail-fast"
402
+ "; pass --no-fail-fast to run without it"
403
+ else
404
+ ""
405
+ end
406
+ warn "mutineer: #{config.origin(:matrix)} cannot be combined with #{config.origin(conflict)} (#{reason}#{hint})"
407
+ exit 2
408
+ end
409
+
361
410
  # --since needs a real git repo and a resolvable ref; either failure is a
362
411
  # usage error (exit 2) so CI sees "bad invocation," not "tests too weak."
363
412
  #
@@ -399,30 +448,30 @@ module Mutineer
399
448
  end
400
449
 
401
450
  # Auto-pair sources to tests by path convention when no --test was given
402
- # (explicit --test wins). Each source with an inferred test on disk joins the
403
- # run; a source with none is dropped with a one-line stderr warning and the
404
- # run continues with the rest. If every source is dropped: in boot mode the
451
+ # (explicit --test wins). Each source with at least one inferred test on
452
+ # disk joins the run; a source with none is dropped with a one-line stderr
453
+ # warning and the run continues with the rest. Split files for one source
454
+ # are all kept (#87). If every source is dropped: in boot mode the
405
455
  # dedicated --boot/--rails-requires-test check reports it; otherwise exit 2
406
456
  # with a usage message. The framework is re-detected from the inferred set
407
457
  # unless it was set explicitly (a spec-only project loads/reports as rspec).
408
458
  #
409
459
  # @api private
410
460
  # @param config [Mutineer::Config] run configuration.
411
- # @param explicit [Set<Symbol>] explicit CLI fields.
412
461
  # @return [void]
413
- def self.autopair!(config, explicit)
462
+ def self.autopair!(config)
414
463
  return unless config.tests.empty?
415
464
 
416
465
  paired = config.sources.filter_map do |s|
417
- t = Pairing.infer_test(s, project_root: config.project_root, prefer: config.framework)
418
- [s, t] if t
466
+ tests = Pairing.infer_tests(s, project_root: config.project_root, prefer: config.framework)
467
+ [s, tests] unless tests.empty?
419
468
  end
420
469
  (config.sources - paired.map(&:first)).each do |s|
421
470
  warn "[mutineer] no test found by convention for #{s}; skipping"
422
471
  end
423
472
  config.sources = paired.map(&:first)
424
- config.tests = paired.map(&:last).uniq
425
- config.framework = Config.detect_framework(config.tests) unless explicit.include?(:framework)
473
+ config.tests = paired.flat_map(&:last).uniq
474
+ config.framework = Config.detect_framework(config.tests) unless config.explicit?(:framework)
426
475
 
427
476
  return unless config.sources.empty?
428
477
  return if config.boot # let the --boot/--rails-requires-test check report it
@@ -472,7 +521,8 @@ module Mutineer
472
521
 
473
522
  aggregate, source_map, extras = Runner.execute(config)
474
523
  warn_legacy_ignore_matches(extras[:legacy_ignore_matches])
475
- reporter = Reporter.new(aggregate, source_map)
524
+ matrix = KillMatrix.new(aggregate.results) if config.matrix
525
+ reporter = Reporter.new(aggregate, source_map, matrix: matrix)
476
526
 
477
527
  # Diff the current run against the baseline (preflighted above) by the
478
528
  # stable survivor id. The delta is rendered inline (human section / additive