evilution 0.24.0 → 0.26.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 (88) hide show
  1. checksums.yaml +4 -4
  2. data/.beads/interactions.jsonl +210 -0
  3. data/.claude/prompts/architect.md +14 -1
  4. data/.claude/skills/create-issue/SKILL.md +55 -0
  5. data/CHANGELOG.md +51 -0
  6. data/README.md +80 -4
  7. data/exe/evil +6 -0
  8. data/lib/evilution/ast/constant_names.rb +34 -0
  9. data/lib/evilution/ast/source_surgeon.rb +15 -1
  10. data/lib/evilution/cli/commands/compare.rb +68 -0
  11. data/lib/evilution/cli/parser/command_extractor.rb +2 -1
  12. data/lib/evilution/cli/parser/options_builder.rb +21 -1
  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/invalid_input.rb +12 -0
  19. data/lib/evilution/compare/normalizer.rb +106 -0
  20. data/lib/evilution/compare/record.rb +16 -0
  21. data/lib/evilution/compare.rb +6 -0
  22. data/lib/evilution/config.rb +165 -3
  23. data/lib/evilution/example_filter.rb +143 -0
  24. data/lib/evilution/integration/base.rb +4 -155
  25. data/lib/evilution/integration/crash_detector.rb +5 -2
  26. data/lib/evilution/integration/loading/concern_state_cleaner.rb +49 -0
  27. data/lib/evilution/integration/loading/constant_pinner.rb +24 -0
  28. data/lib/evilution/integration/loading/mutation_applier.rb +52 -0
  29. data/lib/evilution/integration/loading/redefinition_recovery.rb +54 -0
  30. data/lib/evilution/integration/loading/source_evaluator.rb +15 -0
  31. data/lib/evilution/integration/loading/syntax_validator.rb +19 -0
  32. data/lib/evilution/integration/loading.rb +6 -0
  33. data/lib/evilution/integration/minitest.rb +10 -5
  34. data/lib/evilution/integration/minitest_crash_detector.rb +5 -2
  35. data/lib/evilution/integration/rspec.rb +82 -7
  36. data/lib/evilution/isolation/fork.rb +25 -0
  37. data/lib/evilution/load_path/subpath_resolver.rb +25 -0
  38. data/lib/evilution/load_path.rb +4 -0
  39. data/lib/evilution/mcp/info_tool.rb +77 -5
  40. data/lib/evilution/mcp/mutate_tool/config_builder.rb +20 -0
  41. data/lib/evilution/mcp/mutate_tool/error_payload.rb +17 -0
  42. data/lib/evilution/mcp/mutate_tool/option_parser.rb +54 -0
  43. data/lib/evilution/mcp/mutate_tool/progress_streamer.rb +37 -0
  44. data/lib/evilution/mcp/mutate_tool/report_trimmer.rb +31 -0
  45. data/lib/evilution/mcp/mutate_tool/survived_enricher.rb +52 -0
  46. data/lib/evilution/mcp/mutate_tool.rb +34 -186
  47. data/lib/evilution/mutation.rb +43 -3
  48. data/lib/evilution/mutator/base.rb +39 -1
  49. data/lib/evilution/mutator/operator/argument_nil_substitution.rb +5 -1
  50. data/lib/evilution/mutator/operator/argument_removal.rb +5 -1
  51. data/lib/evilution/parallel/work_queue.rb +149 -31
  52. data/lib/evilution/parallel_db_warning.rb +68 -0
  53. data/lib/evilution/reporter/cli.rb +37 -11
  54. data/lib/evilution/reporter/html/assets/style.css +17 -0
  55. data/lib/evilution/reporter/html/sections/file_section.rb +15 -0
  56. data/lib/evilution/reporter/html/sections/neutral_details.rb +25 -0
  57. data/lib/evilution/reporter/html/sections/unparseable_details.rb +25 -0
  58. data/lib/evilution/reporter/html/sections/unresolved_details.rb +25 -0
  59. data/lib/evilution/reporter/html/templates/file_section.html.erb +3 -0
  60. data/lib/evilution/reporter/html/templates/neutral_details.html.erb +14 -0
  61. data/lib/evilution/reporter/html/templates/summary_cards.html.erb +3 -0
  62. data/lib/evilution/reporter/html/templates/unparseable_details.html.erb +11 -0
  63. data/lib/evilution/reporter/html/templates/unresolved_details.html.erb +11 -0
  64. data/lib/evilution/reporter/json.rb +8 -2
  65. data/lib/evilution/reporter/suggestion/diff_helpers.rb +28 -0
  66. data/lib/evilution/reporter/suggestion/registry.rb +64 -0
  67. data/lib/evilution/reporter/suggestion/templates/generic.rb +55 -0
  68. data/lib/evilution/reporter/suggestion/templates/minitest.rb +659 -0
  69. data/lib/evilution/reporter/suggestion/templates/rspec.rb +613 -0
  70. data/lib/evilution/reporter/suggestion.rb +8 -1327
  71. data/lib/evilution/result/mutation_result.rb +5 -1
  72. data/lib/evilution/result/summary.rb +13 -1
  73. data/lib/evilution/runner/baseline_runner.rb +23 -2
  74. data/lib/evilution/runner/isolation_resolver.rb +12 -1
  75. data/lib/evilution/runner/mutation_executor.rb +83 -13
  76. data/lib/evilution/runner/subject_pipeline.rb +18 -8
  77. data/lib/evilution/runner.rb +6 -0
  78. data/lib/evilution/source_ast_cache.rb +39 -0
  79. data/lib/evilution/spec_ast_cache.rb +166 -0
  80. data/lib/evilution/spec_resolver.rb +6 -1
  81. data/lib/evilution/spec_selector.rb +39 -0
  82. data/lib/evilution/temp_dir_tracker.rb +23 -3
  83. data/lib/evilution/version.rb +1 -1
  84. data/script/memory_check +7 -5
  85. metadata +46 -5
  86. data/lib/evilution/mcp/session_diff_tool.rb +0 -63
  87. data/lib/evilution/mcp/session_list_tool.rb +0 -50
  88. data/lib/evilution/mcp/session_show_tool.rb +0 -57
