evilution 0.24.0 → 0.26.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 +210 -0
- data/.claude/prompts/architect.md +14 -1
- data/.claude/skills/create-issue/SKILL.md +55 -0
- data/CHANGELOG.md +51 -0
- data/README.md +80 -4
- data/exe/evil +6 -0
- data/lib/evilution/ast/constant_names.rb +34 -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/invalid_input.rb +12 -0
- data/lib/evilution/compare/normalizer.rb +106 -0
- data/lib/evilution/compare/record.rb +16 -0
- data/lib/evilution/compare.rb +6 -0
- data/lib/evilution/config.rb +165 -3
- data/lib/evilution/example_filter.rb +143 -0
- data/lib/evilution/integration/base.rb +4 -155
- data/lib/evilution/integration/crash_detector.rb +5 -2
- data/lib/evilution/integration/loading/concern_state_cleaner.rb +49 -0
- data/lib/evilution/integration/loading/constant_pinner.rb +24 -0
- data/lib/evilution/integration/loading/mutation_applier.rb +52 -0
- data/lib/evilution/integration/loading/redefinition_recovery.rb +54 -0
- data/lib/evilution/integration/loading/source_evaluator.rb +15 -0
- data/lib/evilution/integration/loading/syntax_validator.rb +19 -0
- data/lib/evilution/integration/loading.rb +6 -0
- 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/load_path/subpath_resolver.rb +25 -0
- data/lib/evilution/load_path.rb +4 -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/isolation_resolver.rb +12 -1
- data/lib/evilution/runner/mutation_executor.rb +83 -13
- data/lib/evilution/runner/subject_pipeline.rb +18 -8
- 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 +46 -5
- data/lib/evilution/mcp/session_diff_tool.rb +0 -63
- data/lib/evilution/mcp/session_list_tool.rb +0 -50
- data/lib/evilution/mcp/session_show_tool.rb +0 -57
|
@@ -15,10 +15,18 @@ class Evilution::Isolation::Fork
|
|
|
15
15
|
end
|
|
16
16
|
|
|
17
17
|
def call(mutation:, test_command:, timeout:)
|
|
18
|
+
pid = nil
|
|
18
19
|
sandbox_dir = Dir.mktmpdir("evilution-run")
|
|
19
20
|
start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
20
21
|
parent_rss = Evilution::Memory.rss_kb
|
|
21
22
|
read_io, write_io = IO.pipe
|
|
23
|
+
# Marshal result payload is ASCII-8BIT; pipes default to text mode and may
|
|
24
|
+
# transcode according to their external/internal encodings (influenced by
|
|
25
|
+
# Encoding.default_external and/or Encoding.default_internal — Rails sets
|
|
26
|
+
# the latter to UTF-8), failing on bytes with no mapping. Force binmode on
|
|
27
|
+
# both ends.
|
|
28
|
+
read_io.binmode
|
|
29
|
+
write_io.binmode
|
|
22
30
|
|
|
23
31
|
pid = ::Process.fork do
|
|
24
32
|
ENV["TMPDIR"] = sandbox_dir
|
|
@@ -39,6 +47,7 @@ class Evilution::Isolation::Fork
|
|
|
39
47
|
ensure
|
|
40
48
|
read_io&.close
|
|
41
49
|
write_io&.close
|
|
50
|
+
ensure_reaped(pid)
|
|
42
51
|
restore_original_source(mutation)
|
|
43
52
|
FileUtils.rm_rf(sandbox_dir) if sandbox_dir
|
|
44
53
|
end
|
|
@@ -82,6 +91,22 @@ class Evilution::Isolation::Fork
|
|
|
82
91
|
end
|
|
83
92
|
end
|
|
84
93
|
|
|
94
|
+
# Defensive reap: if normal control flow raised before wait_for_result
|
|
95
|
+
# reaped the child (e.g. Marshal.load on corrupt payload), the child becomes
|
|
96
|
+
# a zombie. Reuse terminate_child for the bounded TERM + GRACE_PERIOD + KILL
|
|
97
|
+
# ladder so this never hangs the ensure path; swallow SystemCallError so
|
|
98
|
+
# cleanup can't mask the primary failure.
|
|
99
|
+
def ensure_reaped(pid)
|
|
100
|
+
return unless pid
|
|
101
|
+
|
|
102
|
+
reaped = ::Process.waitpid(pid, ::Process::WNOHANG)
|
|
103
|
+
return if reaped
|
|
104
|
+
|
|
105
|
+
terminate_child(pid)
|
|
106
|
+
rescue SystemCallError
|
|
107
|
+
nil
|
|
108
|
+
end
|
|
109
|
+
|
|
85
110
|
def terminate_child(pid)
|
|
86
111
|
::Process.kill("TERM", pid) rescue nil # rubocop:disable Style/RescueModifier
|
|
87
112
|
_, status = ::Process.waitpid2(pid, ::Process::WNOHANG)
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../load_path"
|
|
4
|
+
|
|
5
|
+
# Given an absolute (or expandable) file path, returns the shortest path
|
|
6
|
+
# relative to any `$LOAD_PATH` entry the file lives under, or nil if the file
|
|
7
|
+
# is outside every entry. The shortest match wins because a deeper LOAD_PATH
|
|
8
|
+
# entry yields a shorter subpath that better matches `require` resolution.
|
|
9
|
+
class Evilution::LoadPath::SubpathResolver
|
|
10
|
+
def call(file_path)
|
|
11
|
+
absolute = File.expand_path(file_path)
|
|
12
|
+
best_subpath = nil
|
|
13
|
+
|
|
14
|
+
$LOAD_PATH.each do |entry|
|
|
15
|
+
dir = File.expand_path(entry)
|
|
16
|
+
prefix = dir.end_with?("/") ? dir : "#{dir}/"
|
|
17
|
+
next unless absolute.start_with?(prefix)
|
|
18
|
+
|
|
19
|
+
candidate = absolute.delete_prefix(prefix)
|
|
20
|
+
best_subpath = candidate if best_subpath.nil? || candidate.length < best_subpath.length
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
best_subpath
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -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"
|