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
@@ -27,6 +27,8 @@ class Evilution::Config
27
27
  show_disabled: false,
28
28
  baseline_session: nil,
29
29
  skip_heredoc_literals: false,
30
+ related_specs_heuristic: false,
31
+ fallback_to_full_suite: false,
30
32
  preload: nil
31
33
  }.freeze
32
34
 
@@ -35,7 +37,8 @@ class Evilution::Config
35
37
  :jobs, :fail_fast, :baseline, :isolation, :incremental, :suggest_tests,
36
38
  :progress, :save_session, :line_ranges, :spec_files, :hooks,
37
39
  :ignore_patterns, :show_disabled, :baseline_session,
38
- :skip_heredoc_literals, :preload
40
+ :skip_heredoc_literals, :related_specs_heuristic,
41
+ :fallback_to_full_suite, :preload
39
42
 
40
43
  def initialize(**options)
41
44
  file_options = options.delete(:skip_config_file) ? {} : load_config_file
@@ -96,6 +99,14 @@ class Evilution::Config
96
99
  skip_heredoc_literals
97
100
  end
98
101
 
102
+ def related_specs_heuristic?
103
+ related_specs_heuristic
104
+ end
105
+
106
+ def fallback_to_full_suite?
107
+ fallback_to_full_suite
108
+ end
109
+
99
110
  def self.file_options
100
111
  CONFIG_FILES.each do |path|
101
112
  next unless File.exist?(path)
@@ -138,8 +149,23 @@ class Evilution::Config
138
149
  # Generate concrete test code in suggestions, matching integration (default: false)
139
150
  # suggest_tests: false
140
151
 
141
- # Skip all string literal mutations inside heredocs (default: false)
142
- # skip_heredoc_literals: false
152
+ # Skip all string literal mutations inside heredocs (default: false).
153
+ # Useful for Rails apps where heredoc content (SQL, templates, fixtures)
154
+ # rarely has meaningful test coverage and produces noisy survivors.
155
+ # skip_heredoc_literals: true
156
+
157
+ # Opt into the RelatedSpecHeuristic, which appends request/integration/
158
+ # feature/system specs for mutations that touch `.includes(...)` calls
159
+ # (default: false). Off by default because the fan-out can be heavy and
160
+ # push runs over the per-mutation timeout. Enable if you need coverage
161
+ # of N+1 regressions that only surface in higher-level specs.
162
+ # related_specs_heuristic: true
163
+
164
+ # When no matching spec resolves for a mutation's source file, the
165
+ # default is to skip that mutation and mark it :unresolved in the
166
+ # report (a coverage gap signal). Set to true to fall back to running
167
+ # the entire test suite for such mutations instead (slow, high memory).
168
+ # fallback_to_full_suite: false
143
169
 
144
170
  # Preload file required in the parent process before forking workers.
145
171
  # For Rails projects, spec/rails_helper.rb or test/test_helper.rb is
@@ -195,6 +221,8 @@ class Evilution::Config
195
221
  @show_disabled = merged[:show_disabled]
196
222
  @baseline_session = merged[:baseline_session]
197
223
  @skip_heredoc_literals = merged[:skip_heredoc_literals]
224
+ @related_specs_heuristic = merged[:related_specs_heuristic]
225
+ @fallback_to_full_suite = merged[:fallback_to_full_suite]
198
226
  @hooks = validate_hooks(merged[:hooks])
199
227
  @preload = validate_preload(merged[:preload])
200
228
  end
@@ -1,10 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "fileutils"
4
3
  require "prism"
5
- require "tmpdir"
6
4
  require_relative "../integration"
7
- require_relative "../temp_dir_tracker"
8
5
 
9
6
  class Evilution::Integration::Base
10
7
  def self.baseline_runner
@@ -20,7 +17,6 @@ class Evilution::Integration::Base
20
17
  end
21
18
 
22
19
  def call(mutation)
23
- @temp_dir = nil
24
20
  ensure_framework_loaded
25
21
  fire_hook(:mutation_insert_pre, mutation: mutation, file_path: mutation.file_path)
26
22
  load_error = apply_mutation(mutation)
