evilution 0.28.0 → 0.29.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 (81) hide show
  1. checksums.yaml +4 -4
  2. data/.beads/interactions.jsonl +52 -0
  3. data/CHANGELOG.md +7 -0
  4. data/lib/evilution/ast/constant_names.rb +28 -11
  5. data/lib/evilution/ast/pattern/parser.rb +29 -17
  6. data/lib/evilution/cli/commands/session_diff.rb +6 -4
  7. data/lib/evilution/cli/commands/subjects.rb +6 -3
  8. data/lib/evilution/cli/commands/util_mutation.rb +24 -19
  9. data/lib/evilution/cli/parser/command_extractor.rb +9 -11
  10. data/lib/evilution/cli/parser/file_args.rb +3 -1
  11. data/lib/evilution/cli/parser/options_builder.rb +29 -1
  12. data/lib/evilution/cli/parser/stdin_reader.rb +2 -2
  13. data/lib/evilution/cli/parser.rb +18 -20
  14. data/lib/evilution/cli/printers/environment.rb +19 -19
  15. data/lib/evilution/cli/printers/session_diff.rb +8 -8
  16. data/lib/evilution/compare/normalizer.rb +10 -5
  17. data/lib/evilution/config.rb +10 -10
  18. data/lib/evilution/disable_comment.rb +21 -12
  19. data/lib/evilution/integration/loading/mutation_applier.rb +17 -12
  20. data/lib/evilution/integration/minitest.rb +25 -16
  21. data/lib/evilution/integration/rspec.rb +4 -0
  22. data/lib/evilution/isolation/fork.rb +27 -17
  23. data/lib/evilution/mcp/info_tool/actions/subjects.rb +32 -23
  24. data/lib/evilution/mcp/info_tool/actions/tests.rb +22 -12
  25. data/lib/evilution/mcp/info_tool/request_parser.rb +3 -1
  26. data/lib/evilution/mcp/info_tool.rb +7 -1
  27. data/lib/evilution/mcp/mutate_tool/option_parser.rb +3 -1
  28. data/lib/evilution/mcp/mutate_tool/survived_enricher.rb +19 -9
  29. data/lib/evilution/mcp/mutate_tool.rb +27 -14
  30. data/lib/evilution/mcp/session_tool.rb +27 -18
  31. data/lib/evilution/mutation.rb +13 -15
  32. data/lib/evilution/mutator/base.rb +17 -15
  33. data/lib/evilution/mutator/operator/argument_nil_substitution.rb +11 -14
  34. data/lib/evilution/mutator/operator/argument_removal.rb +11 -14
  35. data/lib/evilution/mutator/operator/begin_unwrap.rb +17 -5
  36. data/lib/evilution/mutator/operator/bitwise_complement.rb +26 -19
  37. data/lib/evilution/mutator/operator/block_param_removal.rb +18 -8
  38. data/lib/evilution/mutator/operator/block_pass_removal.rb +19 -15
  39. data/lib/evilution/mutator/operator/case_when.rb +7 -5
  40. data/lib/evilution/mutator/operator/conditional_branch.rb +22 -22
  41. data/lib/evilution/mutator/operator/equality_to_identity.rb +8 -3
  42. data/lib/evilution/mutator/operator/explicit_super_mutation.rb +17 -13
  43. data/lib/evilution/mutator/operator/index_to_at.rb +5 -4
  44. data/lib/evilution/mutator/operator/index_to_dig.rb +12 -6
  45. data/lib/evilution/mutator/operator/index_to_fetch.rb +5 -4
  46. data/lib/evilution/mutator/operator/keyword_argument.rb +30 -25
  47. data/lib/evilution/mutator/operator/mixin_removal.rb +20 -14
  48. data/lib/evilution/mutator/operator/multiple_assignment.rb +12 -13
  49. data/lib/evilution/mutator/operator/receiver_replacement.rb +9 -6
  50. data/lib/evilution/mutator/operator/regex_simplification.rb +62 -67
  51. data/lib/evilution/mutator/operator/rescue_body_replacement.rb +9 -8
  52. data/lib/evilution/mutator/operator/rescue_removal.rb +4 -7
  53. data/lib/evilution/mutator/operator/superclass_removal.rb +21 -15
  54. data/lib/evilution/parallel/work_queue/dispatcher.rb +15 -8
  55. data/lib/evilution/parallel/work_queue/worker.rb +10 -7
  56. data/lib/evilution/parallel/work_queue.rb +35 -18
  57. data/lib/evilution/reporter/cli/item_formatters/coverage_gap.rb +13 -8
  58. data/lib/evilution/reporter/cli/line_formatters/mutations.rb +17 -8
  59. data/lib/evilution/reporter/json.rb +52 -18
  60. data/lib/evilution/reporter/suggestion/diff_helpers.rb +0 -13
  61. data/lib/evilution/reporter/suggestion/diff_lines.rb +28 -0
  62. data/lib/evilution/reporter/suggestion/templates/minitest.rb +20 -14
  63. data/lib/evilution/reporter/suggestion/templates/rspec.rb +19 -13
  64. data/lib/evilution/runner/baseline_runner.rb +15 -8
  65. data/lib/evilution/runner/diagnostics.rb +13 -9
  66. data/lib/evilution/runner/isolation_resolver.rb +11 -9
  67. data/lib/evilution/runner/mutation_executor/result_cache.rb +3 -1
  68. data/lib/evilution/runner/mutation_executor/strategy/parallel.rb +32 -10
  69. data/lib/evilution/runner/mutation_executor/strategy/sequential.rb +1 -1
  70. data/lib/evilution/runner/mutation_executor.rb +2 -0
  71. data/lib/evilution/runner/mutation_planner.rb +37 -17
  72. data/lib/evilution/runner/subject_pipeline.rb +21 -11
  73. data/lib/evilution/runner.rb +3 -3
  74. data/lib/evilution/session/diff.rb +15 -6
  75. data/lib/evilution/spec_ast_cache.rb +26 -12
  76. data/lib/evilution/version.rb +1 -1
  77. data/script/memory_check +11 -5
  78. data/scripts/benchmark_density +10 -9
  79. data/scripts/compare_mutations +38 -21
  80. data/scripts/mutant_json_adapter +7 -4
  81. metadata +3 -2
