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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +1194 -0
- data/LICENSE +21 -0
- data/README.md +659 -0
- data/bin/mutineer +6 -0
- data/lib/mutineer/baseline.rb +185 -0
- data/lib/mutineer/changed_lines.rb +136 -0
- data/lib/mutineer/child_stdout.rb +35 -0
- data/lib/mutineer/cli.rb +681 -0
- data/lib/mutineer/config.rb +372 -0
- data/lib/mutineer/coverage_map.rb +1366 -0
- data/lib/mutineer/daemon_backend.rb +351 -0
- data/lib/mutineer/daemon_client.rb +407 -0
- data/lib/mutineer/daemon_server.rb +351 -0
- data/lib/mutineer/external_backend.rb +288 -0
- data/lib/mutineer/file_swap.rb +194 -0
- data/lib/mutineer/isolation.rb +321 -0
- data/lib/mutineer/job_plan.rb +352 -0
- data/lib/mutineer/kill_channel.rb +284 -0
- data/lib/mutineer/kill_matrix.rb +88 -0
- data/lib/mutineer/minitest_integration/kill_recorder.rb +301 -0
- data/lib/mutineer/minitest_integration/outer_reporter.rb +126 -0
- data/lib/mutineer/minitest_integration/stop_at_first_failure.rb +145 -0
- data/lib/mutineer/minitest_integration.rb +137 -0
- data/lib/mutineer/mongoid_failures.rb +82 -0
- data/lib/mutineer/mongoid_worker_db.rb +97 -0
- data/lib/mutineer/mutant_id.rb +126 -0
- data/lib/mutineer/mutation.rb +27 -0
- data/lib/mutineer/mutator_registry.rb +109 -0
- data/lib/mutineer/mutators/arithmetic.rb +35 -0
- data/lib/mutineer/mutators/array_literal.rb +44 -0
- data/lib/mutineer/mutators/base.rb +105 -0
- data/lib/mutineer/mutators/boolean_connector.rb +52 -0
- data/lib/mutineer/mutators/boolean_literal.rb +56 -0
- data/lib/mutineer/mutators/chain_link.rb +132 -0
- data/lib/mutineer/mutators/collection_method.rb +44 -0
- data/lib/mutineer/mutators/comparison.rb +35 -0
- data/lib/mutineer/mutators/condition_forcing.rb +134 -0
- data/lib/mutineer/mutators/condition_negation.rb +49 -0
- data/lib/mutineer/mutators/literal_mutation.rb +51 -0
- data/lib/mutineer/mutators/negation_removal.rb +44 -0
- data/lib/mutineer/mutators/operand_removal.rb +58 -0
- data/lib/mutineer/mutators/operator_assignment.rb +113 -0
- data/lib/mutineer/mutators/range_literal.rb +54 -0
- data/lib/mutineer/mutators/regex_literal.rb +72 -0
- data/lib/mutineer/mutators/return_nil.rb +72 -0
- data/lib/mutineer/mutators/safe_navigation.rb +76 -0
- data/lib/mutineer/mutators/statement_removal.rb +33 -0
- data/lib/mutineer/mutators/string_literal.rb +34 -0
- data/lib/mutineer/orphan_guard.rb +28 -0
- data/lib/mutineer/pairing.rb +298 -0
- data/lib/mutineer/parser.rb +45 -0
- data/lib/mutineer/progress.rb +46 -0
- data/lib/mutineer/project.rb +506 -0
- data/lib/mutineer/project_path.rb +57 -0
- data/lib/mutineer/rails_worker_db.rb +212 -0
- data/lib/mutineer/reporter.rb +926 -0
- data/lib/mutineer/result.rb +240 -0
- data/lib/mutineer/runner.rb +418 -0
- data/lib/mutineer/statement_lines.rb +183 -0
- data/lib/mutineer/subject.rb +38 -0
- data/lib/mutineer/test_runners/minitest.rb +22 -0
- data/lib/mutineer/test_runners/rspec.rb +330 -0
- data/lib/mutineer/test_runners.rb +30 -0
- data/lib/mutineer/version.rb +6 -0
- data/lib/mutineer/worker_pool.rb +147 -0
- data/lib/mutineer.rb +48 -0
- metadata +143 -0
data/lib/mutineer/cli.rb
ADDED
|
@@ -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
|