evilution 0.23.0 → 0.25.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 (106) hide show
  1. checksums.yaml +4 -4
  2. data/.beads/interactions.jsonl +210 -0
  3. data/CHANGELOG.md +51 -0
  4. data/README.md +81 -4
  5. data/exe/evil +6 -0
  6. data/lib/evilution/ast/source_surgeon.rb +15 -1
  7. data/lib/evilution/cli/commands/compare.rb +68 -0
  8. data/lib/evilution/cli/parser/command_extractor.rb +78 -0
  9. data/lib/evilution/cli/parser/file_args.rb +41 -0
  10. data/lib/evilution/cli/parser/options_builder.rb +123 -0
  11. data/lib/evilution/cli/parser/stdin_reader.rb +28 -0
  12. data/lib/evilution/cli/parser.rb +27 -196
  13. data/lib/evilution/cli/printers/compare.rb +159 -0
  14. data/lib/evilution/cli.rb +1 -0
  15. data/lib/evilution/compare/categorizer.rb +109 -0
  16. data/lib/evilution/compare/detector.rb +21 -0
  17. data/lib/evilution/compare/fingerprint.rb +83 -0
  18. data/lib/evilution/compare/normalizer.rb +106 -0
  19. data/lib/evilution/compare/record.rb +16 -0
  20. data/lib/evilution/compare.rb +15 -0
  21. data/lib/evilution/config.rb +178 -3
  22. data/lib/evilution/example_filter.rb +143 -0
  23. data/lib/evilution/integration/base.rb +11 -57
  24. data/lib/evilution/integration/crash_detector.rb +5 -2
  25. data/lib/evilution/integration/minitest.rb +25 -7
  26. data/lib/evilution/integration/minitest_crash_detector.rb +5 -2
  27. data/lib/evilution/integration/rspec.rb +99 -12
  28. data/lib/evilution/isolation/fork.rb +26 -0
  29. data/lib/evilution/isolation/in_process.rb +1 -0
  30. data/lib/evilution/mcp/info_tool.rb +77 -5
  31. data/lib/evilution/mcp/mutate_tool/config_builder.rb +20 -0
  32. data/lib/evilution/mcp/mutate_tool/error_payload.rb +17 -0
  33. data/lib/evilution/mcp/mutate_tool/option_parser.rb +54 -0
  34. data/lib/evilution/mcp/mutate_tool/progress_streamer.rb +37 -0
  35. data/lib/evilution/mcp/mutate_tool/report_trimmer.rb +31 -0
  36. data/lib/evilution/mcp/mutate_tool/survived_enricher.rb +52 -0
  37. data/lib/evilution/mcp/mutate_tool.rb +34 -186
  38. data/lib/evilution/mutation.rb +43 -3
  39. data/lib/evilution/mutator/base.rb +39 -1
  40. data/lib/evilution/mutator/operator/argument_nil_substitution.rb +5 -1
  41. data/lib/evilution/mutator/operator/argument_removal.rb +5 -1
  42. data/lib/evilution/parallel/work_queue.rb +149 -31
  43. data/lib/evilution/parallel_db_warning.rb +68 -0
  44. data/lib/evilution/reporter/cli.rb +38 -11
  45. data/lib/evilution/reporter/html/assets/style.css +85 -0
  46. data/lib/evilution/reporter/html/baseline_keys.rb +28 -0
  47. data/lib/evilution/reporter/html/diff_formatter.rb +27 -0
  48. data/lib/evilution/reporter/html/escape.rb +12 -0
  49. data/lib/evilution/reporter/html/namespace.rb +11 -0
  50. data/lib/evilution/reporter/html/report.rb +68 -0
  51. data/lib/evilution/reporter/html/section.rb +21 -0
  52. data/lib/evilution/reporter/html/sections/baseline_comparison.rb +46 -0
  53. data/lib/evilution/reporter/html/sections/error_details.rb +30 -0
  54. data/lib/evilution/reporter/html/sections/error_entry.rb +22 -0
  55. data/lib/evilution/reporter/html/sections/file_section.rb +62 -0
  56. data/lib/evilution/reporter/html/sections/header.rb +29 -0
  57. data/lib/evilution/reporter/html/sections/mutation_map.rb +32 -0
  58. data/lib/evilution/reporter/html/sections/neutral_details.rb +25 -0
  59. data/lib/evilution/reporter/html/sections/summary_cards.rb +11 -0
  60. data/lib/evilution/reporter/html/sections/survived_details.rb +35 -0
  61. data/lib/evilution/reporter/html/sections/survived_entry.rb +36 -0
  62. data/lib/evilution/reporter/html/sections/truncation_notice.rb +17 -0
  63. data/lib/evilution/reporter/html/sections/unparseable_details.rb +25 -0
  64. data/lib/evilution/reporter/html/sections/unresolved_details.rb +25 -0
  65. data/lib/evilution/reporter/html/sections.rb +4 -0
  66. data/lib/evilution/reporter/html/stylesheet.rb +14 -0
  67. data/lib/evilution/reporter/html/templates/baseline_comparison.html.erb +8 -0
  68. data/lib/evilution/reporter/html/templates/error_details.html.erb +6 -0
  69. data/lib/evilution/reporter/html/templates/error_entry.html.erb +10 -0
  70. data/lib/evilution/reporter/html/templates/file_section.html.erb +12 -0
  71. data/lib/evilution/reporter/html/templates/header.html.erb +4 -0
  72. data/lib/evilution/reporter/html/templates/mutation_map.html.erb +6 -0
  73. data/lib/evilution/reporter/html/templates/neutral_details.html.erb +14 -0
  74. data/lib/evilution/reporter/html/templates/report.html.erb +17 -0
  75. data/lib/evilution/reporter/html/templates/summary_cards.html.erb +26 -0
  76. data/lib/evilution/reporter/html/templates/survived_details.html.erb +21 -0
  77. data/lib/evilution/reporter/html/templates/survived_entry.html.erb +8 -0
  78. data/lib/evilution/reporter/html/templates/truncation_notice.html.erb +1 -0
  79. data/lib/evilution/reporter/html/templates/unparseable_details.html.erb +11 -0
  80. data/lib/evilution/reporter/html/templates/unresolved_details.html.erb +11 -0
  81. data/lib/evilution/reporter/html.rb +11 -390
  82. data/lib/evilution/reporter/json.rb +19 -9
  83. data/lib/evilution/reporter/suggestion/diff_helpers.rb +28 -0
  84. data/lib/evilution/reporter/suggestion/registry.rb +64 -0
  85. data/lib/evilution/reporter/suggestion/templates/generic.rb +55 -0
  86. data/lib/evilution/reporter/suggestion/templates/minitest.rb +659 -0
  87. data/lib/evilution/reporter/suggestion/templates/rspec.rb +613 -0
  88. data/lib/evilution/reporter/suggestion.rb +8 -1327
  89. data/lib/evilution/result/mutation_result.rb +9 -1
  90. data/lib/evilution/result/summary.rb +21 -1
  91. data/lib/evilution/runner/baseline_runner.rb +92 -0
  92. data/lib/evilution/runner/diagnostics.rb +105 -0
  93. data/lib/evilution/runner/isolation_resolver.rb +134 -0
  94. data/lib/evilution/runner/mutation_executor.rb +325 -0
  95. data/lib/evilution/runner/mutation_planner.rb +126 -0
  96. data/lib/evilution/runner/report_publisher.rb +60 -0
  97. data/lib/evilution/runner/subject_pipeline.rb +121 -0
  98. data/lib/evilution/runner.rb +61 -692
  99. data/lib/evilution/source_ast_cache.rb +39 -0
  100. data/lib/evilution/spec_ast_cache.rb +166 -0
  101. data/lib/evilution/spec_resolver.rb +6 -1
  102. data/lib/evilution/spec_selector.rb +39 -0
  103. data/lib/evilution/temp_dir_tracker.rb +23 -3
  104. data/lib/evilution/version.rb +1 -1
  105. data/script/memory_check +7 -5
  106. metadata +75 -2
