evilution 0.27.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 (125) hide show
  1. checksums.yaml +4 -4
  2. data/.beads/interactions.jsonl +65 -0
  3. data/.rubocop_todo.yml +0 -1
  4. data/CHANGELOG.md +39 -0
  5. data/README.md +19 -0
  6. data/lib/evilution/ast/constant_names.rb +28 -11
  7. data/lib/evilution/ast/pattern/parser.rb +29 -17
  8. data/lib/evilution/baseline.rb +5 -4
  9. data/lib/evilution/cli/commands/session_diff.rb +6 -4
  10. data/lib/evilution/cli/commands/subjects.rb +6 -3
  11. data/lib/evilution/cli/commands/util_mutation.rb +24 -19
  12. data/lib/evilution/cli/parser/command_extractor.rb +9 -11
  13. data/lib/evilution/cli/parser/file_args.rb +3 -1
  14. data/lib/evilution/cli/parser/options_builder.rb +36 -1
  15. data/lib/evilution/cli/parser/stdin_reader.rb +2 -2
  16. data/lib/evilution/cli/parser.rb +18 -20
  17. data/lib/evilution/cli/printers/environment.rb +19 -19
  18. data/lib/evilution/cli/printers/session_diff.rb +8 -8
  19. data/lib/evilution/compare/diff_extractor/evilution.rb +22 -0
  20. data/lib/evilution/compare/diff_extractor/mutant.rb +30 -0
  21. data/lib/evilution/compare/diff_extractor.rb +6 -0
  22. data/lib/evilution/compare/fingerprint.rb +15 -72
  23. data/lib/evilution/compare/line_normalizer.rb +72 -0
  24. data/lib/evilution/compare/normalizer.rb +27 -9
  25. data/lib/evilution/config/validators/profile.rb +11 -0
  26. data/lib/evilution/config.rb +49 -32
  27. data/lib/evilution/disable_comment.rb +21 -12
  28. data/lib/evilution/integration/crash_detector.rb +2 -2
  29. data/lib/evilution/integration/loading/mutation_applier.rb +17 -12
  30. data/lib/evilution/integration/loading/source_evaluator.rb +6 -2
  31. data/lib/evilution/integration/minitest.rb +25 -16
  32. data/lib/evilution/integration/minitest_crash_detector.rb +2 -2
  33. data/lib/evilution/integration/rspec/state_guard/object_space_example_groups.rb +11 -3
  34. data/lib/evilution/integration/rspec.rb +4 -0
  35. data/lib/evilution/isolation/fork.rb +43 -28
  36. data/lib/evilution/isolation/in_process.rb +10 -6
  37. data/lib/evilution/mcp/info_tool/actions/subjects.rb +32 -23
  38. data/lib/evilution/mcp/info_tool/actions/tests.rb +22 -12
  39. data/lib/evilution/mcp/info_tool/request_parser.rb +3 -1
  40. data/lib/evilution/mcp/info_tool.rb +7 -3
  41. data/lib/evilution/mcp/mutate_tool/option_parser.rb +3 -1
  42. data/lib/evilution/mcp/mutate_tool/progress_streamer.rb +5 -1
  43. data/lib/evilution/mcp/mutate_tool/survived_enricher.rb +19 -9
  44. data/lib/evilution/mcp/mutate_tool.rb +27 -14
  45. data/lib/evilution/mcp/session_tool.rb +27 -20
  46. data/lib/evilution/mutation.rb +60 -42
  47. data/lib/evilution/mutator/base.rb +23 -21
  48. data/lib/evilution/mutator/operator/argument_nil_substitution.rb +11 -14
  49. data/lib/evilution/mutator/operator/argument_removal.rb +11 -14
  50. data/lib/evilution/mutator/operator/begin_unwrap.rb +17 -5
  51. data/lib/evilution/mutator/operator/bitwise_complement.rb +26 -19
  52. data/lib/evilution/mutator/operator/block_param_removal.rb +18 -8
  53. data/lib/evilution/mutator/operator/block_pass_removal.rb +19 -15
  54. data/lib/evilution/mutator/operator/case_when.rb +7 -5
  55. data/lib/evilution/mutator/operator/conditional_branch.rb +22 -22
  56. data/lib/evilution/mutator/operator/equality_to_identity.rb +8 -3
  57. data/lib/evilution/mutator/operator/explicit_super_mutation.rb +17 -13
  58. data/lib/evilution/mutator/operator/index_to_at.rb +5 -4
  59. data/lib/evilution/mutator/operator/index_to_dig.rb +12 -6
  60. data/lib/evilution/mutator/operator/index_to_fetch.rb +5 -4
  61. data/lib/evilution/mutator/operator/keyword_argument.rb +30 -25
  62. data/lib/evilution/mutator/operator/mixin_removal.rb +20 -14
  63. data/lib/evilution/mutator/operator/multiple_assignment.rb +12 -13
  64. data/lib/evilution/mutator/operator/predicate_to_nil.rb +20 -0
  65. data/lib/evilution/mutator/operator/receiver_replacement.rb +9 -6
  66. data/lib/evilution/mutator/operator/regex_simplification.rb +62 -67
  67. data/lib/evilution/mutator/operator/rescue_body_replacement.rb +9 -8
  68. data/lib/evilution/mutator/operator/rescue_removal.rb +4 -7
  69. data/lib/evilution/mutator/operator/superclass_removal.rb +21 -15
  70. data/lib/evilution/mutator/registry.rb +20 -0
  71. data/lib/evilution/parallel/work_queue/channel/frame.rb +5 -1
  72. data/lib/evilution/parallel/work_queue/dispatcher.rb +15 -8
  73. data/lib/evilution/parallel/work_queue/worker/loop.rb +1 -1
  74. data/lib/evilution/parallel/work_queue/worker.rb +10 -7
  75. data/lib/evilution/parallel/work_queue.rb +35 -18
  76. data/lib/evilution/process_cleanup.rb +19 -0
  77. data/lib/evilution/reporter/cli/item_formatters/coverage_gap.rb +13 -8
  78. data/lib/evilution/reporter/cli/line_formatters/mutations.rb +17 -8
  79. data/lib/evilution/reporter/html/baseline_keys.rb +1 -1
  80. data/lib/evilution/reporter/html/diff_formatter.rb +1 -1
  81. data/lib/evilution/reporter/html/escape.rb +1 -1
  82. data/lib/evilution/reporter/html/section.rb +1 -1
  83. data/lib/evilution/reporter/html/sections.rb +4 -2
  84. data/lib/evilution/reporter/html/stylesheet.rb +1 -1
  85. data/lib/evilution/reporter/html.rb +8 -3
  86. data/lib/evilution/reporter/json.rb +52 -18
  87. data/lib/evilution/reporter/suggestion/diff_helpers.rb +0 -13
  88. data/lib/evilution/reporter/suggestion/diff_lines.rb +28 -0
  89. data/lib/evilution/reporter/suggestion/registry.rb +1 -5
  90. data/lib/evilution/reporter/suggestion/templates/generic.rb +1 -1
  91. data/lib/evilution/reporter/suggestion/templates/minitest.rb +361 -649
  92. data/lib/evilution/reporter/suggestion/templates/rspec.rb +362 -603
  93. data/lib/evilution/reporter/suggestion/templates.rb +6 -0
  94. data/lib/evilution/result/error_info.rb +20 -0
  95. data/lib/evilution/result/memory_stats.rb +20 -0
  96. data/lib/evilution/result/mutation_result.rb +30 -14
  97. data/lib/evilution/runner/baseline_runner.rb +16 -10
  98. data/lib/evilution/runner/diagnostics.rb +14 -11
  99. data/lib/evilution/runner/isolation_resolver.rb +12 -11
  100. data/lib/evilution/runner/mutation_executor/mutation_runner.rb +1 -3
  101. data/lib/evilution/runner/mutation_executor/neutralization_pipeline.rb +1 -2
  102. data/lib/evilution/runner/mutation_executor/neutralizer/baseline_failed.rb +3 -10
  103. data/lib/evilution/runner/mutation_executor/neutralizer/infra_error.rb +3 -10
  104. data/lib/evilution/runner/mutation_executor/neutralizer.rb +11 -0
  105. data/lib/evilution/runner/mutation_executor/result_cache.rb +4 -4
  106. data/lib/evilution/runner/mutation_executor/result_notifier.rb +1 -3
  107. data/lib/evilution/runner/mutation_executor/result_packer.rb +11 -9
  108. data/lib/evilution/runner/mutation_executor/strategy/parallel.rb +33 -13
  109. data/lib/evilution/runner/mutation_executor/strategy/sequential.rb +2 -4
  110. data/lib/evilution/runner/mutation_executor/strategy.rb +11 -0
  111. data/lib/evilution/runner/mutation_executor.rb +14 -20
  112. data/lib/evilution/runner/mutation_planner.rb +38 -19
  113. data/lib/evilution/runner/report_publisher.rb +1 -2
  114. data/lib/evilution/runner/subject_pipeline.rb +22 -13
  115. data/lib/evilution/runner.rb +36 -34
  116. data/lib/evilution/session/diff.rb +15 -6
  117. data/lib/evilution/spec_ast_cache.rb +26 -12
  118. data/lib/evilution/version.rb +1 -1
  119. data/lib/evilution.rb +1 -0
  120. data/script/memory_check +14 -6
  121. data/scripts/benchmark_density +10 -9
  122. data/scripts/compare_mutations +38 -21
  123. data/scripts/mutant_json_adapter +7 -4
  124. metadata +15 -3
  125. data/lib/evilution/reporter/html/namespace.rb +0 -11
