evilution 0.22.7 → 0.24.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 (93) hide show
  1. checksums.yaml +4 -4
  2. data/.beads/interactions.jsonl +8 -0
  3. data/CHANGELOG.md +28 -0
  4. data/README.md +37 -7
  5. data/lib/evilution/cli/command.rb +37 -0
  6. data/lib/evilution/cli/commands/environment_show.rb +20 -0
  7. data/lib/evilution/cli/commands/init.rb +24 -0
  8. data/lib/evilution/cli/commands/mcp.rb +19 -0
  9. data/lib/evilution/cli/commands/run.rb +68 -0
  10. data/lib/evilution/cli/commands/session_diff.rb +30 -0
  11. data/lib/evilution/cli/commands/session_gc.rb +46 -0
  12. data/lib/evilution/cli/commands/session_list.rb +51 -0
  13. data/lib/evilution/cli/commands/session_show.rb +27 -0
  14. data/lib/evilution/cli/commands/subjects.rb +50 -0
  15. data/lib/evilution/cli/commands/tests_list.rb +43 -0
  16. data/lib/evilution/cli/commands/util_mutation.rb +66 -0
  17. data/lib/evilution/cli/commands/version.rb +17 -0
  18. data/lib/evilution/cli/commands.rb +4 -0
  19. data/lib/evilution/cli/dispatcher.rb +23 -0
  20. data/lib/evilution/cli/parsed_args.rb +12 -0
  21. data/lib/evilution/cli/parser/command_extractor.rb +77 -0
  22. data/lib/evilution/cli/parser/file_args.rb +41 -0
  23. data/lib/evilution/cli/parser/options_builder.rb +103 -0
  24. data/lib/evilution/cli/parser/stdin_reader.rb +28 -0
  25. data/lib/evilution/cli/parser.rb +88 -0
  26. data/lib/evilution/cli/printers/environment.rb +53 -0
  27. data/lib/evilution/cli/printers/session_detail.rb +76 -0
  28. data/lib/evilution/cli/printers/session_diff.rb +57 -0
  29. data/lib/evilution/cli/printers/session_list.rb +48 -0
  30. data/lib/evilution/cli/printers/subjects.rb +35 -0
  31. data/lib/evilution/cli/printers/tests_list.rb +45 -0
  32. data/lib/evilution/cli/printers/util_mutation.rb +35 -0
  33. data/lib/evilution/cli/printers.rb +4 -0
  34. data/lib/evilution/cli/result.rb +9 -0
  35. data/lib/evilution/cli.rb +30 -850
  36. data/lib/evilution/config.rb +31 -3
  37. data/lib/evilution/integration/base.rb +23 -55
  38. data/lib/evilution/integration/minitest.rb +22 -4
  39. data/lib/evilution/integration/rspec.rb +28 -8
  40. data/lib/evilution/isolation/fork.rb +11 -9
  41. data/lib/evilution/isolation/in_process.rb +11 -9
  42. data/lib/evilution/mcp/info_tool.rb +261 -0
  43. data/lib/evilution/mcp/mutate_tool.rb +112 -19
  44. data/lib/evilution/mcp/server.rb +3 -4
  45. data/lib/evilution/mcp/session_diff_tool.rb +5 -1
  46. data/lib/evilution/mcp/session_list_tool.rb +5 -1
  47. data/lib/evilution/mcp/session_show_tool.rb +5 -1
  48. data/lib/evilution/mcp/session_tool.rb +157 -0
  49. data/lib/evilution/reporter/cli.rb +2 -1
  50. data/lib/evilution/reporter/html/assets/style.css +68 -0
  51. data/lib/evilution/reporter/html/baseline_keys.rb +28 -0
  52. data/lib/evilution/reporter/html/diff_formatter.rb +27 -0
  53. data/lib/evilution/reporter/html/escape.rb +12 -0
  54. data/lib/evilution/reporter/html/namespace.rb +11 -0
  55. data/lib/evilution/reporter/html/report.rb +68 -0
  56. data/lib/evilution/reporter/html/section.rb +21 -0
  57. data/lib/evilution/reporter/html/sections/baseline_comparison.rb +46 -0
  58. data/lib/evilution/reporter/html/sections/error_details.rb +30 -0
  59. data/lib/evilution/reporter/html/sections/error_entry.rb +22 -0
  60. data/lib/evilution/reporter/html/sections/file_section.rb +47 -0
  61. data/lib/evilution/reporter/html/sections/header.rb +29 -0
  62. data/lib/evilution/reporter/html/sections/mutation_map.rb +32 -0
  63. data/lib/evilution/reporter/html/sections/summary_cards.rb +11 -0
  64. data/lib/evilution/reporter/html/sections/survived_details.rb +35 -0
  65. data/lib/evilution/reporter/html/sections/survived_entry.rb +36 -0
  66. data/lib/evilution/reporter/html/sections/truncation_notice.rb +17 -0
  67. data/lib/evilution/reporter/html/sections.rb +4 -0
  68. data/lib/evilution/reporter/html/stylesheet.rb +14 -0
  69. data/lib/evilution/reporter/html/templates/baseline_comparison.html.erb +8 -0
  70. data/lib/evilution/reporter/html/templates/error_details.html.erb +6 -0
  71. data/lib/evilution/reporter/html/templates/error_entry.html.erb +10 -0
  72. data/lib/evilution/reporter/html/templates/file_section.html.erb +9 -0
  73. data/lib/evilution/reporter/html/templates/header.html.erb +4 -0
  74. data/lib/evilution/reporter/html/templates/mutation_map.html.erb +6 -0
  75. data/lib/evilution/reporter/html/templates/report.html.erb +17 -0
  76. data/lib/evilution/reporter/html/templates/summary_cards.html.erb +23 -0
  77. data/lib/evilution/reporter/html/templates/survived_details.html.erb +21 -0
  78. data/lib/evilution/reporter/html/templates/survived_entry.html.erb +8 -0
  79. data/lib/evilution/reporter/html/templates/truncation_notice.html.erb +1 -0
  80. data/lib/evilution/reporter/html.rb +11 -349
  81. data/lib/evilution/reporter/json.rb +12 -8
  82. data/lib/evilution/result/mutation_result.rb +5 -1
  83. data/lib/evilution/result/summary.rb +9 -1
  84. data/lib/evilution/runner/baseline_runner.rb +71 -0
  85. data/lib/evilution/runner/diagnostics.rb +105 -0
  86. data/lib/evilution/runner/isolation_resolver.rb +134 -0
  87. data/lib/evilution/runner/mutation_executor.rb +255 -0
  88. data/lib/evilution/runner/mutation_planner.rb +126 -0
  89. data/lib/evilution/runner/report_publisher.rb +60 -0
  90. data/lib/evilution/runner/subject_pipeline.rb +121 -0
  91. data/lib/evilution/runner.rb +57 -692
  92. data/lib/evilution/version.rb +1 -1
  93. metadata +71 -2
