evilution 0.24.0 → 0.25.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 +205 -0
- data/CHANGELOG.md +35 -0
- data/README.md +80 -4
- data/exe/evil +6 -0
- data/lib/evilution/ast/source_surgeon.rb +15 -1
- data/lib/evilution/cli/commands/compare.rb +68 -0
- data/lib/evilution/cli/parser/command_extractor.rb +2 -1
- data/lib/evilution/cli/parser/options_builder.rb +21 -1
- data/lib/evilution/cli/printers/compare.rb +159 -0
- data/lib/evilution/cli.rb +1 -0
- data/lib/evilution/compare/categorizer.rb +109 -0
- data/lib/evilution/compare/detector.rb +21 -0
- data/lib/evilution/compare/fingerprint.rb +83 -0
- data/lib/evilution/compare/normalizer.rb +106 -0
- data/lib/evilution/compare/record.rb +16 -0
- data/lib/evilution/compare.rb +15 -0
- data/lib/evilution/config.rb +165 -3
- data/lib/evilution/example_filter.rb +143 -0
- data/lib/evilution/integration/crash_detector.rb +5 -2
- data/lib/evilution/integration/minitest.rb +10 -5
- data/lib/evilution/integration/minitest_crash_detector.rb +5 -2
- data/lib/evilution/integration/rspec.rb +82 -7
- data/lib/evilution/isolation/fork.rb +25 -0
- data/lib/evilution/mcp/info_tool.rb +77 -5
- data/lib/evilution/mcp/mutate_tool/config_builder.rb +20 -0
- data/lib/evilution/mcp/mutate_tool/error_payload.rb +17 -0
- data/lib/evilution/mcp/mutate_tool/option_parser.rb +54 -0
- data/lib/evilution/mcp/mutate_tool/progress_streamer.rb +37 -0
- data/lib/evilution/mcp/mutate_tool/report_trimmer.rb +31 -0
- data/lib/evilution/mcp/mutate_tool/survived_enricher.rb +52 -0
- data/lib/evilution/mcp/mutate_tool.rb +34 -186
- data/lib/evilution/mutation.rb +43 -3
- data/lib/evilution/mutator/base.rb +39 -1
- data/lib/evilution/mutator/operator/argument_nil_substitution.rb +5 -1
- data/lib/evilution/mutator/operator/argument_removal.rb +5 -1
- data/lib/evilution/parallel/work_queue.rb +149 -31
- data/lib/evilution/parallel_db_warning.rb +68 -0
- data/lib/evilution/reporter/cli.rb +37 -11
- data/lib/evilution/reporter/html/assets/style.css +17 -0
- data/lib/evilution/reporter/html/sections/file_section.rb +15 -0
- data/lib/evilution/reporter/html/sections/neutral_details.rb +25 -0
- data/lib/evilution/reporter/html/sections/unparseable_details.rb +25 -0
- data/lib/evilution/reporter/html/sections/unresolved_details.rb +25 -0
- data/lib/evilution/reporter/html/templates/file_section.html.erb +3 -0
- data/lib/evilution/reporter/html/templates/neutral_details.html.erb +14 -0
- data/lib/evilution/reporter/html/templates/summary_cards.html.erb +3 -0
- data/lib/evilution/reporter/html/templates/unparseable_details.html.erb +11 -0
- data/lib/evilution/reporter/html/templates/unresolved_details.html.erb +11 -0
- data/lib/evilution/reporter/json.rb +8 -2
- data/lib/evilution/reporter/suggestion/diff_helpers.rb +28 -0
- data/lib/evilution/reporter/suggestion/registry.rb +64 -0
- data/lib/evilution/reporter/suggestion/templates/generic.rb +55 -0
- data/lib/evilution/reporter/suggestion/templates/minitest.rb +659 -0
- data/lib/evilution/reporter/suggestion/templates/rspec.rb +613 -0
- data/lib/evilution/reporter/suggestion.rb +8 -1327
- data/lib/evilution/result/mutation_result.rb +5 -1
- data/lib/evilution/result/summary.rb +13 -1
- data/lib/evilution/runner/baseline_runner.rb +23 -2
- data/lib/evilution/runner/mutation_executor.rb +83 -13
- data/lib/evilution/runner.rb +6 -0
- data/lib/evilution/source_ast_cache.rb +39 -0
- data/lib/evilution/spec_ast_cache.rb +166 -0
- data/lib/evilution/spec_resolver.rb +6 -1
- data/lib/evilution/spec_selector.rb +39 -0
- data/lib/evilution/temp_dir_tracker.rb +23 -3
- data/lib/evilution/version.rb +1 -1
- data/script/memory_check +7 -5
- metadata +34 -2
|
@@ -5,6 +5,7 @@ require "mcp"
|
|
|
5
5
|
require_relative "../config"
|
|
6
6
|
require_relative "../runner"
|
|
7
7
|
require_relative "../mutator/registry"
|
|
8
|
+
require_relative "../result/mutation_result"
|
|
8
9
|
require_relative "../spec_resolver"
|
|
9
10
|
require_relative "../ast/pattern/filter"
|
|
10
11
|
require_relative "../version"
|
|
@@ -14,20 +15,23 @@ require_relative "../mcp"
|
|
|
14
15
|
class Evilution::MCP::InfoTool < MCP::Tool
|
|
15
16
|
tool_name "evilution-info"
|
|
16
17
|
description "Discover what evilution sees before running any mutations. " \
|
|
17
|
-
"One tool,
|
|
18
|
+
"One tool, four actions: " \
|
|
18
19
|
"'subjects' lists every mutatable method in the target files with its file, line, and mutation count; " \
|
|
19
20
|
"'tests' resolves which spec/test files cover the given sources (so you pick the right --spec before mutating); " \
|
|
20
21
|
"'environment' dumps the effective config (version, ruby, config file, timeout, " \
|
|
21
|
-
"integration, isolation, and every other setting)
|
|
22
|
+
"integration, isolation, and every other setting); " \
|
|
23
|
+
"'statuses' returns the mutation-result status glossary (killed/survived/neutral/error/etc.) with " \
|
|
24
|
+
"per-status meaning and scoring semantics so agents can triage results without guessing. " \
|
|
22
25
|
"Use this instead of shelling out to 'evilution subjects', 'evilution tests list', or 'evilution environment show' — " \
|
|
23
26
|
"the response is structured JSON so you can plan the next mutation run without parsing CLI text."
|
|
24
27
|
input_schema(
|
|
25
28
|
properties: {
|
|
26
29
|
action: {
|
|
27
30
|
type: "string",
|
|
28
|
-
enum: %w[subjects tests environment],
|
|
31
|
+
enum: %w[subjects tests environment statuses],
|
|
29
32
|
description: "Which discovery operation to perform. " \
|
|
30
|
-
"'subjects' lists mutatable methods; 'tests' resolves specs for sources;
|
|
33
|
+
"'subjects' lists mutatable methods; 'tests' resolves specs for sources; " \
|
|
34
|
+
"'environment' dumps effective config; 'statuses' returns the result-status glossary."
|
|
31
35
|
},
|
|
32
36
|
files: {
|
|
33
37
|
type: "array",
|
|
@@ -60,7 +64,61 @@ class Evilution::MCP::InfoTool < MCP::Tool
|
|
|
60
64
|
required: ["action"]
|
|
61
65
|
)
|
|
62
66
|
|
|
63
|
-
VALID_ACTIONS = %w[subjects tests environment].freeze
|
|
67
|
+
VALID_ACTIONS = %w[subjects tests environment statuses].freeze
|
|
68
|
+
|
|
69
|
+
STATUS_GLOSSARY = [
|
|
70
|
+
{
|
|
71
|
+
"status" => "killed",
|
|
72
|
+
"meaning" => "A test failed when the mutation was applied — the test suite caught the mutation. " \
|
|
73
|
+
"This is the desired outcome.",
|
|
74
|
+
"counted_in_score" => true
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
"status" => "survived",
|
|
78
|
+
"meaning" => "No test failed when the mutation was applied — gap in coverage. " \
|
|
79
|
+
"The test suite did not detect the behavioral change.",
|
|
80
|
+
"counted_in_score" => true
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
"status" => "timeout",
|
|
84
|
+
"meaning" => "Test run exceeded the configured per-mutation timeout. " \
|
|
85
|
+
"Treated like survived for scoring (counted in the denominator); " \
|
|
86
|
+
"may indicate an infinite loop introduced by the mutation.",
|
|
87
|
+
"counted_in_score" => true
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
"status" => "error",
|
|
91
|
+
"meaning" => "Mutation execution raised an unexpected error (syntax error at load time, " \
|
|
92
|
+
"boot failure, test-infrastructure crash). The mutation could not be evaluated.",
|
|
93
|
+
"counted_in_score" => false
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
"status" => "neutral",
|
|
97
|
+
"meaning" => "Baseline tests already failed before the mutation was applied — pre-existing " \
|
|
98
|
+
"test-suite problem (flaky spec, infra collision, fixture setup failure). " \
|
|
99
|
+
"Not a meaningful mutation signal.",
|
|
100
|
+
"counted_in_score" => false
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
"status" => "equivalent",
|
|
104
|
+
"meaning" => "Mutation is provably identical to the original source " \
|
|
105
|
+
"(e.g. a no-op replacement that the parser or evaluator treats as semantically equal).",
|
|
106
|
+
"counted_in_score" => false
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
"status" => "unresolved",
|
|
110
|
+
"meaning" => "No spec/test file resolved for the mutated source — coverage gap, not a failure. " \
|
|
111
|
+
"The file has no corresponding test file the resolver could locate.",
|
|
112
|
+
"counted_in_score" => false
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
"status" => "unparseable",
|
|
116
|
+
"meaning" => "Mutated source failed to parse (e.g. dangling heredoc after method_body_replacement). " \
|
|
117
|
+
"Short-circuited before execution; no test run was attempted.",
|
|
118
|
+
"counted_in_score" => false
|
|
119
|
+
}
|
|
120
|
+
].freeze
|
|
121
|
+
private_constant :STATUS_GLOSSARY
|
|
64
122
|
|
|
65
123
|
class << self
|
|
66
124
|
# rubocop:disable Lint/UnusedMethodArgument
|
|
@@ -78,6 +136,8 @@ class Evilution::MCP::InfoTool < MCP::Tool
|
|
|
78
136
|
tests_action(files: parsed_files, spec: spec, integration: integration, skip_config: skip_config)
|
|
79
137
|
when "environment"
|
|
80
138
|
environment_action
|
|
139
|
+
when "statuses"
|
|
140
|
+
statuses_action
|
|
81
141
|
end
|
|
82
142
|
rescue Evilution::Error => e
|
|
83
143
|
error_response_for(e)
|
|
@@ -213,6 +273,18 @@ class Evilution::MCP::InfoTool < MCP::Tool
|
|
|
213
273
|
)
|
|
214
274
|
end
|
|
215
275
|
|
|
276
|
+
def statuses_action
|
|
277
|
+
# Guard against drift: every STATUSES symbol must have a glossary entry.
|
|
278
|
+
defined = Evilution::Result::MutationResult::STATUSES.map(&:to_s).sort
|
|
279
|
+
documented = STATUS_GLOSSARY.map { |s| s["status"] }.sort
|
|
280
|
+
if defined != documented
|
|
281
|
+
missing = (defined - documented) + (documented - defined)
|
|
282
|
+
raise Evilution::Error, "status glossary drift: #{missing.inspect}"
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
success_response("statuses" => STATUS_GLOSSARY)
|
|
286
|
+
end
|
|
287
|
+
|
|
216
288
|
def error_response_for(error)
|
|
217
289
|
type = case error
|
|
218
290
|
when Evilution::ConfigError then "config_error"
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../mutate_tool"
|
|
4
|
+
require_relative "option_parser"
|
|
5
|
+
require_relative "../../config"
|
|
6
|
+
|
|
7
|
+
module Evilution::MCP::MutateTool::ConfigBuilder
|
|
8
|
+
def self.build(files:, line_ranges:, params:)
|
|
9
|
+
# Preload is disabled for MCP invocations: `require`-ing Rails into the
|
|
10
|
+
# long-lived MCP server would poison subsequent runs against other
|
|
11
|
+
# projects. MCP users who want the speedup should use the CLI.
|
|
12
|
+
opts = { target_files: files, line_ranges: line_ranges, format: :json, quiet: true, preload: false }
|
|
13
|
+
opts[:skip_config_file] = true if params[:skip_config]
|
|
14
|
+
opts[:spec_files] = params[:spec] if params[:spec]
|
|
15
|
+
Evilution::MCP::MutateTool::OptionParser::PASSTHROUGH_KEYS.each do |key|
|
|
16
|
+
opts[key] = params[key] unless params[key].nil?
|
|
17
|
+
end
|
|
18
|
+
Evilution::Config.new(**opts)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../mutate_tool"
|
|
4
|
+
|
|
5
|
+
module Evilution::MCP::MutateTool::ErrorPayload
|
|
6
|
+
def self.build(error)
|
|
7
|
+
type = case error
|
|
8
|
+
when Evilution::ConfigError then "config_error"
|
|
9
|
+
when Evilution::ParseError then "parse_error"
|
|
10
|
+
else "runtime_error"
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
payload = { type: type, message: error.message }
|
|
14
|
+
payload[:file] = error.file if error.file
|
|
15
|
+
{ error: payload }
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../mutate_tool"
|
|
4
|
+
|
|
5
|
+
module Evilution::MCP::MutateTool::OptionParser
|
|
6
|
+
VALID_VERBOSITIES = %w[full summary minimal].freeze
|
|
7
|
+
PASSTHROUGH_KEYS = %i[target timeout jobs fail_fast suggest_tests incremental integration
|
|
8
|
+
isolation baseline save_session].freeze
|
|
9
|
+
ALLOWED_OPT_KEYS = (PASSTHROUGH_KEYS + %i[spec skip_config]).freeze
|
|
10
|
+
|
|
11
|
+
def self.parse_files(raw_files)
|
|
12
|
+
files = []
|
|
13
|
+
ranges = {}
|
|
14
|
+
|
|
15
|
+
raw_files.each do |arg|
|
|
16
|
+
file, range_str = arg.split(":", 2)
|
|
17
|
+
files << file
|
|
18
|
+
next unless range_str
|
|
19
|
+
|
|
20
|
+
ranges[file] = parse_line_range(range_str)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
[files, ranges]
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def self.parse_line_range(str)
|
|
27
|
+
if str.include?("-")
|
|
28
|
+
start_str, end_str = str.split("-", 2)
|
|
29
|
+
start_line = Integer(start_str)
|
|
30
|
+
end_line = end_str.empty? ? Float::INFINITY : Integer(end_str)
|
|
31
|
+
start_line..end_line
|
|
32
|
+
else
|
|
33
|
+
line = Integer(str)
|
|
34
|
+
line..line
|
|
35
|
+
end
|
|
36
|
+
rescue ArgumentError, TypeError
|
|
37
|
+
raise Evilution::ParseError, "invalid line range: #{str.inspect}"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def self.normalize_verbosity(value)
|
|
41
|
+
normalized = value.to_s.strip.downcase
|
|
42
|
+
normalized = "summary" if normalized.empty?
|
|
43
|
+
return normalized if VALID_VERBOSITIES.include?(normalized)
|
|
44
|
+
|
|
45
|
+
raise Evilution::ParseError, "invalid verbosity: #{value.inspect} (must be full, summary, or minimal)"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def self.validate!(opts)
|
|
49
|
+
unknown = opts.keys - ALLOWED_OPT_KEYS
|
|
50
|
+
return if unknown.empty?
|
|
51
|
+
|
|
52
|
+
raise Evilution::ParseError, "unknown parameters: #{unknown.join(", ")}"
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require_relative "../mutate_tool"
|
|
5
|
+
require_relative "../../reporter/suggestion"
|
|
6
|
+
|
|
7
|
+
module Evilution::MCP::MutateTool::ProgressStreamer
|
|
8
|
+
def self.build(server_context:, suggest_tests:, integration:)
|
|
9
|
+
return nil unless suggest_tests && server_context.respond_to?(:report_progress)
|
|
10
|
+
|
|
11
|
+
suggestion = Evilution::Reporter::Suggestion.new(suggest_tests: true, integration: integration)
|
|
12
|
+
survivor_index = 0
|
|
13
|
+
|
|
14
|
+
proc do |result|
|
|
15
|
+
next unless result.survived?
|
|
16
|
+
|
|
17
|
+
begin
|
|
18
|
+
survivor_index += 1
|
|
19
|
+
detail = build_suggestion_detail(result.mutation, suggestion)
|
|
20
|
+
server_context.report_progress(survivor_index, message: ::JSON.generate(detail))
|
|
21
|
+
rescue StandardError # rubocop:disable Lint/SuppressedException
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def self.build_suggestion_detail(mutation, suggestion)
|
|
27
|
+
{
|
|
28
|
+
operator: mutation.operator_name,
|
|
29
|
+
file: mutation.file_path,
|
|
30
|
+
line: mutation.line,
|
|
31
|
+
subject: mutation.subject.name,
|
|
32
|
+
diff: mutation.diff,
|
|
33
|
+
suggestion: suggestion.suggestion_for(mutation)
|
|
34
|
+
}
|
|
35
|
+
end
|
|
36
|
+
private_class_method :build_suggestion_detail
|
|
37
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require_relative "../mutate_tool"
|
|
5
|
+
|
|
6
|
+
module Evilution::MCP::MutateTool::ReportTrimmer
|
|
7
|
+
MINIMAL_KEYS = %w[summary survived].freeze
|
|
8
|
+
FULL_DIFF_STRIP_KEYS = %w[killed neutral equivalent unresolved unparseable].freeze
|
|
9
|
+
SUMMARY_DROP_KEYS = %w[killed neutral equivalent unparseable].freeze
|
|
10
|
+
|
|
11
|
+
def self.call(json_string, verbosity:, survived_results:, config:, enricher:)
|
|
12
|
+
data = ::JSON.parse(json_string)
|
|
13
|
+
case verbosity
|
|
14
|
+
when "full"
|
|
15
|
+
FULL_DIFF_STRIP_KEYS.each { |key| strip_diffs(data, key) }
|
|
16
|
+
when "summary"
|
|
17
|
+
SUMMARY_DROP_KEYS.each { |key| data.delete(key) }
|
|
18
|
+
when "minimal"
|
|
19
|
+
data.keep_if { |key, _| MINIMAL_KEYS.include?(key) }
|
|
20
|
+
end
|
|
21
|
+
enricher.call(data, survived_results, config)
|
|
22
|
+
::JSON.generate(data)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def self.strip_diffs(data, key)
|
|
26
|
+
return unless data[key]
|
|
27
|
+
|
|
28
|
+
data[key].each { |entry| entry.delete("diff") }
|
|
29
|
+
end
|
|
30
|
+
private_class_method :strip_diffs
|
|
31
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../mutate_tool"
|
|
4
|
+
require_relative "../../runner"
|
|
5
|
+
require_relative "../../spec_resolver"
|
|
6
|
+
|
|
7
|
+
module Evilution::MCP::MutateTool::SurvivedEnricher
|
|
8
|
+
def self.call(data, survived_results, config)
|
|
9
|
+
entries = data["survived"]
|
|
10
|
+
return unless entries.is_a?(Array)
|
|
11
|
+
|
|
12
|
+
explicit_spec = explicit_spec_override(config)
|
|
13
|
+
resolver = explicit_spec ? nil : resolver_for_integration(config.integration)
|
|
14
|
+
cache = {}
|
|
15
|
+
|
|
16
|
+
entries.each_with_index do |entry, index|
|
|
17
|
+
result = survived_results[index]
|
|
18
|
+
next unless result
|
|
19
|
+
|
|
20
|
+
mutation = result.mutation
|
|
21
|
+
entry["subject"] = mutation.subject.name
|
|
22
|
+
spec_file = explicit_spec || cache.fetch(mutation.file_path) do
|
|
23
|
+
cache[mutation.file_path] = resolver.call(mutation.file_path)
|
|
24
|
+
end
|
|
25
|
+
entry["spec_file"] = spec_file if spec_file
|
|
26
|
+
entry["next_step"] = build_next_step(mutation, spec_file)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def self.explicit_spec_override(config)
|
|
31
|
+
return nil unless config.respond_to?(:spec_files)
|
|
32
|
+
|
|
33
|
+
files = Array(config.spec_files).compact.map(&:to_s).reject(&:empty?)
|
|
34
|
+
files.first
|
|
35
|
+
end
|
|
36
|
+
private_class_method :explicit_spec_override
|
|
37
|
+
|
|
38
|
+
def self.resolver_for_integration(integration)
|
|
39
|
+
integration_class = Evilution::Runner::INTEGRATIONS[integration.to_sym]
|
|
40
|
+
return Evilution::SpecResolver.new unless integration_class
|
|
41
|
+
|
|
42
|
+
integration_class.baseline_options[:spec_resolver] || Evilution::SpecResolver.new
|
|
43
|
+
end
|
|
44
|
+
private_class_method :resolver_for_integration
|
|
45
|
+
|
|
46
|
+
def self.build_next_step(mutation, spec_file)
|
|
47
|
+
target = spec_file || "the covering test file"
|
|
48
|
+
"Add a test in #{target} that fails against this mutation at #{mutation.file_path}:#{mutation.line} " \
|
|
49
|
+
"(#{mutation.subject.name}, #{mutation.operator_name})."
|
|
50
|
+
end
|
|
51
|
+
private_class_method :build_next_step
|
|
52
|
+
end
|
|
@@ -5,7 +5,6 @@ require "mcp"
|
|
|
5
5
|
require_relative "../config"
|
|
6
6
|
require_relative "../runner"
|
|
7
7
|
require_relative "../reporter/json"
|
|
8
|
-
require_relative "../reporter/suggestion"
|
|
9
8
|
require_relative "../spec_resolver"
|
|
10
9
|
|
|
11
10
|
require_relative "../mcp"
|
|
@@ -104,194 +103,43 @@ class Evilution::MCP::MutateTool < MCP::Tool
|
|
|
104
103
|
|
|
105
104
|
class << self
|
|
106
105
|
def call(server_context:, files: [], verbosity: nil, **opts)
|
|
107
|
-
|
|
108
|
-
parsed_files, line_ranges = parse_files(Array(files))
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
106
|
+
Evilution::MCP::MutateTool::OptionParser.validate!(opts)
|
|
107
|
+
parsed_files, line_ranges = Evilution::MCP::MutateTool::OptionParser.parse_files(Array(files))
|
|
108
|
+
config = Evilution::MCP::MutateTool::ConfigBuilder.build(
|
|
109
|
+
files: parsed_files,
|
|
110
|
+
line_ranges: line_ranges,
|
|
111
|
+
params: opts
|
|
112
|
+
)
|
|
113
|
+
on_result = Evilution::MCP::MutateTool::ProgressStreamer.build(
|
|
114
|
+
server_context: server_context,
|
|
115
|
+
suggest_tests: opts[:suggest_tests],
|
|
116
|
+
integration: config.integration
|
|
117
|
+
)
|
|
118
|
+
summary = Evilution::Runner.new(config: config, on_result: on_result).call
|
|
119
|
+
report = Evilution::Reporter::JSON.new(
|
|
120
|
+
suggest_tests: opts[:suggest_tests] == true,
|
|
121
|
+
integration: config.integration
|
|
122
|
+
).call(summary)
|
|
123
|
+
normalized_verbosity = Evilution::MCP::MutateTool::OptionParser.normalize_verbosity(verbosity)
|
|
124
|
+
compact = Evilution::MCP::MutateTool::ReportTrimmer.call(
|
|
125
|
+
report,
|
|
126
|
+
verbosity: normalized_verbosity,
|
|
127
|
+
survived_results: summary.survived_results,
|
|
128
|
+
config: config,
|
|
129
|
+
enricher: Evilution::MCP::MutateTool::SurvivedEnricher
|
|
130
|
+
)
|
|
117
131
|
|
|
118
132
|
::MCP::Tool::Response.new([{ type: "text", text: compact }])
|
|
119
133
|
rescue Evilution::Error => e
|
|
120
|
-
|
|
121
|
-
::MCP::Tool::Response.new([{ type: "text", text: ::JSON.generate(
|
|
122
|
-
end
|
|
123
|
-
|
|
124
|
-
VALID_VERBOSITIES = %w[full summary minimal].freeze
|
|
125
|
-
PASSTHROUGH_KEYS = %i[target timeout jobs fail_fast suggest_tests incremental integration
|
|
126
|
-
isolation baseline save_session].freeze
|
|
127
|
-
ALLOWED_OPT_KEYS = (PASSTHROUGH_KEYS + %i[spec skip_config]).freeze
|
|
128
|
-
|
|
129
|
-
private
|
|
130
|
-
|
|
131
|
-
def parse_files(raw_files)
|
|
132
|
-
files = []
|
|
133
|
-
ranges = {}
|
|
134
|
-
|
|
135
|
-
raw_files.each do |arg|
|
|
136
|
-
file, range_str = arg.split(":", 2)
|
|
137
|
-
files << file
|
|
138
|
-
next unless range_str
|
|
139
|
-
|
|
140
|
-
ranges[file] = parse_line_range(range_str)
|
|
141
|
-
end
|
|
142
|
-
|
|
143
|
-
[files, ranges]
|
|
144
|
-
end
|
|
145
|
-
|
|
146
|
-
def parse_line_range(str)
|
|
147
|
-
if str.include?("-")
|
|
148
|
-
start_str, end_str = str.split("-", 2)
|
|
149
|
-
start_line = Integer(start_str)
|
|
150
|
-
end_line = end_str.empty? ? Float::INFINITY : Integer(end_str)
|
|
151
|
-
start_line..end_line
|
|
152
|
-
else
|
|
153
|
-
line = Integer(str)
|
|
154
|
-
line..line
|
|
155
|
-
end
|
|
156
|
-
rescue ArgumentError, TypeError
|
|
157
|
-
raise Evilution::ParseError, "invalid line range: #{str.inspect}"
|
|
158
|
-
end
|
|
159
|
-
|
|
160
|
-
def validate_opts!(opts)
|
|
161
|
-
unknown = opts.keys - ALLOWED_OPT_KEYS
|
|
162
|
-
return if unknown.empty?
|
|
163
|
-
|
|
164
|
-
raise Evilution::ParseError, "unknown parameters: #{unknown.join(", ")}"
|
|
165
|
-
end
|
|
166
|
-
|
|
167
|
-
def build_config_opts(files, line_ranges, params)
|
|
168
|
-
# Preload is disabled for MCP invocations: `require`-ing Rails into the
|
|
169
|
-
# long-lived MCP server would poison subsequent runs against other
|
|
170
|
-
# projects. MCP users who want the speedup should use the CLI.
|
|
171
|
-
opts = { target_files: files, line_ranges: line_ranges, format: :json, quiet: true, preload: false }
|
|
172
|
-
opts[:skip_config_file] = true if params[:skip_config]
|
|
173
|
-
opts[:spec_files] = params[:spec] if params[:spec]
|
|
174
|
-
PASSTHROUGH_KEYS.each { |key| opts[key] = params[key] unless params[key].nil? }
|
|
175
|
-
opts
|
|
176
|
-
end
|
|
177
|
-
|
|
178
|
-
def normalize_verbosity(value)
|
|
179
|
-
normalized = value.to_s.strip.downcase
|
|
180
|
-
normalized = "summary" if normalized.empty?
|
|
181
|
-
return normalized if VALID_VERBOSITIES.include?(normalized)
|
|
182
|
-
|
|
183
|
-
raise Evilution::ParseError, "invalid verbosity: #{value.inspect} (must be full, summary, or minimal)"
|
|
184
|
-
end
|
|
185
|
-
|
|
186
|
-
def trim_report(json_string, verbosity, survived_results, config)
|
|
187
|
-
data = ::JSON.parse(json_string)
|
|
188
|
-
case verbosity
|
|
189
|
-
when "full"
|
|
190
|
-
strip_diffs(data, "killed")
|
|
191
|
-
strip_diffs(data, "neutral")
|
|
192
|
-
strip_diffs(data, "equivalent")
|
|
193
|
-
when "summary"
|
|
194
|
-
data.delete("killed")
|
|
195
|
-
data.delete("neutral")
|
|
196
|
-
data.delete("equivalent")
|
|
197
|
-
when "minimal"
|
|
198
|
-
data.delete("killed")
|
|
199
|
-
data.delete("neutral")
|
|
200
|
-
data.delete("equivalent")
|
|
201
|
-
data.delete("timed_out")
|
|
202
|
-
data.delete("errors")
|
|
203
|
-
end
|
|
204
|
-
enrich_survived(data, survived_results, config)
|
|
205
|
-
::JSON.generate(data)
|
|
206
|
-
end
|
|
207
|
-
|
|
208
|
-
def enrich_survived(data, survived_results, config)
|
|
209
|
-
entries = data["survived"]
|
|
210
|
-
return unless entries.is_a?(Array)
|
|
211
|
-
|
|
212
|
-
explicit_spec = explicit_spec_override(config)
|
|
213
|
-
resolver = explicit_spec ? nil : resolver_for_integration(config.integration)
|
|
214
|
-
cache = {}
|
|
215
|
-
|
|
216
|
-
entries.each_with_index do |entry, index|
|
|
217
|
-
result = survived_results[index]
|
|
218
|
-
next unless result
|
|
219
|
-
|
|
220
|
-
mutation = result.mutation
|
|
221
|
-
entry["subject"] = mutation.subject.name
|
|
222
|
-
spec_file = explicit_spec || cache.fetch(mutation.file_path) do
|
|
223
|
-
cache[mutation.file_path] = resolver.call(mutation.file_path)
|
|
224
|
-
end
|
|
225
|
-
entry["spec_file"] = spec_file if spec_file
|
|
226
|
-
entry["next_step"] = build_next_step(mutation, spec_file)
|
|
227
|
-
end
|
|
228
|
-
end
|
|
229
|
-
|
|
230
|
-
def explicit_spec_override(config)
|
|
231
|
-
return nil unless config.respond_to?(:spec_files)
|
|
232
|
-
|
|
233
|
-
files = Array(config.spec_files).compact.map(&:to_s).reject(&:empty?)
|
|
234
|
-
files.first
|
|
235
|
-
end
|
|
236
|
-
|
|
237
|
-
def resolver_for_integration(integration)
|
|
238
|
-
integration_class = Evilution::Runner::INTEGRATIONS[integration.to_sym]
|
|
239
|
-
return Evilution::SpecResolver.new unless integration_class
|
|
240
|
-
|
|
241
|
-
integration_class.baseline_options[:spec_resolver] || Evilution::SpecResolver.new
|
|
242
|
-
end
|
|
243
|
-
|
|
244
|
-
def build_next_step(mutation, spec_file)
|
|
245
|
-
target = spec_file || "the covering test file"
|
|
246
|
-
"Add a test in #{target} that fails against this mutation at #{mutation.file_path}:#{mutation.line} " \
|
|
247
|
-
"(#{mutation.subject.name}, #{mutation.operator_name})."
|
|
248
|
-
end
|
|
249
|
-
|
|
250
|
-
def strip_diffs(data, key)
|
|
251
|
-
return unless data[key]
|
|
252
|
-
|
|
253
|
-
data[key].each { |entry| entry.delete("diff") }
|
|
254
|
-
end
|
|
255
|
-
|
|
256
|
-
def build_streaming_callback(server_context, suggest_tests, integration)
|
|
257
|
-
return nil unless suggest_tests && server_context.respond_to?(:report_progress)
|
|
258
|
-
|
|
259
|
-
suggestion = Evilution::Reporter::Suggestion.new(suggest_tests: true, integration: integration)
|
|
260
|
-
survivor_index = 0
|
|
261
|
-
|
|
262
|
-
proc do |result|
|
|
263
|
-
next unless result.survived?
|
|
264
|
-
|
|
265
|
-
begin
|
|
266
|
-
survivor_index += 1
|
|
267
|
-
detail = build_suggestion_detail(result.mutation, suggestion)
|
|
268
|
-
server_context.report_progress(survivor_index, message: ::JSON.generate(detail))
|
|
269
|
-
rescue StandardError # rubocop:disable Lint/SuppressedException
|
|
270
|
-
end
|
|
271
|
-
end
|
|
272
|
-
end
|
|
273
|
-
|
|
274
|
-
def build_suggestion_detail(mutation, suggestion)
|
|
275
|
-
{
|
|
276
|
-
operator: mutation.operator_name,
|
|
277
|
-
file: mutation.file_path,
|
|
278
|
-
line: mutation.line,
|
|
279
|
-
subject: mutation.subject.name,
|
|
280
|
-
diff: mutation.diff,
|
|
281
|
-
suggestion: suggestion.suggestion_for(mutation)
|
|
282
|
-
}
|
|
283
|
-
end
|
|
284
|
-
|
|
285
|
-
def build_error_payload(error)
|
|
286
|
-
error_type = case error
|
|
287
|
-
when Evilution::ConfigError then "config_error"
|
|
288
|
-
when Evilution::ParseError then "parse_error"
|
|
289
|
-
else "runtime_error"
|
|
290
|
-
end
|
|
291
|
-
|
|
292
|
-
payload = { type: error_type, message: error.message }
|
|
293
|
-
payload[:file] = error.file if error.file
|
|
294
|
-
{ error: payload }
|
|
134
|
+
payload = Evilution::MCP::MutateTool::ErrorPayload.build(e)
|
|
135
|
+
::MCP::Tool::Response.new([{ type: "text", text: ::JSON.generate(payload) }], error: true)
|
|
295
136
|
end
|
|
296
137
|
end
|
|
297
138
|
end
|
|
139
|
+
|
|
140
|
+
require_relative "mutate_tool/error_payload"
|
|
141
|
+
require_relative "mutate_tool/option_parser"
|
|
142
|
+
require_relative "mutate_tool/config_builder"
|
|
143
|
+
require_relative "mutate_tool/report_trimmer"
|
|
144
|
+
require_relative "mutate_tool/survived_enricher"
|
|
145
|
+
require_relative "mutate_tool/progress_streamer"
|
data/lib/evilution/mutation.rb
CHANGED
|
@@ -1,27 +1,44 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "diff/lcs"
|
|
4
|
-
require "diff/lcs/hunk"
|
|
5
4
|
|
|
6
5
|
class Evilution::Mutation
|
|
7
6
|
attr_reader :subject, :operator_name, :original_source,
|
|
8
|
-
:mutated_source, :
|
|
7
|
+
:mutated_source, :original_slice, :mutated_slice,
|
|
8
|
+
:file_path, :line, :column, :parse_status
|
|
9
9
|
|
|
10
|
-
|
|
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
|
|
11
15
|
@subject = subject
|
|
12
16
|
@operator_name = operator_name
|
|
13
17
|
@original_source = original_source
|
|
14
18
|
@mutated_source = mutated_source
|
|
19
|
+
@original_slice = original_slice
|
|
20
|
+
@mutated_slice = mutated_slice
|
|
15
21
|
@file_path = file_path
|
|
16
22
|
@line = line
|
|
17
23
|
@column = column
|
|
24
|
+
@parse_status = parse_status
|
|
18
25
|
@diff = nil
|
|
19
26
|
end
|
|
20
27
|
|
|
28
|
+
def unparseable?
|
|
29
|
+
@parse_status == :unparseable
|
|
30
|
+
end
|
|
31
|
+
|
|
21
32
|
def diff
|
|
22
33
|
@diff ||= compute_diff
|
|
23
34
|
end
|
|
24
35
|
|
|
36
|
+
def unified_diff
|
|
37
|
+
return @unified_diff if defined?(@unified_diff)
|
|
38
|
+
|
|
39
|
+
@unified_diff = compute_unified_diff
|
|
40
|
+
end
|
|
41
|
+
|
|
25
42
|
def strip_sources!
|
|
26
43
|
diff # ensure diff is cached before clearing sources
|
|
27
44
|
@original_source = nil
|
|
@@ -49,6 +66,29 @@ class Evilution::Mutation
|
|
|
49
66
|
result.join("\n")
|
|
50
67
|
end
|
|
51
68
|
|
|
69
|
+
def compute_unified_diff
|
|
70
|
+
return nil if @original_slice.nil? || @mutated_slice.nil?
|
|
71
|
+
|
|
72
|
+
original_lines = @original_slice.lines
|
|
73
|
+
mutated_lines = @mutated_slice.lines
|
|
74
|
+
body = ::Diff::LCS.sdiff(original_lines, mutated_lines).map { |c| format_sdiff_change(c) }.join("\n")
|
|
75
|
+
[
|
|
76
|
+
"--- a/#{file_path}",
|
|
77
|
+
"+++ b/#{file_path}",
|
|
78
|
+
"@@ -#{line},#{original_lines.length} +#{line},#{mutated_lines.length} @@",
|
|
79
|
+
body
|
|
80
|
+
].reject(&:empty?).join("\n")
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def format_sdiff_change(change)
|
|
84
|
+
case change.action
|
|
85
|
+
when "=" then " #{change.old_element.chomp}"
|
|
86
|
+
when "-" then "-#{change.old_element.chomp}"
|
|
87
|
+
when "+" then "+#{change.new_element.chomp}"
|
|
88
|
+
when "!" then "-#{change.old_element.chomp}\n+#{change.new_element.chomp}"
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
52
92
|
public
|
|
53
93
|
|
|
54
94
|
def to_s
|