@@ -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
@@ -5,11 +5,15 @@ require_relative "../loading"
5
5
  # Evaluate source with __FILE__ set to the absolute original path so that
6
6
  # `require_relative` and `__dir__` resolve against the real source tree, where
7
7
  # sibling files actually exist.
8
+ #
9
+ # Trust boundary: `source` is never user-supplied. It is always the original
10
+ # on-disk source from a file the user already pointed Evilution at, with
11
+ # byte-level mutations applied by AST::SourceSurgeon. The only difference
12
+ # between this eval path and a plain `require` of the same file is that we
13
+ # substitute the mutated bytes — the privilege level is identical.
8
14
  class Evilution::Integration::Loading::SourceEvaluator
9
15
  def call(source, file_path)
10
16
  absolute = File.expand_path(file_path)
11
- # rubocop:disable Security/Eval
12
17
  eval(source, TOPLEVEL_BINDING, absolute, 1)
13
- # rubocop:enable Security/Eval
14
18
  end
15
19
  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
@@ -34,11 +34,11 @@ class Evilution::Integration::MinitestCrashDetector
34
34
  end
35
35
  end
36
36
 
37
- def has_assertion_failure? # rubocop:disable Naming/PredicatePrefix
37
+ def assertion_failure?
38
38
  @assertion_failures.positive?