@@ -57,11 +57,7 @@ class Evilution::Compare::Normalizer
57
57
  private
58
58
 
59
59
  def build_evilution_record(entry, index:)
60
- file_path = entry["file"] or raise Evilution::Compare::InvalidInput.new("missing 'file' in record", index: index)
61
- line = entry["line"] or raise Evilution::Compare::InvalidInput.new("missing 'line' in record", index: index)
62
- diff = entry["diff"].to_s
63
- status = EVILUTION_STATUS_MAP[entry["status"]] ||
64
- raise(Evilution::Compare::InvalidInput.new("unknown status #{entry["status"].inspect}", index: index))
60
+ file_path, line, diff, status = extract_evilution_fields(entry, index)
65
61
  Evilution::Compare::Record.new(
66
62
  source: :evilution,
67
63
  file_path: file_path,
@@ -74,6 +70,15 @@ class Evilution::Compare::Normalizer
74
70
  )
75
71
  end
76
72
 
73
+ def extract_evilution_fields(entry, index)
74
+ file_path = entry["file"] or raise Evilution::Compare::InvalidInput.new("missing 'file' in record", index: index)
75
+ line = entry["line"] or raise Evilution::Compare::InvalidInput.new("missing 'line' in record", index: index)
76
+ diff = entry["diff"].to_s
77
+ status = EVILUTION_STATUS_MAP[entry["status"]] ||
78
+ raise(Evilution::Compare::InvalidInput.new("unknown status #{entry["status"].inspect}", index: index))
79
+ [file_path, line, diff, status]
80
+ end
81
+
77
82
  def build_mutant_record(cov, source_path:, index:)
78
83
  mr = cov["mutation_result"] or raise Evilution::Compare::InvalidInput.new("missing mutation_result", index: index)
79
84
  cr = cov["criteria_result"] or raise Evilution::Compare::InvalidInput.new("missing criteria_result", index: index)
@@ -239,17 +239,17 @@ class Evilution::Config
239
239
  end
240
240
  end
241
241
 
242
+ VALIDATED_ATTRS = %i[
243
+ integration jobs fail_fast isolation ignore_patterns
244
+ hooks preload spec_mappings spec_pattern profile
245
+ ].freeze
246
+ private_constant :VALIDATED_ATTRS
247
+
242
248
  def assign_validated_attributes(merged)
