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
@@ -6,13 +6,28 @@ require_relative "../config"
6
6
  require_relative "../runner"
7
7
  require_relative "../reporter/json"
8
8
  require_relative "../reporter/suggestion"
9
+ require_relative "../spec_resolver"
9
10
 
10
11
  require_relative "../mcp"
11
12
 
12
13
  class Evilution::MCP::MutateTool < MCP::Tool
13
14
  tool_name "evilution-mutate"
14
- description "Run mutation testing on Ruby source files. " \
15
- "Use suggest_tests: true to get concrete test code (RSpec or Minitest) for surviving mutants."
15
+ description "Run mutation testing on Ruby source files and return structured JSON — not parsed CLI text. " \
16
+ "Built for iterative TDD: " \
17
+ "'incremental: true' caches killed/timeout results so rerunning on unchanged files is fast; " \
18
+ "'save_session: true' persists results for later diffing via evilution-session; " \
19
+ "'suggest_tests: true' streams concrete RSpec/Minitest code for each survivor as progress " \
20
+ "events so you can drop fixes straight into a test file. " \
21
+ "Respects .evilution.yml (timeout, jobs, integration, target, ignore_patterns, isolation) by default — " \
22
+ "pair with evilution-info to discover subjects and specs before you call this tool. " \
23
+ "Supports line-range file targeting (lib/foo.rb:15-30), 'target' method filter, explicit 'spec' overrides, " \
24
+ "'fail_fast' for early exit on N survivors, 'baseline: false' to skip the green-suite precheck, " \
25
+ "and 'verbosity' (full/summary/minimal) to match the agent's context budget. " \
26
+ "Survived mutants are enriched beyond `evilution --format json`: each entry includes " \
27
+ "'subject' (Class#method), resolved 'spec_file', and a concrete 'next_step' hint — " \
28
+ "so the agent can jump straight to writing the missing test. " \
29
+ "Prefer this over shelling out to 'evilution' — the response is machine-readable " \
30
+ "and already trimmed for survived-mutant triage."
16
31
  input_schema(
17
32
  properties: {
18
33
  files: {
@@ -46,6 +61,37 @@ class Evilution::MCP::MutateTool < MCP::Tool
46
61
  description: "When true, suggestions for survived mutants include concrete test code " \
47
62
  "instead of static description text (default: false)"
48
63
  },
64
+ incremental: {
65
+ type: "boolean",
66
+ description: "Cache killed/timeout results and skip re-running them on unchanged files. " \
67
+ "Set true when iterating on the same files to speed up repeat runs."
68
+ },
69
+ integration: {
70
+ type: "string",
71
+ enum: %w[rspec minitest],
72
+ description: "Test integration to use (default: rspec)"
73
+ },
74
+ isolation: {
75
+ type: "string",
76
+ enum: %w[auto fork in_process],
77
+ description: "Isolation strategy for mutation execution (default: auto)"
78
+ },
79
+ baseline: {
80
+ type: "boolean",
81
+ description: "Run a baseline test suite check before mutations (default: true). " \
82
+ "Set false to skip when you already know the suite is green."
83
+ },
84
+ save_session: {
85
+ type: "boolean",
86
+ description: "Save session results to .evilution/results/ for later inspection via evilution-session"
87
+ },
88
+ skip_config: {
89
+ type: "boolean",
90
+ description: "When true, ignore .evilution.yml / config/evilution.yml. " \
91
+ "MCP-specific overrides (JSON output, quiet mode, preload disabled) and explicit tool " \
92
+ "parameters still apply. Default: false — project config is loaded so the MCP run " \
93
+ "matches `evilution` CLI behavior."
94
+ },
49
95
  verbosity: {
50
96
  type: "string",
51
97
  enum: %w[full summary minimal],
@@ -57,27 +103,28 @@ class Evilution::MCP::MutateTool < MCP::Tool
57
103
  )
58
104
 
59
105
  class << self
60
- # rubocop:disable Metrics/ParameterLists
61
- def call(server_context:, files: [], target: nil, timeout: nil, jobs: nil,
62
- fail_fast: nil, spec: nil, suggest_tests: nil, verbosity: nil)
106
+ def call(server_context:, files: [], verbosity: nil, **opts)
107
+ validate_opts!(opts)
63
108
  parsed_files, line_ranges = parse_files(Array(files))
64
- config_opts = build_config_opts(parsed_files, line_ranges, target, timeout, jobs, fail_fast, spec,
65
- suggest_tests)
109
+ config_opts = build_config_opts(parsed_files, line_ranges, opts)
66
110
  config = Evilution::Config.new(**config_opts)
111
+ suggest_tests = opts[:suggest_tests]
67
112
  on_result = build_streaming_callback(server_context, suggest_tests, config.integration)
68
113
  runner = Evilution::Runner.new(config: config, on_result: on_result)
69
114
  summary = runner.call
70
115
  report = Evilution::Reporter::JSON.new(suggest_tests: suggest_tests == true, integration: config.integration).call(summary)
71
- compact = trim_report(report, normalize_verbosity(verbosity))
116
+ compact = trim_report(report, normalize_verbosity(verbosity), summary.survived_results, config)
72
117
 
73
118
  ::MCP::Tool::Response.new([{ type: "text", text: compact }])
74
119
  rescue Evilution::Error => e
75
120
  error_payload = build_error_payload(e)
76
121
  ::MCP::Tool::Response.new([{ type: "text", text: ::JSON.generate(error_payload) }], error: true)
77
122
  end
78
- # rubocop:enable Metrics/ParameterLists
79
123
 
80
124
  VALID_VERBOSITIES = %w[full summary minimal].freeze
125
+ PASSTHROUGH_KEYS = %i[target timeout jobs fail_fast suggest_tests incremental integration
126
+ isolation baseline save_session].freeze
127
+ ALLOWED_OPT_KEYS = (PASSTHROUGH_KEYS + %i[spec skip_config]).freeze
81
128
 
82
129
  private
83
130
 
@@ -110,18 +157,21 @@ class Evilution::MCP::MutateTool < MCP::Tool
110
157
  raise Evilution::ParseError, "invalid line range: #{str.inspect}"
111
158
  end
112
159
 
113
- def build_config_opts(files, line_ranges, target, timeout, jobs, fail_fast, spec, suggest_tests)
160
+ def validate_opts!(opts)
161
+ unknown = opts.keys - ALLOWED_OPT_KEYS
162
+ return if unknown.empty?
163
+
164
+ raise Evilution::ParseError, "unknown parameters: #{unknown.join(", ")}"
165
+ end
166
+
167
+ def build_config_opts(files, line_ranges, params)
114
168
  # Preload is disabled for MCP invocations: `require`-ing Rails into the
115
169
  # long-lived MCP server would poison subsequent runs against other
116
170
  # projects. MCP users who want the speedup should use the CLI.
117
- opts = { target_files: files, line_ranges: line_ranges, format: :json, quiet: true, skip_config_file: true,
118
- preload: false }
119
- opts[:target] = target if target
120
- opts[:timeout] = timeout if timeout
121
- opts[:jobs] = jobs if jobs
122
- opts[:fail_fast] = fail_fast if fail_fast
123
- opts[:spec_files] = spec if spec
124
- opts[:suggest_tests] = true if suggest_tests
171
+ opts = { target_files: files, line_ranges: line_ranges, format: :json, quiet: true, preload: false }
172
+ opts[:skip_config_file] = true if params[:skip_config]
173
+ opts[:spec_files] = params[:spec] if params[:spec]
174
+ PASSTHROUGH_KEYS.each { |key| opts[key] = params[key] unless params[key].nil? }
125
175
  opts
126
176
  end
127
177
 
@@ -133,7 +183,7 @@ class Evilution::MCP::MutateTool < MCP::Tool
133
183
  raise Evilution::ParseError, "invalid verbosity: #{value.inspect} (must be full, summary, or minimal)"
134
184
  end
135
185
 
136
- def trim_report(json_string, verbosity)
186
+ def trim_report(json_string, verbosity, survived_results, config)
137
187
  data = ::JSON.parse(json_string)
138
188
  case verbosity
139
189
  when "full"
@@ -151,9 +201,52 @@ class Evilution::MCP::MutateTool < MCP::Tool
151
201
  data.delete("timed_out")
152
202
  data.delete("errors")
153
203
  end
204
+ enrich_survived(data, survived_results, config)
154
205
  ::JSON.generate(data)
155
206
  end
156
207
 
208
+ def enrich_survived(data, survived_results, config)
209
+ entries = data["survived"]
210
+ return unless entries.is_a?(Array)
211
+
212
+ explicit_spec = explicit_spec_override(config)
213
+ resolver = explicit_spec ? nil : resolver_for_integration(config.integration)
214
+ cache = {}
215
+
216
+ entries.each_with_index do |entry, index|
217
+ result = survived_results[index]
218
+ next unless result
219
+
220
+ mutation = result.mutation
221
+ entry["subject"] = mutation.subject.name
222
+ spec_file = explicit_spec || cache.fetch(mutation.file_path) do
223
+ cache[mutation.file_path] = resolver.call(mutation.file_path)
224
+ end
225
+ entry["spec_file"] = spec_file if spec_file
226
+ entry["next_step"] = build_next_step(mutation, spec_file)
227
+ end
228
+ end
229
+
230
+ def explicit_spec_override(config)
231
+ return nil unless config.respond_to?(:spec_files)
232
+
233
+ files = Array(config.spec_files).compact.map(&:to_s).reject(&:empty?)
234
+ files.first
235
+ end
236
+
237
+ def resolver_for_integration(integration)
238
+ integration_class = Evilution::Runner::INTEGRATIONS[integration.to_sym]
239
+ return Evilution::SpecResolver.new unless integration_class
240
+
241
+ integration_class.baseline_options[:spec_resolver] || Evilution::SpecResolver.new
242
+ end
243
+
244
+ def build_next_step(mutation, spec_file)
245
+ target = spec_file || "the covering test file"
246
+ "Add a test in #{target} that fails against this mutation at #{mutation.file_path}:#{mutation.line} " \
247
+ "(#{mutation.subject.name}, #{mutation.operator_name})."
248
+ end
249
+
157
250
  def strip_diffs(data, key)
158
251
  return unless data[key]
159
252
 
@@ -3,9 +3,8 @@
3
3
  require "mcp"
4
4
  require_relative "../version"
5
5
  require_relative "mutate_tool"
6
- require_relative "session_list_tool"
7
- require_relative "session_show_tool"
8
- require_relative "session_diff_tool"
6
+ require_relative "session_tool"
7
+ require_relative "info_tool"
9
8
 
10
9
  require_relative "../mcp"
11
10
 
@@ -14,7 +13,7 @@ class Evilution::MCP::Server
14
13
  ::MCP::Server.new(
15
14
  name: "evilution",
16
15
  version: Evilution::VERSION,
17
- tools: [Evilution::MCP::MutateTool, Evilution::MCP::SessionListTool, Evilution::MCP::SessionShowTool, Evilution::MCP::SessionDiffTool]
16
+ tools: [Evilution::MCP::MutateTool, Evilution::MCP::SessionTool, Evilution::MCP::InfoTool]
18
17
  )
19
18
  end
20
19
  end
@@ -7,9 +7,13 @@ require_relative "../session/diff"
7
7
 
8
8
  require_relative "../mcp"
9
9
 
10
+ # @deprecated Superseded by {Evilution::MCP::SessionTool} (action: "diff") as of 0.22.8.
11
+ # No longer registered with the MCP server; retained only for direct Ruby callers.
12
+ # Will be removed entirely — tracked by EV-h8pw / GH #686.
10
13
  class Evilution::MCP::SessionDiffTool < MCP::Tool
11
14
  tool_name "evilution-session-diff"
12
- description "Compare two mutation testing sessions and return the diff. " \
15
+ description "DEPRECATED: use evilution-session with action: 'diff'. " \
16
+ "Compare two mutation testing sessions and return the diff. " \
13
17
  "Shows new regressions, fixed mutations, and persistent survivors."
14
18
  input_schema(
15
19
  properties: {
@@ -6,9 +6,13 @@ require_relative "../session/store"
6
6
 
7
7
  require_relative "../mcp"
8
8
 
9
+ # @deprecated Superseded by {Evilution::MCP::SessionTool} (action: "list") as of 0.22.8.
10
+ # No longer registered with the MCP server; retained only for direct Ruby callers.
11
+ # Will be removed entirely — tracked by EV-h8pw / GH #686.
9
12
  class Evilution::MCP::SessionListTool < MCP::Tool
10
13
  tool_name "evilution-session-list"
11
- description "List past mutation testing sessions with summary statistics. " \
14
+ description "DEPRECATED: use evilution-session with action: 'list'. " \
15
+ "List past mutation testing sessions with summary statistics. " \
12
16
  "Returns sessions in reverse chronological order."
13
17
  input_schema(
14
18
  properties: {
@@ -6,9 +6,13 @@ require_relative "../session/store"
6
6
 
7
7
  require_relative "../mcp"
8
8
 
9
+ # @deprecated Superseded by {Evilution::MCP::SessionTool} (action: "show") as of 0.22.8.
10
+ # No longer registered with the MCP server; retained only for direct Ruby callers.
11
+ # Will be removed entirely — tracked by EV-h8pw / GH #686.
9
12
  class Evilution::MCP::SessionShowTool < MCP::Tool
10
13
  tool_name "evilution-session-show"
11
- description "Show full details of a past mutation testing session, " \
14
+ description "DEPRECATED: use evilution-session with action: 'show'. " \
15
+ "Show full details of a past mutation testing session, " \
12
16
  "including survived mutations with diffs."
13
17
  input_schema(
14
18
  properties: {
@@ -0,0 +1,157 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "mcp"
5
+ require_relative "../session/store"
6
+ require_relative "../session/diff"
7
+
8
+ require_relative "../mcp"
9
+
10
+ class Evilution::MCP::SessionTool < MCP::Tool
11
+ tool_name "evilution-session"
12
+ description "Inspect mutation testing history without re-running any tests. " \
13
+ "One tool, three actions: " \
14
+ "'list' browses saved sessions (reverse chronological), " \
15
+ "'show' returns the full report for a session (summary, survived mutations with diffs, git context), " \
16
+ "'diff' compares two sessions and surfaces new regressions, fixed mutations, persistent survivors, and score delta. " \
17
+ "Prefer this over the CLI when auditing mutation score trends, triaging survivors, " \
18
+ "or verifying that a fix killed the right mutant."
19
+ input_schema(
20
+ properties: {
21
+ action: {
22
+ type: "string",
23
+ enum: %w[list show diff],
24
+ description: "Which session operation to perform. 'list' browses history; 'show' displays one session; 'diff' compares two."
25
+ },
26
+ results_dir: {
27
+ type: "string",
28
+ description: "[list|show|diff] Session results directory (default: .evilution/results). " \
29
+ "For show/diff, acts as the containment root: path/base/head must resolve under this directory."
30
+ },
31
+ limit: {
32
+ type: "integer",
33
+ description: "[list] Return only the N most recent sessions"
34
+ },
35
+ path: {
36
+ type: "string",
37
+ description: "[show] Path to a session JSON file (as returned by action=list); must be under results_dir"
38
+ },
39
+ base: {
40
+ type: "string",
41
+ description: "[diff] Path to the base (older) session JSON file; must be under results_dir"
42
+ },
43
+ head: {
44
+ type: "string",
45
+ description: "[diff] Path to the head (newer) session JSON file; must be under results_dir"
46
+ }
47
+ },
48
+ required: ["action"]
49
+ )
50
+
51
+ VALID_ACTIONS = %w[list show diff].freeze
52
+
53
+ class << self
54
+ # rubocop:disable Lint/UnusedMethodArgument
55
+ def call(server_context:, action: nil, results_dir: nil, limit: nil, path: nil, base: nil, head: nil)
56
+ return error_response("config_error", "action is required") unless action
57
+ return error_response("config_error", "unknown action: #{action}") unless VALID_ACTIONS.include?(action)
58
+
59
+ case action
60
+ when "list" then list_action(results_dir: results_dir, limit: limit)
61
+ when "show" then show_action(path: path, results_dir: results_dir)
62
+ when "diff" then diff_action(base: base, head: head, results_dir: results_dir)
63
+ end
64
+ end
65
+ # rubocop:enable Lint/UnusedMethodArgument
66
+
67
+ private
68
+
69
+ def list_action(results_dir:, limit:)
70
+ normalized_limit, limit_error = normalize_limit(limit)
71
+ return error_response("config_error", limit_error) if limit_error
72
+
73
+ store_opts = {}
74
+ store_opts[:results_dir] = results_dir if results_dir
75
+ store = Evilution::Session::Store.new(**store_opts)
76
+ entries = store.list
77
+ entries = entries.first(normalized_limit) unless normalized_limit.nil?
78
+
79
+ payload = entries.map { |e| e.transform_keys(&:to_s) }
80
+ success_response(payload)
81
+ end
82
+
83
+ def normalize_limit(limit)
84
+ return [nil, nil] if limit.nil?
85
+
86
+ coerced = Integer(limit)
87
+ return [nil, "limit must be a non-negative integer"] if coerced.negative?
88
+
89
+ [coerced, nil]
90
+ rescue ArgumentError, TypeError
91
+ [nil, "limit must be a non-negative integer"]
92
+ end
93
+
94
+ def show_action(path:, results_dir:)
95
+ return error_response("config_error", "path is required") unless path
96
+
97
+ dir = results_dir || Evilution::Session::Store::DEFAULT_DIR
98
+ return error_response("config_error", "path must be under results directory") unless within?(path, dir)
99
+
100
+ store = Evilution::Session::Store.new(results_dir: dir)
101
+ data = store.load(path)
102
+ success_response(data)
103
+ rescue Evilution::Error => e
104
+ error_response("not_found", e.message)
105
+ rescue ::JSON::ParserError => e
106
+ error_response("parse_error", e.message)
107
+ rescue SystemCallError => e
108
+ error_response("runtime_error", e.message)
109
+ end
110
+
111
+ def diff_action(base:, head:, results_dir:)
112
+ return error_response("config_error", "base is required") unless base
113
+ return error_response("config_error", "head is required") unless head
114
+
115
+ dir = results_dir || Evilution::Session::Store::DEFAULT_DIR
116
+ return error_response("config_error", "base must be under results directory") unless within?(base, dir)
117
+ return error_response("config_error", "head must be under results directory") unless within?(head, dir)
118
+
119
+ store = Evilution::Session::Store.new(results_dir: dir)
120
+ base_data = store.load(base)
121
+ head_data = store.load(head)
122
+
123
+ diff = Evilution::Session::Diff.new
124
+ result = diff.call(base_data, head_data)
125
+ success_response(result.to_h)
126
+ rescue Evilution::Error => e
127
+ error_response("not_found", e.message)
128
+ rescue ::JSON::ParserError => e
129
+ error_response("parse_error", e.message)
130
+ rescue SystemCallError => e
131
+ error_response("runtime_error", e.message)
132
+ end
133
+
134
+ def within?(path, results_dir)
135
+ resolved_root = canonical_path(results_dir)
136
+ resolved_path = canonical_path(path)
137
+ resolved_path == resolved_root || resolved_path.start_with?(resolved_root + File::SEPARATOR)
138
+ end
139
+
140
+ def canonical_path(path)
141
+ File.realpath(path)
142
+ rescue Errno::ENOENT
143
+ File.expand_path(path)
144
+ end
145
+
146
+ def success_response(payload)
147
+ ::MCP::Tool::Response.new([{ type: "text", text: ::JSON.generate(payload) }])
148
+ end
149
+
150
+ def error_response(type, message)
151
+ ::MCP::Tool::Response.new(
152
+ [{ type: "text", text: ::JSON.generate({ error: { type: type, message: message } }) }],
153
+ error: true
154
+ )
155
+ end
156
+ end
157
+ end
@@ -121,6 +121,7 @@ class Evilution::Reporter::HTML
121
121
  survived_count = results.count(&:survived?)
122
122
  total = results.length
123
123
  survived = results.select(&:survived?)
124
+ errored = results.select(&:error?)
124
125
  map_html = build_mutation_map(results)
125
126
 
126
127
  <<~HTML
@@ -131,6 +132,7 @@ class Evilution::Reporter::HTML
131
132
  </h2>
132
133
  <div class="mutation-map">#{map_html}</div>
133
134
  #{build_survived_details(survived)}
135
+ #{build_error_details(errored)}
134
136
  </section>
135
137
  HTML
136
138
  end
@@ -218,6 +220,38 @@ class Evilution::Reporter::HTML
218
220
  HTML
219
221
  end
220
222
 
223
+ def build_error_details(errored)
224
+ return "" if errored.empty?
225
+
226
+ entries = errored
227
+ .sort_by { |r| [r.mutation.operator_name, r.mutation.line] }
228
+ .map { |r| build_error_entry(r) }
229
+ .join("\n")
230
+ <<~HTML
231
+ <div class="error-details">
232
+ <h3>Errors (#{errored.length})</h3>
233
+ #{entries}
234
+ </div>
235
+ HTML
236
+ end
237
+
238
+ def build_error_entry(result)
239
+ mutation = result.mutation
240
+ message = result.error_message.to_s
241
+ message_html = message.empty? ? "" : %(<pre class="error-message">#{h(message)}</pre>)
242
+ diff_html = format_diff(mutation.diff)
243
+ <<~HTML
244
+ <div class="error-entry">
245
+ <div class="error-header">
246
+ <span class="operator">#{h(mutation.operator_name)}</span>
247
+ <span class="location">#{h(mutation.file_path)}:#{mutation.line}</span>
248
+ </div>
249
+ <pre class="diff">#{diff_html}</pre>
250
+ #{message_html}
251
+ </div>
252
+ HTML
253
+ end
254
+
221
255
  def format_diff(diff)
222
256
  diff.split("\n").map do |line|
223
257
  escaped = h(line)
@@ -353,6 +387,13 @@ class Evilution::Reporter::HTML
353
387
  .delta-positive { color: #3fb950; font-weight: bold; }
354
388
  .delta-negative { color: #f85149; font-weight: bold; }
355
389
  .delta-neutral { color: #8b949e; font-weight: bold; }
390
+ .error-details { border-top: 1px solid #30363d; padding: 1rem; }
391
+ .error-details h3 { color: #d29922; font-size: 0.9rem; margin-bottom: 0.75rem; }
392
+ .error-entry { background: #1c1a10; border: 1px solid #4a3a10; border-radius: 6px; padding: 0.75rem; margin-bottom: 0.75rem; }
393
+ .error-header { display: flex; justify-content: space-between; margin-bottom: 0.5rem; font-size: 0.85rem; }
394
+ .error-header .operator { color: #d29922; font-weight: bold; }
395
+ .error-header .location { color: #8b949e; font-family: monospace; }
396
+ .error-message { background: #0d1117; border-radius: 4px; padding: 0.5rem 0.75rem; font-size: 0.8rem; color: #f85149; margin-top: 0.5rem; white-space: pre-wrap; overflow-x: auto; }
356
397
  .survived-entry.regression { border-color: #f85149; background: #2a1010; }
357
398
  .regression-badge { background: #da3633; color: #fff; font-size: 0.65rem; padding: 0.1rem 0.4rem; border-radius: 4px; margin-left: 0.5rem; text-transform: uppercase; font-weight: bold; }
358
399
  footer { text-align: center; color: #484f58; font-size: 0.75rem; margin-top: 2rem; padding-top: 1rem; border-top: 1px solid #21262d; }
@@ -601,7 +601,9 @@ class Evilution::Runner
601
601
  def build_integration
602
602
  klass = resolve_integration_class
603
603
  test_files = config.spec_files.empty? ? nil : config.spec_files
604
- klass.new(test_files: test_files, hooks: @hooks)
604
+ kwargs = { test_files: test_files, hooks: @hooks }
605
+ kwargs[:related_specs_heuristic] = config.related_specs_heuristic? if klass == Evilution::Integration::RSpec
606
+ klass.new(**kwargs)
605
607
  end
606
608
 
607
609
  def build_neutralization_resolver
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Evilution
4
- VERSION = "0.22.6"
4
+ VERSION = "0.23.0"
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: evilution
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.22.6
4
+ version: 0.23.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Denis Kiselev
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-04-12 00:00:00.000000000 Z
11
+ date: 2026-04-14 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: diff-lcs
@@ -97,6 +97,32 @@ files:
97
97
  - lib/evilution/baseline.rb
98
98
  - lib/evilution/cache.rb
99
99
  - lib/evilution/cli.rb
100
+ - lib/evilution/cli/command.rb
101
+ - lib/evilution/cli/commands.rb
102
+ - lib/evilution/cli/commands/environment_show.rb
103
+ - lib/evilution/cli/commands/init.rb
104
+ - lib/evilution/cli/commands/mcp.rb
105
+ - lib/evilution/cli/commands/run.rb
106
+ - lib/evilution/cli/commands/session_diff.rb
107
+ - lib/evilution/cli/commands/session_gc.rb
108
+ - lib/evilution/cli/commands/session_list.rb
109
+ - lib/evilution/cli/commands/session_show.rb
110
+ - lib/evilution/cli/commands/subjects.rb
111
+ - lib/evilution/cli/commands/tests_list.rb
112
+ - lib/evilution/cli/commands/util_mutation.rb
113
+ - lib/evilution/cli/commands/version.rb
114
+ - lib/evilution/cli/dispatcher.rb
115
+ - lib/evilution/cli/parsed_args.rb
116
+ - lib/evilution/cli/parser.rb
117
+ - lib/evilution/cli/printers.rb
118
+ - lib/evilution/cli/printers/environment.rb
119
+ - lib/evilution/cli/printers/session_detail.rb
120
+ - lib/evilution/cli/printers/session_diff.rb
121
+ - lib/evilution/cli/printers/session_list.rb
122
+ - lib/evilution/cli/printers/subjects.rb
123
+ - lib/evilution/cli/printers/tests_list.rb
124
+ - lib/evilution/cli/printers/util_mutation.rb
125
+ - lib/evilution/cli/result.rb
100
126
  - lib/evilution/config.rb
101
127
  - lib/evilution/disable_comment.rb
102
128
  - lib/evilution/equivalent.rb
@@ -124,11 +150,13 @@ files:
124
150
  - lib/evilution/isolation/fork.rb
125
151
  - lib/evilution/isolation/in_process.rb
126
152
  - lib/evilution/mcp.rb
153
+ - lib/evilution/mcp/info_tool.rb
127
154
  - lib/evilution/mcp/mutate_tool.rb
128
155
  - lib/evilution/mcp/server.rb
129
156
  - lib/evilution/mcp/session_diff_tool.rb
130
157
  - lib/evilution/mcp/session_list_tool.rb
131
158
  - lib/evilution/mcp/session_show_tool.rb
159
+ - lib/evilution/mcp/session_tool.rb
132
160
  - lib/evilution/memory.rb
133
161
  - lib/evilution/memory/leak_check.rb
134
162
  - lib/evilution/mutation.rb