39
39
  end
40
40
 
41
- def has_crash? # rubocop:disable Naming/PredicatePrefix
41
+ def crashed?
42
42
  @crashes.any?
43
43
  end
44
44
 
@@ -9,7 +9,10 @@ class Evilution::Integration::RSpec::StateGuard::ObjectSpaceExampleGroups
9
9
  groups = Set.new
10
10
  ObjectSpace.each_object(Class) do |klass|
11
11
  groups << klass.object_id if klass < ::RSpec::Core::ExampleGroup
12
- rescue TypeError # rubocop:disable Lint/SuppressedException
12
+ rescue TypeError
13
+ # ObjectSpace iteration may surface partially-initialized or anonymous
14
+ # classes whose `<` comparison raises. Skipping them is safe — they
15
+ # cannot be ExampleGroup descendants we need to track.
13
16
  end
14
17
  groups
15
18
  end
@@ -23,13 +26,18 @@ class Evilution::Integration::RSpec::StateGuard::ObjectSpaceExampleGroups
23
26
 
24
27
  klass.constants(false).each do |const|
25
28
  klass.send(:remove_const, const)
26
- rescue NameError # rubocop:disable Lint/SuppressedException
29
+ rescue NameError
30
+ # Constant may have been removed concurrently (e.g. via autoload
31
+ # reload) between #constants(false) and #remove_const. Best-effort
32
+ # cleanup — nothing to do if it's already gone.
27
33
  end