@@ -4,6 +4,7 @@ require "stringio"
4
4
  require_relative "base"
5
5
  require_relative "minitest_crash_detector"
6
6
  require_relative "../spec_resolver"
7
+ require_relative "../spec_selector"
7
8
 
8
9
  require_relative "../integration"
9
10
 
@@ -35,10 +36,13 @@ class Evilution::Integration::Minitest < Evilution::Integration::Base
35
36
  }
36
37
  end
37
38
 
38
- def initialize(test_files: nil, hooks: nil)
39
+ def initialize(test_files: nil, hooks: nil, fallback_to_full_suite: false, spec_selector: nil)
39
40
  @test_files = test_files
40
41
  @minitest_loaded = false
41
- @spec_resolver = Evilution::SpecResolver.new(test_dir: "test", test_suffix: "_test.rb", request_dir: "integration")
42
+ @spec_selector = spec_selector || Evilution::SpecSelector.new(
43
+ spec_resolver: Evilution::SpecResolver.new(test_dir: "test", test_suffix: "_test.rb", request_dir: "integration")
44
+ )
45
+ @fallback_to_full_suite = fallback_to_full_suite
42
46
  @crash_detector = nil
43
47
  @warned_files = Set.new
44
48
  super(hooks: hooks)
@@ -62,6 +66,8 @@ class Evilution::Integration::Minitest < Evilution::Integration::Base
62
66
  def run_tests(mutation)
