evilution 0.26.0 → 0.28.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 (159) hide show
  1. checksums.yaml +4 -4
  2. data/.beads/interactions.jsonl +23 -0
  3. data/.rubocop_todo.yml +6 -0
  4. data/CHANGELOG.md +54 -0
  5. data/README.md +76 -3
  6. data/lib/evilution/baseline.rb +5 -4
  7. data/lib/evilution/cache.rb +2 -0
  8. data/lib/evilution/child_output.rb +24 -0
  9. data/lib/evilution/cli/commands/run.rb +9 -0
  10. data/lib/evilution/cli/commands/version.rb +2 -0
  11. data/lib/evilution/cli/parser/options_builder.rb +23 -2
  12. data/lib/evilution/compare/diff_extractor/evilution.rb +22 -0
  13. data/lib/evilution/compare/diff_extractor/mutant.rb +30 -0
  14. data/lib/evilution/compare/diff_extractor.rb +6 -0
  15. data/lib/evilution/compare/fingerprint.rb +15 -72
  16. data/lib/evilution/compare/line_normalizer.rb +72 -0
  17. data/lib/evilution/compare/normalizer.rb +17 -4
  18. data/lib/evilution/config/builders/spec_resolver.rb +15 -0
  19. data/lib/evilution/config/builders/spec_selector.rb +16 -0
  20. data/lib/evilution/config/builders.rb +4 -0
  21. data/lib/evilution/config/env_loader.rb +12 -0
  22. data/lib/evilution/config/file_loader.rb +22 -0
  23. data/lib/evilution/config/sources.rb +14 -0
  24. data/lib/evilution/config/validators/base.rb +37 -0
  25. data/lib/evilution/config/validators/example_targeting_cache.rb +37 -0
  26. data/lib/evilution/config/validators/example_targeting_fallback.rb +22 -0
  27. data/lib/evilution/config/validators/fail_fast.rb +11 -0
  28. data/lib/evilution/config/validators/hooks.rb +12 -0
  29. data/lib/evilution/config/validators/ignore_patterns.rb +16 -0
  30. data/lib/evilution/config/validators/integration.rb +11 -0
  31. data/lib/evilution/config/validators/isolation.rb +19 -0
  32. data/lib/evilution/config/validators/jobs.rb +9 -0
  33. data/lib/evilution/config/validators/preload.rb +13 -0
  34. data/lib/evilution/config/validators/profile.rb +11 -0
  35. data/lib/evilution/config/validators/spec_mappings.rb +56 -0
  36. data/lib/evilution/config/validators/spec_pattern.rb +12 -0
  37. data/lib/evilution/config/validators.rb +4 -0
  38. data/lib/evilution/config.rb +93 -266
  39. data/lib/evilution/feedback/detector.rb +15 -0
  40. data/lib/evilution/feedback/messages.rb +42 -0
  41. data/lib/evilution/feedback.rb +5 -0
  42. data/lib/evilution/integration/crash_detector.rb +2 -2
  43. data/lib/evilution/integration/loading/source_evaluator.rb +6 -2
  44. data/lib/evilution/integration/minitest_crash_detector.rb +2 -2
  45. data/lib/evilution/integration/rspec/baseline_runner.rb +16 -0
  46. data/lib/evilution/integration/rspec/crash_detector_lifecycle.rb +17 -0
  47. data/lib/evilution/integration/rspec/example_filter_applier.rb +21 -0
  48. data/lib/evilution/integration/rspec/framework_loader.rb +28 -0
  49. data/lib/evilution/integration/rspec/result_builder.rb +40 -0
  50. data/lib/evilution/integration/rspec/state_guard/example_groups_constants.rb +28 -0
  51. data/lib/evilution/integration/rspec/state_guard/internals.rb +19 -0
  52. data/lib/evilution/integration/rspec/state_guard/object_space_example_groups.rb +43 -0
  53. data/lib/evilution/integration/rspec/state_guard/reporter_arrays.rb +32 -0
  54. data/lib/evilution/integration/rspec/state_guard/world_example_groups.rb +20 -0
  55. data/lib/evilution/integration/rspec/state_guard/world_filtered_examples.rb +20 -0
  56. data/lib/evilution/integration/rspec/state_guard/world_sources_by_path.rb +20 -0
  57. data/lib/evilution/integration/rspec/state_guard.rb +40 -0
  58. data/lib/evilution/integration/rspec/test_file_resolver.rb +30 -0
  59. data/lib/evilution/integration/rspec/unresolved_spec_warner.rb +18 -0
  60. data/lib/evilution/integration/rspec.rb +61 -232
  61. data/lib/evilution/isolation/fork.rb +23 -13
  62. data/lib/evilution/isolation/in_process.rb +10 -6
  63. data/lib/evilution/mcp/info_tool/actions/base.rb +22 -0
  64. data/lib/evilution/mcp/info_tool/actions/environment.rb +42 -0
  65. data/lib/evilution/mcp/info_tool/actions/feedback.rb +16 -0
  66. data/lib/evilution/mcp/info_tool/actions/statuses.rb +10 -0
  67. data/lib/evilution/mcp/info_tool/actions/subjects.rb +47 -0
  68. data/lib/evilution/mcp/info_tool/actions/tests.rb +60 -0
  69. data/lib/evilution/mcp/info_tool/actions.rb +16 -0
  70. data/lib/evilution/mcp/info_tool/config_factory.rb +24 -0
  71. data/lib/evilution/mcp/info_tool/error_mapper.rb +15 -0
  72. data/lib/evilution/mcp/info_tool/request_parser.rb +34 -0
  73. data/lib/evilution/mcp/info_tool/response_formatter.rb +24 -0
  74. data/lib/evilution/mcp/info_tool/status_glossary.rb +75 -0
  75. data/lib/evilution/mcp/info_tool.rb +43 -263
  76. data/lib/evilution/mcp/mutate_tool/error_payload.rb +8 -1
  77. data/lib/evilution/mcp/mutate_tool/progress_streamer.rb +5 -1
  78. data/lib/evilution/mcp/mutate_tool/report_trimmer.rb +13 -1
  79. data/lib/evilution/mcp/mutate_tool.rb +5 -2
  80. data/lib/evilution/mcp/session_tool.rb +0 -2
  81. data/lib/evilution/mutation.rb +47 -27
  82. data/lib/evilution/mutator/base.rb +8 -8
  83. data/lib/evilution/mutator/operator/block_removal.rb +1 -1
  84. data/lib/evilution/mutator/operator/method_body_replacement.rb +18 -2
  85. data/lib/evilution/mutator/operator/predicate_to_nil.rb +20 -0
  86. data/lib/evilution/mutator/registry.rb +20 -0
  87. data/lib/evilution/parallel/work_queue/channel/frame.rb +25 -0
  88. data/lib/evilution/parallel/work_queue/channel.rb +23 -0
  89. data/lib/evilution/parallel/work_queue/collection_state.rb +14 -0
  90. data/lib/evilution/parallel/work_queue/dispatcher.rb +133 -0
  91. data/lib/evilution/parallel/work_queue/validators/optional_positive_int.rb +11 -0
  92. data/lib/evilution/parallel/work_queue/validators/optional_positive_number.rb +11 -0
  93. data/lib/evilution/parallel/work_queue/validators/positive_int.rb +11 -0
  94. data/lib/evilution/parallel/work_queue/validators.rb +6 -0
  95. data/lib/evilution/parallel/work_queue/worker/loop.rb +45 -0
  96. data/lib/evilution/parallel/work_queue/worker.rb +114 -0
  97. data/lib/evilution/parallel/work_queue/worker_stat.rb +17 -0
  98. data/lib/evilution/parallel/work_queue.rb +42 -327
  99. data/lib/evilution/process_cleanup.rb +19 -0
  100. data/lib/evilution/reporter/cli/item_formatters/coverage_gap.rb +18 -0
  101. data/lib/evilution/reporter/cli/item_formatters/disabled.rb +9 -0
  102. data/lib/evilution/reporter/cli/item_formatters/error.rb +14 -0
  103. data/lib/evilution/reporter/cli/item_formatters/result_location.rb +10 -0
  104. data/lib/evilution/reporter/cli/item_formatters.rb +6 -0
  105. data/lib/evilution/reporter/cli/line_formatters/duration.rb +9 -0
  106. data/lib/evilution/reporter/cli/line_formatters/efficiency.rb +18 -0
  107. data/lib/evilution/reporter/cli/line_formatters/feedback_footer.rb +13 -0
  108. data/lib/evilution/reporter/cli/line_formatters/header.rb +10 -0
  109. data/lib/evilution/reporter/cli/line_formatters/mutations.rb +16 -0
  110. data/lib/evilution/reporter/cli/line_formatters/peak_memory.rb +12 -0
  111. data/lib/evilution/reporter/cli/line_formatters/result_line.rb +20 -0
  112. data/lib/evilution/reporter/cli/line_formatters/score.rb +14 -0
  113. data/lib/evilution/reporter/cli/line_formatters/truncation_notice.rb +11 -0
  114. data/lib/evilution/reporter/cli/line_formatters.rb +6 -0
  115. data/lib/evilution/reporter/cli/metrics_block.rb +26 -0
  116. data/lib/evilution/reporter/cli/pct.rb +9 -0
  117. data/lib/evilution/reporter/cli/section.rb +13 -0
  118. data/lib/evilution/reporter/cli/section_renderer.rb +15 -0
  119. data/lib/evilution/reporter/cli/trailer.rb +22 -0
  120. data/lib/evilution/reporter/cli.rb +79 -162
  121. data/lib/evilution/reporter/html/baseline_keys.rb +1 -1
  122. data/lib/evilution/reporter/html/diff_formatter.rb +1 -1
  123. data/lib/evilution/reporter/html/escape.rb +1 -1
  124. data/lib/evilution/reporter/html/section.rb +1 -1
  125. data/lib/evilution/reporter/html/sections.rb +4 -2
  126. data/lib/evilution/reporter/html/stylesheet.rb +1 -1
  127. data/lib/evilution/reporter/html.rb +8 -3
  128. data/lib/evilution/reporter/suggestion/registry.rb +1 -5
  129. data/lib/evilution/reporter/suggestion/templates/generic.rb +1 -1
  130. data/lib/evilution/reporter/suggestion/templates/minitest.rb +349 -643
  131. data/lib/evilution/reporter/suggestion/templates/rspec.rb +351 -598
  132. data/lib/evilution/reporter/suggestion/templates.rb +6 -0
  133. data/lib/evilution/result/error_info.rb +20 -0
  134. data/lib/evilution/result/memory_stats.rb +20 -0
  135. data/lib/evilution/result/mutation_result.rb +30 -14
  136. data/lib/evilution/runner/baseline_runner.rb +1 -2
  137. data/lib/evilution/runner/diagnostics.rb +1 -2
  138. data/lib/evilution/runner/isolation_resolver.rb +10 -4
  139. data/lib/evilution/runner/mutation_executor/mutation_runner.rb +30 -0
  140. data/lib/evilution/runner/mutation_executor/neutralization_pipeline.rb +15 -0
  141. data/lib/evilution/runner/mutation_executor/neutralizer/baseline_failed.rb +39 -0
  142. data/lib/evilution/runner/mutation_executor/neutralizer/infra_error.rb +68 -0
  143. data/lib/evilution/runner/mutation_executor/neutralizer.rb +11 -0
  144. data/lib/evilution/runner/mutation_executor/result_cache.rb +67 -0
  145. data/lib/evilution/runner/mutation_executor/result_notifier.rb +46 -0
  146. data/lib/evilution/runner/mutation_executor/result_packer.rb +41 -0
  147. data/lib/evilution/runner/mutation_executor/strategy/parallel.rb +78 -0
  148. data/lib/evilution/runner/mutation_executor/strategy/sequential.rb +32 -0
  149. data/lib/evilution/runner/mutation_executor/strategy.rb +11 -0
  150. data/lib/evilution/runner/mutation_executor.rb +53 -292
  151. data/lib/evilution/runner/mutation_planner.rb +1 -2
  152. data/lib/evilution/runner/report_publisher.rb +1 -2
  153. data/lib/evilution/runner/subject_pipeline.rb +1 -2
  154. data/lib/evilution/runner.rb +53 -30
  155. data/lib/evilution/version.rb +1 -1
  156. data/lib/evilution.rb +1 -0
  157. data/script/memory_check +3 -1
  158. metadata +125 -3
  159. data/lib/evilution/reporter/html/namespace.rb +0 -11