28
34
 
29
35
  klass.instance_variables.each do |ivar|
30
36
  klass.remove_instance_variable(ivar)
31
37
  end
32
- rescue TypeError # rubocop:disable Lint/SuppressedException
38
+ rescue TypeError
39
+ # Same defensive case as #snapshot: skip classes whose `<` raises
40
+ # mid-iteration.
33
41
  end
34
42
  end
35
43
  end
@@ -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
@@ -5,6 +5,7 @@ require "tmpdir"
5
5
  require_relative "../memory"
6
6
  require_relative "../temp_dir_tracker"
7
7
  require_relative "../child_output"
8
+ require_relative "../process_cleanup"
8
9
 
9
10
  require_relative "../isolation"
10
11
 
@@ -20,42 +21,52 @@ class Evilution::Isolation::Fork
20
21
  sandbox_dir = Dir.mktmpdir("evilution-run")
21
22
  start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
22
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
23
42
  read_io, write_io = IO.pipe
24
- # Marshal result payload is ASCII-8BIT; pipes default to text mode and may
25
- # transcode according to their external/internal encodings (influenced by
26
- # Encoding.default_external and/or Encoding.default_internal — Rails sets
27
- # the latter to UTF-8), failing on bytes with no mapping. Force binmode on
28
- # both ends.
29
43
  read_io.binmode
30
44
  write_io.binmode
45
+ [read_io, write_io]
46
+ end
31
47
 
32
- pid = ::Process.fork do
48
+ def fork_child(read_io, write_io, sandbox_dir, mutation, test_command)
49
+ ::Process.fork do
33
50
  ENV["TMPDIR"] = sandbox_dir
34
51
  read_io.close
35
52
  suppress_child_output
36
- @hooks.fire(:worker_process_start, mutation: mutation) if @hooks
53
+ @hooks.fire(:worker_process_start, mutation:) if @hooks
37
54
  result = execute_in_child(mutation, test_command)
38
55
  Marshal.dump(result, write_io)
39
56
  write_io.close
40
57
  exit!(result[:passed] ? 0 : 1)
41
58
  end
59
+ end
42
60
 
43
- write_io.close
44
- result = wait_for_result(pid, read_io, timeout)
45
- duration = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
46
-
47
- build_mutation_result(mutation, result, duration, parent_rss)
48
- ensure
49
- read_io&.close
50
- 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?
51
64
  ensure_reaped(pid)
52
- restore_original_source(mutation)
65
+ restore_original_source
53
66
  FileUtils.rm_rf(sandbox_dir) if sandbox_dir
54
67
  end
55
68
 
56
- private
57
-
58
- def restore_original_source(mutation) # rubocop:disable Lint/UnusedMethodArgument
69
+ def restore_original_source
59
70
  Evilution::TempDirTracker.cleanup_all
60
71
  end
61
72
 
@@ -88,7 +99,7 @@ class Evilution::Isolation::Fork
88
99
  if data.empty?
89
100
  { timeout: false, passed: false, error: "empty result from child" }
90
101
  else
91
- { timeout: false }.merge(Marshal.load(data)) # rubocop:disable Security/MarshalLoad
102
+ { timeout: false }.merge(Marshal.load(data))
92
103
  end
93
104
  else
94
105
  terminate_child(pid)
@@ -113,7 +124,7 @@ class Evilution::Isolation::Fork
113
124
  end
114
125
 
115
126
  def terminate_child(pid)
116
- ::Process.kill("TERM", pid) rescue nil # rubocop:disable Style/RescueModifier
127
+ Evilution::ProcessCleanup.safe_kill("TERM", pid)
117
128
  _, status = ::Process.waitpid2(pid, ::Process::WNOHANG)
118
129
  return if status
119
130
 
@@ -121,8 +132,8 @@ class Evilution::Isolation::Fork
121
132
  _, status = ::Process.waitpid2(pid, ::Process::WNOHANG)
122
133
  return if status
123
134
 