243
- @integration = Validators::Integration.call(merged[:integration])
244
- @jobs = Validators::Jobs.call(merged[:jobs])
245
- @fail_fast = Validators::FailFast.call(merged[:fail_fast])
246
- @isolation = Validators::Isolation.call(merged[:isolation])
247
- @ignore_patterns = Validators::IgnorePatterns.call(merged[:ignore_patterns])
248
- @hooks = Validators::Hooks.call(merged[:hooks])
249
- @preload = Validators::Preload.call(merged[:preload])
250
- @spec_mappings = Validators::SpecMappings.call(merged[:spec_mappings])
251
- @spec_pattern = Validators::SpecPattern.call(merged[:spec_pattern])
252
- @profile = Validators::Profile.call(merged[:profile])
249
+ VALIDATED_ATTRS.each do |key|
250
+ validator = Validators.const_get(key.to_s.split("_").map(&:capitalize).join)
251
+ instance_variable_set(:"@#{key}", validator.call(merged[key]))
252
+ end
253
253
  end
254
254
 
255
255
  def assign_example_targeting(merged)
@@ -20,21 +20,30 @@ class Evilution::DisableComment
20
20
  private
21
21
 
22
22
  def classify_comments(parse_result, source)
23
- parse_result.comments.filter_map do |comment|
24
- loc = comment.location
25
- text = source.byteslice(loc.start_offset, loc.end_offset - loc.start_offset)
26
- .force_encoding(source.encoding)
27
-
28
- if text.match?(DISABLE_MARKER)
29
- line = source.lines[loc.start_line - 1]
30
- standalone = line.strip == text.strip
31
- { type: :disable, line: loc.start_line, standalone: standalone }
32
- elsif text.match?(ENABLE_MARKER)
33
- { type: :enable, line: loc.start_line }
34
- end
23
+ parse_result.comments.filter_map { |comment| classify_comment(comment, source) }
24
+ end
25
+
26
+ def classify_comment(comment, source)
27
+ loc = comment.location
28
+ text = comment_text(loc, source)
29
+
30
+ if text.match?(DISABLE_MARKER)
31
+ disable_entry(loc, text, source)
32
+ elsif text.match?(ENABLE_MARKER)
33
+ { type: :enable, line: loc.start_line }
35
34
  end
36
35
  end
37
36
 
37
+ def comment_text(loc, source)
38
+ source.byteslice(loc.start_offset, loc.end_offset - loc.start_offset)
39
+ .force_encoding(source.encoding)
40
+ end
41
+
42
+ def disable_entry(loc, text, source)
43
+ standalone = source.lines[loc.start_line - 1].strip == text.strip
44
+ { type: :disable, line: loc.start_line, standalone: standalone }
45
+ end
46
+
38
47
  def scan_comments(comments, method_ranges, total_lines)
39
48
  disabled = []
40
49
  range_start = nil
@@ -28,25 +28,30 @@ class Evilution::Integration::Loading::MutationApplier
28
28
  syntax_error = @syntax_validator.call(mutation.mutated_source)
29
29
  return syntax_error if syntax_error
30
30
 
31
+ apply(mutation)
32
+ nil
33
+ rescue SyntaxError => e
34
+ failure_result(e, "syntax error in mutated source: #{e.message}")
35
+ rescue ScriptError, StandardError => e
36
+ failure_result(e, "#{e.class}: #{e.message}")
37
+ end
38
+
39
+ private
40
+
41
+ def apply(mutation)
31
42
  @constant_pinner.call(mutation.original_source)
32
43
  @concern_state_cleaner.call(mutation.file_path)
33
44
  @redefinition_recovery.call(mutation.original_source) do
34
45
  @source_evaluator.call(mutation.mutated_source, mutation.file_path)
35
46
  end
36
- nil
37
- rescue SyntaxError => e
38
- {
39
- passed: false,
40
- error: "syntax error in mutated source: #{e.message}",
41
- error_class: e.class.name,
42
- error_backtrace: Array(e.backtrace).first(5)
43
- }
44
- rescue ScriptError, StandardError => e
47
+ end
48
+
49
+ def failure_result(error, message)
45
50
  {
46
51
  passed: false,
47
- error: "#{e.class}: #{e.message}",
48
- error_class: e.class.name,
49
- error_backtrace: Array(e.backtrace).first(5)
52
+ error: message,
53
+ error_class: error.class.name,
54
+ error_backtrace: Array(error.backtrace).first(5)
50
55
  }
51
56
  end
52
57
  end
@@ -10,22 +10,31 @@ require_relative "../integration"
10
10
 
11
11
  class Evilution::Integration::Minitest < Evilution::Integration::Base
12
12
  def self.baseline_runner