@@ -13,25 +13,30 @@ require_relative "../version"
13
13
  require_relative "../mcp"
14
14
 
15
15
  class Evilution::MCP::InfoTool < MCP::Tool
16
+ VALID_ACTIONS = %w[subjects tests environment statuses feedback].freeze
17
+
16
18
  tool_name "evilution-info"
17
19
  description "Discover what evilution sees before running any mutations. " \
18
- "One tool, four actions: " \
20
+ "One tool, five actions: " \
19
21
  "'subjects' lists every mutatable method in the target files with its file, line, and mutation count; " \
20
22
  "'tests' resolves which spec/test files cover the given sources (so you pick the right --spec before mutating); " \
21
23
  "'environment' dumps the effective config (version, ruby, config file, timeout, " \
22
24
  "integration, isolation, and every other setting); " \
23
25
  "'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. " \
26
+ "per-status meaning and scoring semantics so agents can triage results without guessing; " \
27
+ "'feedback' returns the public discussion URL plus consent and privacy guidance for posting " \
28
+ "feedback on errors, usage problems, friction, or missing capabilities. " \
25
29
  "Use this instead of shelling out to 'evilution subjects', 'evilution tests list', or 'evilution environment show' — " \
26
30
  "the response is structured JSON so you can plan the next mutation run without parsing CLI text."
