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.
- checksums.yaml +4 -4
- data/.beads/interactions.jsonl +23 -0
- data/.rubocop_todo.yml +6 -0
- data/CHANGELOG.md +54 -0
- data/README.md +76 -3
- data/lib/evilution/baseline.rb +5 -4
- data/lib/evilution/cache.rb +2 -0
- data/lib/evilution/child_output.rb +24 -0
- data/lib/evilution/cli/commands/run.rb +9 -0
- data/lib/evilution/cli/commands/version.rb +2 -0
- data/lib/evilution/cli/parser/options_builder.rb +23 -2
- data/lib/evilution/compare/diff_extractor/evilution.rb +22 -0
- data/lib/evilution/compare/diff_extractor/mutant.rb +30 -0
- data/lib/evilution/compare/diff_extractor.rb +6 -0
- data/lib/evilution/compare/fingerprint.rb +15 -72
- data/lib/evilution/compare/line_normalizer.rb +72 -0
- data/lib/evilution/compare/normalizer.rb +17 -4
- data/lib/evilution/config/builders/spec_resolver.rb +15 -0
- data/lib/evilution/config/builders/spec_selector.rb +16 -0
- data/lib/evilution/config/builders.rb +4 -0
- data/lib/evilution/config/env_loader.rb +12 -0
- data/lib/evilution/config/file_loader.rb +22 -0
- data/lib/evilution/config/sources.rb +14 -0
- data/lib/evilution/config/validators/base.rb +37 -0
- data/lib/evilution/config/validators/example_targeting_cache.rb +37 -0
- data/lib/evilution/config/validators/example_targeting_fallback.rb +22 -0
- data/lib/evilution/config/validators/fail_fast.rb +11 -0
- data/lib/evilution/config/validators/hooks.rb +12 -0
- data/lib/evilution/config/validators/ignore_patterns.rb +16 -0
- data/lib/evilution/config/validators/integration.rb +11 -0
- data/lib/evilution/config/validators/isolation.rb +19 -0
- data/lib/evilution/config/validators/jobs.rb +9 -0
- data/lib/evilution/config/validators/preload.rb +13 -0
- data/lib/evilution/config/validators/profile.rb +11 -0
- data/lib/evilution/config/validators/spec_mappings.rb +56 -0
- data/lib/evilution/config/validators/spec_pattern.rb +12 -0
- data/lib/evilution/config/validators.rb +4 -0
- data/lib/evilution/config.rb +93 -266
- data/lib/evilution/feedback/detector.rb +15 -0
- data/lib/evilution/feedback/messages.rb +42 -0
- data/lib/evilution/feedback.rb +5 -0
- data/lib/evilution/integration/crash_detector.rb +2 -2
- data/lib/evilution/integration/loading/source_evaluator.rb +6 -2
- data/lib/evilution/integration/minitest_crash_detector.rb +2 -2
- data/lib/evilution/integration/rspec/baseline_runner.rb +16 -0
- data/lib/evilution/integration/rspec/crash_detector_lifecycle.rb +17 -0
- data/lib/evilution/integration/rspec/example_filter_applier.rb +21 -0
- data/lib/evilution/integration/rspec/framework_loader.rb +28 -0
- data/lib/evilution/integration/rspec/result_builder.rb +40 -0
- data/lib/evilution/integration/rspec/state_guard/example_groups_constants.rb +28 -0
- data/lib/evilution/integration/rspec/state_guard/internals.rb +19 -0
- data/lib/evilution/integration/rspec/state_guard/object_space_example_groups.rb +43 -0
- data/lib/evilution/integration/rspec/state_guard/reporter_arrays.rb +32 -0
- data/lib/evilution/integration/rspec/state_guard/world_example_groups.rb +20 -0
- data/lib/evilution/integration/rspec/state_guard/world_filtered_examples.rb +20 -0
- data/lib/evilution/integration/rspec/state_guard/world_sources_by_path.rb +20 -0
- data/lib/evilution/integration/rspec/state_guard.rb +40 -0
- data/lib/evilution/integration/rspec/test_file_resolver.rb +30 -0
- data/lib/evilution/integration/rspec/unresolved_spec_warner.rb +18 -0
- data/lib/evilution/integration/rspec.rb +61 -232
- data/lib/evilution/isolation/fork.rb +23 -13
- data/lib/evilution/isolation/in_process.rb +10 -6
- data/lib/evilution/mcp/info_tool/actions/base.rb +22 -0
- data/lib/evilution/mcp/info_tool/actions/environment.rb +42 -0
- data/lib/evilution/mcp/info_tool/actions/feedback.rb +16 -0
- data/lib/evilution/mcp/info_tool/actions/statuses.rb +10 -0
- data/lib/evilution/mcp/info_tool/actions/subjects.rb +47 -0
- data/lib/evilution/mcp/info_tool/actions/tests.rb +60 -0
- data/lib/evilution/mcp/info_tool/actions.rb +16 -0
- data/lib/evilution/mcp/info_tool/config_factory.rb +24 -0
- data/lib/evilution/mcp/info_tool/error_mapper.rb +15 -0
- data/lib/evilution/mcp/info_tool/request_parser.rb +34 -0
- data/lib/evilution/mcp/info_tool/response_formatter.rb +24 -0
- data/lib/evilution/mcp/info_tool/status_glossary.rb +75 -0
- data/lib/evilution/mcp/info_tool.rb +43 -263
- data/lib/evilution/mcp/mutate_tool/error_payload.rb +8 -1
- data/lib/evilution/mcp/mutate_tool/progress_streamer.rb +5 -1
- data/lib/evilution/mcp/mutate_tool/report_trimmer.rb +13 -1
- data/lib/evilution/mcp/mutate_tool.rb +5 -2
- data/lib/evilution/mcp/session_tool.rb +0 -2
- data/lib/evilution/mutation.rb +47 -27
- data/lib/evilution/mutator/base.rb +8 -8
- data/lib/evilution/mutator/operator/block_removal.rb +1 -1
- data/lib/evilution/mutator/operator/method_body_replacement.rb +18 -2
- data/lib/evilution/mutator/operator/predicate_to_nil.rb +20 -0
- data/lib/evilution/mutator/registry.rb +20 -0
- data/lib/evilution/parallel/work_queue/channel/frame.rb +25 -0
- data/lib/evilution/parallel/work_queue/channel.rb +23 -0
- data/lib/evilution/parallel/work_queue/collection_state.rb +14 -0
- data/lib/evilution/parallel/work_queue/dispatcher.rb +133 -0
- data/lib/evilution/parallel/work_queue/validators/optional_positive_int.rb +11 -0
- data/lib/evilution/parallel/work_queue/validators/optional_positive_number.rb +11 -0
- data/lib/evilution/parallel/work_queue/validators/positive_int.rb +11 -0
- data/lib/evilution/parallel/work_queue/validators.rb +6 -0
- data/lib/evilution/parallel/work_queue/worker/loop.rb +45 -0
- data/lib/evilution/parallel/work_queue/worker.rb +114 -0
- data/lib/evilution/parallel/work_queue/worker_stat.rb +17 -0
- data/lib/evilution/parallel/work_queue.rb +42 -327
- data/lib/evilution/process_cleanup.rb +19 -0
- data/lib/evilution/reporter/cli/item_formatters/coverage_gap.rb +18 -0
- data/lib/evilution/reporter/cli/item_formatters/disabled.rb +9 -0
- data/lib/evilution/reporter/cli/item_formatters/error.rb +14 -0
- data/lib/evilution/reporter/cli/item_formatters/result_location.rb +10 -0
- data/lib/evilution/reporter/cli/item_formatters.rb +6 -0
- data/lib/evilution/reporter/cli/line_formatters/duration.rb +9 -0
- data/lib/evilution/reporter/cli/line_formatters/efficiency.rb +18 -0
- data/lib/evilution/reporter/cli/line_formatters/feedback_footer.rb +13 -0
- data/lib/evilution/reporter/cli/line_formatters/header.rb +10 -0
- data/lib/evilution/reporter/cli/line_formatters/mutations.rb +16 -0
- data/lib/evilution/reporter/cli/line_formatters/peak_memory.rb +12 -0
- data/lib/evilution/reporter/cli/line_formatters/result_line.rb +20 -0
- data/lib/evilution/reporter/cli/line_formatters/score.rb +14 -0
- data/lib/evilution/reporter/cli/line_formatters/truncation_notice.rb +11 -0
- data/lib/evilution/reporter/cli/line_formatters.rb +6 -0
- data/lib/evilution/reporter/cli/metrics_block.rb +26 -0
- data/lib/evilution/reporter/cli/pct.rb +9 -0
- data/lib/evilution/reporter/cli/section.rb +13 -0
- data/lib/evilution/reporter/cli/section_renderer.rb +15 -0
- data/lib/evilution/reporter/cli/trailer.rb +22 -0
- data/lib/evilution/reporter/cli.rb +79 -162
- data/lib/evilution/reporter/html/baseline_keys.rb +1 -1
- data/lib/evilution/reporter/html/diff_formatter.rb +1 -1
- data/lib/evilution/reporter/html/escape.rb +1 -1
- data/lib/evilution/reporter/html/section.rb +1 -1
- data/lib/evilution/reporter/html/sections.rb +4 -2
- data/lib/evilution/reporter/html/stylesheet.rb +1 -1
- data/lib/evilution/reporter/html.rb +8 -3
- data/lib/evilution/reporter/suggestion/registry.rb +1 -5
- data/lib/evilution/reporter/suggestion/templates/generic.rb +1 -1
- data/lib/evilution/reporter/suggestion/templates/minitest.rb +349 -643
- data/lib/evilution/reporter/suggestion/templates/rspec.rb +351 -598
- data/lib/evilution/reporter/suggestion/templates.rb +6 -0
- data/lib/evilution/result/error_info.rb +20 -0
- data/lib/evilution/result/memory_stats.rb +20 -0
- data/lib/evilution/result/mutation_result.rb +30 -14
- data/lib/evilution/runner/baseline_runner.rb +1 -2
- data/lib/evilution/runner/diagnostics.rb +1 -2
- data/lib/evilution/runner/isolation_resolver.rb +10 -4
- data/lib/evilution/runner/mutation_executor/mutation_runner.rb +30 -0
- data/lib/evilution/runner/mutation_executor/neutralization_pipeline.rb +15 -0
- data/lib/evilution/runner/mutation_executor/neutralizer/baseline_failed.rb +39 -0
- data/lib/evilution/runner/mutation_executor/neutralizer/infra_error.rb +68 -0
- data/lib/evilution/runner/mutation_executor/neutralizer.rb +11 -0
- data/lib/evilution/runner/mutation_executor/result_cache.rb +67 -0
- data/lib/evilution/runner/mutation_executor/result_notifier.rb +46 -0
- data/lib/evilution/runner/mutation_executor/result_packer.rb +41 -0
- data/lib/evilution/runner/mutation_executor/strategy/parallel.rb +78 -0
- data/lib/evilution/runner/mutation_executor/strategy/sequential.rb +32 -0
- data/lib/evilution/runner/mutation_executor/strategy.rb +11 -0
- data/lib/evilution/runner/mutation_executor.rb +53 -292
- data/lib/evilution/runner/mutation_planner.rb +1 -2
- data/lib/evilution/runner/report_publisher.rb +1 -2
- data/lib/evilution/runner/subject_pipeline.rb +1 -2
- data/lib/evilution/runner.rb +53 -30
- data/lib/evilution/version.rb +1 -1
- data/lib/evilution.rb +1 -0
- data/script/memory_check +3 -1
- metadata +125 -3
- 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,
|
|
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:
|
|
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
|
|
127
|
-
return
|
|
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
|
-
|
|
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
|
-
|
|
327
|
-
|
|
328
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
data/lib/evilution/mutation.rb
CHANGED
|
@@ -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
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
-
@
|
|
18
|
-
@
|
|
19
|
-
@
|
|
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
|
-
@
|
|
45
|
-
|
|
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 @
|
|
96
|
+
return nil if @slice.nil?
|
|
71
97
|
|
|
72
|
-
original_lines = @
|
|
73
|
-
mutated_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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|