13
- lambda { |test_file|
14
- require "minitest"
15
- require "stringio"
16
- ::Minitest::Runnable.runnables.clear
17
- files = File.directory?(test_file) ? Dir.glob(File.join(test_file, "**/*_test.rb")) : [test_file]
18
- files.each { |f| load(File.expand_path(f)) }
19
- out = StringIO.new
20
- options = ::Minitest.process_args(["--seed", "0"])
21
- options[:io] = out
22
- reporter = ::Minitest::CompositeReporter.new
23
- reporter << ::Minitest::SummaryReporter.new(out, options)
24
- reporter.start
25
- ::Minitest.__run(reporter, options)
26
- reporter.report
27
- reporter.passed?
28
- }
13
+ ->(test_file) { run_baseline_test_file(test_file) }
14
+ end
15
+
16
+ def self.run_baseline_test_file(test_file)
17
+ require "minitest"
18
+ require "stringio"
19
+ ::Minitest::Runnable.runnables.clear
20
+ baseline_test_files(test_file).each { |f| load(File.expand_path(f)) }
21
+ run_baseline_minitest
22
+ end
23
+
24
+ def self.baseline_test_files(test_file)
25
+ File.directory?(test_file) ? Dir.glob(File.join(test_file, "**/*_test.rb")) : [test_file]
26
+ end
27
+
28
+ def self.run_baseline_minitest
29
+ out = StringIO.new
30
+ options = ::Minitest.process_args(["--seed", "0"])
31
+ options[:io] = out
32
+ reporter = ::Minitest::CompositeReporter.new
33
+ reporter << ::Minitest::SummaryReporter.new(out, options)
34
+ reporter.start
35
+ ::Minitest.__run(reporter, options)
36
+ reporter.report
37
+ reporter.passed?
29
38
  end
30
39
 
31
40
  def self.baseline_options
@@ -74,6 +74,10 @@ class Evilution::Integration::RSpec < Evilution::Integration::Base
74
74
  command = "rspec #{args.join(" ")}"
75
75
 
76
76
  reset_examples
77
+ execute_run(args, command)
78
+ end
79
+
80
+ def execute_run(args, command)
77
81
  detector = @crash_detector_lifecycle.current
78
82
  snapshot = @state_guard.snapshot
79
83
  begin
@@ -21,41 +21,51 @@ class Evilution::Isolation::Fork
21
21
  sandbox_dir = Dir.mktmpdir("evilution-run")
22
22
  start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
23
23
  parent_rss = Evilution::Memory.rss_kb
24
+ read_io, write_io = binary_pipe
25
+ pid = fork_child(read_io, write_io, sandbox_dir, mutation, test_command)
26
+ write_io.close
27
+ result = wait_for_result(pid, read_io, timeout)
28
+ duration = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
29
+ build_mutation_result(mutation, result, duration, parent_rss)
30
+ ensure
31
+ cleanup_resources(read_io, write_io, pid, sandbox_dir)
32
+ end
33
+
34
+ private
35
+
36
+ # Marshal result payload is ASCII-8BIT; pipes default to text mode and may
37
+ # transcode according to their external/internal encodings (influenced by
38
+ # Encoding.default_external and/or Encoding.default_internal — Rails sets
39
+ # the latter to UTF-8), failing on bytes with no mapping. Force binmode on
40
+ # both ends.
41
+ def binary_pipe
24
42
  read_io, write_io = IO.pipe
25
- # Marshal result payload is ASCII-8BIT; pipes default to text mode and may
26
- # transcode according to their external/internal encodings (influenced by
27
- # Encoding.default_external and/or Encoding.default_internal — Rails sets
28
- # the latter to UTF-8), failing on bytes with no mapping. Force binmode on
29
- # both ends.
30
43
  read_io.binmode
31
44
  write_io.binmode
45
+ [read_io, write_io]
46
+ end
32
47
 
33
- pid = ::Process.fork do
48
+ def fork_child(read_io, write_io, sandbox_dir, mutation, test_command)
49
+ ::Process.fork do
34
50
  ENV["TMPDIR"] = sandbox_dir
35
51
  read_io.close
36
52
  suppress_child_output
37
- @hooks.fire(:worker_process_start, mutation: mutation) if @hooks
53
+ @hooks.fire(:worker_process_start, mutation:) if @hooks
38
54
  result = execute_in_child(mutation, test_command)
39
55
  Marshal.dump(result, write_io)
40
56
  write_io.close
41
57
  exit!(result[:passed] ? 0 : 1)
42
58
  end
59
+ end
43
60
 