27
31
  input_schema(
28
32
  properties: {
29
33
  action: {
30
34
  type: "string",
31
- enum: %w[subjects tests environment statuses],
35
+ enum: VALID_ACTIONS,
32
36
  description: "Which discovery operation to perform. " \
33
37
  "'subjects' lists mutatable methods; 'tests' resolves specs for sources; " \
34
- "'environment' dumps effective config; 'statuses' returns the result-status glossary."
38
+ "'environment' dumps effective config; 'statuses' returns the result-status glossary; " \
39
+ "'feedback' returns the discussion URL and consent/privacy guidance."
35
40
  },
36
41
  files: {
37
42
  type: "array",
@@ -64,270 +69,45 @@ class Evilution::MCP::InfoTool < MCP::Tool
64
69
  required: ["action"]
65
70
  )
66
71
 
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
122
-
123
72
  class << self
124
- # rubocop:disable Lint/UnusedMethodArgument
125
73
  def call(server_context:, action: nil, files: nil, target: nil, spec: nil, integration: nil, skip_config: nil)
126
- return error_response("config_error", "action is required") unless action
127
- return error_response("config_error", "unknown action: #{action}") unless VALID_ACTIONS.include?(action)
128
-
129
- parsed_files, line_ranges = parse_files(Array(files)) if files
130
-
131
- case action
132
- when "subjects"
133
- subjects_action(files: parsed_files, line_ranges: line_ranges, target: target,
134
- integration: integration, skip_config: skip_config)
135
- when "tests"
136
- tests_action(files: parsed_files, spec: spec, integration: integration, skip_config: skip_config)
137
- when "environment"
138
- environment_action
139
- when "statuses"
140
- statuses_action
141
- end
142
- rescue Evilution::Error => e
143
- error_response_for(e)
144
- end
145
- # rubocop:enable Lint/UnusedMethodArgument
146
-
147
- private
148
-
149
- def parse_files(raw_files)
150
- files = []
151
- ranges = {}
152
-
153
- raw_files.each do |arg|
154
- file, range_str = arg.split(":", 2)
155
- files << file
156
- ranges[file] = parse_line_range(range_str) if range_str
157
- end
158
-
159
- [files, ranges]
160
- end
161
-
162
- def parse_line_range(str)
163
- if str.include?("-")
164
- start_str, end_str = str.split("-", 2)
165
- start_line = Integer(start_str)
166
- end_line = end_str.empty? ? Float::INFINITY : Integer(end_str)
167
- start_line..end_line
168
- else
169
- line = Integer(str)
170
- line..line
171
- end
172
- rescue ArgumentError, TypeError
173
- raise Evilution::ParseError, "invalid line range: #{str.inspect}"
174
- end
175
-
176
- def subjects_action(files:, line_ranges:, target:, integration:, skip_config:)
177
- return error_response("config_error", "files is required") if files.nil? || files.empty?
178
-
179
- config = build_subjects_config(files: files, line_ranges: line_ranges,
180
- target: target, integration: integration, skip_config: skip_config)
181
- runner = Evilution::Runner.new(config: config)
182
- subjects = runner.parse_and_filter_subjects
183
-
184
- registry = Evilution::Mutator::Registry.default
185
- filter = build_subject_filter(config)
186
- operator_options = { skip_heredoc_literals: config.skip_heredoc_literals? }
187
-
188
- entries = subjects.map do |subj|
189
- count = registry.mutations_for(subj, filter: filter, operator_options: operator_options).length
190
- { "name" => subj.name, "file" => subj.file_path, "line" => subj.line_number, "mutations" => count }
191
- ensure
192
- subj.release_node!
193
- end
194
-
195
- success_response(
196
- "subjects" => entries,
197
- "total_subjects" => entries.length,
198
- "total_mutations" => entries.sum { |e| e["mutations"] }
199
- )
200
- end
201
-
202
- def tests_action(files:, spec:, integration:, skip_config:)
203
- return error_response("config_error", "files is required") if files.nil? || files.empty?
204
-
205
- config = build_tests_config(files: files, spec: spec, integration: integration, skip_config: skip_config)
206
- return explicit_specs_response(files, config.spec_files) if config.spec_files.any?
207
-
208
- resolver = resolver_for_integration(config.integration)
209
- resolved, unresolved = resolve_specs(files, resolver)
210
- success_response(
211
- "specs" => resolved,
212
- "unresolved" => unresolved,
213
- "total_sources" => files.length,
214
- "total_specs" => resolved.map { |r| r["spec"] }.uniq.length
215
- )
216
- end
217
-
218
- def build_subjects_config(files:, line_ranges:, target:, integration:, skip_config:)
219
- opts = { target_files: files, line_ranges: line_ranges || {} }
220
- opts[:skip_config_file] = true if skip_config
221
- opts[:target] = target if target
222
- opts[:integration] = integration if integration
223
- Evilution::Config.new(**opts)
224
- end
225
-
226
- def build_tests_config(files:, spec:, integration:, skip_config:)
227
- opts = { target_files: files }
228
- opts[:skip_config_file] = true if skip_config
229
- opts[:spec_files] = spec if spec
230
- opts[:integration] = integration if integration
231
- Evilution::Config.new(**opts)
232
- end
233
-
234
- def resolver_for_integration(integration)
235
- integration_class = Evilution::Runner::INTEGRATIONS[integration.to_sym]
236
- return Evilution::SpecResolver.new unless integration_class
237
-
238
- integration_class.baseline_options[:spec_resolver] || Evilution::SpecResolver.new
239
- end
240
-
241
- def explicit_specs_response(files, spec_files)
242
- success_response(
243
- "specs" => spec_files.map { |f| { "source" => nil, "spec" => f } },
244
- "unresolved" => [],
245
- "total_sources" => files.length,
246
- "total_specs" => spec_files.length
247
- )
248
- end
249
-
250
- def resolve_specs(files, resolver)
251
- resolved = []
252
- unresolved = []
253
- files.each do |source|
254
- found = resolver.call(source)
255
- if found
256
- resolved << { "source" => source, "spec" => found }
257
- else
258
- unresolved << source
259
- end
260
- end
261
- [resolved, unresolved]
262
- end
263
-
264
- def environment_action
265
- config = Evilution::Config.new(skip_config_file: false)
266
- config_file = Evilution::Config::CONFIG_FILES.find { |path| File.exist?(path) }
267
-
268
- success_response(
269
- "version" => Evilution::VERSION,
270
- "ruby" => RUBY_VERSION,
271
- "config_file" => config_file,
272
- "settings" => environment_settings(config)
273
- )
274
- end
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
-
288
- def error_response_for(error)
289
- type = case error
290
- when Evilution::ConfigError then "config_error"
291
- when Evilution::ParseError then "parse_error"
292
- else "runtime_error"
293
- end
294
- error_response(type, error.message)
295
- end
296
-
297
- def environment_settings(config)
298
- {
299
- "timeout" => config.timeout,
300
- "format" => config.format,
301
- "integration" => config.integration,
302
- "jobs" => config.jobs,
303
- "isolation" => config.isolation,
304
- "baseline" => config.baseline,
305
- "incremental" => config.incremental,
306
- "fail_fast" => config.fail_fast,
307
- "min_score" => config.min_score,
308
- "suggest_tests" => config.suggest_tests,
309
- "save_session" => config.save_session,
310
- "target" => config.target,
311
- "skip_heredoc_literals" => config.skip_heredoc_literals,
312
- "ignore_patterns" => config.ignore_patterns
313
- }
314
- end
315
-
316
- def build_subject_filter(config)
317
- return nil if config.ignore_patterns.empty?
318
-
319
- Evilution::AST::Pattern::Filter.new(config.ignore_patterns)
320
- end
74
+ return ResponseFormatter.error("config_error", "action is required") unless action
75
+ return ResponseFormatter.error("config_error", "unknown action: #{action}") unless ACTIONS.key?(action)
321
76
 
322
- def success_response(payload)
323
- ::MCP::Tool::Response.new([{ type: "text", text: ::JSON.generate(payload) }])
324
- end
77
+ parsed_files, line_ranges = RequestParser.parse_files(Array(files)) if files
325
78
 
326
- def error_response(type, message)
327
- ::MCP::Tool::Response.new(
328
- [{ type: "text", text: ::JSON.generate({ error: { type: type, message: message } }) }],
329
- error: true
79
+ ACTIONS[action].call(
80
+ files: parsed_files, line_ranges: line_ranges, target: target, spec: spec,
81
+ integration: integration, skip_config: skip_config
330
82
  )
83
+ rescue Evilution::Error => e
84
+ ResponseFormatter.error_for(e)
331
85
  end
332
86
  end
333
87
  end
88
+
89
+ require_relative "info_tool/request_parser"
90
+ require_relative "info_tool/error_mapper"
91
+ require_relative "info_tool/response_formatter"
92
+ require_relative "info_tool/status_glossary"
93
+ require_relative "info_tool/config_factory"
94
+ require_relative "info_tool/actions"
95
+ require_relative "info_tool/actions/base"
96
+ require_relative "info_tool/actions/subjects"
97
+ require_relative "info_tool/actions/tests"
98
+ require_relative "info_tool/actions/environment"
99
+ require_relative "info_tool/actions/statuses"
100
+ require_relative "info_tool/actions/feedback"
101
+
102
+ Evilution::MCP::InfoTool.const_set(:ACTIONS, {
103
+ "subjects" => Evilution::MCP::InfoTool::Actions::Subjects,
104
+ "tests" => Evilution::MCP::InfoTool::Actions::Tests,
105
+ "environment" => Evilution::MCP::InfoTool::Actions::Environment,
106
+ "statuses" => Evilution::MCP::InfoTool::Actions::Statuses,
107
+ "feedback" => Evilution::MCP::InfoTool::Actions::Feedback
108
+ }.freeze)
109
+ Evilution::MCP::InfoTool.send(:private_constant, :ACTIONS)
110
+
111
+ unless Evilution::MCP::InfoTool.send(:const_get, :ACTIONS).keys == Evilution::MCP::InfoTool::VALID_ACTIONS
112
+ raise "InfoTool action drift: ACTIONS keys do not match VALID_ACTIONS"
113
+ end
@@ -1,6 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "../mutate_tool"
4
+ require_relative "../../feedback"
5
+ require_relative "../../feedback/messages"
4
6
 
5
7
  module Evilution::MCP::MutateTool::ErrorPayload
6
8
  def self.build(error)
@@ -12,6 +14,11 @@ module Evilution::MCP::MutateTool::ErrorPayload
12
14
 
13
15
  payload = { type: type, message: error.message }
14
16
  payload[:file] = error.file if error.file
15
- { error: payload }
17
+
18
+ {
19
+ error: payload,
20
+ feedback_url: Evilution::Feedback::DISCUSSION_URL,
21
+ feedback_hint: Evilution::Feedback::Messages.mcp_hint
22
+ }
16
23
  end
17
24
  end
@@ -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
@@ -2,13 +2,16 @@
2
2
 
3
3
  require "json"
4
4
  require_relative "../mutate_tool"
5
+ require_relative "../../feedback"
6
+ require_relative "../../feedback/detector"
7
+ require_relative "../../feedback/messages"
5
8
 
6
9
  module Evilution::MCP::MutateTool::ReportTrimmer
7
10
  MINIMAL_KEYS = %w[summary survived].freeze
8
11
  FULL_DIFF_STRIP_KEYS = %w[killed neutral equivalent unresolved unparseable].freeze
9
12
  SUMMARY_DROP_KEYS = %w[killed neutral equivalent unparseable].freeze
10
13
 
11
- def self.call(json_string, verbosity:, survived_results:, config:, enricher:)
14
+ def self.call(json_string, verbosity:, survived_results:, config:, enricher:, summary: nil)
12
15
  data = ::JSON.parse(json_string)
13
16
  case verbosity
14
17
  when "full"
@@ -19,6 +22,7 @@ module Evilution::MCP::MutateTool::ReportTrimmer
19
22
  data.keep_if { |key, _| MINIMAL_KEYS.include?(key) }
20
23
  end
21
24
  enricher.call(data, survived_results, config)
25
+ embed_feedback(data, summary) unless verbosity == "minimal"
22
26
  ::JSON.generate(data)
23
27
  end
24
28
 
@@ -28,4 +32,12 @@ module Evilution::MCP::MutateTool::ReportTrimmer
28
32
  data[key].each { |entry| entry.delete("diff") }
29
33
  end
30
34
  private_class_method :strip_diffs
35
+
36
+ def self.embed_feedback(data, summary)
37
+ return unless Evilution::Feedback::Detector.friction?(summary)
38
+
39
+ data["feedback_url"] = Evilution::Feedback::DISCUSSION_URL
40
+ data["feedback_hint"] = Evilution::Feedback::Messages.mcp_hint
41
+ end
42
+ private_class_method :embed_feedback
31
43
  end
@@ -26,7 +26,9 @@ class Evilution::MCP::MutateTool < MCP::Tool
26
26
  "'subject' (Class#method), resolved 'spec_file', and a concrete 'next_step' hint — " \
27
27
  "so the agent can jump straight to writing the missing test. " \
28
28
  "Prefer this over shelling out to 'evilution' — the response is machine-readable " \
29
- "and already trimmed for survived-mutant triage."
29
+ "and already trimmed for survived-mutant triage. " \
30
+ "Hitting errors, friction, or missing capabilities? See evilution-info action=feedback for the " \
31
+ "public feedback channel — ask the user before posting anything."
30
32
  input_schema(
31
33
  properties: {
32
34
  files: {
@@ -126,7 +128,8 @@ class Evilution::MCP::MutateTool < MCP::Tool
126
128
  verbosity: normalized_verbosity,
127
129
  survived_results: summary.survived_results,
128
130
  config: config,
129
- enricher: Evilution::MCP::MutateTool::SurvivedEnricher
131
+ enricher: Evilution::MCP::MutateTool::SurvivedEnricher,
132
+ summary: summary
130
133
  )
131
134
 
132
135
  ::MCP::Tool::Response.new([{ type: "text", text: compact }])
@@ -51,7 +51,6 @@ class Evilution::MCP::SessionTool < MCP::Tool
51
51
  VALID_ACTIONS = %w[list show diff].freeze
52
52
 
53
53
  class << self
54
- # rubocop:disable Lint/UnusedMethodArgument
55
54
  def call(server_context:, action: nil, results_dir: nil, limit: nil, path: nil, base: nil, head: nil)
56
55
  return error_response("config_error", "action is required") unless action
57
56
  return error_response("config_error", "unknown action: #{action}") unless VALID_ACTIONS.include?(action)
@@ -62,7 +61,6 @@ class Evilution::MCP::SessionTool < MCP::Tool
62
61
  when "diff" then diff_action(base: base, head: head, results_dir: results_dir)
63
62
  end
64
63
  end
65
- # rubocop:enable Lint/UnusedMethodArgument
66
64
 
67
65
  private
68
66
 
@@ -1,30 +1,53 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "diff/lcs"
4
+ require_relative "../evilution"
4
5
 
5
6
  class Evilution::Mutation
6
- attr_reader :subject, :operator_name, :original_source,
7
- :mutated_source, :original_slice, :mutated_slice,
8
- :file_path, :line, :column, :parse_status
9
-
10
- # rubocop:disable Metrics/ParameterLists
11
- def initialize(subject:, operator_name:, original_source:, mutated_source:,
12
- file_path:, line:, column: 0, original_slice: nil, mutated_slice: nil,
13
- parse_status: :ok)
14
- # rubocop:enable Metrics/ParameterLists
7
+ Sources = Data.define(:original, :mutated)
8
+ Slice = Data.define(:original, :mutated)
9
+ Location = Data.define(:file_path, :line, :column)
10
+
11
+ attr_reader :subject, :operator_name, :parse_status, :location
12
+
13
+ def initialize(subject:, operator_name:, sources:, location:, slice: nil, parse_status: :ok)
15
14
  @subject = subject
16
15
  @operator_name = operator_name
17
- @original_source = original_source
18
- @mutated_source = mutated_source
19
- @original_slice = original_slice
20
- @mutated_slice = mutated_slice
21
- @file_path = file_path
22
- @line = line
23
- @column = column
16
+ @sources = sources
17
+ @location = location
18
+ @slice = slice
24
19
  @parse_status = parse_status
25
20
  @diff = nil
26
21
  end
27
22
 
23
+ def original_source
24
+ @sources&.original
25
+ end
26
+
27
+ def mutated_source
28
+ @sources&.mutated
29
+ end
30
+
31
+ def original_slice
32
+ @slice&.original
33
+ end
34
+
35
+ def mutated_slice
36
+ @slice&.mutated
37
+ end
38
+
39
+ def file_path
40
+ @location.file_path
41
+ end
42
+
43
+ def line
44
+ @location.line
45
+ end
46
+
47
+ def column
48
+ @location.column
49
+ end
50
+
28
51
  def unparseable?
29
52
  @parse_status == :unparseable
30
53
  end
@@ -41,8 +64,11 @@ class Evilution::Mutation
41
64
 
42
65
  def strip_sources!
43
66
  diff # ensure diff is cached before clearing sources
44
- @original_source = nil
45
- @mutated_source = nil
67
+ @sources = nil
68
+ end
69
+
70
+ def to_s
71
+ "#{operator_name}: #{file_path}:#{line}"
46
72
  end
47
73
 
48
74
  private
@@ -67,10 +93,10 @@ class Evilution::Mutation
67
93
  end
68
94
 
69
95
  def compute_unified_diff
70
- return nil if @original_slice.nil? || @mutated_slice.nil?
96
+ return nil if @slice.nil?
71
97
 
72
- original_lines = @original_slice.lines
73
- mutated_lines = @mutated_slice.lines
98
+ original_lines = @slice.original.lines
99
+ mutated_lines = @slice.mutated.lines
74
100
  body = ::Diff::LCS.sdiff(original_lines, mutated_lines).map { |c| format_sdiff_change(c) }.join("\n")
75
101
  [
76
102
  "--- a/#{file_path}",
@@ -88,10 +114,4 @@ class Evilution::Mutation
88
114
  when "!" then "-#{change.old_element.chomp}\n+#{change.new_element.chomp}"
89
115
  end
90
116
  end
91
-
92
- public
93
-
94
- def to_s
95
- "#{operator_name}: #{file_path}:#{line}"
96
- end
97
117
  end
@@ -45,14 +45,14 @@ class Evilution::Mutator::Base < Prism::Visitor
45
45
  @mutations << Evilution::Mutation.new(
46
46
  subject: @subject,
47
47
  operator_name: self.class.operator_name,
48
- original_source: @file_source,
49
- mutated_source: mutated_source,
50
- original_slice: original_slice,
51
- mutated_slice: mutated_slice,
52
- parse_status: surgery.status,
53
- file_path: @subject.file_path,
54
- line: node.location.start_line,
55
- column: node.location.start_column
48
+ sources: Evilution::Mutation::Sources.new(original: @file_source, mutated: mutated_source),
49
+ slice: Evilution::Mutation::Slice.new(original: original_slice, mutated: mutated_slice),
50
+ location: Evilution::Mutation::Location.new(
51
+ file_path: @subject.file_path,
52
+ line: node.location.start_line,
53
+ column: node.location.start_column
54
+ ),
55
+ parse_status: surgery.status
56
56
  )
57
57
  end
58
58
 
@@ -4,7 +4,7 @@ require_relative "../operator"
4
4
 
5
5
  class Evilution::Mutator::Operator::BlockRemoval < Evilution::Mutator::Base
6
6
  def visit_call_node(node)
7
- if node.block
7
+ if node.block && !node.block.is_a?(Prism::BlockArgumentNode)
8
8
  block_node = node.block
9
9
  call_end = block_node.location.start_offset
10
10
  call_start = node.location.start_offset
@@ -3,11 +3,15 @@
3
3
  require_relative "../operator"
4
4
 
5
5
  class Evilution::Mutator::Operator::MethodBodyReplacement < Evilution::Mutator::Base
6
- REPLACEMENTS = %w[nil self super].freeze
6
+ ALWAYS_SAFE_REPLACEMENTS = %w[nil self].freeze
7
+ SUPER_REPLACEMENT = "super"
7
8
 
8
9
  def visit_def_node(node)
9
10
  if node.body
10
- REPLACEMENTS.each do |replacement|
11
+ replacements = ALWAYS_SAFE_REPLACEMENTS.dup
12
+ replacements << SUPER_REPLACEMENT if body_calls_super?(node.body)
13
+
14
+ replacements.each do |replacement|
11
15
  add_mutation(
12
16
  offset: node.body.location.start_offset,
13
17
  length: node.body.location.length,
@@ -19,4 +23,16 @@ class Evilution::Mutator::Operator::MethodBodyReplacement < Evilution::Mutator::
19
23
 
20
24
  super
21
25
  end
26
+
27
+ private
28
+
29
+ # The bare-super replacement raises NoMethodError at runtime when the enclosing
30
+ # class has no parent implementation of the method. We emit it only when the
31
+ # original body already calls super, using that as a heuristic that a super
32
+ # target is intended in this context.
33
+ def body_calls_super?(node)
34
+ return true if node.is_a?(Prism::SuperNode) || node.is_a?(Prism::ForwardingSuperNode)
35
+
36
+ node.child_nodes.any? { |child| child && body_calls_super?(child) }
37
+ end
22
38
  end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../operator"
4
+
5
+ class Evilution::Mutator::Operator::PredicateToNil < Evilution::Mutator::Base
6
+ def visit_call_node(node)
7
+ if node.name.to_s.end_with?("?")
8
+ loc = node.location
9
+
10
+ add_mutation(
11
+ offset: loc.start_offset,
12
+ length: loc.length,
13
+ replacement: "nil",
14
+ node: node
15
+ )
16
+ end
17
+
18
+ super
19
+ end
20
+ end