124
- ::Process.kill("KILL", pid) rescue nil # rubocop:disable Style/RescueModifier
125
- ::Process.wait(pid) rescue nil # rubocop:disable Style/RescueModifier
135
+ Evilution::ProcessCleanup.safe_kill("KILL", pid)
136
+ Evilution::ProcessCleanup.safe_wait(pid)
126
137
  end
127
138
 
128
139
  def classify_status(result)
@@ -143,11 +154,15 @@ class Evilution::Isolation::Fork
143
154
  status: status,
144
155
  duration: duration,
145
156
  test_command: result[:test_command],
146
- child_rss_kb: result[:child_rss_kb],
147
- parent_rss_kb: parent_rss_kb,
148
- error_message: result[:error],
149
- error_class: result[:error_class],
150
- error_backtrace: result[:error_backtrace]
157
+ memory: Evilution::Result::MemoryStats.from_fields(
158
+ child_rss_kb: result[:child_rss_kb],
159
+ parent_rss_kb: parent_rss_kb
160
+ ),
161
+ error: Evilution::Result::ErrorInfo.from_fields(
162
+ message: result[:error],
163
+ klass: result[:error_class],
164
+ backtrace: result[:error_backtrace]
165
+ )
151
166
  )
152
167
  end
153
168
  end
@@ -80,12 +80,16 @@ class Evilution::Isolation::InProcess
80
80
  status: status,
81
81
  duration: duration,
82
82
  test_command: result[:test_command],
83
- child_rss_kb: rss_after,
84
- memory_delta_kb: memory_delta_kb,
85
- parent_rss_kb: rss_before,
86
- error_message: result[:error],
87
- error_class: result[:error_class],
88
- error_backtrace: result[:error_backtrace]
83
+ memory: Evilution::Result::MemoryStats.from_fields(
84
+ child_rss_kb: rss_after,
85
+ memory_delta_kb: memory_delta_kb,
86
+ parent_rss_kb: rss_before
87
+ ),
88
+ error: Evilution::Result::ErrorInfo.from_fields(
89
+ message: result[:error],
90
+ klass: result[:error_class],
91
+ backtrace: result[:error_backtrace]
92
+ )
89
93
  )
90
94
  end
91
95
  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)
@@ -70,12 +70,17 @@ class Evilution::MCP::InfoTool < MCP::Tool
70
70
  )
71
71
 
72
72
  class << self
73
- # rubocop:disable Lint/UnusedMethodArgument
74
73
  def call(server_context:, action: nil, files: nil, target: nil, spec: nil, integration: nil, skip_config: nil)
75
74
  return ResponseFormatter.error("config_error", "action is required") unless action
76
75
  return ResponseFormatter.error("config_error", "unknown action: #{action}") unless ACTIONS.key?(action)
77
76
 
78
- 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
79
84
 
80
85
  ACTIONS[action].call(
81
86
  files: parsed_files, line_ranges: line_ranges, target: target, spec: spec,
@@ -84,7 +89,6 @@ class Evilution::MCP::InfoTool < MCP::Tool
84
89
  rescue Evilution::Error => e
85
90
  ResponseFormatter.error_for(e)
86
91
  end
87
- # rubocop:enable Lint/UnusedMethodArgument
88
92
  end
89
93
  end
90
94
 
@@ -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)
@@ -10,15 +10,19 @@ module Evilution::MCP::MutateTool::ProgressStreamer
10
10
 
11
11
  suggestion = Evilution::Reporter::Suggestion.new(suggest_tests: true, integration: integration)
12
12
  survivor_index = 0
13
+ disabled = false
13
14
 
14
15
  proc do |result|
15
16
  next unless result.survived?
17
+ next if disabled
16
18
 
17
19
  begin
18
20
  survivor_index += 1
19
21
  detail = build_suggestion_detail(result.mutation, suggestion)
20
22
  server_context.report_progress(survivor_index, message: ::JSON.generate(detail))
21
- rescue StandardError # rubocop:disable Lint/SuppressedException
23
+ rescue StandardError => e
24
+ warn "[evilution] progress stream disabled after error: #{e.class}: #{e.message}"
25
+ disabled = true
22
26
  end
23
27
  end
24
28
  end
@@ -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