63
67
  reset_state
64
68
  files = resolve_test_files(mutation)
69
+ return unresolved_result(mutation) if files.nil?
70
+
65
71
  command = "ruby -Itest #{files.join(" ")}"
66
72
 
67
73
  files.each { |f| load(File.expand_path(f)) }
@@ -75,6 +81,15 @@ class Evilution::Integration::Minitest < Evilution::Integration::Base
75
81
  { passed: false, error: e.message, test_command: command }
76
82
  end
77
83
 
84
+ def unresolved_result(mutation)
85
+ {
86
+ passed: false,
87
+ unresolved: true,
88
+ error: "no matching test resolved for #{mutation.file_path}",
89
+ test_command: "ruby -Itest (skipped: no test resolved for #{mutation.file_path})"
90
+ }
91
+ end
92
+
78
93
  def build_args(_mutation)
79
94
  ["--seed", "0"]
80
95
  end
@@ -112,10 +127,12 @@ class Evilution::Integration::Minitest < Evilution::Integration::Base
112
127
  if passed
113
128
  { passed: true, test_command: command }
114
129
  elsif detector.only_crashes?
130
+ classes = detector.unique_crash_classes
115
131
  {
116
132
  passed: false,
117
133
  test_crashed: true,
118
134
  error: "test crashes: #{detector.crash_summary}",
135
+ error_class: (classes.first if classes.length == 1),
119
136
  test_command: command
120
137
  }
121
138
  else
@@ -126,13 +143,13 @@ class Evilution::Integration::Minitest < Evilution::Integration::Base
126
143
  def resolve_test_files(mutation)
127
144
  return test_files if test_files
128
145
 
129
- resolved = @spec_resolver.call(mutation.file_path)
130
- unless resolved
146
+ resolved = Array(@spec_selector.call(mutation.file_path))
147
+ if resolved.empty?
131
148
  warn_unresolved_test(mutation.file_path)
132
- return glob_test_files
149
+ return @fallback_to_full_suite ? glob_test_files : nil
133
150
  end
134
151
 
135
- [resolved]
152
+ resolved
136
153
  end
137
154
 
138
155
  def glob_test_files
@@ -144,7 +161,8 @@ class Evilution::Integration::Minitest < Evilution::Integration::Base
144
161
  return if @warned_files.include?(file_path)
145
162
 
146
163
  @warned_files << file_path
147
- warn "[evilution] No matching test found for #{file_path}, running full suite. " \
164
+ action = @fallback_to_full_suite ? "running full suite" : "marking mutation unresolved"
165
+ warn "[evilution] No matching test found for #{file_path}, #{action}. " \
148
166
  "Use --spec to specify the test file."
149
167
  end
150
168
  end
@@ -49,7 +49,10 @@ class Evilution::Integration::MinitestCrashDetector
49
49
  def crash_summary
50
50
  return nil if @crashes.empty?
51
51
 
52
- types = @crashes.map { |e| e.class.name }.uniq
53
- "#{types.join(", ")} (#{@crashes.length} crash#{"es" unless @crashes.length == 1})"
52
+ "#{unique_crash_classes.join(", ")} (#{@crashes.length} crash#{"es" unless @crashes.length == 1})"
53
+ end
54
+
55
+ def unique_crash_classes
56
+ @crashes.map { |e| e.class.name }.uniq
54
57
  end
55
58
  end
@@ -4,6 +4,7 @@ require "stringio"
4
4
  require_relative "base"
5
5
  require_relative "crash_detector"
6
6
  require_relative "../spec_resolver"
7
+ require_relative "../spec_selector"
7
8
  require_relative "../related_spec_heuristic"
8
9
 
9
10
  require_relative "../integration"
@@ -26,12 +27,15 @@ class Evilution::Integration::RSpec < Evilution::Integration::Base
26
27
  { runner: baseline_runner }
27
28
  end
28
29
 
29
- def initialize(test_files: nil, hooks: nil, related_specs_heuristic: false)
30
+ def initialize(test_files: nil, hooks: nil, related_specs_heuristic: false, fallback_to_full_suite: false,
31
+ spec_selector: nil, example_filter: nil)
30
32
  @test_files = test_files
