evilution 0.22.6 → 0.23.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 (48) hide show
  1. checksums.yaml +4 -4
  2. data/.beads/interactions.jsonl +3 -0
  3. data/CHANGELOG.md +18 -0
  4. data/README.md +36 -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.rb +257 -0
  22. data/lib/evilution/cli/printers/environment.rb +53 -0
  23. data/lib/evilution/cli/printers/session_detail.rb +76 -0
  24. data/lib/evilution/cli/printers/session_diff.rb +57 -0
  25. data/lib/evilution/cli/printers/session_list.rb +48 -0
  26. data/lib/evilution/cli/printers/subjects.rb +35 -0
  27. data/lib/evilution/cli/printers/tests_list.rb +45 -0
  28. data/lib/evilution/cli/printers/util_mutation.rb +35 -0
  29. data/lib/evilution/cli/printers.rb +4 -0
  30. data/lib/evilution/cli/result.rb +9 -0
  31. data/lib/evilution/cli.rb +30 -850
  32. data/lib/evilution/config.rb +18 -3
  33. data/lib/evilution/integration/base.rb +59 -2
  34. data/lib/evilution/integration/minitest.rb +6 -1
  35. data/lib/evilution/integration/rspec.rb +10 -2
  36. data/lib/evilution/isolation/fork.rb +10 -9
  37. data/lib/evilution/isolation/in_process.rb +10 -9
  38. data/lib/evilution/mcp/info_tool.rb +261 -0
  39. data/lib/evilution/mcp/mutate_tool.rb +112 -19
  40. data/lib/evilution/mcp/server.rb +3 -4
  41. data/lib/evilution/mcp/session_diff_tool.rb +5 -1
  42. data/lib/evilution/mcp/session_list_tool.rb +5 -1
  43. data/lib/evilution/mcp/session_show_tool.rb +5 -1
  44. data/lib/evilution/mcp/session_tool.rb +157 -0
  45. data/lib/evilution/reporter/html.rb +41 -0
  46. data/lib/evilution/runner.rb +3 -1
  47. data/lib/evilution/version.rb +1 -1
  48. metadata +30 -2
@@ -27,6 +27,7 @@ class Evilution::Config
27
27
  show_disabled: false,
28
28
  baseline_session: nil,
29
29
  skip_heredoc_literals: false,
30
+ related_specs_heuristic: false,
30
31
  preload: nil
31
32
  }.freeze
32
33
 
@@ -35,7 +36,7 @@ class Evilution::Config
35
36
  :jobs, :fail_fast, :baseline, :isolation, :incremental, :suggest_tests,
36
37
  :progress, :save_session, :line_ranges, :spec_files, :hooks,
37
38
  :ignore_patterns, :show_disabled, :baseline_session,
38
- :skip_heredoc_literals, :preload
39
+ :skip_heredoc_literals, :related_specs_heuristic, :preload
39
40
 
40
41
  def initialize(**options)
41
42
  file_options = options.delete(:skip_config_file) ? {} : load_config_file
@@ -96,6 +97,10 @@ class Evilution::Config
96
97
  skip_heredoc_literals
97
98
  end
98
99
 
100
+ def related_specs_heuristic?
101
+ related_specs_heuristic
102
+ end
103
+
99
104
  def self.file_options
100
105
  CONFIG_FILES.each do |path|
101
106
  next unless File.exist?(path)
@@ -138,8 +143,17 @@ class Evilution::Config
138
143
  # Generate concrete test code in suggestions, matching integration (default: false)
139
144
  # suggest_tests: false
140
145
 
141
- # Skip all string literal mutations inside heredocs (default: false)
142
- # skip_heredoc_literals: false
146
+ # Skip all string literal mutations inside heredocs (default: false).
147
+ # Useful for Rails apps where heredoc content (SQL, templates, fixtures)
148
+ # rarely has meaningful test coverage and produces noisy survivors.
149
+ # skip_heredoc_literals: true
150
+
151
+ # Opt into the RelatedSpecHeuristic, which appends request/integration/
152
+ # feature/system specs for mutations that touch `.includes(...)` calls
153
+ # (default: false). Off by default because the fan-out can be heavy and
154
+ # push runs over the per-mutation timeout. Enable if you need coverage
155
+ # of N+1 regressions that only surface in higher-level specs.
156
+ # related_specs_heuristic: true
143
157
 
144
158
  # Preload file required in the parent process before forking workers.
145
159
  # For Rails projects, spec/rails_helper.rb or test/test_helper.rb is
@@ -195,6 +209,7 @@ class Evilution::Config
195
209
  @show_disabled = merged[:show_disabled]
196
210
  @baseline_session = merged[:baseline_session]
197
211
  @skip_heredoc_literals = merged[:skip_heredoc_literals]
212
+ @related_specs_heuristic = merged[:related_specs_heuristic]
198
213
  @hooks = validate_hooks(merged[:hooks])
199
214
  @preload = validate_preload(merged[:preload])
200
215
  end
@@ -55,6 +55,9 @@ class Evilution::Integration::Base
55
55
  end
56
56
 
57
57
  def apply_mutation(mutation)
58
+ prism_error = validate_mutated_syntax(mutation.mutated_source)
59
+ return prism_error if prism_error
60
+
58
61
  @temp_dir = Dir.mktmpdir("evilution")
59
62
  Evilution::TempDirTracker.register(@temp_dir)
60
63
  @displaced_feature = nil