@@ -28,8 +24,6 @@ class Evilution::Integration::Base
28
24
 
29
25
  fire_hook(:mutation_insert_post, mutation: mutation, file_path: mutation.file_path)
30
26
  run_tests(mutation)
31
- ensure
32
- restore_original(mutation)
33
27
  end
34
28
 
35
29
  private
@@ -55,15 +49,13 @@ class Evilution::Integration::Base
55
49
  end
56
50
 
57
51
  def apply_mutation(mutation)
58
- @temp_dir = Dir.mktmpdir("evilution")
59
- Evilution::TempDirTracker.register(@temp_dir)
60
- @displaced_feature = nil
61
- subpath = resolve_require_subpath(mutation.file_path)
62
-
63
- if subpath
64
- apply_via_require(mutation, subpath)
65
- else
66
- apply_via_load(mutation)
52
+ prism_error = validate_mutated_syntax(mutation.mutated_source)
53
+ return prism_error if prism_error
54
+
55
+ pin_autoloaded_constants(mutation.original_source)
56
+ clear_concern_state(mutation.file_path)
57
+ with_redefinition_recovery(mutation.original_source) do
58
+ eval_mutated_source(mutation)
67
59
  end
68
60
  nil
69
61
  rescue SyntaxError => e
@@ -82,29 +74,25 @@ class Evilution::Integration::Base
82
74
  }
83
75
  end
84
76
 
85
- def apply_via_require(mutation, subpath)
86
- dest = File.join(@temp_dir, subpath)
87
- FileUtils.mkdir_p(File.dirname(dest))
88
- File.write(dest, mutation.mutated_source)
89
- $LOAD_PATH.unshift(@temp_dir)
90
- displace_loaded_feature(mutation.file_path)
91
- pin_autoloaded_constants(mutation.original_source)
92
- clear_concern_state(mutation.file_path)
93
- with_redefinition_recovery(mutation.original_source) do
94
- require(subpath.delete_suffix(".rb"))
95
- end
77
+ def validate_mutated_syntax(source)
78
+ return nil if Prism.parse(source).success?
79
+
80
+ {
81
+ passed: false,
82
+ error: "mutated source has syntax errors",
83
+ error_class: "SyntaxError",
84
+ error_backtrace: []
85
+ }
96
86
  end
97
87
 
98
- def apply_via_load(mutation)
88
+ # Evaluate the mutated source with __FILE__ set to the original path so
89
+ # that `require_relative` and `__dir__` resolve against the real source
90
+ # tree, where sibling files actually exist.
91
+ def eval_mutated_source(mutation)
99
92
  absolute = File.expand_path(mutation.file_path)
100
- dest = File.join(@temp_dir, absolute)
101
- FileUtils.mkdir_p(File.dirname(dest))
102
- File.write(dest, mutation.mutated_source)
103
- pin_autoloaded_constants(mutation.original_source)
104
- clear_concern_state(mutation.file_path)
105
- with_redefinition_recovery(mutation.original_source) do
106
- load(dest)
107
- end
93
+ # rubocop:disable Security/Eval
94
+ eval(mutation.mutated_source, TOPLEVEL_BINDING, absolute, 1)
95
+ # rubocop:enable Security/Eval
108
96
  end
109
97
 
110
98
  def with_redefinition_recovery(original_source)
@@ -120,18 +108,6 @@ class Evilution::Integration::Base
120
108
  error.message.include?("already defined")
121
109
  end
122
110
 
123
- def restore_original(_mutation)
124
- return unless @temp_dir
125
-
126
- $LOAD_PATH.delete(@temp_dir)
127
- $LOADED_FEATURES.reject! { |f| f.start_with?(@temp_dir) }
128
- $LOADED_FEATURES << @displaced_feature if @displaced_feature && !$LOADED_FEATURES.include?(@displaced_feature)
129
- @displaced_feature = nil
130
- FileUtils.rm_rf(@temp_dir)
131
- Evilution::TempDirTracker.unregister(@temp_dir)
132
- @temp_dir = nil
133
- end
134
-
135
111
  def pin_autoloaded_constants(source)