data/lib/evilution/cli.rb CHANGED
@@ -1,867 +1,47 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "json"
4
- require "optparse"
5
- require "tempfile"
6
3
  require_relative "version"
7
- require_relative "config"
8
- require_relative "hooks"
9
- require_relative "hooks/registry"
10
- require_relative "hooks/loader"
11
- require_relative "runner"
12
- require_relative "spec_resolver"
13
- require_relative "git/changed_files"
4
+ require_relative "cli/parser"
5
+ require_relative "cli/parsed_args"
6
+ require_relative "cli/printers/subjects"
7
+ require_relative "cli/dispatcher"
8
+ require_relative "cli/commands/version"
9
+ require_relative "cli/commands/init"
10
+ require_relative "cli/commands/mcp"
11
+ require_relative "cli/commands/subjects"
12
+ require_relative "cli/commands/tests_list"
13
+ require_relative "cli/commands/environment_show"
14
+ require_relative "cli/commands/util_mutation"
15
+ require_relative "cli/commands/session_list"
16
+ require_relative "cli/commands/session_show"
17
+ require_relative "cli/commands/session_diff"
18
+ require_relative "cli/commands/session_gc"
19
+ require_relative "cli/commands/run"
14
20
 
15
21
  class Evilution::CLI
16
22
  def initialize(argv, stdin: $stdin)
17
- @options = {}
18
- @command = :run
19
- @stdin = stdin
20
- argv = argv.dup
21
- argv = extract_command(argv)
22
- argv = preprocess_flags(argv)
23
- raw_args = build_option_parser.parse!(argv)
24
- @files, @line_ranges = parse_file_args(raw_args)
25
- read_stdin_files if @options.delete(:stdin) && %i[run subjects].include?(@command)
23
+ parsed = Parser.new(argv, stdin: stdin).parse
24
+ @parsed = parsed
25
+ @command = parsed.command
26
+ @options = parsed.options
27
+ @files = parsed.files
28
+ @line_ranges = parsed.line_ranges
29
+ @stdin_error = parsed.stdin_error
30
+ @parse_error = parsed.parse_error
26
31
  end
27
32
 
28
- def call # rubocop:disable Metrics/CyclomaticComplexity
29
- case @command
30
- when :version then run_version
31
- when :init then run_init
32
- when :mcp then run_mcp
33
- when :session_list then run_session_list
34
- when :session_show then run_session_show
35
- when :session_diff then run_session_diff
36
- when :session_gc then run_session_gc
37
- when :session_error then run_subcommand_error(@session_error)
38
- when :subjects then run_subjects
39
- when :tests_list then run_tests_list
40
- when :tests_error then run_subcommand_error(@tests_error)
41
- when :environment_show then run_environment_show
42
- when :environment_error then run_subcommand_error(@environment_error)
43
- when :util_mutation then run_util_mutation
44
- when :util_error then run_subcommand_error(@util_error)
45
- when :run then run_mutations
46
- end
47
- end
48
-
49
- private
33
+ def call
34
+ return run_subcommand_error(@parse_error) if @command == :parse_error
50
35
 