@@ -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)
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../load_path"
4
+
5
+ # Given an absolute (or expandable) file path, returns the shortest path
6
+ # relative to any `$LOAD_PATH` entry the file lives under, or nil if the file
7
+ # is outside every entry. The shortest match wins because a deeper LOAD_PATH
8
+ # entry yields a shorter subpath that better matches `require` resolution.
9
+ class Evilution::LoadPath::SubpathResolver
10
+ def call(file_path)
11
+ absolute = File.expand_path(file_path)
12
+ best_subpath = nil
13
+
14
+ $LOAD_PATH.each do |entry|
15
+ dir = File.expand_path(entry)
16
+ prefix = dir.end_with?("/") ? dir : "#{dir}/"
17
+ next unless absolute.start_with?(prefix)
18
+
19
+ candidate = absolute.delete_prefix(prefix)
20
+ best_subpath = candidate if best_subpath.nil? || candidate.length < best_subpath.length
21
+ end
22
+
23
+ best_subpath
24
+ end
25
+ end
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Evilution::LoadPath
4
+ end
@@ -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
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../mutate_tool"
4
+ require_relative "../../runner"
5
+ require_relative "../../spec_resolver"
6
+
7
+ module Evilution::MCP::MutateTool::SurvivedEnricher
8
+ def self.call(data, survived_results, config)
9
+ entries = data["survived"]
10
+ return unless entries.is_a?(Array)
11
+
12
+ explicit_spec = explicit_spec_override(config)
13
+ resolver = explicit_spec ? nil : resolver_for_integration(config.integration)
14
+ cache = {}
15
+
16
+ entries.each_with_index do |entry, index|
17
+ result = survived_results[index]
18
+ next unless result
19
+
20
+ mutation = result.mutation
21
+ entry["subject"] = mutation.subject.name
22
+ spec_file = explicit_spec || cache.fetch(mutation.file_path) do
23
+ cache[mutation.file_path] = resolver.call(mutation.file_path)
24
+ end
25
+ entry["spec_file"] = spec_file if spec_file
26
+ entry["next_step"] = build_next_step(mutation, spec_file)
27
+ end
28
+ end
29
+
30
+ def self.explicit_spec_override(config)
31
+ return nil unless config.respond_to?(:spec_files)
32
+
33
+ files = Array(config.spec_files).compact.map(&:to_s).reject(&:empty?)
34
+ files.first
35
+ end
36
+ private_class_method :explicit_spec_override
37
+
38
+ def self.resolver_for_integration(integration)
39
+ integration_class = Evilution::Runner::INTEGRATIONS[integration.to_sym]
40
+ return Evilution::SpecResolver.new unless integration_class
41
+
42
+ integration_class.baseline_options[:spec_resolver] || Evilution::SpecResolver.new
43
+ end
44
+ private_class_method :resolver_for_integration
45
+
46
+ def self.build_next_step(mutation, spec_file)
47
+ target = spec_file || "the covering test file"
48
+ "Add a test in #{target} that fails against this mutation at #{mutation.file_path}:#{mutation.line} " \
49
+ "(#{mutation.subject.name}, #{mutation.operator_name})."
50
+ end
51
+ private_class_method :build_next_step
52
+ end
@@ -5,7 +5,6 @@ require "mcp"
5
5
  require_relative "../config"