136
112
  collect_constant_names(Prism.parse(source).value).each do |name|
137
113
  Object.const_get(name) if Object.const_defined?(name, false)
@@ -223,12 +199,4 @@ class Evilution::Integration::Base
223
199
 
224
200
  best_subpath
225
201
  end
226
-
227
- def displace_loaded_feature(file_path)
228
- absolute = File.expand_path(file_path)
229
- return unless $LOADED_FEATURES.include?(absolute)
230
-
231
- @displaced_feature = absolute
232
- $LOADED_FEATURES.delete(absolute)
233
- end
234
202
  end
@@ -35,10 +35,11 @@ class Evilution::Integration::Minitest < Evilution::Integration::Base
35
35
  }
36
36
  end
37
37
 
38
- def initialize(test_files: nil, hooks: nil)
38
+ def initialize(test_files: nil, hooks: nil, fallback_to_full_suite: false)
39
39
  @test_files = test_files
40
40
  @minitest_loaded = false
41
41
  @spec_resolver = Evilution::SpecResolver.new(test_dir: "test", test_suffix: "_test.rb", request_dir: "integration")
42
+ @fallback_to_full_suite = fallback_to_full_suite
42
43
  @crash_detector = nil
43
44
  @warned_files = Set.new
44
45
  super(hooks: hooks)
@@ -62,6 +63,8 @@ class Evilution::Integration::Minitest < Evilution::Integration::Base
62
63
  def run_tests(mutation)
63
64
  reset_state
64
65
  files = resolve_test_files(mutation)
66
+ return unresolved_result(mutation) if files.nil?
67
+
65
68
  command = "ruby -Itest #{files.join(" ")}"
66
69
 
67
70
  files.each { |f| load(File.expand_path(f)) }
@@ -75,6 +78,15 @@ class Evilution::Integration::Minitest < Evilution::Integration::Base
75
78
  { passed: false, error: e.message, test_command: command }
76
79
  end
77
80
 
81
+ def unresolved_result(mutation)
82
+ {
83
+ passed: false,
84
+ unresolved: true,
85
+ error: "no matching test resolved for #{mutation.file_path}",
86
+ test_command: "ruby -Itest (skipped: no test resolved for #{mutation.file_path})"
87
+ }
88
+ end
89
+
78
90
  def build_args(_mutation)
79
91
  ["--seed", "0"]
80
92
  end
@@ -112,7 +124,12 @@ class Evilution::Integration::Minitest < Evilution::Integration::Base
112
124
  if passed
113
125
  { passed: true, test_command: command }
114
126
  elsif detector.only_crashes?
115
- { passed: false, error: "test crashes: #{detector.crash_summary}", test_command: command }
127
+ {
128
+ passed: false,
129
+ test_crashed: true,
130
+ error: "test crashes: #{detector.crash_summary}",
131
+ test_command: command
132
+ }
116
133
  else
117
134
  { passed: false, test_command: command }
118
135
  end
@@ -124,7 +141,7 @@ class Evilution::Integration::Minitest < Evilution::Integration::Base
124
141
  resolved = @spec_resolver.call(mutation.file_path)
125
142
  unless resolved
126
143
  warn_unresolved_test(mutation.file_path)
127
- return glob_test_files
144
+ return @fallback_to_full_suite ? glob_test_files : nil
128
145
  end
129
146
 
130
147
  [resolved]
@@ -139,7 +156,8 @@ class Evilution::Integration::Minitest < Evilution::Integration::Base
139
156
  return if @warned_files.include?(file_path)
140
157
 
141
158
  @warned_files << file_path
142
- warn "[evilution] No matching test found for #{file_path}, running full suite. " \
159
+ action = @fallback_to_full_suite ? "running full suite" : "marking mutation unresolved"
160
+ warn "[evilution] No matching test found for #{file_path}, #{action}. " \
143
161
  "Use --spec to specify the test file."
144
162
  end
145
163
  end
@@ -26,11 +26,13 @@ class Evilution::Integration::RSpec < Evilution::Integration::Base
26
26
  { runner: baseline_runner }