44
- write_io.close
45
- result = wait_for_result(pid, read_io, timeout)
46
- duration = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
47
-
48
- build_mutation_result(mutation, result, duration, parent_rss)
49
- ensure
50
- read_io&.close
51
- write_io&.close
61
+ def cleanup_resources(read_io, write_io, pid, sandbox_dir)
62
+ read_io.close unless read_io.nil?
63
+ write_io.close unless write_io.nil?
52
64
  ensure_reaped(pid)
53
65
  restore_original_source
54
66
  FileUtils.rm_rf(sandbox_dir) if sandbox_dir
55
67
  end
56
68
 
57
- private
58
-
59
69
  def restore_original_source
60
70
  Evilution::TempDirTracker.cleanup_all
61
71
  end
@@ -10,34 +10,43 @@ class Evilution::MCP::InfoTool::Actions::Subjects < Evilution::MCP::InfoTool::Ac
10
10
  def self.call(files: nil, line_ranges: nil, target: nil, integration: nil, skip_config: nil, **)
11
11
  return config_error("files is required") if files.nil? || files.empty?
12
12
 
13
- config = Evilution::MCP::InfoTool::ConfigFactory.subjects(
14
- files: files, line_ranges: line_ranges,
15
- target: target, integration: integration, skip_config: skip_config
16
- )
17
- runner = Evilution::Runner.new(config: config)
18
- subjects = runner.parse_and_filter_subjects
19
-
20
- registry = Evilution::Mutator::Registry.default
21
- filter = build_subject_filter(config)
22
- operator_options = { skip_heredoc_literals: config.skip_heredoc_literals? }
23
-
24
- entries = subjects.map do |subj|
25
- count = registry.mutations_for(subj, filter: filter, operator_options: operator_options).length
26
- { "name" => subj.name, "file" => subj.file_path, "line" => subj.line_number, "mutations" => count }
27
- ensure
28
- subj.release_node!
29
- end
30
-
31
- success(
32
- "subjects" => entries,
33
- "total_subjects" => entries.length,
34
- "total_mutations" => entries.sum { |e| e["mutations"] }
35
- )
13
+ config = build_config(files, line_ranges, target, integration, skip_config)
14
+ subjects = Evilution::Runner.new(config: config).parse_and_filter_subjects
15
+ entries = subject_entries(subjects, config)
16
+ success_response(entries)
36
17
  end
37
18
 
38
19
  class << self
39
20
  private
40
21
 
22
+ def build_config(files, line_ranges, target, integration, skip_config)
23
+ Evilution::MCP::InfoTool::ConfigFactory.subjects(
24
+ files: files, line_ranges: line_ranges,
25
+ target: target, integration: integration, skip_config: skip_config
26
+ )
27
+ end
28
+
29
+ def subject_entries(subjects, config)
30
+ registry = Evilution::Mutator::Registry.default
31
+ filter = build_subject_filter(config)
32
+ operator_options = { skip_heredoc_literals: config.skip_heredoc_literals? }
33
+
34
+ subjects.map do |subj|
35
+ count = registry.mutations_for(subj, filter: filter, operator_options: operator_options).length
36
+ { "name" => subj.name, "file" => subj.file_path, "line" => subj.line_number, "mutations" => count }
37
+ ensure
38
+ subj.release_node!
39
+ end
40
+ end
41
+
42
+ def success_response(entries)
43
+ success(
44
+ "subjects" => entries,
45
+ "total_subjects" => entries.length,
46
+ "total_mutations" => entries.sum { |e| e["mutations"] }
47
+ )
48
+ end
49
+
41
50
  def build_subject_filter(config)
42
51
  return nil if config.ignore_patterns.empty?
43
52
 
@@ -6,27 +6,37 @@ require_relative "../../../runner"
6
6
  require_relative "../../../spec_resolver"
7
7
 
8
8
  class Evilution::MCP::InfoTool::Actions::Tests < Evilution::MCP::InfoTool::Actions::Base
9
+ ResolveResult = Data.define(:resolved, :unresolved)
10
+ private_constant :ResolveResult
11
+
9
12
  def self.call(files: nil, spec: nil, integration: nil, skip_config: nil, **)
10
13
  return config_error("files is required") if files.nil? || files.empty?
11
14
 
12
- config = Evilution::MCP::InfoTool::ConfigFactory.tests(
13
- files: files, spec: spec, integration: integration, skip_config: skip_config
14
- )
15
+ config = build_config(files, spec, integration, skip_config)
15
16
  return explicit_specs_response(files, config.spec_files) if config.spec_files.any?
