mutineer-mongoid 2.0.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 (68) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +1194 -0
  3. data/LICENSE +21 -0
  4. data/README.md +659 -0
  5. data/bin/mutineer +6 -0
  6. data/lib/mutineer/baseline.rb +185 -0
  7. data/lib/mutineer/changed_lines.rb +136 -0
  8. data/lib/mutineer/child_stdout.rb +35 -0
  9. data/lib/mutineer/cli.rb +681 -0
  10. data/lib/mutineer/config.rb +372 -0
  11. data/lib/mutineer/coverage_map.rb +1366 -0
  12. data/lib/mutineer/daemon_backend.rb +351 -0
  13. data/lib/mutineer/daemon_client.rb +407 -0
  14. data/lib/mutineer/daemon_server.rb +351 -0
  15. data/lib/mutineer/external_backend.rb +288 -0
  16. data/lib/mutineer/file_swap.rb +194 -0
  17. data/lib/mutineer/isolation.rb +321 -0
  18. data/lib/mutineer/job_plan.rb +352 -0
  19. data/lib/mutineer/kill_channel.rb +284 -0
  20. data/lib/mutineer/kill_matrix.rb +88 -0
  21. data/lib/mutineer/minitest_integration/kill_recorder.rb +301 -0
  22. data/lib/mutineer/minitest_integration/outer_reporter.rb +126 -0
  23. data/lib/mutineer/minitest_integration/stop_at_first_failure.rb +145 -0
  24. data/lib/mutineer/minitest_integration.rb +137 -0
  25. data/lib/mutineer/mongoid_failures.rb +82 -0
  26. data/lib/mutineer/mongoid_worker_db.rb +97 -0
  27. data/lib/mutineer/mutant_id.rb +126 -0
  28. data/lib/mutineer/mutation.rb +27 -0
  29. data/lib/mutineer/mutator_registry.rb +109 -0
  30. data/lib/mutineer/mutators/arithmetic.rb +35 -0
  31. data/lib/mutineer/mutators/array_literal.rb +44 -0
  32. data/lib/mutineer/mutators/base.rb +105 -0
  33. data/lib/mutineer/mutators/boolean_connector.rb +52 -0
  34. data/lib/mutineer/mutators/boolean_literal.rb +56 -0
  35. data/lib/mutineer/mutators/chain_link.rb +132 -0
  36. data/lib/mutineer/mutators/collection_method.rb +44 -0
  37. data/lib/mutineer/mutators/comparison.rb +35 -0
  38. data/lib/mutineer/mutators/condition_forcing.rb +134 -0
  39. data/lib/mutineer/mutators/condition_negation.rb +49 -0
  40. data/lib/mutineer/mutators/literal_mutation.rb +51 -0
  41. data/lib/mutineer/mutators/negation_removal.rb +44 -0
  42. data/lib/mutineer/mutators/operand_removal.rb +58 -0
  43. data/lib/mutineer/mutators/operator_assignment.rb +113 -0
  44. data/lib/mutineer/mutators/range_literal.rb +54 -0
  45. data/lib/mutineer/mutators/regex_literal.rb +72 -0
  46. data/lib/mutineer/mutators/return_nil.rb +72 -0
  47. data/lib/mutineer/mutators/safe_navigation.rb +76 -0
  48. data/lib/mutineer/mutators/statement_removal.rb +33 -0
  49. data/lib/mutineer/mutators/string_literal.rb +34 -0
  50. data/lib/mutineer/orphan_guard.rb +28 -0
  51. data/lib/mutineer/pairing.rb +298 -0
  52. data/lib/mutineer/parser.rb +45 -0
  53. data/lib/mutineer/progress.rb +46 -0
  54. data/lib/mutineer/project.rb +506 -0
  55. data/lib/mutineer/project_path.rb +57 -0
  56. data/lib/mutineer/rails_worker_db.rb +212 -0
  57. data/lib/mutineer/reporter.rb +926 -0
  58. data/lib/mutineer/result.rb +240 -0
  59. data/lib/mutineer/runner.rb +418 -0
  60. data/lib/mutineer/statement_lines.rb +183 -0
  61. data/lib/mutineer/subject.rb +38 -0
  62. data/lib/mutineer/test_runners/minitest.rb +22 -0
  63. data/lib/mutineer/test_runners/rspec.rb +330 -0
  64. data/lib/mutineer/test_runners.rb +30 -0
  65. data/lib/mutineer/version.rb +6 -0
  66. data/lib/mutineer/worker_pool.rb +147 -0
  67. data/lib/mutineer.rb +48 -0
  68. metadata +143 -0