27
27
  end
28
28
 
29
- def initialize(test_files: nil, hooks: nil)
29
+ def initialize(test_files: nil, hooks: nil, related_specs_heuristic: false, fallback_to_full_suite: false)
30
30
  @test_files = test_files
31
31
  @rspec_loaded = false
32
32
  @spec_resolver = Evilution::SpecResolver.new
33
33
  @related_spec_heuristic = Evilution::RelatedSpecHeuristic.new
34
+ @related_specs_heuristic_enabled = related_specs_heuristic
35
+ @fallback_to_full_suite = fallback_to_full_suite
34
36
  @crash_detector = nil
35
37
  @warned_files = Set.new
36
38
  super(hooks: hooks)
@@ -56,10 +58,12 @@ class Evilution::Integration::RSpec < Evilution::Integration::Base
56
58
  def run_tests(mutation)
57
59
  reset_state
58
60
 
61
+ files = resolve_test_files(mutation)
62
+ return unresolved_result(mutation) if files.nil?
63
+
59
64
  out = StringIO.new
60
65
  err = StringIO.new
61
- command = "rspec"
62
- args = build_args(mutation)
66
+ args = build_args(files)
63
67
  command = "rspec #{args.join(" ")}"
64
68
 
65
69
  detector = reset_crash_detector
@@ -73,11 +77,19 @@ class Evilution::Integration::RSpec < Evilution::Integration::Base
73
77
  release_rspec_state(eg_before)
74
78
  end
75
79
 
76
- def build_args(mutation)
77
- files = resolve_test_files(mutation)
80
+ def build_args(files)
78
81
  ["--format", "progress", "--no-color", "--order", "defined", *files]
79
82
  end
80
83
 
84
+ def unresolved_result(mutation)
85
+ {
86
+ passed: false,
87
+ unresolved: true,
88
+ error: "no matching spec resolved for #{mutation.file_path}",
89
+ test_command: "rspec (skipped: no spec resolved for #{mutation.file_path})"
90
+ }
91
+ end
92
+
81
93
  def reset_state
82
94
  if ::RSpec.respond_to?(:clear_examples)
83
95
  ::RSpec.clear_examples
@@ -140,7 +152,12 @@ class Evilution::Integration::RSpec < Evilution::Integration::Base
140
152
  if status.zero?
141
153
  { passed: true, test_command: command }
142
154
  elsif detector.only_crashes?
143
- { passed: false, error: "test crashes: #{detector.crash_summary}", test_command: command }
155
+ {
156
+ passed: false,
157
+ test_crashed: true,
158
+ error: "test crashes: #{detector.crash_summary}",
159
+ test_command: command
160
+ }
144
161
  else
145
162
  { passed: false, test_command: command }
146
163
  end
@@ -152,9 +169,11 @@ class Evilution::Integration::RSpec < Evilution::Integration::Base
152
169
  resolved = @spec_resolver.call(mutation.file_path)
153
170
  unless resolved
154
171
  warn_unresolved_spec(mutation.file_path)
155
- return ["spec"]
172
+ return @fallback_to_full_suite ? ["spec"] : nil
156
173
  end
157
174
 
175
+ return [resolved] unless @related_specs_heuristic_enabled
176
+
158
177
  related = @related_spec_heuristic.call(mutation)
159
178
  ([resolved] + related).uniq
160
179
  end
@@ -163,7 +182,8 @@ class Evilution::Integration::RSpec < Evilution::Integration::Base
163
182
  return if @warned_files.include?(file_path)
164
183
 
165
184
  @warned_files << file_path
166
- warn "[evilution] No matching spec found for #{file_path}, running full suite. " \
185
+ action = @fallback_to_full_suite ? "running full suite" : "marking mutation unresolved"
186
+ warn "[evilution] No matching spec found for #{file_path}, #{action}. " \
167
187
  "Use --spec to specify the spec file."
168
188
  end
169
189
 
@@ -95,16 +95,18 @@ class Evilution::Isolation::Fork
95
95
  ::Process.wait(pid) rescue nil # rubocop:disable Style/RescueModifier
96
96
  end