16
17
 
17
- resolver = resolver_for(config.integration)
18
- resolved, unresolved = resolve_specs(files, resolver)
19
- success(
20
- "specs" => resolved,
21
- "unresolved" => unresolved,
22
- "total_sources" => files.length,
23
- "total_specs" => resolved.map { |r| r["spec"] }.uniq.length
24
- )
18
+ resolved_specs_response(files, resolver_for(config.integration))
25
19
  end
26
20
 
27
21
  class << self
28
22
  private
29
23
 
24
+ def build_config(files, spec, integration, skip_config)
25
+ Evilution::MCP::InfoTool::ConfigFactory.tests(
26
+ files: files, spec: spec, integration: integration, skip_config: skip_config
27
+ )
28
+ end
29
+
30
+ def resolved_specs_response(files, resolver)
31
+ result = resolve_specs(files, resolver)
32
+ success(
33
+ "specs" => result.resolved,
34
+ "unresolved" => result.unresolved,
35
+ "total_sources" => files.length,
36
+ "total_specs" => result.resolved.map { |r| r["spec"] }.uniq.length
37
+ )
38
+ end
39
+
30
40
  def resolver_for(integration)
31
41
  integration_class = Evilution::Runner::INTEGRATIONS[integration.to_sym]
32
42
  return Evilution::SpecResolver.new unless integration_class
@@ -54,7 +64,7 @@ class Evilution::MCP::InfoTool::Actions::Tests < Evilution::MCP::InfoTool::Actio
54
64
  unresolved << source
55
65
  end
56
66
  end
57
- [resolved, unresolved]
67
+ ResolveResult.new(resolved: resolved, unresolved: unresolved)
58
68
  end
59
69
  end
60
70
  end
@@ -3,6 +3,8 @@
3
3
  require_relative "../info_tool"
4
4
 
5
5
  module Evilution::MCP::InfoTool::RequestParser
6
+ ParsedPaths = Data.define(:files, :ranges)
7
+
6
8
  module_function
7
9
 
8
10
  def parse_files(raw_files)
@@ -15,7 +17,7 @@ module Evilution::MCP::InfoTool::RequestParser
15
17
  ranges[file] = parse_line_range(range_str) if range_str
16
18
  end
17
19
 
18
- [files, ranges]
20
+ ParsedPaths.new(files: files, ranges: ranges)
19
21
  end
20
22
 
21
23
  def parse_line_range(str)
@@ -74,7 +74,13 @@ class Evilution::MCP::InfoTool < MCP::Tool
74
74
  return ResponseFormatter.error("config_error", "action is required") unless action
75
75
  return ResponseFormatter.error("config_error", "unknown action: #{action}") unless ACTIONS.key?(action)
76
76
 
77
- parsed_files, line_ranges = RequestParser.parse_files(Array(files)) if files
77
+ parsed_files = nil
78
+ line_ranges = nil
79
+ if files
80
+ parsed = RequestParser.parse_files(Array(files))
81
+ parsed_files = parsed.files
82
+ line_ranges = parsed.ranges
83
+ end
78
84
 
79
85
  ACTIONS[action].call(
80
86
  files: parsed_files, line_ranges: line_ranges, target: target, spec: spec,
@@ -8,6 +8,8 @@ module Evilution::MCP::MutateTool::OptionParser
8
8
  isolation baseline save_session].freeze
9
9
  ALLOWED_OPT_KEYS = (PASSTHROUGH_KEYS + %i[spec skip_config]).freeze
10
10
 
11
+ ParsedPaths = Data.define(:files, :ranges)
12
+
11
13
  def self.parse_files(raw_files)
12
14
  files = []
13
15
  ranges = {}
@@ -20,7 +22,7 @@ module Evilution::MCP::MutateTool::OptionParser
20
22
  ranges[file] = parse_line_range(range_str)
21
23
  end
22
24
 
23
- [files, ranges]
25
+ ParsedPaths.new(files: files, ranges: ranges)
24
26
  end
25
27
 
26
28
  def self.parse_line_range(str)
@@ -9,23 +9,33 @@ module Evilution::MCP::MutateTool::SurvivedEnricher
9
9
  entries = data["survived"]
10
10
  return unless entries.is_a?(Array)
11
11
 
12
- explicit_spec = explicit_spec_override(config)
13
- resolver = explicit_spec ? nil : resolver_for_integration(config.integration)
12
+ explicit_spec, resolver = build_resolver(config)
14
13
  cache = {}
