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.
Files changed (47) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +13 -0
  3. data/Gemfile +9 -0
  4. data/Gemfile.lock +83 -0
  5. data/LICENSE.txt +19 -0
  6. data/Rakefile +57 -0
  7. data/docs/ci.md +158 -0
  8. data/docs/execution-runners.md +163 -0
  9. data/docs/json-schema.md +171 -0
  10. data/docs/mutation-types.md +207 -0
  11. data/examples/calculator.rb +35 -0
  12. data/examples/calculator_100_perc_spec.rb +121 -0
  13. data/examples/calculator_minitest.rb +53 -0
  14. data/examples/calculator_spec.rb +105 -0
  15. data/examples/github_actions/ai_mutation_gate.yml +75 -0
  16. data/examples/github_actions/mutation_test.yml +87 -0
  17. data/examples/hooks/pre-push +41 -0
  18. data/examples/run_example.rb +31 -0
  19. data/examples/run_example_minitest.rb +38 -0
  20. data/examples/run_example_parallel_minitest.rb +48 -0
  21. data/examples/run_example_parallel_rspec.rb +48 -0
  22. data/examples/run_example_rspec.rb +38 -0
  23. data/examples/spec_helper.rb +21 -0
  24. data/exe/mutation_test +345 -0
  25. data/lib/mutation_tester/batch_runner.rb +286 -0
  26. data/lib/mutation_tester/configuration.rb +105 -0
  27. data/lib/mutation_tester/core.rb +193 -0
  28. data/lib/mutation_tester/fork_runner/worker.rb +202 -0
  29. data/lib/mutation_tester/fork_runner.rb +382 -0
  30. data/lib/mutation_tester/framework_detector.rb +25 -0
  31. data/lib/mutation_tester/in_memory_loader.rb +25 -0
  32. data/lib/mutation_tester/mutation_runner.rb +644 -0
  33. data/lib/mutation_tester/mutator.rb +874 -0
  34. data/lib/mutation_tester/progress_display.rb +130 -0
  35. data/lib/mutation_tester/railtie.rb +7 -0
  36. data/lib/mutation_tester/rake_task.rb +18 -0
  37. data/lib/mutation_tester/reporters/base_reporter.rb +154 -0
  38. data/lib/mutation_tester/reporters/batch_json_reporter.rb +72 -0
  39. data/lib/mutation_tester/reporters/console_reporter.rb +130 -0
  40. data/lib/mutation_tester/reporters/html_reporter.rb +693 -0
  41. data/lib/mutation_tester/reporters/json_reporter.rb +67 -0
  42. data/lib/mutation_tester/test_command.rb +165 -0
  43. data/lib/mutation_tester/version.rb +3 -0
  44. data/lib/mutation_tester.rb +52 -0
  45. data/lib/tasks/mutation_tester.rake +80 -0
  46. data/readme.md +925 -0
  47. 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