@@ -0,0 +1,681 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+ require "open3"
5
+ require_relative "version"
6
+ require_relative "config"
7
+ require_relative "parser"
8
+ require_relative "project"
9
+ require_relative "pairing"
10
+ require_relative "changed_lines"
11
+ require_relative "runner"
12
+ require_relative "job_plan"
13
+ require_relative "reporter"
14
+ require_relative "kill_matrix"
15
+ require_relative "baseline"
16
+ require_relative "mutator_registry"
17
+
18
+ module Mutineer
19
+ # Command-line entry point. `start` is the single public method called by
20
+ # bin/mutineer; it parses argv, acts, and exits with a pinned code.
21
+ #
22
+ # Exit codes:
23
+ # 0 success / requested output (--version, --help, score >= threshold)
24
+ # 1 survivors below threshold, or a runtime error
25
+ # 2 usage / flag error (unknown subcommand, invalid flag, unknown operator,
26
+ # out-of-range threshold)
27
+ class CLI
28
+ # Command-line usage banner.
29
+ BANNER = <<~USAGE
30
+ Usage: mutineer [options] <command> [args]
31
+
32
+ Commands:
33
+ run [options] <source...> --test <test> [--test <test>...]
34
+ Mutate, run, and report
35
+ run --dry-run [options] <source...> Print candidate mutations only
36
+
37
+ Run options:
38
+ --test FILE Test file covering the sources (one per flag; repeat it)
39
+ --operators LIST Comma-separated operator names (default: Tier 1 set)
40
+ --threshold FLOAT Fail (exit 1) when score < FLOAT (default: 0 = off)
41
+ --baseline FILE Fail (exit 1) on NEW survivors / score drop vs a prior
42
+ --format json run (CI delta gate)
43
+ --baseline-epsilon FLOAT Score-drop tolerance for --baseline (default: 0)
44
+ --only NAME Restrict to one fully-qualified subject
45
+ --since REF Only mutate lines changed since git REF (e.g. origin/main)
46
+ --no-since Disable diff scoping (a typed no beats a .mutineer.yml since:)
47
+ --jobs N Parallel worker count (default: processor count);
48
+ --test-command, --fail-fast, or --rails
49
+ without --daemon forces 1
50
+ --strategy NAME reload (whole-file) or redefine (surgical); default: reload
51
+ --timeout SECONDS Per-mutant time limit for in-process runs (default: 10;
52
+ not with --daemon or --test-command); a mutant over it
53
+ is a timeout, excluded from the score
54
+ --capture-timeout SECONDS Time limit for each coverage-capture subprocess
55
+ and the clean check (default: 120; not with --test-command)
56
+ --cache-dir DIR Directory for the coverage cache (default: .mutineer;
57
+ not with --test-command)
58
+ --framework NAME minitest or rspec (default: auto-detect from --test names)
59
+ --boot FILE Require FILE once in the parent to boot the app env, then
60
+ fork per mutant (Rails apps; requires --test)
61
+ --rails Boot config/environment; without --daemon, defaults to
62
+ redefine and serial execution
63
+ --test-command CMD Run the target suite in the app's own runtime as a
64
+ subprocess (for apps on Ruby < 3.4). CMD must contain
65
+ %{files}. Scrubs Mutineer Ruby PATH pins; set RAILS_ENV
66
+ on the mutineer command (not as KEY=val inside CMD)
67
+ --daemon Boot the app ONCE in a persistent daemon and fork per
68
+ mutant, with per-worker DB isolation so --jobs N is safe
69
+ under Rails (needs --rails/--boot; not with --test-command)
70
+ --format human|json|html Report format (default: human)
71
+ --output FILE Write the report to FILE instead of stdout
72
+ --dry-run List mutations without executing
73
+ --fail-fast Stop at the first surviving mutant (--no-fail-fast beats a
74
+ .mutineer.yml fail_fast:)
75
+ --matrix Run every covering test for each mutant and report blind and
76
+ redundant tests; the JSON report also lists each mutant's
77
+ killers (in-process only; not with --daemon, --test-command,
78
+ --fail-fast or --dry-run; --no-matrix beats a .mutineer.yml
79
+ matrix:)
80
+ --verbose Surface the real error when a fork capture fails (alias: --debug)
81
+
82
+ Options:
83
+ --list-operators List available operators (default vs optional) and exit
84
+ --version Print version and exit
85
+ --help Print this help and exit
86
+ USAGE
87
+
88
+ # Parses arguments, executes the command, and exits.
89
+ #
90
+ # @param argv [Array<String>] raw command-line arguments.
91
+ # @return [void]
92
+ def self.start(argv)
93
+ # The CLI layer holds only the fields the user typed, so Config.resolve
94
+ # can tell a typed `false`/`nil` from an absent flag by whether the key exists.
95
+ opts = {}
96
+ show_operators = false
97
+
98
+ parser = OptionParser.new do |o|
99
+ o.banner = BANNER
100
+ o.on("--version") do
101
+ puts Mutineer::VERSION
102
+ exit 0
103
+ end
104
+ o.on("--help") do
105
+ puts BANNER
106
+ exit 0
107
+ end
108
+ o.on("--list-operators") { show_operators = true }
109
+ o.on("--dry-run") { opts[:dry_run] = true }
110
+ # A typed --no-* beats the .mutineer.yml key, so a file key that
111
+ # conflicts with another flag can be turned off for one run.
112
+ o.on("--[no-]fail-fast") { |on| opts[:fail_fast] = on }
113
+ o.on("--[no-]matrix") { |on| opts[:matrix] = on }
114
+ o.on("--only NAME") { |v| opts[:only] = v }
115
+ o.on("--since REF") { |v| opts[:since] = Config.parse(:since, v) }
116
+ # A typed "no" must beat a .mutineer.yml `since:` key: the key is present
117
+ # with a nil value, and nil is a value.
118
+ o.on("--no-since") { opts[:since] = nil }
119
+ o.on("--test FILE") { |v| (opts[:tests] ||= []) << v }
120
+ o.on("--operators LIST") do |v|
121
+ opts[:operators] = Config.parse(:operators, v.split(",").map(&:strip))
122
+ end
123
+ o.on("--threshold FLOAT") { |v| opts[:threshold] = Config.parse(:threshold, v) }
124
+ o.on("--jobs N") { |v| opts[:jobs] = Config.parse(:jobs, v) }
125
+ o.on("--timeout SECONDS") { |v| opts[:timeout] = Config.parse(:timeout, v) }
126
+ o.on("--capture-timeout SECONDS") { |v| opts[:capture_timeout] = Config.parse(:capture_timeout, v) }
127
+ o.on("--cache-dir DIR") { |v| opts[:cache_dir] = Config.parse(:cache_dir, v) }
128
+ o.on("--strategy STRAT") { |v| opts[:strategy] = Config.parse(:strategy, v) }
129
+ o.on("--framework NAME") { |v| opts[:framework] = Config.parse(:framework, v) }
130
+ o.on("--boot FILE") { |v| opts[:boot] = v }
131
+ o.on("--rails") { opts[:rails] = true }
132
+ o.on("--verbose") { opts[:verbose] = true }
133
+ o.on("--debug") { opts[:verbose] = true } # alias of --verbose
134
+ o.on("--format FORMAT") { |v| opts[:format] = Config.parse(:format, v) }
135
+ o.on("--output FILE") { |v| opts[:output] = v }
136
+ # --baseline-epsilon is CLI-only.
137
+ o.on("--baseline FILE") { |v| opts[:baseline] = v }
138
+ o.on("--baseline-epsilon FLOAT") { |v| opts[:baseline_epsilon] = Config.parse(:baseline_epsilon, v) }
139
+ # Run the target suite as a subprocess in the app's OWN runtime so
140
+ # mutineer (Ruby >= 3.4) can mutation-test apps pinned to an older Ruby.
141
+ o.on("--test-command CMD") { |v| opts[:test_command] = v }
142
+ # Boot the app ONCE in a persistent daemon and fork per mutant, with
143
+ # per-worker DB isolation so --jobs N is safe under Rails.
144
+ o.on("--daemon") { opts[:daemon] = true }
145
+ end
146
+
147
+ begin
148
+ parser.parse!(argv)
149
+ rescue OptionParser::InvalidOption, OptionParser::MissingArgument, Mutineer::ConfigError => e
150
+ warn "mutineer: #{e.message}"
151
+ exit 2
152
+ end
153
+
154
+ if show_operators
155
+ list_operators
156
+ exit 0
157
+ end
158
+
159
+ if argv.empty?
160
+ puts BANNER
161
+ exit 0
162
+ end
163
+
164
+ begin
165
+ file_path = Config.find_file
166
+ file_hash = file_path ? Config.from_file(file_path, defer_operators: opts.key?(:operators)) : {}
167
+ config = Config.resolve(opts, file_hash)
168
+ rescue Mutineer::ConfigError => e
169
+ # The lib layer raises instead of killing the host; the CLI maps a
170
+ # config (usage) error to exit 2.
171
+ warn "mutineer: #{e.message}"
172
+ exit 2
173
+ end
174
+
175
+ case argv.first
176
+ when "run"
177
+ warn_config_root_mismatch(file_path, config.project_root) if file_path
178
+ tests_as_sources = argv[1..].grep(%r{(\A|/)(test|spec)/.*_(test|spec)\.rb\z})
179
+ if config.explicit?(:tests) && tests_as_sources.any?
180
+ warn "mutineer: #{tests_as_sources.join(', ')} looks like a test file, not a source. " \
181
+ "--test takes one file; repeat it for each test file (--test a_test.rb --test b_test.rb)"
182
+ exit 2
183
+ end
184
+ # A directory source expands to its **/*.rb files; a file inside the
185
+ # project becomes its root-relative path (#104). Test inference (when --test is omitted) happens in validate!.
186
+ config.sources = Pairing.expand_sources(argv[1..], project_root: config.project_root)
187
+ run(config)
188
+ else
189
+ warn "mutineer: unknown command '#{argv.first}'"
190
+ exit 2
191
+ end
192
+ end
193
+
194
+ # Lists available operators.
195
+ #
196
+ # @return [void]
197
+ def self.list_operators
198
+ MutatorRegistry::ALL.each_key do |name|
199
+ state = MutatorRegistry.default?(name) ? "default" : "disabled"
200
+ puts format("%-20s tier %d %-9s %s",
201
+ name, MutatorRegistry.tier(name), state, MutatorRegistry::DESCRIPTIONS[name])
202
+ end
203
+ end
204
+
205
+ # Runs the requested command after validation.
206
+ #
207
+ # @param config [Mutineer::Config] run configuration.
208
+ # @return [void]
209
+ def self.run(config)
210
+ if config.sources.empty?
211
+ warn "mutineer: run requires at least one source file"
212
+ exit 2
213
+ end
214
+ validate!(config)
215
+
216
+ config.dry_run ? dry_run(config) : execute(config)
217
+ rescue ArgumentError => e
218
+ # Unknown --operators value surfaces here; no backtrace reaches the user.
219
+ warn "mutineer: #{e.message}"
220
+ exit 2
221
+ rescue SystemCallError => e
222
+ # A missing/unreadable path reaches here as Errno::ENOENT etc. A plain
223
+ # message and usage exit, never a raw backtrace.
224
+ warn "mutineer: #{e.message}"
225
+ exit 2
226
+ rescue SyntaxError => e
227
+ # A syntactically invalid source file surfaces when `require`d; report it
228
+ # cleanly rather than dumping a backtrace.
229
+ warn "mutineer: cannot load source: #{e.message}"
230
+ exit 1
231
+ rescue Mutineer::ParseError => e
232
+ warn "mutineer: error reading: #{e.message}"
233
+ exit 1
234
+ rescue Mutineer::SmokeCheckError => e
235
+ # The unmutated suite is not green under --test-command: a broken
236
+ # environment, not weak tests. Runtime error (exit 1), not usage (exit 2).
237
+ warn "mutineer: #{e.message}"
238
+ exit 1
239
+ rescue Mutineer::ConcurrentRunError => e
240
+ # Another process owns a source file. Runtime error (exit 1), not a
241
+ # backtrace: the working tree is still the other run's responsibility.
242
+ warn "mutineer: #{e.message}"
243
+ exit 1
244
+ rescue Mutineer::DaemonBootError => e
245
+ # The daemon is gone for good, so the run ended rather than scoring the rest
246
+ # against it. A deliberate stop deserves a message, not a raw backtrace.
247
+ warn "mutineer: #{e.message}"
248
+ exit 1
249
+ end
250
+
251
+ # Flag validation: every flag/usage failure exits 2, consistent with the
252
+ # taxonomy above. CI can tell "mistyped flag" from "tests too weak."
253
+ #
254
+ # @api private
255
+ # @param config [Mutineer::Config] run configuration.
256
+ # @return [void]
257
+ def self.validate!(config)
258
+ # First, so a conflict is reported before another check rewrites the config.
259
+ validate_matrix!(config) if config.matrix
260
+ validate_test_command!(config) if config.test_command
261
+
262
+ validate_since!(config) if config.since
263
+ preflight_output!(config.output) if config.output
264
+ preflight_baseline!(config.baseline) if config.baseline
265
+
266
+ # When --test is omitted, infer each source's test by convention. Autopair
267
+ # also re-detects framework from inferred tests when --framework was not set.
268
+ autopair!(config) unless config.dry_run
269
+
270
+ # Daemon validation runs AFTER autopair so auto-inferred *_spec.rb tests
271
+ # cannot bypass the RSpec rejection (framework would still be minitest if we
272
+ # validated before discovery).
273
+ validate_daemon!(config) if config.daemon
274
+
275
+ # Boot mode needs at least one --test file (nothing to select from otherwise).
276
+ if config.boot && config.tests.empty?
277
+ warn "mutineer: --boot/--rails requires at least one --test file"
278
+ exit 2
279
+ end
280
+
281
+ validate_paths!(config)
282
+ end
283
+
284
+ # --test-command runs the target suite in the app's own runtime. Validate its
285
+ # shape up front (usage errors → exit 2) and force serial execution: each
286
+ # subprocess boots the app and opens its own fixture transaction against the
287
+ # same DB, so --jobs > 1 would corrupt results (fixture-contention hazard).
288
+ # Unlike --rails, this path has NO per-worker DB isolation to opt into, so an
289
+ # explicit --jobs N is forced to 1 rather than honored.
290
+ #
291
+ # @api private
292
+ # @param config [Mutineer::Config] run configuration.
293
+ # @return [void]
294
+ def self.validate_test_command!(config)
295
+ if config.test_command.strip.empty?
296
+ warn "mutineer: --test-command must not be empty"
297
+ exit 2
298
+ end
299
+ unless config.test_command.include?("%{files}")
300
+ warn "mutineer: --test-command must contain %{files} (where the --test paths are substituted)"
301
+ exit 2
302
+ end
303
+ if config.boot
304
+ warn "mutineer: --test-command cannot be combined with --boot/--rails " \
305
+ "(the external subprocess boots the app itself)"
306
+ exit 2
307
+ end
308
+ if config.strategy == "redefine"
309
+ warn "mutineer: --test-command supports only --strategy reload " \
310
+ "(surgical redefine needs a shared VM; the subprocess has its own)"
311
+ exit 2
312
+ end
313
+ warn_unused_settings(config, "--test-command", %i[timeout capture_timeout cache_dir])
314
+ return unless config.jobs > 1
315
+
316
+ warn "[mutineer] --test-command runs serially (no per-worker DB isolation yet); forcing --jobs 1."
317
+ config.jobs = 1
318
+ end
319
+
320
+ # --daemon selects the persistent-daemon backend (boot once, fork per mutant,
321
+ # per-worker DB isolation on SQLite). Usage errors exit 2: cannot combine with
322
+ # --test-command; requires --rails or --boot; reload strategy only. RSpec
323
+ # requires the Mongoid adapter, checked after the application boots.
324
+ # (redefine needs a shared VM surgical path the daemon does not ship).
325
+ #
326
+ # @api private
327
+ # @param config [Mutineer::Config] run configuration.
328
+ # @return [void]
329
+ def self.validate_daemon!(config)
330
+ if config.test_command
331
+ warn "mutineer: choose one backend — --daemon and --test-command cannot be combined"
332
+ exit 2
333
+ end
334
+ unless config.rails || config.boot
335
+ warn "mutineer: --daemon needs an app to boot; add --rails (or --boot FILE)"
336
+ exit 2
337
+ end
338
+ warn_unused_settings(config, "--daemon", %i[timeout])
339
+ return if config.strategy == "reload"
340
+
341
+ # --rails defaults strategy to redefine; daemon always whole-file loads.
342
+ warn "[mutineer] --daemon uses --strategy reload " \
343
+ "(redefine is not supported on the daemon path); forcing reload."
344
+ config.strategy = "reload"
345
+ end
346
+
347
+ # Where each setting a backend may ignore applies, for the warning.
348
+ UNUSED_SETTING_SCOPE = {
349
+ timeout: "in-process runs only",
350
+ capture_timeout: "in-process and --daemon coverage capture",
351
+ cache_dir: "the in-process and --daemon coverage cache"
352
+ }.freeze
353
+
354
+ # A backend that never reads a setting the user set would silently do
355
+ # nothing with it, so say so. `--test-command` builds no coverage map and
356
+ # has its own runtime, so it reads none of {UNUSED_SETTING_SCOPE}; the
357
+ # daemon reads the capture limit and the cache directory but keeps its own
358
+ # per-mutant limit.
359
+ #
360
+ # @api private
361
+ # @param config [Mutineer::Config] run configuration.
362
+ # @param backend [String] the backend's flag.
363
+ # @param keys [Array<Symbol>] the settings that backend ignores.
364
+ # @return [void]
365
+ def self.warn_unused_settings(config, backend, keys)
366
+ keys.each do |key|
367
+ next unless config.explicit?(key)
368
+
369
+ applies = UNUSED_SETTING_SCOPE.fetch(key)
370
+ warn "[mutineer] #{config.origin(key)} has no effect with #{backend} (it applies to #{applies}); ignoring it."
371
+ end
372
+ end
373
+
374
+ # --matrix runs on the in-process backend and needs every mutant's whole
375
+ # covering run, so a backend that cannot name the failing test, or a run that
376
+ # stops early, is a usage error (exit 2), never a quietly partial matrix.
377
+ #
378
+ # @api private
379
+ # @param config [Mutineer::Config] run configuration.
380
+ # @return [void]
381
+ def self.validate_matrix!(config)
382
+ conflict, reason =
383
+ if config.dry_run
384
+ [:dry_run, "a dry run runs no tests, so it has no matrix"]
385
+ elsif config.daemon
386
+ [:daemon, "the kill matrix runs on the in-process backend only"]
387
+ elsif config.test_command
388
+ [:test_command, "the external suite reports pass or fail, not which test failed"]
389
+ elsif config.fail_fast
390
+ [:fail_fast, "a fail-fast run is partial, so blind and redundant tests would be wrong"]
391
+ end
392
+ return unless conflict
393
+
394
+ # Say how to turn off whichever side came from the file, for this run.
395
+ hint =
396
+ if config.origin(:matrix) != "--matrix"
397
+ "; pass --no-matrix to run without it"
398
+ elsif conflict == :fail_fast && config.origin(:fail_fast) != "--fail-fast"
399
+ "; pass --no-fail-fast to run without it"
400
+ else
401
+ ""
402
+ end
403
+ warn "mutineer: #{config.origin(:matrix)} cannot be combined with #{config.origin(conflict)} (#{reason}#{hint})"
404
+ exit 2
405
+ end
406
+
407
+ # --since needs a real git repo and a resolvable ref; either failure is a
408
+ # usage error (exit 2) so CI sees "bad invocation," not "tests too weak."
409
+ #
410
+ # @api private
411
+ # @param config [Mutineer::Config] run configuration.
412
+ # @return [void]
413
+ def self.validate_since!(config)
414
+ _out, _err, status = Open3.capture3(
415
+ "git", "-C", config.project_root, "rev-parse", "--verify", "--quiet",
416
+ "#{config.since}^{commit}"
417
+ )
418
+ return if status.success?
419
+
420
+ inside, = Open3.capture3(
421
+ "git", "-C", config.project_root, "rev-parse", "--is-inside-work-tree"
422
+ )
423
+ msg = inside.strip == "true" ? "unknown git ref: #{config.since}" : "--since requires a git repository"
424
+ warn "mutineer: #{msg}"
425
+ exit 2
426
+ rescue Errno::ENOENT
427
+ warn "mutineer: --since requires git on PATH"
428
+ exit 2
429
+ end
430
+
431
+ # Validate path existence up front so a typo is a clean usage error (exit 2),
432
+ # not an Errno::ENOENT backtrace from deep in the run. Flag checks run first
433
+ # so a bad flag still reports the flag, not the missing file.
434
+ #
435
+ # @api private
436
+ # @param config [Mutineer::Config] run configuration.
437
+ # @return [void]
438
+ def self.validate_paths!(config)
439
+ missing = (config.sources + config.tests)
440
+ .reject { |p| File.exist?(File.expand_path(p, config.project_root)) }
441
+ return if missing.empty?
442
+
443
+ warn "mutineer: no such file: #{missing.join(', ')}"
444
+ exit 2
445
+ end
446
+
447
+ # Auto-pair sources to tests by path convention when no --test was given
448
+ # (explicit --test wins). Each source with at least one inferred test on
449
+ # disk joins the run; a source with none is dropped with a one-line stderr
450
+ # warning and the run continues with the rest. Split files for one source
451
+ # are all kept (#87). If every source is dropped: in boot mode the
452
+ # dedicated --boot/--rails-requires-test check reports it; otherwise exit 2
453
+ # with a usage message. The framework is re-detected from the inferred set
454
+ # unless it was set explicitly (a spec-only project loads/reports as rspec).
455
+ #
456
+ # @api private
457
+ # @param config [Mutineer::Config] run configuration.
458
+ # @return [void]
459
+ def self.autopair!(config)
460
+ return unless config.tests.empty?
461
+
462
+ paired = config.sources.filter_map do |s|
463
+ tests = Pairing.infer_tests(s, project_root: config.project_root, prefer: config.framework)
464
+ [s, tests] unless tests.empty?
465
+ end
466
+ (config.sources - paired.map(&:first)).each do |s|
467
+ warn "[mutineer] no test found by convention for #{s}; skipping"
468
+ end
469
+ config.sources = paired.map(&:first)
470
+ config.tests = paired.flat_map(&:last).uniq
471
+ config.framework = Config.detect_framework(config.tests) unless config.explicit?(:framework)
472
+
473
+ return unless config.sources.empty?
474
+ return if config.boot # let the --boot/--rails-requires-test check report it
475
+
476
+ warn "mutineer: no test files found by convention; pass --test or add tests"
477
+ exit 2
478
+ end
479
+
480
+ # A missing/unreadable/unparseable baseline is a usage error (exit 2),
481
+ # mirroring --output/--since preflight, so CI sees "bad invocation," not a
482
+ # backtrace mid-run. Validating up front = attempting the load (it raises
483
+ # ConfigError/SystemCallError; the actual diff reloads in execute).
484
+ #
485
+ # @api private
486
+ # @param path [String] baseline file path.
487
+ # @return [void]
488
+ def self.preflight_baseline!(path)
489
+ Baseline.load(path)
490
+ rescue Mutineer::ConfigError, SystemCallError => e
491
+ warn "mutineer: #{e.message}"
492
+ exit 2
493
+ end
494
+
495
+ # Preflights an output path.
496
+ #
497
+ # @api private
498
+ # @param path [String] output file path.
499
+ # @return [void]
500
+ def self.preflight_output!(path)
501
+ dir = File.dirname(File.expand_path(path))
502
+ return if File.directory?(dir) && File.writable?(dir)
503
+
504
+ reason = File.directory?(dir) ? "directory is not writable" : "no such directory"
505
+ warn "mutineer: cannot write to #{path}: #{reason}"
506
+ exit 2
507
+ end
508
+
509
+ # Executes the run command.
510
+ #
511
+ # @param config [Mutineer::Config] run configuration.
512
+ # @return [void]
513
+ def self.execute(config)
514
+ if config.tests.empty?
515
+ warn "mutineer: run requires at least one --test file (or use --dry-run)"
516
+ exit 2
517
+ end
518
+
519
+ aggregate, source_map, extras = Runner.execute(config)
520
+ warn_legacy_ignore_matches(extras[:legacy_ignore_matches])
521
+ matrix = KillMatrix.new(aggregate.results) if config.matrix
522
+ reporter = Reporter.new(aggregate, source_map, matrix: matrix)
523
+
524
+ # Diff the current run against the baseline (preflighted above) by the
525
+ # stable survivor id. The delta is rendered inline (human section / additive
526
+ # json block) and gates exit independently of --threshold. A --since run is
527
+ # scoped: its score covers a different denominator than a full-run baseline,
528
+ # so only the new-survivor half of the gate applies (see Baseline#diff).
529
+ delta = if config.baseline
530
+ Baseline.load(config.baseline).diff(aggregate, epsilon: config.baseline_epsilon,
531
+ scoped: !config.since.nil?,
532
+ id_map: extras[:id_map],
533
+ project_root: config.project_root)
534
+ end
535
+ warn_legacy_baseline if delta&.legacy_matches&.positive?
536
+
537
+ # ignore counts old-format entries (one warning each), not the ids they matched.
538
+ legacy_id_matches = { ignore: extras[:legacy_ignore_matches].size,
539
+ baseline: delta ? delta.legacy_matches : 0 }
540
+ reporter.report(out: $stdout, err: $stderr, threshold: config.threshold,
541
+ format: config.format, output: config.output, baseline: delta,
542
+ scoped: !config.since.nil?, legacy_id_matches: legacy_id_matches)
543
+
544
+ # Warn (stderr, so it never pollutes json/html) that an external run's score
545
+ # is not comparable to an in-process run: no coverage narrowing (uncovered
546
+ # mutants count as survivors), and an infra failure is scored as a kill
547
+ # (upper bound). Daemon coverage fallback warnings are emitted from the runner
548
+ # only when the map is unavailable, not on every --daemon run.
549
+ if config.test_command
550
+ warn "[mutineer] --test-command score is an upper bound, not comparable to an " \
551
+ "in-process run: no coverage narrowing (uncovered mutants count as survivors) " \
552
+ "and an infra failure is scored as a kill."
553
+ end
554
+
555
+ # Nudge toward the opt-in tier-2 operators (human report only: never
556
+ # pollute JSON output).
557
+ if !%w[json html].include?(config.format) && (hint = tier2_hint(config.operators))
558
+ puts hint
559
+ end
560
+
561
+ # --baseline and --threshold are independent gates OR'd together.
562
+ # `max` of two 0/1 codes is the OR; usage (2) is handled earlier and wins.
563
+ baseline_exit = delta&.regressed ? 1 : 0
564
+ exit [reporter.exit_code(threshold: config.threshold), baseline_exit].max
565
+ end
566
+
567
+ # The tier-2 operators not in the active set, as a one-line hint (or nil when
568
+ # they are all already enabled). `active` nil means the default (Tier-1) set.
569
+ #
570
+ # @param active [Array<String>, nil] active operator names.
571
+ # @return [String, nil] hint text or nil.
572
+ def self.tier2_hint(active)
573
+ active ||= MutatorRegistry::DEFAULT_NAMES
574
+ unused = MutatorRegistry::TIER2_NAMES - active
575
+ return if unused.empty?
576
+
577
+ "#{unused.size} tier-2 operators available (#{unused.join(', ')}) — " \
578
+ "enable with --operators <list>."
579
+ end
580
+
581
+ # Warns once when the loaded .mutineer.yml sits outside the run directory
582
+ # (#126). Mutant ids hash each file's path relative to the run directory,
583
+ # but the config is found by walking up, so a run from a subdirectory loads
584
+ # the same ignore list while its ids no longer match. A config in the home
585
+ # directory is a personal default, not a project root, so it never warns.
586
+ #
587
+ # @param file_path [String] the .mutineer.yml that was loaded.
588
+ # @param project_root [String] the run directory ids are relative to.
589
+ # @return [void]
590
+ def self.warn_config_root_mismatch(file_path, project_root)
591
+ config_dir = ProjectPath.root_real(File.dirname(file_path))
592
+ return if config_dir == ProjectPath.root_real(project_root)
593
+ return if config_dir == ProjectPath.root_real(Dir.home)
594
+
595
+ warn "[mutineer] loaded #{file_path}, but mutant ids are relative to the run directory " \
596
+ "#{project_root}, not to #{config_dir}. Ignore ids and baselines written from " \
597
+ "#{config_dir} will not match this run. Run mutineer from #{config_dir}."
598
+ end
599
+
600
+ # Warns once per old-format `ignore:` entry (#126), naming each new id it
601
+ # matched with that mutant's file and subject. mutineer cannot tell a full
602
+ # run from a narrowed one, so the text always says the list covers only this
603
+ # run's mutants. An entry that matched more than one distinct mutant (in
604
+ # other files, or same-named methods in one file) over-matched: the old id
605
+ # could not tell them apart, so replacing it with every new id would keep
606
+ # suppressing the mutants it hid by accident.
607
+ #
608
+ # @param matches [Hash{String => Array<Hash{Symbol => String}>}] old-format
609
+ # entry => one `{id:, file:, subject:}` hash per matched mutant.
610
+ # @return [void]
611
+ def self.warn_legacy_ignore_matches(matches)
612
+ matches.each do |old, hits|
613
+ listed = hits.map { |h| "#{h[:id]} (#{h[:file]}, #{h[:subject]})" }.join(", ")
614
+ advice = if hits.map { |h| h[:id] }.uniq.size > 1
615
+ "#{old} over-matched: the old format could not tell these mutants apart. " \
616
+ "Replace #{old} and keep only the ids for the mutant you meant to ignore, not all of them."
617
+ else
618
+ "Replace #{old} with the new ids in your ignore list."
619
+ end
620
+ warn "[mutineer] ignore entry #{old} uses the old id format, which did not include the " \
621
+ "file path. It matched these new ids: #{listed}. This list covers only mutants in " \
622
+ "this run's sources and operators; a run over every source gives the complete " \
623
+ "replacement. #{advice}"
624
+ end
625
+ end
626
+
627
+ # Warns once that the --baseline file stores old-format ids (#126), so the
628
+ # diff fell back to matching on them. Called only when a survivor matched
629
+ # through an old id alone.
630
+ #
631
+ # @return [void]
632
+ def self.warn_legacy_baseline
633
+ warn "[mutineer] the baseline uses the old id format, which did not include the file " \
634
+ "path, so survivors were matched on their old ids and files. Regenerate the baseline " \
635
+ "(run with --format json and save the output), but only after every gate that reads " \
636
+ "it runs this mutineer version or later."
637
+ end
638
+
639
+ # Runs dry-run mode. Reuses JobPlan.collect_jobs (+ filter_since) so the
640
+ # candidate list cannot drift from a real run's job selection.
641
+ #
642
+ # @param config [Mutineer::Config] run configuration.
643
+ # @return [void]
644
+ def self.dry_run(config)
645
+ operator_classes = MutatorRegistry.resolve(config.operators || MutatorRegistry::DEFAULT_NAMES)
646
+ jobs, ignored_results, source_map, extras = JobPlan.collect_jobs(config, operator_classes)
647
+ warn_legacy_ignore_matches(extras[:legacy_ignore_matches])
648
+ # Narrow jobs and ignored the same way so the summary matches the printed list.
649
+ if config.since
650
+ jobs = JobPlan.filter_since(jobs, source_map, config)
651
+ ignored_jobs = ignored_results.map { |r| [r.subject, r.mutation, r.id] }
652
+ ignored = JobPlan.filter_since(ignored_jobs, source_map, config).size
653
+ else
654
+ ignored = ignored_results.size
655
+ end
656
+
657
+ per_operator = Hash.new(0)
658
+ skipped = 0
659
+ jobs.each do |subject, mutation, _id|
660
+ source = source_map[subject.file]
661
+ unless mutation.valid?(source)
662
+ skipped += 1
663
+ next
664
+ end
665
+
666
+ line = source.byteslice(0, mutation.start_offset).count("\n") + 1
667
+ per_operator[mutation.operator] += 1
668
+ original = source.byteslice(mutation.start_offset...mutation.end_offset)
669
+ puts "[#{mutation.operator}] #{subject.qualified_name} " \
670
+ "#{subject.file}:#{line} `#{original}` -> `#{mutation.replacement}`"
671
+ end
672
+
673
+ total = per_operator.values.sum
674
+ breakdown = per_operator.map { |op, n| "#{op}: #{n}" }.join(", ")
675
+ summary = breakdown.empty? ? "" : "#{breakdown} — "
676
+ puts "#{summary}#{total} mutations (dry run, not executed); " \
677
+ "#{skipped} skipped (invalid); #{ignored} ignored (suppressed)"
678
+ exit 0
679
+ end
680
+ end
681
+ end