15
14
 
16
15
  entries.each_with_index do |entry, index|
17
16
  result = survived_results[index]
18
17
  next unless result
19
18
 
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)
19
+ enrich_entry(entry, result.mutation, explicit_spec, resolver, cache)
20
+ end
21
+ end
22
+
23
+ def self.build_resolver(config)
24
+ explicit_spec = explicit_spec_override(config)
25
+ resolver = explicit_spec ? nil : resolver_for_integration(config.integration)
26
+ [explicit_spec, resolver]
27
+ end
28
+ private_class_method :build_resolver
29
+
30
+ def self.enrich_entry(entry, mutation, explicit_spec, resolver, cache)
31
+ entry["subject"] = mutation.subject.name
32
+ spec_file = explicit_spec || cache.fetch(mutation.file_path) do
33
+ cache[mutation.file_path] = resolver.call(mutation.file_path)
27
34
  end
35
+ entry["spec_file"] = spec_file if spec_file
36
+ entry["next_step"] = build_next_step(mutation, spec_file)
28
37
  end
38
+ private_class_method :enrich_entry
29
39
 
30
40
  def self.explicit_spec_override(config)
31
41
  return nil unless config.respond_to?(:spec_files)
@@ -105,37 +105,50 @@ class Evilution::MCP::MutateTool < MCP::Tool
105
105
 
106
106
  class << self
107
107
  def call(server_context:, files: [], verbosity: nil, **opts)
108
+ config = build_config(files, opts)
109
+ on_result = build_progress_streamer(server_context, opts, config)
110
+ summary = Evilution::Runner.new(config: config, on_result: on_result).call
111
+ compact = build_compact_report(summary, verbosity, opts, config)
112
+
113
+ ::MCP::Tool::Response.new([{ type: "text", text: compact }])
114
+ rescue Evilution::Error => e
115
+ payload = Evilution::MCP::MutateTool::ErrorPayload.build(e)
116
+ ::MCP::Tool::Response.new([{ type: "text", text: ::JSON.generate(payload) }], error: true)
117
+ end
118
+
119
+ private
120
+
121
+ def build_config(files, opts)
108
122
  Evilution::MCP::MutateTool::OptionParser.validate!(opts)