31
33
  @rspec_loaded = false
32
- @spec_resolver = Evilution::SpecResolver.new
34
+ @spec_selector = spec_selector || Evilution::SpecSelector.new
33
35
  @related_spec_heuristic = Evilution::RelatedSpecHeuristic.new
34
36
  @related_specs_heuristic_enabled = related_specs_heuristic
37
+ @fallback_to_full_suite = fallback_to_full_suite
38
+ @example_filter = example_filter
35
39
  @crash_detector = nil
36
40
  @warned_files = Set.new
37
41
  super(hooks: hooks)
@@ -57,14 +61,21 @@ class Evilution::Integration::RSpec < Evilution::Integration::Base
57
61
  def run_tests(mutation)
58
62
  reset_state
59
63
 
64
+ files = resolve_test_files(mutation)
65
+ return unresolved_result(mutation) if files.nil?
66
+
67
+ targets = apply_example_filter(mutation, files)
68
+ return unresolved_example_result(mutation) if targets.nil?
69
+
60
70
  out = StringIO.new
61
71
  err = StringIO.new
62
- command = "rspec"
63
- args = build_args(mutation)
72
+ args = build_args(targets)
64
73
  command = "rspec #{args.join(" ")}"
65
74
 
66
75
  detector = reset_crash_detector
67
76
  eg_before = snapshot_example_groups
77
+ fe_before = snapshot_filtered_examples_keys
78
+ rep_before = snapshot_reporter_lengths
68
79
  status = ::RSpec::Core::Runner.run(args, out, err)
69
80
 
70
81
  build_rspec_result(status, command, detector)
@@ -72,13 +83,38 @@ class Evilution::Integration::RSpec < Evilution::Integration::Base
72
83
  { passed: false, error: e.message, test_command: command }
73
84
  ensure
74
85
  release_rspec_state(eg_before)
86
+ release_filtered_examples(fe_before)
87
+ release_reporter_state(rep_before)
75
88
  end
76
89
 
77
- def build_args(mutation)
78
- files = resolve_test_files(mutation)
90
+ def build_args(files)
79
91
  ["--format", "progress", "--no-color", "--order", "defined", *files]
80
92
  end
81
93
 
94
+ def apply_example_filter(mutation, files)
95
+ return files unless @example_filter
96
+
97
+ @example_filter.call(mutation, files)
98
+ end
99
+
100
+ def unresolved_result(mutation)
101
+ {
102
+ passed: false,
103
+ unresolved: true,
104
+ error: "no matching spec resolved for #{mutation.file_path}",
105
+ test_command: "rspec (skipped: no spec resolved for #{mutation.file_path})"
106
+ }
107
+ end
108
+
109
+ def unresolved_example_result(mutation)
110
+ {
111
+ passed: false,
112
+ unresolved: true,
113
+ error: "no matching example found for #{mutation.file_path}",
114
+ test_command: "rspec (skipped: no matching example for #{mutation.file_path})"
115
+ }
116
+ end
117
+
82
118
  def reset_state
83
119
  if ::RSpec.respond_to?(:clear_examples)
84
120
  ::RSpec.clear_examples
@@ -127,6 +163,54 @@ class Evilution::Integration::RSpec < Evilution::Integration::Base
127
163
  world.instance_variable_set(:@sources_by_path, {}) if world.instance_variable_defined?(:@sources_by_path)
128
164
  end
129
165
 
166
+ def snapshot_filtered_examples_keys
167
+ fe = rspec_world_ivar(:@filtered_examples)
168
+ fe ? Set.new(fe.keys.map(&:object_id)) : nil
169
+ end
170
+
171
+ def snapshot_reporter_lengths
172
+ reporter = rspec_config_ivar(:@reporter)
173
+ return nil unless reporter
174
+
175
+ %i[@examples @failed_examples @pending_examples].each_with_object({}) do |ivar, acc|
176
+ next unless reporter.instance_variable_defined?(ivar)
177
+
178
+ arr = reporter.instance_variable_get(ivar)
179
+ acc[ivar] = arr.length if arr.is_a?(Array)
180
+ end
181
+ end
182
+
183
+ def release_filtered_examples(snapshot_keys)
184
+ fe = rspec_world_ivar(:@filtered_examples)
185
+ return unless fe && snapshot_keys
186
+
187
+ fe.each_key.to_a.each do |k|
188
+ fe.delete(k) unless snapshot_keys.include?(k.object_id)
189
+ end
190
+ end
191
+
192
+ def release_reporter_state(lengths)
193
+ return unless lengths
194
+
195
+ reporter = rspec_config_ivar(:@reporter)
196
+ return unless reporter
197
+
198
+ lengths.each do |ivar, length|
199
+ arr = reporter.instance_variable_get(ivar)
200
+ arr.slice!(length..) if arr.is_a?(Array) && arr.length > length
201
+ end
202
+ end
203
+
204
+ def rspec_world_ivar(ivar)
205
+ world = ::RSpec.world
206
+ world.instance_variable_defined?(ivar) ? world.instance_variable_get(ivar) : nil
207
+ end
208
+
209
+ def rspec_config_ivar(ivar)
210
+ config = ::RSpec.configuration
211
+ config.instance_variable_defined?(ivar) ? config.instance_variable_get(ivar) : nil
212
+ end
213
+
130
214
  def reset_crash_detector