51
- def run_version
52
- $stdout.puts(Evilution::VERSION)
53
- 0
36
+ result = Dispatcher.lookup(@command).new(@parsed, stdout: $stdout, stderr: $stderr).call
37
+ warn("Error: #{result.error.message}") if result.error && !result.error_rendered
38
+ result.exit_code
54
39
  end
55
40
 
56
- def expand_spec_dir(dir)
57
- unless File.directory?(dir)
58
- warn("Error: #{dir} is not a directory")
59
- return
60
- end
61
-
62
- specs = Dir.glob(File.join(dir, "**/*_spec.rb"))
63
- @options[:spec_files] = Array(@options[:spec_files]) + specs
64
- end
41
+ private
65
42
 
66
43
  def run_subcommand_error(message)
67
44
  warn("Error: #{message}")
68
45
  2
69
46
  end
70
-
71
- def extract_command(argv)
72
- case argv.first
73
- when "version"
74
- @command = :version
75
- argv.shift
76
- when "init"
77
- @command = :init
78
- argv.shift
79
- when "mcp"
80
- @command = :mcp
81
- argv.shift
82
- when "session"
83
- argv.shift
84
- extract_session_subcommand(argv)
85
- when "subjects"
86
- @command = :subjects
87
- argv.shift
88
- when "tests"
89
- argv.shift
90
- extract_tests_subcommand(argv)
91
- when "environment"
92
- argv.shift
93
- extract_environment_subcommand(argv)
94
- when "util"
95
- argv.shift
96
- extract_util_subcommand(argv)
97
- when "run"
98
- argv.shift
99
- end
100
- argv
101
- end
102
-
103
- def extract_session_subcommand(argv)
104
- subcommand = argv.first
105
- case subcommand
106
- when "list"
107
- @command = :session_list
108
- argv.shift
109
- when "show"
110
- @command = :session_show
111
- argv.shift
112
- when "diff"
113
- @command = :session_diff
114
- argv.shift
115
- when "gc"
116
- @command = :session_gc
117
- argv.shift
118
- when nil
119
- @command = :session_error
120
- @session_error = "Missing session subcommand. Available subcommands: list, show, diff, gc"
121
- else
122
- @command = :session_error
123
- @session_error = "Unknown session subcommand: #{subcommand}. Available subcommands: list, show, diff, gc"
124
- argv.shift
125
- end
126
- end
127
-
128
- def extract_environment_subcommand(argv)
129
- subcommand = argv.first
130
- case subcommand
131
- when "show"
132
- @command = :environment_show
133
- argv.shift
134
- when nil
135
- @command = :environment_error
136
- @environment_error = "Missing environment subcommand. Available subcommands: show"
137
- else
138
- @command = :environment_error
139
- @environment_error = "Unknown environment subcommand: #{subcommand}. Available subcommands: show"
140
- argv.shift
141
- end
142
- end
143
-
144
- def extract_tests_subcommand(argv)
145
- subcommand = argv.first
146
- case subcommand
147
- when "list"
148
- @command = :tests_list
149
- argv.shift
150
- when nil
151
- @command = :tests_error
152
- @tests_error = "Missing tests subcommand. Available subcommands: list"
153
- else
154
- @command = :tests_error
155
- @tests_error = "Unknown tests subcommand: #{subcommand}. Available subcommands: list"
156
- argv.shift
157
- end
158
- end
159
-
160
- def extract_util_subcommand(argv)
161
- subcommand = argv.first
162
- case subcommand
163
- when "mutation"
164
- @command = :util_mutation
165
- argv.shift
166
- when nil
167
- @command = :util_error
168
- @util_error = "Missing util subcommand. Available subcommands: mutation"
169
- else
170
- @command = :util_error
171
- @util_error = "Unknown util subcommand: #{subcommand}. Available subcommands: mutation"
172
- argv.shift
173
- end
174
- end
175
-
176
- def preprocess_flags(argv)
177
- result = []
178
- i = 0
179
- while i < argv.length
180
- arg = argv[i]
181
- if arg == "--fail-fast"
182
- next_arg = argv[i + 1]
183
-
184
- if next_arg && next_arg.match?(/\A-?\d+\z/)
185
- @options[:fail_fast] = next_arg
186
- i += 2
187
- else
188
- result << arg
189
- i += 1
190
- end
191
- elsif arg.start_with?("--fail-fast=")
192
- @options[:fail_fast] = arg.delete_prefix("--fail-fast=")
193
- i += 1
194
- else
195
- result << arg
196
- i += 1
197
- end
198
- end
199
- result
200
- end
201
-
202
- def build_option_parser
203
- OptionParser.new do |opts|
204
- opts.banner = "Usage: evilution [command] [options] [files...]"
205
- opts.version = Evilution::VERSION
206
- add_separators(opts)
207
- add_options(opts)
208
- end
209
- end
210
-
211
- def add_separators(opts)
212
- opts.separator ""
213
- opts.separator "Line-range targeting: lib/foo.rb:15-30, lib/foo.rb:15, lib/foo.rb:15-"
214
- opts.separator ""
215
- opts.separator "Commands: run (default), init, session {list,show,diff,gc}, subjects, tests {list},"
216
- opts.separator " util {mutation}, environment {show}, mcp, version"
217
- opts.separator ""
218
- opts.separator "Options:"
219
- end
220
-
221
- def add_options(opts)
222
- add_core_options(opts)
223
- add_filter_options(opts)
224
- add_flag_options(opts)
225
- add_session_options(opts)
226
- end
227
-
228
- def add_core_options(opts)
229
- opts.on("-j", "--jobs N", Integer, "Number of parallel workers (default: 1)") { |n| @options[:jobs] = n }
230
- opts.on("-t", "--timeout N", Integer, "Per-mutation timeout in seconds") { |n| @options[:timeout] = n }
231
- opts.on("-f", "--format FORMAT", "Output format: text, json, html") { |f| @options[:format] = f.to_sym }
232
- end
233
-
234
- def add_filter_options(opts)
235
- opts.on("--min-score FLOAT", Float, "Minimum mutation score to pass") { |s| @options[:min_score] = s }
236
- opts.on("--spec FILES", Array, "Spec files to run (comma-separated)") { |f| @options[:spec_files] = f }
237
- opts.on("--spec-dir DIR", "Include all specs in DIR") { |d| expand_spec_dir(d) }
238
- opts.on("--target EXPR",
239
- "Filter: method (Foo#bar), type (Foo#/Foo.), namespace (Foo*),",
240
- "class (Foo), glob (source:**/*.rb), hierarchy (descendants:Foo)") do |m|
241
- @options[:target] = m
242
- end
243
- end
244
-
245
- def add_flag_options(opts)
246
- opts.on("--fail-fast", "Stop after N surviving mutants " \
247
- "(default: disabled; if provided without N, uses 1; use --fail-fast=N)") { @options[:fail_fast] ||= 1 }
248
- opts.on("--no-baseline", "Skip baseline test suite check") { @options[:baseline] = false }
249
- opts.on("--incremental", "Cache killed/timeout results; skip re-running them on unchanged files") { @options[:incremental] = true }
250
- opts.on("--integration NAME", "Test integration: rspec, minitest (default: rspec)") { |i| @options[:integration] = i }
251
- opts.on("--isolation STRATEGY", "Isolation: auto, fork, in_process (default: auto)") { |s| @options[:isolation] = s }
252
- opts.on("--preload FILE", "Preload FILE in the parent process before forking " \
253
- "(default: auto-detect spec/rails_helper.rb for Rails projects)") { |f| @options[:preload] = f }
254
- opts.on("--no-preload", "Disable parent-process preload even for Rails projects") { @options[:preload] = false }
255
- opts.on("--stdin", "Read target file paths from stdin (one per line)") { @options[:stdin] = true }
256
- opts.on("--suggest-tests", "Generate concrete test code in suggestions (RSpec or Minitest)") { @options[:suggest_tests] = true }
257
- opts.on("--no-progress", "Disable progress bar") { @options[:progress] = false }
258
- add_extra_flag_options(opts)
259
- end
260
-
261
- def add_extra_flag_options(opts)
262
- opts.on("--skip-heredoc-literals", "Skip all string literal mutations inside heredocs") { @options[:skip_heredoc_literals] = true }
263
- opts.on("--show-disabled", "Report mutations skipped by # evilution:disable") { @options[:show_disabled] = true }
264
- opts.on("--baseline-session PATH", "Compare against a baseline session in HTML report") { |p| @options[:baseline_session] = p }
265
- opts.on("--save-session", "Save session results to .evilution/results/") { @options[:save_session] = true }
266
- opts.on("-e", "--eval CODE", "Evaluate code snippet (for util mutation)") { |c| @options[:eval] = c }
267
- opts.on("-v", "--verbose", "Verbose output") { @options[:verbose] = true }
268
- opts.on("-q", "--quiet", "Suppress output") { @options[:quiet] = true }
269
- end
270
-
271
- def add_session_options(opts)
272
- opts.on("--results-dir DIR", "Session results directory") { |d| @options[:results_dir] = d }
273
- opts.on("--limit N", Integer, "Show only the N most recent sessions") { |n| @options[:limit] = n }
274
- opts.on("--since DATE", "Show sessions since DATE (YYYY-MM-DD)") { |d| @options[:since] = d }
275
- opts.on("--older-than DURATION", "Delete sessions older than DURATION (e.g., 30d, 24h, 1w)") do |d|
276
- @options[:older_than] = d
277
- end
278
- end
279
-
280
- def run_init
281
- path = ".evilution.yml"
282
- if File.exist?(path)
283
- warn("#{path} already exists")
284
- return 1
285
- end
286
-
287
- File.write(path, Evilution::Config.default_template)
288
- $stdout.puts("Created #{path}")
289
- 0
290
- end
291
-
292
- def run_subjects
293
- raise Evilution::ConfigError, @stdin_error if @stdin_error
294
-
295
- config = Evilution::Config.new(target_files: @files, line_ranges: @line_ranges, **@options)
296
- runner = Evilution::Runner.new(config: config)
297
- subjects = runner.parse_and_filter_subjects
298
-
299
- if subjects.empty?
300
- $stdout.puts("No subjects found")
301
- return 0
302
- end
303
-
304
- registry = Evilution::Mutator::Registry.default
305
- filter = build_subject_filter(config)
306
- operator_options = build_operator_options(config)
307
- total_mutations = 0
308
-
309
- subjects.each do |subj|
310
- count = registry.mutations_for(subj, filter: filter, operator_options: operator_options).length
311
- total_mutations += count
312
- label = count == 1 ? "1 mutation" : "#{count} mutations"
313
- $stdout.puts(" #{subj.name} #{subj.file_path}:#{subj.line_number} (#{label})")
314
- ensure
315
- subj.release_node!
316
- end
317
-
318
- $stdout.puts("")
319
- $stdout.puts("#{subjects.length} subjects, #{total_mutations} mutations")
320
- 0
321
- rescue Evilution::Error => e
322
- warn("Error: #{e.message}")
323
- 2
324
- end
325
-
326
- def build_operator_options(config)
327
- { skip_heredoc_literals: config.skip_heredoc_literals? }
328
- end
329
-
330
- def build_subject_filter(config)
331
- return nil if config.ignore_patterns.empty?
332
-
333
- require_relative "ast/pattern/filter"
334
- Evilution::AST::Pattern::Filter.new(config.ignore_patterns)
335
- end
336
-
337
- def run_tests_list
338
- config = Evilution::Config.new(target_files: @files, line_ranges: @line_ranges, **@options)
339
-
340
- if config.spec_files.any?
341
- print_explicit_spec_files(config.spec_files)
342
- return 0
343
- end
344
-
345
- source_files = resolve_source_files(config)
346
- if source_files.empty?
347
- $stdout.puts("No source files found")
348
- return 0
349
- end
350
-
351
- resolver = Evilution::SpecResolver.new
352
- print_resolved_specs(source_files, resolver)
353
- 0
354
- rescue Evilution::Error => e
355
- warn("Error: #{e.message}")
356
- 2
357
- end
358
-
359
- def resolve_source_files(config)
360
- return config.target_files unless config.target_files.empty?
361
-
362
- Evilution::Git::ChangedFiles.new.call
363
- rescue Evilution::Error
364
- []
365
- end
366
-
367
- def print_explicit_spec_files(spec_files)
368
- spec_files.each { |f| $stdout.puts(" #{f}") }
369
- label = spec_files.length == 1 ? "1 spec file" : "#{spec_files.length} spec files"
370
- $stdout.puts("")
371
- $stdout.puts(label)
372
- end
373
-
374
- def print_resolved_specs(source_files, resolver)
375
- unique_specs = []
376
- source_files.each do |source|
377
- spec = resolver.call(source)
378
- if spec
379
- unique_specs << spec
380
- $stdout.puts(" #{spec} (#{source})")
381
- else
382
- $stdout.puts(" #{source} (no spec found)")
383
- end
384
- end
385
-
386
- unique_specs.uniq!
387
- $stdout.puts("")
388
- spec_label = unique_specs.length == 1 ? "1 spec file" : "#{unique_specs.length} spec files"
389
- $stdout.puts("#{source_files.length} source files, #{spec_label}")
390
- end
391
-
392
- def run_environment_show
393
- config = Evilution::Config.new(**@options)
394
- $stdout.puts(format_environment(config))
395
- 0
396
- rescue Evilution::ConfigError => e
397
- warn("Error: #{e.message}")
398
- 2
399
- end
400
-
401
- def format_environment(config)
402
- config_file = Evilution::Config::CONFIG_FILES.find { |path| File.exist?(path) }
403
- lines = environment_header(config_file)
404
- lines.concat(environment_settings(config))
405
- lines.join("\n")
406
- end
407
-
408
- def environment_header(config_file)
409
- [
410
- "Evilution Environment",
411
- ("=" * 30),
412
- "",
413
- "evilution: #{Evilution::VERSION}",
414
- "ruby: #{RUBY_VERSION}",
415
- "config_file: #{config_file || "(none)"}",
416
- "",
417
- "Settings:"
418
- ]
419
- end
420
-
421
- def environment_settings(config)
422
- [
423
- " timeout: #{config.timeout}",
424
- " format: #{config.format}",
425
- " integration: #{config.integration}",
426
- " jobs: #{config.jobs}",
427
- " isolation: #{config.isolation}",
428
- " baseline: #{config.baseline}",
429
- " incremental: #{config.incremental}",
430
- " verbose: #{config.verbose}",
431
- " quiet: #{config.quiet}",
432
- " progress: #{config.progress}",
433
- " fail_fast: #{config.fail_fast || "(disabled)"}",
434
- " min_score: #{config.min_score}",
435
- " suggest_tests: #{config.suggest_tests}",
436
- " save_session: #{config.save_session}",
437
- " target: #{config.target || "(all files)"}",
438
- " skip_heredoc_literals: #{config.skip_heredoc_literals}",
439
- " ignore_patterns: #{config.ignore_patterns.empty? ? "(none)" : config.ignore_patterns.inspect}"
440
- ]
441
- end
442
-
443
- def run_mcp
444
- require_relative "mcp/server"
445
- server = Evilution::MCP::Server.build
446
- transport = ::MCP::Server::Transports::StdioTransport.new(server)
447
- transport.open
448
- 0
449
- end
450
-
451
- def read_stdin_files
452
- @stdin_error = "--stdin cannot be combined with positional file arguments" unless @files.empty?
453
- return if @stdin_error
454
-
455
- lines = []
456
- @stdin.each_line do |line|
457
- line = line.strip
458
- lines << line unless line.empty?
459
- end
460
- stdin_files, stdin_ranges = parse_file_args(lines)
461
- @files = stdin_files
462
- @line_ranges = @line_ranges.merge(stdin_ranges)
463
- end
464
-
465
- def parse_file_args(raw_args)
466
- files = []
467
- ranges = {}
468
-
469
- raw_args.each do |arg|
470
- file, range_str = arg.split(":", 2)
471
- files << file
472
- next unless range_str
473
-
474
- ranges[file] = parse_line_range(range_str)
475
- end
476
-
477
- [files, ranges]
478
- end
479
-
480
- def parse_line_range(str)
481
- if str.include?("-")
482
- start_str, end_str = str.split("-", 2)
483
- start_line = Integer(start_str)
484
- end_line = end_str.empty? ? Float::INFINITY : Integer(end_str)
485
- start_line..end_line
486
- else
487
- line = Integer(str)
488
- line..line
489
- end
490
- end
491
-
492
- def run_util_mutation
493
- source, file_path = resolve_util_mutation_source
494
- subjects = parse_source_to_subjects(source, file_path)
495
- config = Evilution::Config.new(**@options)
496
- registry = Evilution::Mutator::Registry.default
497
- operator_options = build_operator_options(config)
498
- mutations = subjects.flat_map { |s| registry.mutations_for(s, operator_options: operator_options) }
499
-
500
- if mutations.empty?
501
- $stdout.puts("No mutations generated")
502
- return 0
503
- end
504
-
505
- if @options[:format] == :json
506
- print_util_mutations_json(mutations)
507
- else
508
- print_util_mutations_text(mutations)
509
- end
510
-
511
- 0
512
- rescue Evilution::Error => e
513
- warn("Error: #{e.message}")
514
- 2
515
- ensure
516
- @util_tmpfile&.close!
517
- end
518
-
519
- def resolve_util_mutation_source
520
- if @options[:eval]
521
- tmpfile = Tempfile.new(["evilution_eval", ".rb"])
522
- tmpfile.write(@options[:eval])
523
- tmpfile.flush
524
- @util_tmpfile = tmpfile
525
- [@options[:eval], tmpfile.path]
526
- elsif @files.first
527
- path = @files.first
528
- raise Evilution::Error, "file not found: #{path}" unless File.exist?(path)
529
-
530
- begin
531
- [File.read(path), path]
532
- rescue SystemCallError => e
533
- raise Evilution::Error, e.message
534
- end
535
- else
536
- raise Evilution::Error, "source required: use -e 'code' or provide a file path"
537
- end
538
- end
539
-
540
- def parse_source_to_subjects(source, file_label)
541
- result = Prism.parse(source)
542
- raise Evilution::Error, "failed to parse source: #{result.errors.map(&:message).join(", ")}" if result.failure?
543
-
544
- finder = Evilution::AST::SubjectFinder.new(source, file_label)
545
- finder.visit(result.value)
546
- finder.subjects
547
- end
548
-
549
- def print_util_mutations_text(mutations)
550
- mutations.each_with_index do |m, i|
551
- $stdout.puts("#{i + 1}. #{m.operator_name} — #{m.subject.name} (line #{m.line})")
552
- m.diff.each_line { |line| $stdout.puts(" #{line}") }
553
- $stdout.puts("")
554
- end
555
- label = mutations.length == 1 ? "1 mutation" : "#{mutations.length} mutations"
556
- $stdout.puts(label)
557
- end
558
-
559
- def print_util_mutations_json(mutations)
560
- data = mutations.map do |m|
561
- { operator: m.operator_name, subject: m.subject.name,
562
- file: m.file_path, line: m.line, diff: m.diff }
563
- end
564
- $stdout.puts(JSON.pretty_generate(data))
565
- end
566
-
567
- def run_session_list
568
- require_relative "session/store"
569
-
570
- store_opts = {}
571
- store_opts[:results_dir] = @options[:results_dir] if @options[:results_dir]
572
- store = Evilution::Session::Store.new(**store_opts)
573
- sessions = store.list
574
- sessions = filter_sessions(sessions)
575
-
576
- if sessions.empty?
577
- $stdout.puts("No sessions found")
578
- return 0
579
- end
580
-
581
- if @options[:format] == :json
582
- $stdout.puts(JSON.pretty_generate(sessions.map { |s| session_to_hash(s) }))
583
- else
584
- print_session_table(sessions)
585
- end
586
-
587
- 0
588
- rescue Evilution::ConfigError => e
589
- warn("Error: #{e.message}")
590
- 2
591
- end
592
-
593
- def filter_sessions(sessions)
594
- if @options[:since]
595
- cutoff = parse_date(@options[:since])
596
- sessions = sessions.select do |s|
597
- ts = s[:timestamp]
598
- next false unless ts.is_a?(String)
599
-
600
- Time.parse(ts) >= cutoff
601
- rescue ArgumentError
602
- false
603
- end
604
- end
605
- sessions = sessions.first(@options[:limit]) if @options[:limit]
606
- sessions
607
- end
608
-
609
- def parse_date(value)
610
- Time.parse(value)
611
- rescue ArgumentError
612
- raise Evilution::ConfigError, "invalid --since date: #{value.inspect}. Use YYYY-MM-DD format"
613
- end
614
-
615
- def run_session_show
616
- require_relative "session/store"
617
-
618
- path = @files.first
619
- raise Evilution::ConfigError, "session file path required" unless path
620
-
621
- store = Evilution::Session::Store.new
622
- data = store.load(path)
623
-
624
- if @options[:format] == :json
625
- $stdout.puts(JSON.pretty_generate(data))
626
- else
627
- print_session_detail(data)
628
- end
629
-
630
- 0
631
- rescue Evilution::Error => e
632
- warn("Error: #{e.message}")
633
- 2
634
- rescue ::JSON::ParserError => e
635
- warn("Error: invalid session file: #{e.message}")
636
- 2
637
- end
638
-
639
- def run_session_diff
640
- require_relative "session/store"
641
- require_relative "session/diff"
642
-
643
- raise Evilution::ConfigError, "two session file paths required" unless @files.length == 2
644
-
645
- store = Evilution::Session::Store.new
646
- base_data = store.load(@files[0])
647
- head_data = store.load(@files[1])
648
-
649
- diff = Evilution::Session::Diff.new
650
- result = diff.call(base_data, head_data)
651
-
652
- if @options[:format] == :json
653
- $stdout.puts(JSON.pretty_generate(result.to_h))
654
- else
655
- print_session_diff(result)
656
- end
657
-
658
- 0
659
- rescue Evilution::Error, SystemCallError => e
660
- warn("Error: #{e.message}")
661
- 2
662
- rescue ::JSON::ParserError => e
663
- warn("Error: invalid session file: #{e.message}")
664
- 2
665
- end
666
-
667
- def print_session_diff(result)
668
- print_diff_summary(result.summary)
669
- print_diff_section("Fixed (survived \u2192 killed)", result.fixed, "\e[32m")
670
- print_diff_section("New survivors (killed \u2192 survived)", result.new_survivors, "\e[31m")
671
- print_diff_section("Persistent survivors", result.persistent, "\e[33m")
672
-
673
- return unless result.fixed.empty? && result.new_survivors.empty? && result.persistent.empty?
674
-
675
- $stdout.puts("")
676
- $stdout.puts("No mutation changes between sessions")
677
- end
678
-
679
- def print_diff_summary(summary)
680
- delta_str = format("%+.2f%%", summary.score_delta * 100)
681
- $stdout.puts("Session Diff")
682
- $stdout.puts("=" * 40)
683
- $stdout.puts(format("Base score: %<score>6.2f%% (%<killed>d/%<total>d killed)",
684
- score: summary.base_score * 100, killed: summary.base_killed,
685
- total: summary.base_total))
686
- $stdout.puts(format("Head score: %<score>6.2f%% (%<killed>d/%<total>d killed)",
687
- score: summary.head_score * 100, killed: summary.head_killed,
688
- total: summary.head_total))
689
- $stdout.puts("Delta: #{delta_str}")
690
- end
691
-
692
- def print_diff_section(title, mutations, color)
693
- return if mutations.empty?
694
-
695
- reset = "\e[0m"
696
- $stdout.puts("")
697
- $stdout.puts("#{color}#{title} (#{mutations.length}):#{reset}")
698
- mutations.each do |m|
699
- $stdout.puts(" #{m["operator"]} — #{m["file"]}:#{m["line"]} #{m["subject"]}")
700
- end
701
- end
702
-
703
- def run_session_gc
704
- require_relative "session/store"
705
-
706
- raise Evilution::ConfigError, "--older-than is required for session gc" unless @options[:older_than]
707
-
708
- cutoff = parse_duration(@options[:older_than])
709
- store_opts = {}
710
- store_opts[:results_dir] = @options[:results_dir] if @options[:results_dir]
711
- store = Evilution::Session::Store.new(**store_opts)
712
- deleted = store.gc(older_than: cutoff)
713
-
714
- if deleted.empty?
715
- $stdout.puts("No sessions to delete")
716
- else
717
- $stdout.puts("Deleted #{deleted.length} session#{"s" unless deleted.length == 1}")
718
- end
719
-
720
- 0
721
- rescue Evilution::ConfigError => e
722
- warn("Error: #{e.message}")
723
- 2
724
- end
725
-
726
- def parse_duration(value)
727
- match = value.match(/\A(\d+)([dhw])\z/)
728
- raise Evilution::ConfigError, "invalid --older-than format: #{value.inspect}. Use Nd, Nh, or Nw (e.g., 30d)" unless match
729
-
730
- amount = match[1].to_i
731
- seconds = case match[2]
732
- when "h" then amount * 3600
733
- when "d" then amount * 86_400
734
- when "w" then amount * 604_800
735
- end
736
- Time.now - seconds
737
- end
738
-
739
- def print_session_detail(data)
740
- print_session_header(data)
741
- print_session_summary(data["summary"])
742
- print_survived_section(data["survived"] || [])
743
- end
744
-
745
- def print_session_header(data)
746
- $stdout.puts("Session: #{data["timestamp"]}")
747
- $stdout.puts("Version: #{data["version"]}")
748
- print_git_context(data["git"])
749
- end
750
-
751
- def print_git_context(git)
752
- return unless git.is_a?(Hash)
753
-
754
- branch = git["branch"]
755
- sha = git["sha"]
756
- return if branch.to_s.empty? && sha.to_s.empty?
757
-
758
- $stdout.puts("Git: #{branch} (#{sha})")
759
- end
760
-
761
- def print_session_summary(summary)
762
- $stdout.puts("")
763
- $stdout.puts(
764
- format(
765
- "Score: %<score>.2f%% Total: %<total>d Killed: %<killed>d Survived: %<surv>d " \
766
- "Timed out: %<to>d Errors: %<err>d Duration: %<dur>.1fs",
767
- score: summary["score"] * 100, total: summary["total"], killed: summary["killed"],
768
- surv: summary["survived"], to: summary["timed_out"], err: summary["errors"],
769
- dur: summary["duration"]
770
- )
771
- )
772
- end
773
-
774
- def print_survived_section(survived)
775
- $stdout.puts("")
776
- if survived.empty?
777
- $stdout.puts("No survived mutations")
778
- else
779
- $stdout.puts("Survived mutations (#{survived.length}):")
780
- survived.each_with_index { |m, i| print_mutation_detail(m, i + 1) }
781
- end
782
- end
783
-
784
- def print_mutation_detail(mutation, index)
785
- $stdout.puts("")
786
- $stdout.puts(" #{index}. #{mutation["operator"]} — #{mutation["file"]}:#{mutation["line"]}")
787
- $stdout.puts(" Subject: #{mutation["subject"]}")
788
- return unless mutation["diff"]
789
-
790
- $stdout.puts(" Diff:")
791
- mutation["diff"].each_line { |line| $stdout.puts(" #{line}") }
792
- end
793
-
794
- def session_to_hash(session)
795
- {
796
- "timestamp" => session[:timestamp],
797
- "total" => session[:total],
798
- "killed" => session[:killed],
799
- "survived" => session[:survived],
800
- "score" => session[:score],
801
- "duration" => session[:duration],
802
- "file" => session[:file]
803
- }
804
- end
805
-
806
- def print_session_table(sessions)
807
- header = "Timestamp Total Killed Surv. Score Duration"
808
- $stdout.puts(header)
809
- $stdout.puts("-" * header.length)
810
- sessions.each { |s| print_session_row(s) }
811
- end
812
-
813
- def print_session_row(session)
814
- $stdout.puts(
815
- format(
816
- "%-30<ts>s %6<total>d %6<killed>d %6<surv>d %7.2<score>f%% %7.1<dur>fs",
817
- ts: session[:timestamp], total: session[:total], killed: session[:killed],
818
- surv: session[:survived], score: session[:score] * 100, dur: session[:duration]
819
- )
820
- )
821
- end
822
-
823
- def run_mutations
824
- raise Evilution::ConfigError, @stdin_error if @stdin_error
825
-
826
- file_options = Evilution::Config.file_options
827
- config = Evilution::Config.new(**@options, target_files: @files, line_ranges: @line_ranges)
828
- hooks = build_hooks(config)
829
- runner = Evilution::Runner.new(config: config, hooks: hooks)
830
- summary = runner.call
831
- summary.success?(min_score: config.min_score) ? 0 : 1
832
- rescue Evilution::Error => e
833
- if json_format?(config, file_options)
834
- $stdout.puts(JSON.generate(error_payload(e)))
835
- else
836
- warn("Error: #{e.message}")
837
- end
838
- 2
839
- end
840
-
841
- def build_hooks(config)
842
- return nil if config.hooks.empty?
843
-
844
- registry = Evilution::Hooks::Registry.new
845
- Evilution::Hooks::Loader.call(registry, config.hooks)
846
- registry
847
- end
848
-
849
- def json_format?(config, file_options)
850
- return config.json? if config
851
-
852
- format = @options[:format] || (file_options && file_options[:format])
853
- format && format.to_sym == :json
854
- end
855
-
856
- def error_payload(error)
857
- error_type = case error
858
- when Evilution::ConfigError then "config_error"
859
- when Evilution::ParseError then "parse_error"
860
- else "runtime_error"
861
- end
862
-
863
- payload = { type: error_type, message: error.message }
864
- payload[:file] = error.file if error.file
865
- { error: payload }
866
- end
867
47
  end