109
- parsed_files, line_ranges = Evilution::MCP::MutateTool::OptionParser.parse_files(Array(files))
110
- config = Evilution::MCP::MutateTool::ConfigBuilder.build(
111
- files: parsed_files,
112
- line_ranges: line_ranges,
123
+ parsed = Evilution::MCP::MutateTool::OptionParser.parse_files(Array(files))
124
+ Evilution::MCP::MutateTool::ConfigBuilder.build(
125
+ files: parsed.files,
126
+ line_ranges: parsed.ranges,
113
127
  params: opts
114
128
  )
115
- on_result = Evilution::MCP::MutateTool::ProgressStreamer.build(
129
+ end
130
+
131
+ def build_progress_streamer(server_context, opts, config)
132
+ Evilution::MCP::MutateTool::ProgressStreamer.build(
116
133
  server_context: server_context,
117
134
  suggest_tests: opts[:suggest_tests],
118
135
  integration: config.integration
119
136
  )
120
- summary = Evilution::Runner.new(config: config, on_result: on_result).call
137
+ end
138
+
139
+ def build_compact_report(summary, verbosity, opts, config)
121
140
  report = Evilution::Reporter::JSON.new(
122
141
  suggest_tests: opts[:suggest_tests] == true,
123
142
  integration: config.integration
124
143
  ).call(summary)
125
- normalized_verbosity = Evilution::MCP::MutateTool::OptionParser.normalize_verbosity(verbosity)
126
- compact = Evilution::MCP::MutateTool::ReportTrimmer.call(
144
+ Evilution::MCP::MutateTool::ReportTrimmer.call(
127
145
  report,
128
- verbosity: normalized_verbosity,
146
+ verbosity: Evilution::MCP::MutateTool::OptionParser.normalize_verbosity(verbosity),
129
147
  survived_results: summary.survived_results,
130
148
  config: config,
131
149
  enricher: Evilution::MCP::MutateTool::SurvivedEnricher,
132
150
  summary: summary
133
151
  )
134
-
135
- ::MCP::Tool::Response.new([{ type: "text", text: compact }])
136
- rescue Evilution::Error => e
137
- payload = Evilution::MCP::MutateTool::ErrorPayload.build(e)
138
- ::MCP::Tool::Response.new([{ type: "text", text: ::JSON.generate(payload) }], error: true)
139
152
  end
140
153
  end
141
154
  end
@@ -50,6 +50,9 @@ class Evilution::MCP::SessionTool < MCP::Tool
50
50
 
51
51
  VALID_ACTIONS = %w[list show diff].freeze
52
52
 
53
+ LimitResult = Data.define(:limit, :error)
54
+ private_constant :LimitResult
55
+
53
56
  class << self
54
57
  def call(server_context:, action: nil, results_dir: nil, limit: nil, path: nil, base: nil, head: nil)
55
58
  return error_response("config_error", "action is required") unless action
@@ -65,28 +68,28 @@ class Evilution::MCP::SessionTool < MCP::Tool
65
68
  private
66
69
 
67
70
  def list_action(results_dir:, limit:)
68
- normalized_limit, limit_error = normalize_limit(limit)
69
- return error_response("config_error", limit_error) if limit_error
71
+ result = normalize_limit(limit)
72
+ return error_response("config_error", result.error) if result.error
70
73
 
71
74
  store_opts = {}
72
75
  store_opts[:results_dir] = results_dir if results_dir
73
76
  store = Evilution::Session::Store.new(**store_opts)
74
77
  entries = store.list
75
- entries = entries.first(normalized_limit) unless normalized_limit.nil?
78
+ entries = entries.first(result.limit) unless result.limit.nil?
76
79
 
77
80
  payload = entries.map { |e| e.transform_keys(&:to_s) }
78
81
  success_response(payload)
79
82
  end
80
83
 
81
84
  def normalize_limit(limit)
82
- return [nil, nil] if limit.nil?
85
+ return LimitResult.new(limit: nil, error: nil) if limit.nil?
83
86
 
84
87
  coerced = Integer(limit)
85
- return [nil, "limit must be a non-negative integer"] if coerced.negative?
88
+ return LimitResult.new(limit: nil, error: "limit must be a non-negative integer") if coerced.negative?
86
89
 
87
- [coerced, nil]
90
+ LimitResult.new(limit: coerced, error: nil)
88
91
  rescue ArgumentError, TypeError
89
- [nil, "limit must be a non-negative integer"]
92
+ LimitResult.new(limit: nil, error: "limit must be a non-negative integer")
90
93
  end
91
94
 
92
95
  def show_action(path:, results_dir:)
@@ -107,19 +110,11 @@ class Evilution::MCP::SessionTool < MCP::Tool
107
110
  end
108
111
 
109
112
  def diff_action(base:, head:, results_dir:)
110
- return error_response("config_error", "base is required") unless base
111
- return error_response("config_error", "head is required") unless head
112
-
113
113
  dir = results_dir || Evilution::Session::Store::DEFAULT_DIR
114
- return error_response("config_error", "base must be under results directory") unless within?(base, dir)
115
- return error_response("config_error", "head must be under results directory") unless within?(head, dir)
116
-
117
- store = Evilution::Session::Store.new(results_dir: dir)
118
- base_data = store.load(base)
119
- head_data = store.load(head)
114
+ validation = validate_diff_args(base, head, dir)
115
+ return validation if validation
120
116
 
121
- diff = Evilution::Session::Diff.new
122
- result = diff.call(base_data, head_data)
117
+ result = load_and_diff(base, head, dir)
123
118
  success_response(result.to_h)
124
119
  rescue Evilution::Error => e
125
120
  error_response("not_found", e.message)
@@ -129,6 +124,20 @@ class Evilution::MCP::SessionTool < MCP::Tool
129
124
  error_response("runtime_error", e.message)
130
125
  end
131
126
 
127
+ def validate_diff_args(base, head, dir)
128
+ return error_response("config_error", "base is required") unless base
129
+ return error_response("config_error", "head is required") unless head
130
+ return error_response("config_error", "base must be under results directory") unless within?(base, dir)
131
+ return error_response("config_error", "head must be under results directory") unless within?(head, dir)
132
+
133
+ nil
134
+ end
135
+
136
+ def load_and_diff(base, head, dir)
137
+ store = Evilution::Session::Store.new(results_dir: dir)
138
+ Evilution::Session::Diff.new.call(store.load(base), store.load(head))
139
+ end
140
+
132
141
  def within?(path, results_dir)
133
142
  resolved_root = canonical_path(results_dir)
134
143
  resolved_path = canonical_path(path)