97
97
 
98
+ def classify_status(result)
99
+ return :timeout if result[:timeout]
100
+ return :killed if result[:test_crashed]
101
+ return :unresolved if result[:unresolved]
102
+ return :error if result[:error]
103
+ return :survived if result[:passed]
104
+
105
+ :killed
106
+ end
107
+
98
108
  def build_mutation_result(mutation, result, duration, parent_rss_kb)
99
- status = if result[:timeout]
100
- :timeout
101
- elsif result[:error]
102
- :error
103
- elsif result[:passed]
104
- :survived
105
- else
106
- :killed
107
- end
109
+ status = classify_status(result)
108
110
 
109
111
  Evilution::Result::MutationResult.new(
110
112
  mutation: mutation,
@@ -62,16 +62,18 @@ class Evilution::Isolation::InProcess
62
62
  rss_after - rss_before
63
63
  end
64
64
 
65
+ def classify_status(result)
66
+ return :timeout if result[:timeout]
67
+ return :killed if result[:test_crashed]
68
+ return :unresolved if result[:unresolved]
69
+ return :error if result[:error]
70
+ return :survived if result[:passed]
71
+
72
+ :killed
73
+ end
74
+
65
75
  def build_mutation_result(mutation, result, duration, rss_before, rss_after, memory_delta_kb)
66
- status = if result[:timeout]
67
- :timeout
68
- elsif result[:error]
69
- :error
70
- elsif result[:passed]
71
- :survived
72
- else
73
- :killed
74
- end
76
+ status = classify_status(result)
75
77
 
76
78
  Evilution::Result::MutationResult.new(
77
79
  mutation: mutation,
@@ -0,0 +1,261 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "mcp"
5
+ require_relative "../config"
6
+ require_relative "../runner"
7
+ require_relative "../mutator/registry"
8
+ require_relative "../spec_resolver"
9
+ require_relative "../ast/pattern/filter"
10
+ require_relative "../version"
11
+
12
+ require_relative "../mcp"
13
+
14
+ class Evilution::MCP::InfoTool < MCP::Tool
15
+ tool_name "evilution-info"
16
+ description "Discover what evilution sees before running any mutations. " \
17
+ "One tool, three actions: " \
18
+ "'subjects' lists every mutatable method in the target files with its file, line, and mutation count; " \
19
+ "'tests' resolves which spec/test files cover the given sources (so you pick the right --spec before mutating); " \
20
+ "'environment' dumps the effective config (version, ruby, config file, timeout, " \
21
+ "integration, isolation, and every other setting). " \
22
+ "Use this instead of shelling out to 'evilution subjects', 'evilution tests list', or 'evilution environment show' — " \
23
+ "the response is structured JSON so you can plan the next mutation run without parsing CLI text."
24
+ input_schema(
25
+ properties: {
26
+ action: {
27
+ type: "string",
28
+ enum: %w[subjects tests environment],
29
+ description: "Which discovery operation to perform. " \
30
+ "'subjects' lists mutatable methods; 'tests' resolves specs for sources; 'environment' dumps effective config."
31
+ },
32
+ files: {
33
+ type: "array",
34
+ items: { type: "string" },
35
+ description: "[subjects, tests] Target source files. Supports line-range syntax " \
36
+ "(lib/foo.rb:15-30, lib/foo.rb:15, lib/foo.rb:15-); for 'tests' the range is " \
37
+ "stripped before spec resolution."
38
+ },
39
+ target: {
40
+ type: "string",
41
+ description: "[subjects] Filter expression: method (Foo#bar), type (Foo#/Foo.), namespace (Foo*), class (Foo)"
42
+ },
43
+ spec: {
44
+ type: "array",
45
+ items: { type: "string" },
46
+ description: "[tests] Explicit spec files to return instead of auto-resolving from sources"
47
+ },
48
+ integration: {
49
+ type: "string",
50
+ description: "[subjects, tests] Test integration (rspec, minitest) — 'tests' selects " \
51
+ "the matching spec resolver (spec/*_spec.rb for rspec, test/*_test.rb for minitest)"
52
+ },
53
+ skip_config: {
54
+ type: "boolean",
55
+ description: "[subjects, tests] When true, ignore .evilution.yml / config/evilution.yml; " \
56
+ "explicit tool parameters still apply. " \
57
+ "Default: false — project config is loaded so the result reflects what `evilution` CLI would see."
58
+ }
59
+ },
60
+ required: ["action"]
61
+ )
62
+
63
+ VALID_ACTIONS = %w[subjects tests environment].freeze
64
+
65
+ class << self
66
+ # rubocop:disable Lint/UnusedMethodArgument
67
+ def call(server_context:, action: nil, files: nil, target: nil, spec: nil, integration: nil, skip_config: nil)
68
+ return error_response("config_error", "action is required") unless action
69
+ return error_response("config_error", "unknown action: #{action}") unless VALID_ACTIONS.include?(action)
70
+
71
+ parsed_files, line_ranges = parse_files(Array(files)) if files
72
+
73
+ case action
74
+ when "subjects"
75
+ subjects_action(files: parsed_files, line_ranges: line_ranges, target: target,
76
+ integration: integration, skip_config: skip_config)
77
+ when "tests"
78
+ tests_action(files: parsed_files, spec: spec, integration: integration, skip_config: skip_config)
79
+ when "environment"
80
+ environment_action
81
+ end
82
+ rescue Evilution::Error => e
83
+ error_response_for(e)
84
+ end
85
+ # rubocop:enable Lint/UnusedMethodArgument
86
+
87
+ private
88
+
89
+ def parse_files(raw_files)
90
+ files = []
91
+ ranges = {}
92
+
93
+ raw_files.each do |arg|
94
+ file, range_str = arg.split(":", 2)
95
+ files << file
96
+ ranges[file] = parse_line_range(range_str) if range_str
97
+ end
98
+
99
+ [files, ranges]
100
+ end
101
+
102
+ def parse_line_range(str)
103
+ if str.include?("-")
104
+ start_str, end_str = str.split("-", 2)
105
+ start_line = Integer(start_str)
106
+ end_line = end_str.empty? ? Float::INFINITY : Integer(end_str)
107
+ start_line..end_line
108
+ else
109
+ line = Integer(str)
110
+ line..line
111
+ end
112
+ rescue ArgumentError, TypeError
113
+ raise Evilution::ParseError, "invalid line range: #{str.inspect}"
114
+ end
115
+
116
+ def subjects_action(files:, line_ranges:, target:, integration:, skip_config:)
117
+ return error_response("config_error", "files is required") if files.nil? || files.empty?
118
+
119
+ config = build_subjects_config(files: files, line_ranges: line_ranges,
120
+ target: target, integration: integration, skip_config: skip_config)
121
+ runner = Evilution::Runner.new(config: config)
122
+ subjects = runner.parse_and_filter_subjects
123
+
124
+ registry = Evilution::Mutator::Registry.default
125
+ filter = build_subject_filter(config)
126
+ operator_options = { skip_heredoc_literals: config.skip_heredoc_literals? }
127
+
128
+ entries = subjects.map do |subj|
129
+ count = registry.mutations_for(subj, filter: filter, operator_options: operator_options).length
130
+ { "name" => subj.name, "file" => subj.file_path, "line" => subj.line_number, "mutations" => count }
131
+ ensure
132
+ subj.release_node!
133
+ end
134
+
135
+ success_response(
136
+ "subjects" => entries,
137
+ "total_subjects" => entries.length,
138
+ "total_mutations" => entries.sum { |e| e["mutations"] }
139
+ )
140
+ end
141
+
142
+ def tests_action(files:, spec:, integration:, skip_config:)
143
+ return error_response("config_error", "files is required") if files.nil? || files.empty?
144
+
145
+ config = build_tests_config(files: files, spec: spec, integration: integration, skip_config: skip_config)
146
+ return explicit_specs_response(files, config.spec_files) if config.spec_files.any?
147
+
148
+ resolver = resolver_for_integration(config.integration)
149
+ resolved, unresolved = resolve_specs(files, resolver)
150
+ success_response(
151
+ "specs" => resolved,
152
+ "unresolved" => unresolved,
153
+ "total_sources" => files.length,
154
+ "total_specs" => resolved.map { |r| r["spec"] }.uniq.length
155
+ )
156
+ end
157
+
158
+ def build_subjects_config(files:, line_ranges:, target:, integration:, skip_config:)
159
+ opts = { target_files: files, line_ranges: line_ranges || {} }
160
+ opts[:skip_config_file] = true if skip_config
161
+ opts[:target] = target if target
162
+ opts[:integration] = integration if integration
163
+ Evilution::Config.new(**opts)
164
+ end
165
+
166
+ def build_tests_config(files:, spec:, integration:, skip_config:)
167
+ opts = { target_files: files }
168
+ opts[:skip_config_file] = true if skip_config
169
+ opts[:spec_files] = spec if spec
170
+ opts[:integration] = integration if integration
171
+ Evilution::Config.new(**opts)
172
+ end
173
+
174
+ def resolver_for_integration(integration)
175
+ integration_class = Evilution::Runner::INTEGRATIONS[integration.to_sym]
176
+ return Evilution::SpecResolver.new unless integration_class
177
+
178
+ integration_class.baseline_options[:spec_resolver] || Evilution::SpecResolver.new
179
+ end
180
+
181
+ def explicit_specs_response(files, spec_files)
182
+ success_response(
183
+ "specs" => spec_files.map { |f| { "source" => nil, "spec" => f } },
184
+ "unresolved" => [],
185
+ "total_sources" => files.length,
186
+ "total_specs" => spec_files.length
187
+ )
188
+ end
189
+
190
+ def resolve_specs(files, resolver)
191
+ resolved = []
192
+ unresolved = []
193
+ files.each do |source|
194
+ found = resolver.call(source)
195
+ if found
196
+ resolved << { "source" => source, "spec" => found }
197
+ else
198
+ unresolved << source
199
+ end
200
+ end
201
+ [resolved, unresolved]
202
+ end
203
+
204
+ def environment_action
205
+ config = Evilution::Config.new(skip_config_file: false)
206
+ config_file = Evilution::Config::CONFIG_FILES.find { |path| File.exist?(path) }
207
+
208
+ success_response(
209
+ "version" => Evilution::VERSION,
210
+ "ruby" => RUBY_VERSION,
211
+ "config_file" => config_file,
212
+ "settings" => environment_settings(config)
213
+ )
214
+ end
215
+
216
+ def error_response_for(error)
217
+ type = case error
218
+ when Evilution::ConfigError then "config_error"
219
+ when Evilution::ParseError then "parse_error"
220
+ else "runtime_error"
221
+ end
222
+ error_response(type, error.message)
223
+ end
224
+
225
+ def environment_settings(config)
226
+ {
227
+ "timeout" => config.timeout,
228
+ "format" => config.format,
229
+ "integration" => config.integration,
230
+ "jobs" => config.jobs,
231
+ "isolation" => config.isolation,
232
+ "baseline" => config.baseline,
233
+ "incremental" => config.incremental,
234
+ "fail_fast" => config.fail_fast,
235
+ "min_score" => config.min_score,
236
+ "suggest_tests" => config.suggest_tests,
237
+ "save_session" => config.save_session,
238
+ "target" => config.target,
239
+ "skip_heredoc_literals" => config.skip_heredoc_literals,
240
+ "ignore_patterns" => config.ignore_patterns
241
+ }
242
+ end
243
+
244
+ def build_subject_filter(config)
245
+ return nil if config.ignore_patterns.empty?
246
+
247
+ Evilution::AST::Pattern::Filter.new(config.ignore_patterns)
248
+ end
249
+
250
+ def success_response(payload)
251
+ ::MCP::Tool::Response.new([{ type: "text", text: ::JSON.generate(payload) }])
252
+ end
253
+
254
+ def error_response(type, message)
255
+ ::MCP::Tool::Response.new(
256
+ [{ type: "text", text: ::JSON.generate({ error: { type: type, message: message } }) }],
257
+ error: true
258
+ )
259
+ end
260
+ end
261
+ end