@@ -82,6 +85,17 @@ class Evilution::Integration::Base
82
85
  }
83
86
  end
84
87
 
88
+ def validate_mutated_syntax(source)
89
+ return nil if Prism.parse(source).success?
90
+
91
+ {
92
+ passed: false,
93
+ error: "mutated source has syntax errors",
94
+ error_class: "SyntaxError",
95
+ error_backtrace: []
96
+ }
97
+ end
98
+
85
99
  def apply_via_require(mutation, subpath)
86
100
  dest = File.join(@temp_dir, subpath)
87
101
  FileUtils.mkdir_p(File.dirname(dest))
@@ -90,7 +104,9 @@ class Evilution::Integration::Base
90
104
  displace_loaded_feature(mutation.file_path)
91
105
  pin_autoloaded_constants(mutation.original_source)
92
106
  clear_concern_state(mutation.file_path)
93
- require(subpath.delete_suffix(".rb"))
107
+ with_redefinition_recovery(mutation.original_source) do
108
+ require(subpath.delete_suffix(".rb"))
109
+ end
94
110
  end
95
111
 
96
112
  def apply_via_load(mutation)
@@ -100,7 +116,22 @@ class Evilution::Integration::Base
100
116
  File.write(dest, mutation.mutated_source)
101
117
  pin_autoloaded_constants(mutation.original_source)
102
118
  clear_concern_state(mutation.file_path)
103
- load(dest)
119
+ with_redefinition_recovery(mutation.original_source) do
120
+ load(dest)
121
+ end
122
+ end
123
+
124
+ def with_redefinition_recovery(original_source)
125
+ yield
126
+ rescue ArgumentError => e
127
+ raise unless redefinition_conflict?(e)
128
+
129
+ remove_defined_constants(original_source)
130
+ yield
131
+ end
132
+
133
+ def redefinition_conflict?(error)
134
+ error.message.include?("already defined")
104
135
  end
105
136
 
106
137
  def restore_original(_mutation)
@@ -139,6 +170,32 @@ class Evilution::Integration::Base
139
170
  names
140
171
  end
141
172
 
173
+ def remove_defined_constants(source)
174
+ collect_constant_names(Prism.parse(source).value).reverse_each do |name|
175
+ parent_name, _, local_name = name.rpartition("::")
176
+ parent = resolve_loaded_constant_parent(parent_name)
177
+ next unless parent
178
+ next unless parent.const_defined?(local_name, false)
179
+ next if parent.autoload?(local_name)
180
+
181
+ parent.send(:remove_const, local_name.to_sym)
182
+ end
183
+ end
184
+
185
+ def resolve_loaded_constant_parent(parent_name)
186
+ return Object if parent_name.empty?
187
+
188
+ parent_name.split("::").reduce(Object) do |mod, part|
189
+ return nil unless mod.const_defined?(part, false)
190
+ return nil if mod.autoload?(part)
191
+
192
+ resolved = mod.const_get(part, false)
193
+ return nil unless resolved.is_a?(Module)
194
+
195
+ resolved
196
+ end
197
+ end
198
+
142
199
  def clear_concern_state(file_path)
143
200
  return unless defined?(ActiveSupport::Concern)
144
201
 
@@ -112,7 +112,12 @@ class Evilution::Integration::Minitest < Evilution::Integration::Base
112
112
  if passed
113
113
  { passed: true, test_command: command }
114
114
  elsif detector.only_crashes?
115
- { passed: false, error: "test crashes: #{detector.crash_summary}", test_command: command }
115
+ {
116
+ passed: false,
117
+ test_crashed: true,
118
+ error: "test crashes: #{detector.crash_summary}",
119
+ test_command: command
120
+ }
116
121
  else
117
122
  { passed: false, test_command: command }
118
123
  end
@@ -26,11 +26,12 @@ 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)
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
34
35
  @crash_detector = nil
35
36
  @warned_files = Set.new
36
37
  super(hooks: hooks)
@@ -140,7 +141,12 @@ class Evilution::Integration::RSpec < Evilution::Integration::Base
140
141
  if status.zero?
141
142
  { passed: true, test_command: command }
142
143
  elsif detector.only_crashes?
143
- { passed: false, error: "test crashes: #{detector.crash_summary}", test_command: command }
144
+ {
145
+ passed: false,
146
+ test_crashed: true,
147
+ error: "test crashes: #{detector.crash_summary}",
148
+ test_command: command
149
+ }
144
150
  else
145
151
  { passed: false, test_command: command }
146
152
  end
@@ -155,6 +161,8 @@ class Evilution::Integration::RSpec < Evilution::Integration::Base
155
161
  return ["spec"]
156
162
  end
157
163
 
164
+ return [resolved] unless @related_specs_heuristic_enabled
165
+
158
166
  related = @related_spec_heuristic.call(mutation)
159
167
  ([resolved] + related).uniq
160
168
  end
@@ -95,16 +95,17 @@ 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 :error if result[:error]
102
+ return :survived if result[:passed]
103
+
104
+ :killed
105
+ end
106
+
98
107
  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
108
+ status = classify_status(result)
108
109
 
109
110
  Evilution::Result::MutationResult.new(
110
111
  mutation: mutation,
@@ -62,16 +62,17 @@ 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 :error if result[:error]
69
+ return :survived if result[:passed]
70
+
71
+ :killed
72
+ end
73
+
65
74
  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
75
+ status = classify_status(result)
75
76
 
76
77
  Evilution::Result::MutationResult.new(
77
78
  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