131
215
  if @crash_detector
132
216
  @crash_detector.reset
@@ -141,10 +225,12 @@ class Evilution::Integration::RSpec < Evilution::Integration::Base
141
225
  if status.zero?
142
226
  { passed: true, test_command: command }
143
227
  elsif detector.only_crashes?
228
+ classes = detector.unique_crash_classes
144
229
  {
145
230
  passed: false,
146
231
  test_crashed: true,
147
232
  error: "test crashes: #{detector.crash_summary}",
233
+ error_class: (classes.first if classes.length == 1),
148
234
  test_command: command
149
235
  }
150
236
  else
@@ -155,23 +241,24 @@ class Evilution::Integration::RSpec < Evilution::Integration::Base
155
241
  def resolve_test_files(mutation)
156
242
  return test_files if test_files
157
243
 
158
- resolved = @spec_resolver.call(mutation.file_path)
159
- unless resolved
244
+ resolved = Array(@spec_selector.call(mutation.file_path))
245
+ if resolved.empty?
160
246
  warn_unresolved_spec(mutation.file_path)
161
- return ["spec"]
247
+ return @fallback_to_full_suite ? ["spec"] : nil
162
248
  end
163
249
 
164
- return [resolved] unless @related_specs_heuristic_enabled
250
+ return resolved unless @related_specs_heuristic_enabled
165
251
 
166
252
  related = @related_spec_heuristic.call(mutation)
167
- ([resolved] + related).uniq
253
+ (resolved + related).uniq
168
254
  end
169
255
 
170
256
  def warn_unresolved_spec(file_path)
171
257
  return if @warned_files.include?(file_path)
172
258
 
173
259
  @warned_files << file_path
174
- warn "[evilution] No matching spec found for #{file_path}, running full suite. " \
260
+ action = @fallback_to_full_suite ? "running full suite" : "marking mutation unresolved"
261
+ warn "[evilution] No matching spec found for #{file_path}, #{action}. " \
175
262
  "Use --spec to specify the spec file."
176
263
  end
177
264
 
@@ -15,10 +15,18 @@ class Evilution::Isolation::Fork
15
15
  end
16
16
 
17
17
  def call(mutation:, test_command:, timeout:)
18
+ pid = nil
18
19
  sandbox_dir = Dir.mktmpdir("evilution-run")
19
20
  start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
20
21
  parent_rss = Evilution::Memory.rss_kb
21
22
  read_io, write_io = IO.pipe
23
+ # Marshal result payload is ASCII-8BIT; pipes default to text mode and may
24
+ # transcode according to their external/internal encodings (influenced by
25
+ # Encoding.default_external and/or Encoding.default_internal — Rails sets
26
+ # the latter to UTF-8), failing on bytes with no mapping. Force binmode on
27
+ # both ends.
28
+ read_io.binmode
29
+ write_io.binmode
22
30
 
23
31
  pid = ::Process.fork do
24
32
  ENV["TMPDIR"] = sandbox_dir
@@ -39,6 +47,7 @@ class Evilution::Isolation::Fork
39
47
  ensure
40
48
  read_io&.close
41
49
  write_io&.close
50
+ ensure_reaped(pid)
42
51
  restore_original_source(mutation)
43
52
  FileUtils.rm_rf(sandbox_dir) if sandbox_dir
44
53
  end
@@ -82,6 +91,22 @@ class Evilution::Isolation::Fork
82
91
  end
83
92
  end
84
93
 
