evilution 0.20.0 → 0.22.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 (49) hide show
  1. checksums.yaml +4 -4
  2. data/.beads/.gitignore +4 -0
  3. data/.beads/.migration-hint-ts +1 -1
  4. data/.beads/interactions.jsonl +12 -0
  5. data/.beads/issues.jsonl +22 -19
  6. data/CHANGELOG.md +35 -0
  7. data/README.md +17 -11
  8. data/comparison_results/baseline_2026-04-09.md +35 -0
  9. data/comparison_results/operator_classification.md +79 -0
  10. data/comparison_results/operator_prioritization.md +68 -0
  11. data/docs/mutation_density_benchmark.md +91 -0
  12. data/lib/evilution/ast/parser.rb +2 -1
  13. data/lib/evilution/baseline.rb +14 -11
  14. data/lib/evilution/cli.rb +13 -3
  15. data/lib/evilution/config.rb +27 -5
  16. data/lib/evilution/disable_comment.rb +2 -1
  17. data/lib/evilution/integration/base.rb +98 -1
  18. data/lib/evilution/integration/minitest.rb +145 -0
  19. data/lib/evilution/integration/minitest_crash_detector.rb +55 -0
  20. data/lib/evilution/integration/rspec.rb +33 -92
  21. data/lib/evilution/isolation/fork.rb +3 -6
  22. data/lib/evilution/mcp/mutate_tool.rb +6 -6
  23. data/lib/evilution/mutator/base.rb +5 -1
  24. data/lib/evilution/mutator/operator/bitwise_complement.rb +1 -1
  25. data/lib/evilution/mutator/operator/block_pass_removal.rb +30 -0
  26. data/lib/evilution/mutator/operator/ensure_removal.rb +1 -1
  27. data/lib/evilution/mutator/operator/index_to_at.rb +30 -0
  28. data/lib/evilution/mutator/operator/index_to_dig.rb +3 -3
  29. data/lib/evilution/mutator/operator/index_to_fetch.rb +2 -2
  30. data/lib/evilution/mutator/operator/keyword_argument.rb +1 -1
  31. data/lib/evilution/mutator/operator/regex_simplification.rb +169 -0
  32. data/lib/evilution/mutator/operator/rescue_body_replacement.rb +1 -1
  33. data/lib/evilution/mutator/operator/rescue_removal.rb +1 -1
  34. data/lib/evilution/mutator/operator/string_literal.rb +18 -0
  35. data/lib/evilution/mutator/registry.rb +12 -2
  36. data/lib/evilution/reporter/html.rb +2 -2
  37. data/lib/evilution/reporter/json.rb +2 -2
  38. data/lib/evilution/reporter/suggestion.rb +659 -2
  39. data/lib/evilution/runner.rb +59 -13
  40. data/lib/evilution/spec_resolver.rb +24 -16
  41. data/lib/evilution/temp_dir_tracker.rb +39 -0
  42. data/lib/evilution/version.rb +1 -1
  43. data/lib/evilution.rb +4 -0
  44. data/scripts/benchmark_density +261 -0
  45. data/scripts/benchmark_density.yml +19 -0
  46. data/scripts/compare_mutations +404 -0
  47. data/scripts/compare_mutations.yml +24 -0
  48. data/scripts/mutant_json_adapter +224 -0
  49. metadata +17 -2
