mutation_tester 1.2.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 +13 -0
- data/Gemfile +9 -0
- data/Gemfile.lock +83 -0
- data/LICENSE.txt +19 -0
- data/Rakefile +57 -0
- data/docs/ci.md +158 -0
- data/docs/execution-runners.md +163 -0
- data/docs/json-schema.md +171 -0
- data/docs/mutation-types.md +207 -0
- data/examples/calculator.rb +35 -0
- data/examples/calculator_100_perc_spec.rb +121 -0
- data/examples/calculator_minitest.rb +53 -0
- data/examples/calculator_spec.rb +105 -0
- data/examples/github_actions/ai_mutation_gate.yml +75 -0
- data/examples/github_actions/mutation_test.yml +87 -0
- data/examples/hooks/pre-push +41 -0
- data/examples/run_example.rb +31 -0
- data/examples/run_example_minitest.rb +38 -0
- data/examples/run_example_parallel_minitest.rb +48 -0
- data/examples/run_example_parallel_rspec.rb +48 -0
- data/examples/run_example_rspec.rb +38 -0
- data/examples/spec_helper.rb +21 -0
- data/exe/mutation_test +345 -0
- data/lib/mutation_tester/batch_runner.rb +286 -0
- data/lib/mutation_tester/configuration.rb +105 -0
- data/lib/mutation_tester/core.rb +193 -0
- data/lib/mutation_tester/fork_runner/worker.rb +202 -0
- data/lib/mutation_tester/fork_runner.rb +382 -0
- data/lib/mutation_tester/framework_detector.rb +25 -0
- data/lib/mutation_tester/in_memory_loader.rb +25 -0
- data/lib/mutation_tester/mutation_runner.rb +644 -0
- data/lib/mutation_tester/mutator.rb +874 -0
- data/lib/mutation_tester/progress_display.rb +130 -0
- data/lib/mutation_tester/railtie.rb +7 -0
- data/lib/mutation_tester/rake_task.rb +18 -0
- data/lib/mutation_tester/reporters/base_reporter.rb +154 -0
- data/lib/mutation_tester/reporters/batch_json_reporter.rb +72 -0
- data/lib/mutation_tester/reporters/console_reporter.rb +130 -0
- data/lib/mutation_tester/reporters/html_reporter.rb +693 -0
- data/lib/mutation_tester/reporters/json_reporter.rb +67 -0
- data/lib/mutation_tester/test_command.rb +165 -0
- data/lib/mutation_tester/version.rb +3 -0
- data/lib/mutation_tester.rb +52 -0
- data/lib/tasks/mutation_tester.rake +80 -0
- data/readme.md +925 -0
- metadata +213 -0
data/exe/mutation_test
ADDED
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
def project_bundle_state
|
|
5
|
+
require 'bundler'
|
|
6
|
+
Bundler.definition.dependencies.any? { |dep| dep.name == 'mutation_tester' } ? :in_bundle : :outside
|
|
7
|
+
rescue LoadError, StandardError
|
|
8
|
+
:no_gemfile
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
case project_bundle_state
|
|
12
|
+
when :in_bundle then require 'bundler/setup'
|
|
13
|
+
when :outside then warn 'mutation_tester loaded outside the project bundle'
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
require 'mutation_tester'
|
|
17
|
+
require 'optparse'
|
|
18
|
+
|
|
19
|
+
KNOWN_REPORTERS = %i[console html json].freeze
|
|
20
|
+
|
|
21
|
+
options = { parallel: nil }
|
|
22
|
+
|
|
23
|
+
OptionParser.new do |opts|
|
|
24
|
+
opts.banner = <<~BANNER
|
|
25
|
+
#{Rainbow("MutationTester").cyan.bold} - Improve your test quality through mutation testing
|
|
26
|
+
|
|
27
|
+
#{Rainbow("Usage:").bright}
|
|
28
|
+
mutation_test [OPTIONS] FILE...
|
|
29
|
+
mutation_test [OPTIONS] --staged
|
|
30
|
+
mutation_test [OPTIONS] SOURCE_FILE TEST_FILE
|
|
31
|
+
mutation_test [OPTIONS] --glob 'lib/**/*.rb'
|
|
32
|
+
|
|
33
|
+
#{Rainbow("Arguments:").bright}
|
|
34
|
+
FILE... Ruby source files to mutate; each is mapped to its spec by convention (lib/X.rb -> spec/X_spec.rb, override with --spec-glob)
|
|
35
|
+
SOURCE_FILE TEST_FILE
|
|
36
|
+
Explicit pair: exactly two arguments where the second is recognized as a test file (RSpec or Minitest)
|
|
37
|
+
BANNER
|
|
38
|
+
|
|
39
|
+
opts.on('-p', '--parallel N', Integer, "Run with N parallel processes (default: auto, derived from the CPU core count with a cap of #{MutationTester::Configuration::AUTO_PARALLEL_CAP}; -p 1 forces serial execution)") do |n|
|
|
40
|
+
options[:parallel] = n
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
opts.on('--runner MODE', 'Mutant execution runner: auto (default) tries in_memory first (RSpec with Process.fork available and a passing unmutated-source probe), then falls back to fork, then spawn, announcing every step down on stderr with its reason; fork (preloaded environment, RSpec on platforms with Process.fork), spawn (one full process per mutant) and in_memory (mutations applied in child-process memory, zero file writes per mutant, RSpec only) force the specific mode') do |mode|
|
|
44
|
+
options[:runner] = mode
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
opts.on('--no-test-selection', 'Disable two-phase test selection (always run the full test file for every mutant)') do
|
|
48
|
+
options[:no_test_selection] = true
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
opts.on('--staged', 'Mutation-test the files staged in git (git diff --cached --name-only; files staged as deleted are ignored), mapping each to its spec like a positional FILE list') do
|
|
52
|
+
options[:staged] = true
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
opts.on('--glob PATTERN', 'Batch mode: mutation-test every source file matching PATTERN, mapping each to its spec by convention (lib/X.rb -> spec/X_spec.rb)') do |pattern|
|
|
56
|
+
options[:glob] = pattern
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
opts.on('--spec-glob TEMPLATE', 'Spec-mapping template with a {name} placeholder (default: spec/{name}_spec.rb; e.g. test/{name}_test.rb). {name} is the source path minus a leading lib/ and the .rb extension. Requires a FILE list, --staged, or --glob.') do |template|
|
|
60
|
+
options[:spec_glob] = template
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
opts.on('--since REV', 'Incremental batch mode: mutate only the files matched by --glob that changed since git revision REV (new files count as changed). Requires --glob.') do |rev|
|
|
64
|
+
options[:since] = rev
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
opts.on('--fail-fast', 'Stop the run at the first surviving mutant and finish with a failing status') do
|
|
68
|
+
options[:fail_fast] = true
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
opts.on('--strict-equality', 'Enable strict equality probes (== to eql? and equal?); default off, expect noise on code that does not distinguish numeric types or object identity') do
|
|
72
|
+
options[:strict_equality] = true
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
opts.on('--worker-env NAME', 'Set environment variable NAME to a distinct per-worker value before each parallel worker boots (parallel_tests TEST_ENV_NUMBER convention: worker 0 -> "", worker N -> N+1), so a parallel_tests-style database.yml selects a per-worker database. Provision the databases yourself (e.g. rake parallel:prepare). The in_memory runner cannot isolate a per-worker database, so this flag makes the runner fall back to fork.') do |name|
|
|
76
|
+
options[:worker_env] = name
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
opts.on('-h', '--help', 'Show this help message') do
|
|
80
|
+
puts opts
|
|
81
|
+
exit
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
opts.on('-v', '--version', 'Show version') do
|
|
85
|
+
puts "MutationTester v#{MutationTester::VERSION}"
|
|
86
|
+
exit
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
opts.on('--verbose', 'Show a per-mutation warning for every skipped mutation (quiet by default)') do
|
|
90
|
+
options[:verbose] = true
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
opts.on('--no-progress', 'Disable progress display') do
|
|
94
|
+
options[:no_progress] = true
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
opts.on('--json', 'Machine mode: print ONLY the JSON report to stdout (logs and progress go to stderr); a multi-file run (FILE list, --staged, --glob) prints one aggregate envelope with a survivors list') do
|
|
98
|
+
options[:json] = true
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
opts.on('--reporters LIST', Array, "Comma-separated reporters to run (#{KNOWN_REPORTERS.join(',')})") do |list|
|
|
102
|
+
options[:reporters] = list
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
opts.on('--output-dir PATH', 'Directory for the generated report files (default: tmp/mutation_reports)') do |dir|
|
|
106
|
+
options[:output_dir] = dir
|
|
107
|
+
end
|
|
108
|
+
end.parse!
|
|
109
|
+
|
|
110
|
+
json_mode = options.fetch(:json, false)
|
|
111
|
+
|
|
112
|
+
Rainbow.enabled = false if json_mode
|
|
113
|
+
|
|
114
|
+
emit_diagnostic = lambda do |*lines|
|
|
115
|
+
lines.each { |line| json_mode ? warn(line) : puts(line) }
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
usage_error = lambda do |message, hint|
|
|
119
|
+
warn Rainbow("❌ Error: #{message}").red
|
|
120
|
+
warn Rainbow("💡 #{hint}").yellow
|
|
121
|
+
exit 2
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
guard_interrupt = lambda do |&block|
|
|
125
|
+
block.call
|
|
126
|
+
rescue Interrupt
|
|
127
|
+
warn 'Interrupted: run stopped, workspaces cleaned up, partial results discarded.'
|
|
128
|
+
exit 130
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
selected_reporters = nil
|
|
132
|
+
if options[:reporters]
|
|
133
|
+
selected_reporters = options[:reporters].map { |name| name.strip.to_sym }
|
|
134
|
+
unknown = selected_reporters - KNOWN_REPORTERS
|
|
135
|
+
unless unknown.empty?
|
|
136
|
+
warn Rainbow("❌ Error: Unknown reporter(s): #{unknown.map(&:to_s).join(", ")}").red
|
|
137
|
+
warn Rainbow("💡 Valid reporters: #{KNOWN_REPORTERS.map(&:to_s).join(", ")}").yellow
|
|
138
|
+
exit 1
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
glob_mode = !options[:glob].nil?
|
|
143
|
+
staged_mode = options.fetch(:staged, false)
|
|
144
|
+
|
|
145
|
+
apply_configuration = lambda do
|
|
146
|
+
MutationTester.configure do |config|
|
|
147
|
+
config.parallel_processes = options[:parallel] if options[:parallel]
|
|
148
|
+
config.runner = options[:runner] if options[:runner]
|
|
149
|
+
config.verbose = options.fetch(:verbose, false)
|
|
150
|
+
config.show_progress = !options[:no_progress]
|
|
151
|
+
config.reporters = selected_reporters if selected_reporters
|
|
152
|
+
config.output_dir = options[:output_dir] if options[:output_dir]
|
|
153
|
+
config.test_selection = false if options[:no_test_selection]
|
|
154
|
+
config.worker_env_var = options[:worker_env] if options.key?(:worker_env)
|
|
155
|
+
config.fail_fast = options.fetch(:fail_fast, false)
|
|
156
|
+
config.mutation_types[:strict_equality] = true if options[:strict_equality]
|
|
157
|
+
|
|
158
|
+
if json_mode
|
|
159
|
+
config.reporters = []
|
|
160
|
+
config.show_progress = false
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
run_batch_machine = lambda do |batch, passed_for|
|
|
166
|
+
real_stdout = $stdout
|
|
167
|
+
$stdout = $stderr
|
|
168
|
+
begin
|
|
169
|
+
result = guard_interrupt.call { batch.run }
|
|
170
|
+
ensure
|
|
171
|
+
$stdout = real_stdout
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
passed = passed_for.call(result)
|
|
175
|
+
reporter = MutationTester::Reporters::BatchJsonReporter.new(result, MutationTester.configuration, passed: passed)
|
|
176
|
+
real_stdout.puts reporter.render
|
|
177
|
+
exit(passed ? 0 : 1)
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
if options[:since] && !glob_mode
|
|
181
|
+
usage_error.call('--since requires --glob (batch mode).',
|
|
182
|
+
'Usage: mutation_test --glob "lib/**/*.rb" --since origin/main')
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
if staged_mode && glob_mode
|
|
186
|
+
usage_error.call('--staged cannot be combined with --glob.',
|
|
187
|
+
'Use --staged for the git staging area or --glob for a pattern, not both.')
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
if staged_mode && !ARGV.empty?
|
|
191
|
+
usage_error.call('--staged cannot be combined with positional FILE arguments.',
|
|
192
|
+
'With --staged the file list comes from the git staging area; drop the positional arguments.')
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
if glob_mode
|
|
196
|
+
unless ARGV.empty?
|
|
197
|
+
usage_error.call('SOURCE_FILE/TEST_FILE positional arguments cannot be combined with --glob.',
|
|
198
|
+
'In batch mode the files come from --glob; drop the positional arguments.')
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
changed_files = nil
|
|
202
|
+
if options[:since]
|
|
203
|
+
begin
|
|
204
|
+
changed_files = MutationTester::BatchRunner.changed_files_since(options[:since])
|
|
205
|
+
rescue MutationTester::Error => e
|
|
206
|
+
warn Rainbow("❌ Error: --since #{options[:since]} is unusable: #{e.message}").red
|
|
207
|
+
warn Rainbow('💡 Run inside a git repository and pass an existing revision (e.g. origin/main or HEAD~1).').yellow
|
|
208
|
+
exit 2
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
apply_configuration.call
|
|
213
|
+
|
|
214
|
+
batch = MutationTester::BatchRunner.new(
|
|
215
|
+
glob: options[:glob],
|
|
216
|
+
spec_template: options[:spec_glob],
|
|
217
|
+
config: MutationTester.configuration,
|
|
218
|
+
since: options[:since],
|
|
219
|
+
changed_files: changed_files
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
run_batch_machine.call(batch, ->(result) { result.matched_any? && result.success? }) if json_mode
|
|
223
|
+
|
|
224
|
+
result = guard_interrupt.call { batch.run }
|
|
225
|
+
|
|
226
|
+
exit 1 unless result.matched_any?
|
|
227
|
+
|
|
228
|
+
exit(result.success? ? 0 : 1)
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
if !staged_mode && ARGV.empty?
|
|
232
|
+
emit_diagnostic.call(
|
|
233
|
+
Rainbow('❌ Error: No input files given').red,
|
|
234
|
+
Rainbow('💡 Usage: mutation_test FILE... | mutation_test --staged | mutation_test SOURCE_FILE TEST_FILE').yellow,
|
|
235
|
+
Rainbow(' Example: mutation_test lib/calculator.rb').cyan,
|
|
236
|
+
Rainbow(' Run with --help for more options').faint
|
|
237
|
+
)
|
|
238
|
+
exit 1
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
legacy_pair = !staged_mode && ARGV.length == 2 && MutationTester::BatchRunner.test_file?(ARGV[1])
|
|
242
|
+
|
|
243
|
+
if options[:spec_glob] && legacy_pair
|
|
244
|
+
usage_error.call('--spec-glob does not apply to an explicit SOURCE_FILE TEST_FILE pair.',
|
|
245
|
+
'Use --spec-glob with a positional FILE list, --staged, or --glob.')
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
if legacy_pair
|
|
249
|
+
source_file, test_file = ARGV
|
|
250
|
+
else
|
|
251
|
+
sources = ARGV
|
|
252
|
+
if staged_mode
|
|
253
|
+
begin
|
|
254
|
+
sources = MutationTester::BatchRunner.staged_files
|
|
255
|
+
rescue MutationTester::Error => e
|
|
256
|
+
warn Rainbow("❌ Error: --staged is unusable: #{e.message}").red
|
|
257
|
+
warn Rainbow('💡 Run --staged inside a git repository with git installed.').yellow
|
|
258
|
+
exit 2
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
if sources.empty?
|
|
262
|
+
warn Rainbow('❌ Error: --staged found no staged files.').red
|
|
263
|
+
warn Rainbow('💡 Stage changes with git add first; files staged as deleted are ignored.').yellow
|
|
264
|
+
exit 1
|
|
265
|
+
end
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
single_file_json = json_mode && !staged_mode && sources.length == 1
|
|
269
|
+
|
|
270
|
+
if single_file_json
|
|
271
|
+
outcome = MutationTester::BatchRunner.new(files: sources, spec_template: options[:spec_glob]).classify(sources.first)
|
|
272
|
+
if outcome.is_a?(MutationTester::BatchRunner::SkippedEntry)
|
|
273
|
+
reason = MutationTester::BatchRunner::SKIP_REASONS.fetch(outcome.reason)
|
|
274
|
+
detail = outcome.expected_spec ? " (expected #{outcome.expected_spec})" : ''
|
|
275
|
+
warn Rainbow("❌ Error: #{sources.first} cannot be mutation-tested: #{reason}#{detail}").red
|
|
276
|
+
exit 1
|
|
277
|
+
end
|
|
278
|
+
source_file = sources.first
|
|
279
|
+
test_file = outcome
|
|
280
|
+
else
|
|
281
|
+
apply_configuration.call
|
|
282
|
+
|
|
283
|
+
batch = MutationTester::BatchRunner.new(
|
|
284
|
+
files: sources,
|
|
285
|
+
spec_template: options[:spec_glob],
|
|
286
|
+
config: MutationTester.configuration
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
run_batch_machine.call(batch, ->(result) { result.processed.any? && result.success? }) if json_mode
|
|
290
|
+
|
|
291
|
+
result = guard_interrupt.call { batch.run }
|
|
292
|
+
|
|
293
|
+
exit(result.processed.any? && result.success? ? 0 : 1)
|
|
294
|
+
end
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
unless File.exist?(source_file)
|
|
298
|
+
emit_diagnostic.call(
|
|
299
|
+
Rainbow("❌ Error: Source file not found: #{source_file}").red,
|
|
300
|
+
Rainbow('💡 Tip: Make sure the path is correct and the file exists').yellow
|
|
301
|
+
)
|
|
302
|
+
exit 1
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
unless File.exist?(test_file)
|
|
306
|
+
emit_diagnostic.call(
|
|
307
|
+
Rainbow("❌ Error: Test file not found: #{test_file}").red,
|
|
308
|
+
Rainbow('💡 Tip: Make sure the path is correct and the file exists').yellow
|
|
309
|
+
)
|
|
310
|
+
exit 1
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
apply_configuration.call
|
|
314
|
+
|
|
315
|
+
core = MutationTester::Core.new(source_file, test_file)
|
|
316
|
+
|
|
317
|
+
if json_mode
|
|
318
|
+
real_stdout = $stdout
|
|
319
|
+
$stdout = $stderr
|
|
320
|
+
begin
|
|
321
|
+
success = guard_interrupt.call { core.run }
|
|
322
|
+
ensure
|
|
323
|
+
$stdout = real_stdout
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
reporter = MutationTester::Reporters::JsonReporter.new(
|
|
327
|
+
core.results, core.source_file, core.spec_file, core.config, interrupted: core.interrupted?
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
unless core.results.empty?
|
|
331
|
+
FileUtils.mkdir_p(core.config.output_dir)
|
|
332
|
+
$stdout = $stderr
|
|
333
|
+
begin
|
|
334
|
+
reporter.generate
|
|
335
|
+
ensure
|
|
336
|
+
$stdout = real_stdout
|
|
337
|
+
end
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
real_stdout.puts reporter.render
|
|
341
|
+
else
|
|
342
|
+
success = guard_interrupt.call { core.run }
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
exit(success ? 0 : 1)
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
require 'digest'
|
|
2
|
+
require 'open3'
|
|
3
|
+
require 'pathname'
|
|
4
|
+
require 'set'
|
|
5
|
+
|
|
6
|
+
module MutationTester
|
|
7
|
+
class BatchRunner
|
|
8
|
+
NAME_PLACEHOLDER = '{name}'.freeze
|
|
9
|
+
|
|
10
|
+
DEFAULT_SPEC_TEMPLATE = 'spec/{name}_spec.rb'.freeze
|
|
11
|
+
|
|
12
|
+
TEST_FILE_SUFFIXES = ['_spec.rb', '.spec.rb', '_test.rb'].freeze
|
|
13
|
+
|
|
14
|
+
SKIP_REASONS = {
|
|
15
|
+
missing: 'file not found',
|
|
16
|
+
not_ruby: 'not a Ruby source file',
|
|
17
|
+
test_file: 'a test file, not a mutable source',
|
|
18
|
+
no_spec: 'no matching spec file'
|
|
19
|
+
}.freeze
|
|
20
|
+
|
|
21
|
+
SKIP_ORDER = %i[missing not_ruby test_file no_spec].freeze
|
|
22
|
+
|
|
23
|
+
ProcessedEntry = Struct.new(:source_file, :spec_file, :score, :passed, :output_dir, :results, :interrupted, keyword_init: true) do
|
|
24
|
+
def passed?
|
|
25
|
+
passed
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
SkippedEntry = Struct.new(:source_file, :expected_spec, :reason, keyword_init: true)
|
|
30
|
+
|
|
31
|
+
Result = Struct.new(:processed, :skipped, :unchanged, :interrupted, keyword_init: true) do
|
|
32
|
+
def success?
|
|
33
|
+
processed.all?(&:passed?)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def survivors
|
|
37
|
+
processed.flat_map do |entry|
|
|
38
|
+
(entry.results || [])
|
|
39
|
+
.select { |r| Reporters::BaseReporter.status_for(r) == :survived }
|
|
40
|
+
.map do |r|
|
|
41
|
+
{
|
|
42
|
+
file: entry.source_file,
|
|
43
|
+
line: r[:line],
|
|
44
|
+
type: r[:type],
|
|
45
|
+
original: r[:original],
|
|
46
|
+
mutated: r[:mutated]
|
|
47
|
+
}
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def matched_any?
|
|
53
|
+
!(processed.empty? && skipped.empty? && (unchanged || []).empty?)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def interrupted?
|
|
57
|
+
!!interrupted
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def self.changed_files_since(revision, dir: Dir.pwd)
|
|
62
|
+
toplevel = git_capture(dir, 'rev-parse', '--show-toplevel') do |stderr|
|
|
63
|
+
"not a git repository (--since needs one): #{stderr}"
|
|
64
|
+
end.strip
|
|
65
|
+
|
|
66
|
+
git_capture(dir, 'rev-parse', '--verify', '--quiet', "#{revision}^{commit}") do |stderr|
|
|
67
|
+
detail = stderr.empty? ? 'not a commit' : stderr
|
|
68
|
+
"unknown revision #{revision.inspect}: #{detail}"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
diff = git_capture(dir, 'diff', '--name-only', revision) do |stderr|
|
|
72
|
+
"git diff --name-only #{revision} failed: #{stderr}"
|
|
73
|
+
end
|
|
74
|
+
untracked = git_capture(dir, 'ls-files', '--others', '--exclude-standard') do |stderr|
|
|
75
|
+
"git ls-files failed: #{stderr}"
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
(diff.lines + untracked.lines)
|
|
79
|
+
.map(&:strip).reject(&:empty?)
|
|
80
|
+
.map { |path| File.expand_path(path, toplevel) }
|
|
81
|
+
.to_set
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def self.staged_files(dir: Dir.pwd)
|
|
85
|
+
toplevel = git_capture(dir, 'rev-parse', '--show-toplevel') do |stderr|
|
|
86
|
+
"not a git repository (--staged needs one): #{stderr}"
|
|
87
|
+
end.strip
|
|
88
|
+
|
|
89
|
+
staged = git_capture(dir, 'diff', '--cached', '--name-only', '--diff-filter=d') do |stderr|
|
|
90
|
+
"git diff --cached --name-only failed: #{stderr}"
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
base = Pathname.new(File.realpath(dir))
|
|
94
|
+
staged.lines
|
|
95
|
+
.map(&:strip).reject(&:empty?)
|
|
96
|
+
.map { |path| Pathname.new(File.expand_path(path, toplevel)).relative_path_from(base).to_s }
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def self.test_file?(path)
|
|
100
|
+
basename = File.basename(path)
|
|
101
|
+
return true if TEST_FILE_SUFFIXES.any? { |suffix| basename.end_with?(suffix) }
|
|
102
|
+
return true if basename.start_with?('test_') && basename.end_with?('.rb')
|
|
103
|
+
|
|
104
|
+
FrameworkDetector.minitest_content?(path)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def self.git_capture(dir, *args)
|
|
108
|
+
stdout, stderr, status = Open3.capture3('git', '-C', dir, *args)
|
|
109
|
+
raise MutationTester::Error, yield(stderr.strip) unless status.success?
|
|
110
|
+
|
|
111
|
+
stdout
|
|
112
|
+
rescue Errno::ENOENT
|
|
113
|
+
raise MutationTester::Error, 'git executable not found; --since and --staged need git and a git repository'
|
|
114
|
+
end
|
|
115
|
+
private_class_method :git_capture
|
|
116
|
+
|
|
117
|
+
def initialize(glob: nil, files: nil, spec_template: nil, config: MutationTester.configuration, since: nil, changed_files: nil)
|
|
118
|
+
raise ArgumentError, 'provide exactly one of glob: or files:' unless glob.nil? ^ files.nil?
|
|
119
|
+
|
|
120
|
+
@glob = glob
|
|
121
|
+
@files = files
|
|
122
|
+
@spec_template = spec_template.nil? || spec_template.empty? ? DEFAULT_SPEC_TEMPLATE : spec_template
|
|
123
|
+
@config = config
|
|
124
|
+
@since = since
|
|
125
|
+
@changed_files = changed_files
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def run
|
|
129
|
+
sources = @files ? @files.uniq : Dir.glob(@glob).select { |path| File.file?(path) }.sort
|
|
130
|
+
if sources.empty?
|
|
131
|
+
puts Rainbow("❌ No source files matched glob: #{@glob}").red if @glob
|
|
132
|
+
return Result.new(processed: [], skipped: [], unchanged: [])
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
sources, unchanged = partition_by_change(sources)
|
|
136
|
+
if sources.empty?
|
|
137
|
+
puts Rainbow("✓ Nothing to mutate: none of the #{unchanged.size} matched files changed since #{@since}.").green
|
|
138
|
+
return Result.new(processed: [], skipped: [], unchanged: unchanged)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
processed = []
|
|
142
|
+
skipped = []
|
|
143
|
+
interrupted = false
|
|
144
|
+
|
|
145
|
+
sources.each do |source_file|
|
|
146
|
+
outcome = classify(source_file)
|
|
147
|
+
if outcome.is_a?(SkippedEntry)
|
|
148
|
+
skipped << outcome
|
|
149
|
+
next
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
entry, interrupted = run_one(source_file, outcome)
|
|
153
|
+
processed << entry
|
|
154
|
+
break if interrupted
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
result = Result.new(processed: processed, skipped: skipped, unchanged: unchanged, interrupted: interrupted)
|
|
158
|
+
print_summary(result)
|
|
159
|
+
result
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def classify(source_file)
|
|
163
|
+
if @files
|
|
164
|
+
return SkippedEntry.new(source_file: source_file, reason: :missing) unless File.file?(source_file)
|
|
165
|
+
return SkippedEntry.new(source_file: source_file, reason: :not_ruby) unless source_file.end_with?('.rb')
|
|
166
|
+
return SkippedEntry.new(source_file: source_file, reason: :test_file) if self.class.test_file?(source_file)
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
spec_file = spec_path_for(source_file)
|
|
170
|
+
return SkippedEntry.new(source_file: source_file, expected_spec: spec_file, reason: :no_spec) unless File.exist?(spec_file)
|
|
171
|
+
|
|
172
|
+
spec_file
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
private
|
|
176
|
+
|
|
177
|
+
def partition_by_change(sources)
|
|
178
|
+
return [sources, []] if @changed_files.nil?
|
|
179
|
+
|
|
180
|
+
sources.partition { |source| @changed_files.include?(File.expand_path(source)) }
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def run_one(source_file, spec_file)
|
|
184
|
+
print_separator(source_file, spec_file)
|
|
185
|
+
file_config = @config.merge(output_dir: report_subdir_for(source_file))
|
|
186
|
+
core = Core.new(source_file, spec_file, file_config)
|
|
187
|
+
passed = core.run
|
|
188
|
+
entry = ProcessedEntry.new(
|
|
189
|
+
source_file: source_file,
|
|
190
|
+
spec_file: spec_file,
|
|
191
|
+
score: core.mutation_score,
|
|
192
|
+
passed: passed,
|
|
193
|
+
output_dir: file_config.output_dir,
|
|
194
|
+
results: core.results,
|
|
195
|
+
interrupted: core.interrupted?
|
|
196
|
+
)
|
|
197
|
+
[entry, core.interrupted?]
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def spec_path_for(source_file)
|
|
201
|
+
@spec_template.gsub(NAME_PLACEHOLDER, source_name(source_file))
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def source_name(source_file)
|
|
205
|
+
source_file.sub(%r{\A\./}, '').sub(%r{\Alib/}, '').sub(/\.rb\z/, '')
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def report_subdir_for(source_file)
|
|
209
|
+
slug = source_file.gsub(/[^0-9A-Za-z]+/, '_').gsub(/\A_+|_+\z/, '')
|
|
210
|
+
digest = Digest::SHA256.hexdigest(source_file)[0, 10]
|
|
211
|
+
File.join(@config.output_dir, "#{slug}_#{digest}")
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def print_separator(source_file, spec_file)
|
|
215
|
+
puts
|
|
216
|
+
puts Rainbow('=' * 80).bright
|
|
217
|
+
puts Rainbow("Testing: #{source_file}").cyan
|
|
218
|
+
puts Rainbow("Spec: #{spec_file}").cyan
|
|
219
|
+
puts Rainbow('=' * 80).bright
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def print_summary(result)
|
|
223
|
+
puts
|
|
224
|
+
puts Rainbow('=' * 80).bright
|
|
225
|
+
unchanged_note = (result.unchanged || []).empty? ? '' : ", #{result.unchanged.size} unchanged since #{@since}"
|
|
226
|
+
puts Rainbow("📦 Batch summary: #{result.processed.size} processed, #{result.skipped.size} skipped#{unchanged_note}").bright.cyan
|
|
227
|
+
puts Rainbow('=' * 80).bright
|
|
228
|
+
|
|
229
|
+
result.processed.each do |entry|
|
|
230
|
+
status = entry.passed? ? Rainbow('PASS').green : Rainbow('FAIL').red
|
|
231
|
+
puts "#{status} #{format('%6.2f', entry.score)}% #{entry.source_file}"
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
print_skipped(result.skipped)
|
|
235
|
+
|
|
236
|
+
unless (result.unchanged || []).empty?
|
|
237
|
+
puts Rainbow('-' * 80).faint
|
|
238
|
+
puts Rainbow("SKIPPED (unchanged since #{@since}):").yellow
|
|
239
|
+
result.unchanged.each do |source_file|
|
|
240
|
+
puts Rainbow(" - #{source_file}").yellow
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
if result.interrupted?
|
|
245
|
+
puts Rainbow('-' * 80).faint
|
|
246
|
+
puts Rainbow('🛑 Batch interrupted by --fail-fast: a mutant survived; remaining files were not run.').red
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
puts Rainbow('=' * 80).bright
|
|
250
|
+
if @files && result.processed.empty?
|
|
251
|
+
puts Rainbow('❌ No files were mutation-tested: every listed file was skipped').red
|
|
252
|
+
elsif result.success?
|
|
253
|
+
puts Rainbow('✓ All processed files met the mutation score threshold').green
|
|
254
|
+
else
|
|
255
|
+
puts Rainbow('❌ One or more files did not meet the mutation score threshold').red
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
print_survivors(result.survivors)
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def print_survivors(survivors)
|
|
262
|
+
return if survivors.empty?
|
|
263
|
+
|
|
264
|
+
puts Rainbow('-' * 80).faint
|
|
265
|
+
puts Rainbow("🧟 Surviving mutants (#{survivors.size}):").red
|
|
266
|
+
survivors.each do |survivor|
|
|
267
|
+
puts Rainbow(" #{survivor[:file]}:#{survivor[:line]} #{survivor[:original]} -> #{survivor[:mutated]}").red
|
|
268
|
+
end
|
|
269
|
+
puts Rainbow('A surviving mutant is a change to your code that your tests do not detect; add or strengthen a test that fails on it.').yellow
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
def print_skipped(skipped)
|
|
273
|
+
SKIP_ORDER.each do |reason|
|
|
274
|
+
entries = skipped.select { |entry| (entry.reason || :no_spec) == reason }
|
|
275
|
+
next if entries.empty?
|
|
276
|
+
|
|
277
|
+
puts Rainbow('-' * 80).faint
|
|
278
|
+
puts Rainbow("SKIPPED (#{SKIP_REASONS.fetch(reason)}):").yellow
|
|
279
|
+
entries.each do |entry|
|
|
280
|
+
suffix = entry.expected_spec ? " (expected #{entry.expected_spec})" : ''
|
|
281
|
+
puts Rainbow(" - #{entry.source_file}#{suffix}").yellow
|
|
282
|
+
end
|
|
283
|
+
end
|
|
284
|
+
end
|
|
285
|
+
end
|
|
286
|
+
end
|