94
+ # Defensive reap: if normal control flow raised before wait_for_result
95
+ # reaped the child (e.g. Marshal.load on corrupt payload), the child becomes
96
+ # a zombie. Reuse terminate_child for the bounded TERM + GRACE_PERIOD + KILL
97
+ # ladder so this never hangs the ensure path; swallow SystemCallError so
98
+ # cleanup can't mask the primary failure.
99
+ def ensure_reaped(pid)
100
+ return unless pid
101
+
102
+ reaped = ::Process.waitpid(pid, ::Process::WNOHANG)
103
+ return if reaped
104
+
105
+ terminate_child(pid)
106
+ rescue SystemCallError
107
+ nil
108
+ end
109
+
85
110
  def terminate_child(pid)
86
111
  ::Process.kill("TERM", pid) rescue nil # rubocop:disable Style/RescueModifier
87
112
  _, status = ::Process.waitpid2(pid, ::Process::WNOHANG)
@@ -98,6 +123,7 @@ class Evilution::Isolation::Fork
98
123
  def classify_status(result)
99
124
  return :timeout if result[:timeout]
100
125
  return :killed if result[:test_crashed]
126
+ return :unresolved if result[:unresolved]
101
127
  return :error if result[:error]
102
128
  return :survived if result[:passed]
103
129
 
@@ -65,6 +65,7 @@ class Evilution::Isolation::InProcess
65
65
  def classify_status(result)
66
66
  return :timeout if result[:timeout]
67
67
  return :killed if result[:test_crashed]
68
+ return :unresolved if result[:unresolved]
68
69
  return :error if result[:error]
69
70
  return :survived if result[:passed]
70
71
 
@@ -5,6 +5,7 @@ require "mcp"
5
5
  require_relative "../config"
6
6
  require_relative "../runner"
7
7
  require_relative "../mutator/registry"
8
+ require_relative "../result/mutation_result"
8
9
  require_relative "../spec_resolver"
9
10
  require_relative "../ast/pattern/filter"
10
11
  require_relative "../version"
@@ -14,20 +15,23 @@ require_relative "../mcp"
14
15
  class Evilution::MCP::InfoTool < MCP::Tool
15
16
  tool_name "evilution-info"
16
17
  description "Discover what evilution sees before running any mutations. " \
17
- "One tool, three actions: " \
18
+ "One tool, four actions: " \
18
19
  "'subjects' lists every mutatable method in the target files with its file, line, and mutation count; " \
19
20
  "'tests' resolves which spec/test files cover the given sources (so you pick the right --spec before mutating); " \
20
21
  "'environment' dumps the effective config (version, ruby, config file, timeout, " \
21
- "integration, isolation, and every other setting). " \
22
+ "integration, isolation, and every other setting); " \
23
+ "'statuses' returns the mutation-result status glossary (killed/survived/neutral/error/etc.) with " \
24
+ "per-status meaning and scoring semantics so agents can triage results without guessing. " \
22
25
  "Use this instead of shelling out to 'evilution subjects', 'evilution tests list', or 'evilution environment show' — " \
23
26
  "the response is structured JSON so you can plan the next mutation run without parsing CLI text."