@@ -0,0 +1,404 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Head-to-Head Mutation Comparison
5
+ #
6
+ # Runs evilution and a reference mutation testing tool on a set of files,
7
+ # catalogs every mutation the reference tool produces that evilution doesn't,
8
+ # and outputs a structured comparison report.
9
+ #
10
+ # Usage:
11
+ # scripts/compare_mutations CONFIG_FILE [--verbose]
12
+ #
13
+ # See scripts/compare_mutations.yml for config format.
14
+
15
+ require "yaml"
16
+ require "json"
17
+ require "open3"
18
+ require "optparse"
19
+ require "fileutils"
20
+
21
+ module CompareMutations
22
+ class ConfigError < StandardError; end
23
+ class ToolError < StandardError; end
24
+
25
+ class Config
26
+ attr_reader :project_root, :reference_cmd, :output_dir, :files
27
+
28
+ def initialize(path)
29
+ data = YAML.safe_load_file(path, symbolize_names: true, permitted_classes: [Symbol])
30
+ raise ConfigError, "config file must contain a YAML mapping" unless data.is_a?(Hash)
31
+
32
+ @project_root = data[:project_root]
33
+ @reference_cmd = data.key?(:reference_cmd) ? data[:reference_cmd] : []
34
+ @output_dir = data[:output_dir] || "comparison_results"
35
+ @files = data.key?(:files) ? data[:files] : []
36
+
37
+ validate!
38
+ rescue Psych::Exception => e
39
+ raise ConfigError, "failed to parse config: #{e.message}"
40
+ end
41
+
42
+ private
43
+
44
+ def validate!
45
+ raise ConfigError, "project_root is required in config" if @project_root.nil?
46
+ raise ConfigError, "project_root does not exist: #{@project_root}" unless Dir.exist?(@project_root)
47
+ raise ConfigError, "reference_cmd is required in config" if @reference_cmd.empty?
48
+ raise ConfigError, "files list is empty — add files to the config" if @files.empty?
49
+
50
+ @files.each_with_index { |entry, index| validate_file_entry!(entry, index) }
51
+ end
52
+
53
+ def validate_file_entry!(entry, index)
54
+ raise ConfigError, "files[#{index}] must be a mapping" unless entry.is_a?(Hash)
55
+
56
+ path = entry[:path]
57
+ target = entry[:reference_target]
58
+ raise ConfigError, "files[#{index}] is missing required path" if path.nil? || path.to_s.strip.empty?
59
+ raise ConfigError, "files[#{index}] is missing required reference_target" if target.nil? || target.to_s.strip.empty?
60
+ end
61
+ end
62
+
63
+ class MutationSet
64
+ attr_reader :mutations, :fingerprints
65
+
66
+ def self.from_json(data)
67
+ mutations = data.map do |entry|
68
+ entry = entry.transform_keys(&:to_s)
69
+ entry["fingerprint"] = compute_fingerprint(entry)
70
+ entry
71
+ end
72
+ new(mutations)
73
+ end
74
+
75
+ def self.compute_fingerprint(entry)
76
+ normalized_diff = entry["diff"].to_s.gsub(/[[:space:]]/, "")
77
+ "#{entry["line"]}:#{normalized_diff}"
78
+ end
79
+
80
+ def initialize(mutations)
81
+ @mutations = mutations
82
+ @fingerprints = mutations.map { |m| m["fingerprint"] }
83
+ end
84
+
85
+ def size
86
+ @mutations.size
87
+ end
88
+
89
+ def empty?
90
+ @mutations.empty?
91
+ end
92
+ end
93
+
94
+ class Comparison
95
+ attr_reader :evilution_set, :reference_set
96
+
97
+ def initialize(evilution:, reference:)
98
+ @evilution_set = evilution
99
+ @reference_set = reference
100
+ end
101
+
102
+ def extra_in_reference
103
+ ev_fps = Set.new(@evilution_set.fingerprints)
104
+ @reference_set.mutations.reject { |m| ev_fps.include?(m["fingerprint"]) }
105
+ end
106
+
107
+ def extra_in_evilution
108
+ ref_fps = Set.new(@reference_set.fingerprints)
109
+ @evilution_set.mutations.reject { |m| ref_fps.include?(m["fingerprint"]) }
110
+ end
111
+
112
+ def density_ratio
113
+ return 0.0 if @evilution_set.empty?
114
+
115
+ @reference_set.size.to_f / @evilution_set.size
116
+ end
117
+ end
118
+
119
+ class Catalog
120
+ def initialize(extras)
121
+ @extras = extras
122
+ end
123
+
124
+ def by_operator
125
+ @extras.group_by { |m| m["operator"] }
126
+ end
127
+
128
+ def summary
129
+ by_operator.map { |op, mutations| { operator: op, count: mutations.size } }
130
+ .sort_by { |entry| -entry[:count] }
131
+ end
132
+ end
133
+
134
+ class EvilutionCollector
135
+ def initialize(project_root)
136
+ @project_root = project_root
137
+ end
138
+
139
+ def collect(file_path)
140
+ full_path = File.join(@project_root, file_path)
141
+ cmd = ["bundle", "exec", "evilution", "util", "mutation", full_path, "--format", "json"]
142
+ stdout, stderr, status = Open3.capture3(*cmd, chdir: @project_root)
143
+
144
+ raise ToolError, "evilution failed for #{file_path}: #{stderr.strip}" unless status.success?
145
+
146
+ JSON.parse(stdout)
147
+ rescue JSON::ParserError => e
148
+ raise ToolError, "evilution produced invalid JSON for #{file_path}: #{e.message}"
149
+ end
150
+ end
151
+
152
+ class ReferenceCollector
153
+ def initialize(project_root, reference_cmd)
154
+ @project_root = project_root
155
+ @reference_cmd = reference_cmd
156
+ end
157
+
158
+ def collect(reference_target)
159
+ cmd = @reference_cmd + [reference_target]
160
+ stdout, stderr, status = Open3.capture3(*cmd, chdir: @project_root)
161
+
162
+ raise ToolError, "reference tool failed for #{reference_target}: #{stderr.lines.first&.strip}" unless status.success?
163
+
164
+ JSON.parse(stdout)
165
+ rescue JSON::ParserError => e
166
+ raise ToolError, "reference tool produced invalid JSON for #{reference_target}: #{e.message}"
167
+ end
168
+ end
169
+
170
+ class Reporter
171
+ def initialize(output_dir)
172
+ @output_dir = output_dir
173
+ end
174
+
175
+ def write_file_report(file_path, comparison, catalog)
176
+ basename = File.basename(file_path, ".rb")
177
+ write_json_report(basename, file_path, comparison, catalog)
178
+ write_text_report(basename, file_path, comparison, catalog)
179
+ end
180
+
181
+ def write_summary(file_results)
182
+ write_summary_json(file_results)
183
+ write_summary_text(file_results)
184
+ end
185
+
186
+ private
187
+
188
+ def write_json_report(basename, file_path, comparison, catalog)
189
+ data = {
190
+ file: file_path,
191
+ evilution_count: comparison.evilution_set.size,
192
+ reference_count: comparison.reference_set.size,
193
+ density_ratio: comparison.density_ratio.round(2),
194
+ extra_in_reference: comparison.extra_in_reference,
195
+ extra_in_evilution: comparison.extra_in_evilution,
196
+ operator_summary: catalog.summary
197
+ }
198
+ File.write(File.join(@output_dir, "#{basename}.json"), JSON.pretty_generate(data))
199
+ end
200
+
201
+ def write_text_report(basename, file_path, comparison, catalog)
202
+ lines = build_file_header(file_path, comparison)
203
+ lines.concat(build_extra_reference_section(comparison, catalog))
204
+ lines.concat(build_extra_evilution_section(comparison))
205
+ File.write(File.join(@output_dir, "#{basename}.txt"), lines.join("\n"))
206
+ end
207
+
208
+ def build_file_header(file_path, comparison)
209
+ [
210
+ "# Mutation Comparison: #{file_path}",
211
+ "",
212
+ "Evilution: #{comparison.evilution_set.size} mutations",
213
+ "Reference: #{comparison.reference_set.size} mutations",
214
+ "Ratio: #{comparison.density_ratio.round(2)}x",
215
+ ""
216
+ ]
217
+ end
218
+
219
+ def build_extra_reference_section(comparison, catalog)
220
+ extra = comparison.extra_in_reference
221
+ lines = ["## Extra in reference (#{extra.size})", ""]
222
+
223
+ catalog.summary.each do |entry|
224
+ lines << "### #{entry[:operator]} (#{entry[:count]})"
225
+ catalog.by_operator[entry[:operator]].each do |m|
226
+ lines << " Line #{m["line"]}:"
227
+ m["diff"].to_s.each_line { |l| lines << " #{l.chomp}" }
228
+ lines << ""
229
+ end
230
+ end
231
+
232
+ lines
233
+ end
234
+
235
+ def build_extra_evilution_section(comparison)
236
+ ev_extra = comparison.extra_in_evilution
237
+ return [] if ev_extra.empty?
238
+
239
+ lines = ["## Extra in evilution (#{ev_extra.size})", ""]
240
+ ev_extra.each do |m|
241
+ lines << " #{m["operator"]} line #{m["line"]}: #{m["diff"].to_s.lines.first&.chomp}"
242
+ end
243
+ lines
244
+ end
245
+
246
+ def write_summary_json(file_results)
247
+ data = {
248
+ timestamp: Time.now.iso8601,
249
+ files: file_results,
250
+ totals: compute_summary_totals(file_results)
251
+ }
252
+ File.write(File.join(@output_dir, "summary.json"), JSON.pretty_generate(data))
253
+ end
254
+
255
+ def write_summary_text(file_results)
256
+ totals = compute_summary_totals(file_results)
257
+ lines = build_summary_header
258
+ lines.concat(build_summary_rows(file_results))
259
+ lines.concat(build_summary_totals(totals))
260
+ lines.concat(build_operator_breakdown(totals))
261
+ File.write(File.join(@output_dir, "summary.txt"), lines.join("\n"))
262
+ end
263
+
264
+ def build_summary_header
265
+ header = "File " \
266
+ "Evilution Reference Ratio Extra"
267
+ ["# Head-to-Head Mutation Comparison Summary", "", header, ("-" * 83)]
268
+ end
269
+
270
+ def build_summary_rows(file_results)
271
+ file_results.map do |r|
272
+ format("%-45<file>s %10<ev>d %10<ref>d %7.2<ratio>fx %8<extra>d",
273
+ file: r[:file], ev: r[:evilution_count], ref: r[:reference_count],
274
+ ratio: r[:density_ratio], extra: r[:extra_count])
275
+ end
276
+ end
277
+
278
+ def build_summary_totals(totals)
279
+ total_line = format("%-45<file>s %10<ev>d %10<ref>d %7.2<ratio>fx %8<extra>d",
280
+ file: "TOTAL", ev: totals[:evilution_total], ref: totals[:reference_total],
281
+ ratio: totals[:density_ratio], extra: totals[:extra_total])
282
+ [("-" * 83), total_line, ""]
283
+ end
284
+
285
+ def build_operator_breakdown(totals)
286
+ lines = ["## Operator breakdown (reference extras)", ""]
287
+ totals[:operator_summary].each do |entry|
288
+ lines << " #{entry[:operator]}: #{entry[:count]}"
289
+ end
290
+ lines
291
+ end
292
+
293
+ def compute_summary_totals(file_results)
294
+ ev_total = file_results.sum { |r| r[:evilution_count] }
295
+ ref_total = file_results.sum { |r| r[:reference_count] }
296
+ extra_total = file_results.sum { |r| r[:extra_count] }
297
+ ratio = ev_total.positive? ? (ref_total.to_f / ev_total).round(2) : 0.0
298
+ merged = merge_operator_summaries(file_results)
299
+
300
+ { evilution_total: ev_total, reference_total: ref_total,
301
+ density_ratio: ratio, extra_total: extra_total, operator_summary: merged }
302
+ end
303
+
304
+ def merge_operator_summaries(file_results)
305
+ all_operators = file_results.flat_map { |r| r[:operator_summary] }
306
+ all_operators.group_by { |e| e[:operator] }
307
+ .map { |op, entries| { operator: op, count: entries.sum { |e| e[:count] } } }
308
+ .sort_by { |e| -e[:count] }
309
+ end
310
+ end
311
+
312
+ class Runner
313
+ def initialize(config:, verbose: false)
314
+ @config = config
315
+ @verbose = verbose
316
+ @evilution = EvilutionCollector.new(config.project_root)
317
+ @reference = ReferenceCollector.new(config.project_root, config.reference_cmd)
318
+ end
319
+
320
+ def call
321
+ FileUtils.mkdir_p(@config.output_dir)
322
+ reporter = Reporter.new(@config.output_dir)
323
+ file_results = process_files(reporter)
324
+ reporter.write_summary(file_results)
325
+ print_summary(file_results)
326
+ 0
327
+ rescue ToolError => e
328
+ warn "Tool error: #{e.message}"
329
+ 1
330
+ end
331
+
332
+ private
333
+
334
+ def process_files(reporter)
335
+ @config.files.map do |entry|
336
+ path = entry[:path]
337
+ reference_target = entry[:reference_target]
338
+
339
+ warn " Comparing #{path}..." if @verbose
340
+ compare_file(path, reference_target, reporter)
341
+ end
342
+ end
343
+
344
+ def compare_file(path, _reference_target, reporter)
345
+ full_path = File.join(@config.project_root, path)
346
+ ev_data = @evilution.collect(path)
347
+ ref_data = @reference.collect(full_path)
348
+
349
+ ev_set = MutationSet.from_json(ev_data)
350
+ ref_set = MutationSet.from_json(ref_data)
351
+ comparison = Comparison.new(evilution: ev_set, reference: ref_set)
352
+ catalog = Catalog.new(comparison.extra_in_reference)
353
+
354
+ reporter.write_file_report(path, comparison, catalog)
355
+
356
+ { file: path, evilution_count: ev_set.size, reference_count: ref_set.size,
357
+ density_ratio: comparison.density_ratio.round(2),
358
+ extra_count: comparison.extra_in_reference.size,
359
+ operator_summary: catalog.summary }
360
+ end
361
+
362
+ def print_summary(file_results)
363
+ ev_total = file_results.sum { |r| r[:evilution_count] }
364
+ ref_total = file_results.sum { |r| r[:reference_count] }
365
+ extra_total = file_results.sum { |r| r[:extra_count] }
366
+ ratio = ev_total.positive? ? (ref_total.to_f / ev_total).round(2) : 0.0
367
+
368
+ puts "Comparison complete. Results in #{@config.output_dir}/"
369
+ puts " Files: #{file_results.size}"
370
+ puts " Evilution: #{ev_total} mutations"
371
+ puts " Reference: #{ref_total} mutations"
372
+ puts " Ratio: #{ratio}x"
373
+ puts " Extra in reference: #{extra_total}"
374
+ end
375
+ end
376
+ end
377
+
378
+ if __FILE__ == $PROGRAM_NAME
379
+ verbose = false
380
+ parser = OptionParser.new do |opts|
381
+ opts.banner = "Usage: #{$PROGRAM_NAME} CONFIG_FILE [--verbose]"
382
+ opts.on("-v", "--verbose", "Show progress") { verbose = true }
383
+ opts.on("-h", "--help", "Show help") do
384
+ puts opts
385
+ exit 0
386
+ end
387
+ end
388
+ parser.parse!
389
+
390
+ config_path = ARGV.first
391
+ unless config_path
392
+ warn parser.banner
393
+ exit 2
394
+ end
395
+
396
+ begin
397
+ config = CompareMutations::Config.new(config_path)
398
+ runner = CompareMutations::Runner.new(config: config, verbose: verbose)
399
+ exit runner.call
400
+ rescue CompareMutations::ConfigError => e
401
+ warn "Config error: #{e.message}"
402
+ exit 2
403
+ end
404
+ end
@@ -0,0 +1,24 @@
1
+ # Head-to-head mutation comparison configuration
2
+ #
3
+ # Each file entry specifies a source file and the corresponding reference tool
4
+ # subject pattern. The reference_cmd must produce JSON output with the same
5
+ # schema as `evilution util mutation --format json`:
6
+ # [{"operator": "...", "line": N, "diff": "..."}, ...]
7
+ #
8
+ # To use: set project_root and reference_cmd for your environment.
9
+ #
10
+ # Example:
11
+ #
12
+ # project_root: /path/to/rails/app
13
+ # reference_cmd: ["ruby", "scripts/mutant_json_adapter", "--project-root", "/path/to/rails/app"]
14
+ # output_dir: comparison_results
15
+ # files:
16
+ # - path: app/models/user.rb
17
+ # reference_target: "User"
18
+ # - path: app/services/payment_processor.rb
19
+ # reference_target: "PaymentProcessor"
20
+
21
+ project_root: null
22
+ reference_cmd: []
23
+ output_dir: comparison_results
24
+ files: []
@@ -0,0 +1,224 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Mutant JSON Adapter
5
+ #
6
+ # Extracts method bodies from a Ruby file using Prism, feeds each to
7
+ # `mutant util mutation -e`, parses the diff output, and produces JSON
8
+ # in the same schema as `evilution util mutation --format json`:
9
+ #
10
+ # [{"operator": "...", "subject": "...", "file": "...", "line": N, "diff": "..."}, ...]
11
+ #
12
+ # This works around mutant's inability to parse Ruby 4.0 files by processing
13
+ # individual method snippets through its eval mode.
14
+ #
15
+ # Usage:
16
+ # scripts/mutant_json_adapter FILE_PATH [--project-root DIR] [--verbose]
17
+
18
+ require "json"
19
+ require "open3"
20
+ require "optparse"
21
+ require "prism"
22
+
23
+ module MutantJsonAdapter
24
+ class ParseError < StandardError; end
25
+
26
+ class MethodExtractor
27
+ MethodInfo = Struct.new(:name, :line_number, :source)
28
+
29
+ def initialize(file_path)
30
+ @file_path = file_path
31
+ end
32
+
33
+ def extract
34
+ source = File.read(@file_path)
35
+ binary = source.b
36
+ encoding = source.encoding
37
+ result = Prism.parse(source)
38
+
39
+ raise ParseError, "failed to parse #{@file_path}" if result.failure?
40
+
41
+ visitor = MethodVisitor.new(binary, encoding)
42
+ visitor.visit(result.value)
43
+ visitor.methods
44
+ end
45
+
46
+ class MethodVisitor < Prism::Visitor
47
+ attr_reader :methods
48
+
49
+ def initialize(source_binary, encoding)
50
+ super()
51
+ @source_binary = source_binary
52
+ @encoding = encoding
53
+ @methods = []
54
+ @context = []
55
+ end
56
+
57
+ def visit_module_node(node)
58
+ @context.push(node.constant_path&.full_name || node.name.to_s)
59
+ super
60
+ @context.pop
61
+ end
62
+
63
+ def visit_class_node(node)
64
+ @context.push(node.constant_path&.full_name || node.name.to_s)
65
+ super
66
+ @context.pop
67
+ end
68
+
69
+ def visit_def_node(node)
70
+ loc = node.location
71
+ text = @source_binary[loc.start_offset...loc.end_offset].force_encoding(@encoding)
72
+ separator = node.receiver ? "." : "#"
73
+ name = "#{@context.join("::")}#{separator}#{node.name}"
74
+
75
+ @methods << MethodInfo.new(name: name, line_number: loc.start_line, source: text)
76
+ super
77
+ end
78
+ end
79
+ end
80
+
81
+ class MutantRunner
82
+ def initialize(project_root: nil)
83
+ @project_root = project_root
84
+ end
85
+
86
+ def mutations_for(source)
87
+ cmd = ["bundle", "exec", "mutant", "util", "mutation", "-e", source]
88
+ opts = @project_root ? { chdir: @project_root } : {}
89
+ stdout, _stderr, _status = Open3.capture3(*cmd, **opts)
90
+ stdout
91
+ end
92
+ end
93
+
94
+ class OutputParser
95
+ def parse(raw_output, subject:, file_path:)
96
+ return [] if raw_output.strip.empty?
97
+
98
+ mutations = []
99
+ current_diff_lines = []
100
+
101
+ raw_output.each_line do |line|
102
+ if line.start_with?("@@ ")
103
+ flush_mutation(mutations, current_diff_lines, subject, file_path) if current_diff_lines.any?
104
+ current_diff_lines = [line]
105
+ elsif line.start_with?("-", "+", " ") && current_diff_lines.any?
106
+ current_diff_lines << line
107
+ end
108
+ end
109
+
110
+ flush_mutation(mutations, current_diff_lines, subject, file_path) if current_diff_lines.any?
111
+ mutations
112
+ end
113
+
114
+ private
115
+
116
+ def flush_mutation(mutations, diff_lines, subject, file_path)
117
+ diff_text = diff_lines.join
118
+ operator = infer_operator(diff_lines)
119
+
120
+ mutations << {
121
+ "operator" => operator,
122
+ "subject" => subject.name,
123
+ "file" => file_path,
124
+ "line" => subject.line_number,
125
+ "diff" => diff_text
126
+ }
127
+ diff_lines.clear
128
+ end
129
+
130
+ def infer_operator(diff_lines)
131
+ removed = diff_lines.select { |l| l.start_with?("-") && !l.start_with?("---") }
132
+ .map { |l| l[1..].strip }
133
+ added = diff_lines.select { |l| l.start_with?("+") && !l.start_with?("+++") }
134
+ .map { |l| l[1..].strip }
135
+
136
+ categorize_mutation(removed, added)
137
+ end
138
+
139
+ def categorize_mutation(removed, added)
140
+ return "replacement" if removed.empty?
141
+
142
+ body_lines = added.reject { |l| l == "end" || l.start_with?("def ") }
143
+ return "removal" if body_lines.empty?
144
+
145
+ classify_added(body_lines.first)
146
+ end
147
+
148
+ def classify_added(added_body)
149
+ case added_body
150
+ when "raise" then "raise_insertion"
151
+ when "super" then "super_replacement"
152
+ when "nil" then "nil_replacement"
153
+ when "true", "false" then "boolean_swap"
154
+ when /\.eql\?/ then "equality_change"
155
+ when /\.equal\?/ then "identity_change"
156
+ when /-?\d+\b/ then "literal_boundary"
157
+ else "mutation"
158
+ end
159
+ end
160
+ end
161
+
162
+ class Runner
163
+ def initialize(file_path:, project_root: nil, verbose: false)
164
+ @file_path = File.expand_path(file_path)
165
+ @project_root = project_root
166
+ @verbose = verbose
167
+ end
168
+
169
+ def call
170
+ subjects = MethodExtractor.new(@file_path).extract
171
+
172
+ if subjects.empty?
173
+ warn "No methods found in #{@file_path}" if @verbose
174
+ puts "[]"
175
+ return 0
176
+ end
177
+
178
+ all_mutations = process_subjects(subjects)
179
+ puts JSON.pretty_generate(all_mutations)
180
+ 0
181
+ rescue ParseError => e
182
+ warn e.message
183
+ 2
184
+ end
185
+
186
+ private
187
+
188
+ def process_subjects(subjects)
189
+ mutant_runner = MutantRunner.new(project_root: @project_root)
190
+ parser = OutputParser.new
191
+
192
+ subjects.flat_map do |subject|
193
+ warn " Processing #{subject.name}..." if @verbose
194
+ raw = mutant_runner.mutations_for(subject.source)
195
+ parser.parse(raw, subject: subject, file_path: @file_path)
196
+ end
197
+ end
198
+ end
199
+ end
200
+
201
+ if __FILE__ == $PROGRAM_NAME
202
+ verbose = false
203
+ project_root = nil
204
+
205
+ opt_parser = OptionParser.new do |opts|
206
+ opts.banner = "Usage: #{$PROGRAM_NAME} FILE_PATH [--project-root DIR] [--verbose]"
207
+ opts.on("-v", "--verbose", "Show progress") { verbose = true }
208
+ opts.on("-p", "--project-root DIR", "Project root for bundle exec") { |d| project_root = d }
209
+ opts.on("-h", "--help", "Show help") do
210
+ puts opts
211
+ exit 0
212
+ end
213
+ end
214
+ opt_parser.parse!
215
+
216
+ file_path = ARGV.first
217
+ unless file_path
218
+ warn opt_parser.banner
219
+ exit 2
220
+ end
221
+
222
+ runner = MutantJsonAdapter::Runner.new(file_path: file_path, project_root: project_root, verbose: verbose)
223
+ exit runner.call
224
+ end