6
6
  require_relative "../runner"
7
7
  require_relative "../reporter/json"
8
- require_relative "../reporter/suggestion"
9
8
  require_relative "../spec_resolver"
10
9
 
11
10
  require_relative "../mcp"
@@ -104,194 +103,43 @@ class Evilution::MCP::MutateTool < MCP::Tool
104
103
 
105
104
  class << self
106
105
  def call(server_context:, files: [], verbosity: nil, **opts)
107
- validate_opts!(opts)
108
- parsed_files, line_ranges = parse_files(Array(files))
109
- config_opts = build_config_opts(parsed_files, line_ranges, opts)
110
- config = Evilution::Config.new(**config_opts)
111
- suggest_tests = opts[:suggest_tests]
112
- on_result = build_streaming_callback(server_context, suggest_tests, config.integration)
113
- runner = Evilution::Runner.new(config: config, on_result: on_result)
114
- summary = runner.call
115
- report = Evilution::Reporter::JSON.new(suggest_tests: suggest_tests == true, integration: config.integration).call(summary)
116
- compact = trim_report(report, normalize_verbosity(verbosity), summary.survived_results, config)
106
+ Evilution::MCP::MutateTool::OptionParser.validate!(opts)
107
+ parsed_files, line_ranges = Evilution::MCP::MutateTool::OptionParser.parse_files(Array(files))
108
+ config = Evilution::MCP::MutateTool::ConfigBuilder.build(
109
+ files: parsed_files,
110
+ line_ranges: line_ranges,
111
+ params: opts
112
+ )
113
+ on_result = Evilution::MCP::MutateTool::ProgressStreamer.build(
114
+ server_context: server_context,
115
+ suggest_tests: opts[:suggest_tests],
116
+ integration: config.integration
117
+ )
118
+ summary = Evilution::Runner.new(config: config, on_result: on_result).call
119
+ report = Evilution::Reporter::JSON.new(
120
+ suggest_tests: opts[:suggest_tests] == true,
121
+ integration: config.integration
122
+ ).call(summary)
123
+ normalized_verbosity = Evilution::MCP::MutateTool::OptionParser.normalize_verbosity(verbosity)
124
+ compact = Evilution::MCP::MutateTool::ReportTrimmer.call(
125
+ report,
126
+ verbosity: normalized_verbosity,
127
+ survived_results: summary.survived_results,
128
+ config: config,
129
+ enricher: Evilution::MCP::MutateTool::SurvivedEnricher
130
+ )
117
131
 
118
132
  ::MCP::Tool::Response.new([{ type: "text", text: compact }])
119
133
  rescue Evilution::Error => e