24
27
  input_schema(
25
28
  properties: {
26
29
  action: {
27
30
  type: "string",
28
- enum: %w[subjects tests environment],
31
+ enum: %w[subjects tests environment statuses],
29
32
  description: "Which discovery operation to perform. " \
30
- "'subjects' lists mutatable methods; 'tests' resolves specs for sources; 'environment' dumps effective config."
33
+ "'subjects' lists mutatable methods; 'tests' resolves specs for sources; " \
34
+ "'environment' dumps effective config; 'statuses' returns the result-status glossary."
31
35
  },
32
36
  files: {
33
37
  type: "array",
@@ -60,7 +64,61 @@ class Evilution::MCP::InfoTool < MCP::Tool
60
64
  required: ["action"]
61
65
  )
62
66
 
63
- VALID_ACTIONS = %w[subjects tests environment].freeze
67
+ VALID_ACTIONS = %w[subjects tests environment statuses].freeze
68
+
69
+ STATUS_GLOSSARY = [
70
+ {
71
+ "status" => "killed",
72
+ "meaning" => "A test failed when the mutation was applied — the test suite caught the mutation. " \
73
+ "This is the desired outcome.",
74
+ "counted_in_score" => true
75
+ },
76
+ {
77
+ "status" => "survived",
78
+ "meaning" => "No test failed when the mutation was applied — gap in coverage. " \
79
+ "The test suite did not detect the behavioral change.",
80
+ "counted_in_score" => true
81
+ },
82
+ {
83
+ "status" => "timeout",
84
+ "meaning" => "Test run exceeded the configured per-mutation timeout. " \
85
+ "Treated like survived for scoring (counted in the denominator); " \
86
+ "may indicate an infinite loop introduced by the mutation.",
87
+ "counted_in_score" => true
88
+ },
89
+ {
90
+ "status" => "error",
91
+ "meaning" => "Mutation execution raised an unexpected error (syntax error at load time, " \
92
+ "boot failure, test-infrastructure crash). The mutation could not be evaluated.",
93
+ "counted_in_score" => false
94
+ },
95
+ {
96
+ "status" => "neutral",
97
+ "meaning" => "Baseline tests already failed before the mutation was applied — pre-existing " \
98
+ "test-suite problem (flaky spec, infra collision, fixture setup failure). " \
99
+ "Not a meaningful mutation signal.",
100
+ "counted_in_score" => false
101
+ },
102
+ {
103
+ "status" => "equivalent",
104
+ "meaning" => "Mutation is provably identical to the original source " \
105
+ "(e.g. a no-op replacement that the parser or evaluator treats as semantically equal).",
106
+ "counted_in_score" => false
107
+ },
108
+ {
109
+ "status" => "unresolved",
110
+ "meaning" => "No spec/test file resolved for the mutated source — coverage gap, not a failure. " \
111
+ "The file has no corresponding test file the resolver could locate.",
112
+ "counted_in_score" => false
113
+ },
114
+ {
115
+ "status" => "unparseable",
116
+ "meaning" => "Mutated source failed to parse (e.g. dangling heredoc after method_body_replacement). " \
117
+ "Short-circuited before execution; no test run was attempted.",
118
+ "counted_in_score" => false
119
+ }
120
+ ].freeze
121
+ private_constant :STATUS_GLOSSARY
64
122
 
65
123
  class << self
66
124
  # rubocop:disable Lint/UnusedMethodArgument
@@ -78,6 +136,8 @@ class Evilution::MCP::InfoTool < MCP::Tool
78
136
  tests_action(files: parsed_files, spec: spec, integration: integration, skip_config: skip_config)
79
137
  when "environment"
80
138
  environment_action
139
+ when "statuses"
140
+ statuses_action
81
141
  end
82
142
  rescue Evilution::Error => e
83
143
  error_response_for(e)
@@ -213,6 +273,18 @@ class Evilution::MCP::InfoTool < MCP::Tool
213
273
  )
214
274
  end
215
275
 
276
+ def statuses_action
277
+ # Guard against drift: every STATUSES symbol must have a glossary entry.
278
+ defined = Evilution::Result::MutationResult::STATUSES.map(&:to_s).sort
279
+ documented = STATUS_GLOSSARY.map { |s| s["status"] }.sort
280
+ if defined != documented
281
+ missing = (defined - documented) + (documented - defined)
282
+ raise Evilution::Error, "status glossary drift: #{missing.inspect}"
283
+ end
284
+
285
+ success_response("statuses" => STATUS_GLOSSARY)
286
+ end
287
+
216
288
  def error_response_for(error)
217
289
  type = case error
218
290
  when Evilution::ConfigError then "config_error"
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../mutate_tool"
4
+ require_relative "option_parser"
5
+ require_relative "../../config"
6
+
7
+ module Evilution::MCP::MutateTool::ConfigBuilder
8
+ def self.build(files:, line_ranges:, params:)
9
+ # Preload is disabled for MCP invocations: `require`-ing Rails into the
10
+ # long-lived MCP server would poison subsequent runs against other
11
+ # projects. MCP users who want the speedup should use the CLI.
12
+ opts = { target_files: files, line_ranges: line_ranges, format: :json, quiet: true, preload: false }
13
+ opts[:skip_config_file] = true if params[:skip_config]
14
+ opts[:spec_files] = params[:spec] if params[:spec]
15
+ Evilution::MCP::MutateTool::OptionParser::PASSTHROUGH_KEYS.each do |key|
16
+ opts[key] = params[key] unless params[key].nil?
17
+ end
18
+ Evilution::Config.new(**opts)
19
+ end
20
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../mutate_tool"
4
+
5
+ module Evilution::MCP::MutateTool::ErrorPayload
6
+ def self.build(error)
7
+ type = case error
8
+ when Evilution::ConfigError then "config_error"
9
+ when Evilution::ParseError then "parse_error"
10
+ else "runtime_error"
11
+ end
12
+
13
+ payload = { type: type, message: error.message }
14
+ payload[:file] = error.file if error.file
15
+ { error: payload }
16
+ end
17
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../mutate_tool"
4
+
5
+ module Evilution::MCP::MutateTool::OptionParser
6
+ VALID_VERBOSITIES = %w[full summary minimal].freeze
7
+ PASSTHROUGH_KEYS = %i[target timeout jobs fail_fast suggest_tests incremental integration
8
+ isolation baseline save_session].freeze
9
+ ALLOWED_OPT_KEYS = (PASSTHROUGH_KEYS + %i[spec skip_config]).freeze
10
+
11
+ def self.parse_files(raw_files)
12
+ files = []
13
+ ranges = {}
14
+
15
+ raw_files.each do |arg|
16
+ file, range_str = arg.split(":", 2)
17
+ files << file
18
+ next unless range_str
19
+
20
+ ranges[file] = parse_line_range(range_str)
21
+ end
22
+
23
+ [files, ranges]
24
+ end
25
+
26
+ def self.parse_line_range(str)
27
+ if str.include?("-")
28
+ start_str, end_str = str.split("-", 2)
29
+ start_line = Integer(start_str)
30
+ end_line = end_str.empty? ? Float::INFINITY : Integer(end_str)
31
+ start_line..end_line
32
+ else
33
+ line = Integer(str)
34
+ line..line
35
+ end
36
+ rescue ArgumentError, TypeError
37
+ raise Evilution::ParseError, "invalid line range: #{str.inspect}"
38
+ end
39
+
40
+ def self.normalize_verbosity(value)
41
+ normalized = value.to_s.strip.downcase
42
+ normalized = "summary" if normalized.empty?
43
+ return normalized if VALID_VERBOSITIES.include?(normalized)
44
+
45
+ raise Evilution::ParseError, "invalid verbosity: #{value.inspect} (must be full, summary, or minimal)"
46
+ end
47
+
48
+ def self.validate!(opts)
49
+ unknown = opts.keys - ALLOWED_OPT_KEYS
50
+ return if unknown.empty?
51
+
52
+ raise Evilution::ParseError, "unknown parameters: #{unknown.join(", ")}"
53
+ end
54
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "../mutate_tool"
5
+ require_relative "../../reporter/suggestion"
6
+
7
+ module Evilution::MCP::MutateTool::ProgressStreamer
8
+ def self.build(server_context:, suggest_tests:, integration:)
9
+ return nil unless suggest_tests && server_context.respond_to?(:report_progress)
10
+
11
+ suggestion = Evilution::Reporter::Suggestion.new(suggest_tests: true, integration: integration)
12
+ survivor_index = 0
13
+
14
+ proc do |result|
15
+ next unless result.survived?
16
+
17
+ begin
18
+ survivor_index += 1
19
+ detail = build_suggestion_detail(result.mutation, suggestion)
20
+ server_context.report_progress(survivor_index, message: ::JSON.generate(detail))
21
+ rescue StandardError # rubocop:disable Lint/SuppressedException
22
+ end
23
+ end
24
+ end
25
+
26
+ def self.build_suggestion_detail(mutation, suggestion)
27
+ {
28
+ operator: mutation.operator_name,
29
+ file: mutation.file_path,
30
+ line: mutation.line,
31
+ subject: mutation.subject.name,
32
+ diff: mutation.diff,
33
+ suggestion: suggestion.suggestion_for(mutation)
34
+ }
35
+ end
36
+ private_class_method :build_suggestion_detail
37
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "../mutate_tool"
5
+
6
+ module Evilution::MCP::MutateTool::ReportTrimmer
7
+ MINIMAL_KEYS = %w[summary survived].freeze
8
+ FULL_DIFF_STRIP_KEYS = %w[killed neutral equivalent unresolved unparseable].freeze
9
+ SUMMARY_DROP_KEYS = %w[killed neutral equivalent unparseable].freeze
10
+
11
+ def self.call(json_string, verbosity:, survived_results:, config:, enricher:)
12
+ data = ::JSON.parse(json_string)
13
+ case verbosity
14
+ when "full"
15
+ FULL_DIFF_STRIP_KEYS.each { |key| strip_diffs(data, key) }
16
+ when "summary"
17
+ SUMMARY_DROP_KEYS.each { |key| data.delete(key) }
18
+ when "minimal"
19
+ data.keep_if { |key, _| MINIMAL_KEYS.include?(key) }
20
+ end
21
+ enricher.call(data, survived_results, config)
22
+ ::JSON.generate(data)
23
+ end
24
+
25
+ def self.strip_diffs(data, key)
26
+ return unless data[key]
27
+
28
+ data[key].each { |entry| entry.delete("diff") }
29
+ end
30
+ private_class_method :strip_diffs
31
+ end