120
- error_payload = build_error_payload(e)
121
- ::MCP::Tool::Response.new([{ type: "text", text: ::JSON.generate(error_payload) }], error: true)
122
- end
123
-
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
128
-
129
- private
130
-
131
- def parse_files(raw_files)
132
- files = []
133
- ranges = {}
134
-
135
- raw_files.each do |arg|
136
- file, range_str = arg.split(":", 2)
137
- files << file
138
- next unless range_str
139
-
140
- ranges[file] = parse_line_range(range_str)
141
- end
142
-
143
- [files, ranges]
144
- end
145
-
146
- def parse_line_range(str)
147
- if str.include?("-")
148
- start_str, end_str = str.split("-", 2)
149
- start_line = Integer(start_str)
150
- end_line = end_str.empty? ? Float::INFINITY : Integer(end_str)
151
- start_line..end_line
152
- else
153
- line = Integer(str)
154
- line..line
155
- end
156
- rescue ArgumentError, TypeError
157
- raise Evilution::ParseError, "invalid line range: #{str.inspect}"
158
- end
159
-
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)
168
- # Preload is disabled for MCP invocations: `require`-ing Rails into the
169
- # long-lived MCP server would poison subsequent runs against other
170
- # projects. MCP users who want the speedup should use the CLI.
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? }
175
- opts
176
- end
177
-
178
- def normalize_verbosity(value)
179
- normalized = value.to_s.strip.downcase
180
- normalized = "summary" if normalized.empty?
181
- return normalized if VALID_VERBOSITIES.include?(normalized)
182
-
183
- raise Evilution::ParseError, "invalid verbosity: #{value.inspect} (must be full, summary, or minimal)"
184
- end
185
-
186
- def trim_report(json_string, verbosity, survived_results, config)
187
- data = ::JSON.parse(json_string)
188
- case verbosity
189
- when "full"
190
- strip_diffs(data, "killed")
191
- strip_diffs(data, "neutral")
192
- strip_diffs(data, "equivalent")
193
- when "summary"
194
- data.delete("killed")
195
- data.delete("neutral")
196
- data.delete("equivalent")
197
- when "minimal"
198
- data.delete("killed")
199
- data.delete("neutral")
200
- data.delete("equivalent")
201
- data.delete("timed_out")
202
- data.delete("errors")
203
- end
204
- enrich_survived(data, survived_results, config)
205
- ::JSON.generate(data)
206
- end
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
-
250
- def strip_diffs(data, key)
251
- return unless data[key]
252
-
253
- data[key].each { |entry| entry.delete("diff") }
254
- end
255
-
256
- def build_streaming_callback(server_context, suggest_tests, integration)
257
- return nil unless suggest_tests && server_context.respond_to?(:report_progress)
258
-
259
- suggestion = Evilution::Reporter::Suggestion.new(suggest_tests: true, integration: integration)
260
- survivor_index = 0
261
-
262
- proc do |result|
263
- next unless result.survived?
264
-
265
- begin
266
- survivor_index += 1
267
- detail = build_suggestion_detail(result.mutation, suggestion)
268
- server_context.report_progress(survivor_index, message: ::JSON.generate(detail))
269
- rescue StandardError # rubocop:disable Lint/SuppressedException
270
- end
271
- end
272
- end
273
-
274
- def build_suggestion_detail(mutation, suggestion)
275
- {
276
- operator: mutation.operator_name,
277
- file: mutation.file_path,
278
- line: mutation.line,
279
- subject: mutation.subject.name,
280
- diff: mutation.diff,
281
- suggestion: suggestion.suggestion_for(mutation)
282
- }
283
- end
284
-
285
- def build_error_payload(error)
286
- error_type = case error
287
- when Evilution::ConfigError then "config_error"
288
- when Evilution::ParseError then "parse_error"
289
- else "runtime_error"
290
- end
291
-
292
- payload = { type: error_type, message: error.message }
293
- payload[:file] = error.file if error.file
294
- { error: payload }
134
+ payload = Evilution::MCP::MutateTool::ErrorPayload.build(e)
135
+ ::MCP::Tool::Response.new([{ type: "text", text: ::JSON.generate(payload) }], error: true)
295
136
  end
296
137
  end
297
138
  end
139
+
140
+ require_relative "mutate_tool/error_payload"
141
+ require_relative "mutate_tool/option_parser"
142
+ require_relative "mutate_tool/config_builder"
143
+ require_relative "mutate_tool/report_trimmer"
144
+ require_relative "mutate_tool/survived_enricher"
145
+ require_relative "mutate_tool/progress_streamer"