evilution 0.13.0 → 0.15.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/.migration-hint-ts +1 -1
- data/.beads/issues.jsonl +17 -17
- data/CHANGELOG.md +39 -0
- data/lib/evilution/ast/inheritance_scanner.rb +70 -0
- data/lib/evilution/ast/parser.rb +73 -68
- data/lib/evilution/ast/source_surgeon.rb +7 -9
- data/lib/evilution/ast.rb +4 -0
- data/lib/evilution/baseline.rb +73 -75
- data/lib/evilution/cache.rb +75 -77
- data/lib/evilution/cli.rb +412 -173
- data/lib/evilution/config.rb +141 -136
- data/lib/evilution/equivalent/detector.rb +29 -27
- data/lib/evilution/equivalent/heuristic/alias_swap.rb +32 -33
- data/lib/evilution/equivalent/heuristic/arithmetic_identity.rb +30 -0
- data/lib/evilution/equivalent/heuristic/comment_marking.rb +21 -0
- data/lib/evilution/equivalent/heuristic/dead_code.rb +41 -45
- data/lib/evilution/equivalent/heuristic/method_body_nil.rb +11 -15
- data/lib/evilution/equivalent/heuristic/noop_source.rb +5 -9
- data/lib/evilution/equivalent/heuristic.rb +6 -0
- data/lib/evilution/equivalent.rb +4 -0
- data/lib/evilution/git/changed_files.rb +35 -37
- data/lib/evilution/git.rb +4 -0
- data/lib/evilution/integration/base.rb +5 -7
- data/lib/evilution/integration/rspec.rb +114 -116
- data/lib/evilution/integration.rb +4 -0
- data/lib/evilution/isolation/fork.rb +98 -100
- data/lib/evilution/isolation/in_process.rb +59 -61
- data/lib/evilution/isolation.rb +4 -0
- data/lib/evilution/mcp/mutate_tool.rb +172 -143
- data/lib/evilution/mcp/server.rb +12 -11
- data/lib/evilution/mcp/session_diff_tool.rb +89 -0
- data/lib/evilution/mcp/session_list_tool.rb +46 -0
- data/lib/evilution/mcp/session_show_tool.rb +53 -0
- data/lib/evilution/mcp.rb +4 -0
- data/lib/evilution/memory/leak_check.rb +80 -84
- data/lib/evilution/memory.rb +34 -36
- data/lib/evilution/mutation.rb +40 -42
- data/lib/evilution/mutator/base.rb +62 -48
- data/lib/evilution/mutator/operator/argument_nil_substitution.rb +32 -36
- data/lib/evilution/mutator/operator/argument_removal.rb +32 -36
- data/lib/evilution/mutator/operator/arithmetic_replacement.rb +26 -30
- data/lib/evilution/mutator/operator/array_literal.rb +18 -22
- data/lib/evilution/mutator/operator/block_removal.rb +16 -20
- data/lib/evilution/mutator/operator/boolean_literal_replacement.rb +38 -42
- data/lib/evilution/mutator/operator/boolean_operator_replacement.rb +41 -45
- data/lib/evilution/mutator/operator/collection_replacement.rb +32 -36
- data/lib/evilution/mutator/operator/comparison_replacement.rb +24 -28
- data/lib/evilution/mutator/operator/compound_assignment.rb +114 -118
- data/lib/evilution/mutator/operator/conditional_branch.rb +25 -29
- data/lib/evilution/mutator/operator/conditional_flip.rb +26 -30
- data/lib/evilution/mutator/operator/conditional_negation.rb +25 -29
- data/lib/evilution/mutator/operator/float_literal.rb +22 -26
- data/lib/evilution/mutator/operator/hash_literal.rb +18 -22
- data/lib/evilution/mutator/operator/integer_literal.rb +2 -0
- data/lib/evilution/mutator/operator/method_body_replacement.rb +12 -16
- data/lib/evilution/mutator/operator/method_call_removal.rb +12 -16
- data/lib/evilution/mutator/operator/mixin_removal.rb +80 -0
- data/lib/evilution/mutator/operator/negation_insertion.rb +12 -16
- data/lib/evilution/mutator/operator/nil_replacement.rb +13 -17
- data/lib/evilution/mutator/operator/range_replacement.rb +12 -16
- data/lib/evilution/mutator/operator/receiver_replacement.rb +16 -20
- data/lib/evilution/mutator/operator/regexp_mutation.rb +15 -19
- data/lib/evilution/mutator/operator/return_value_removal.rb +12 -16
- data/lib/evilution/mutator/operator/send_mutation.rb +36 -40
- data/lib/evilution/mutator/operator/statement_deletion.rb +13 -17
- data/lib/evilution/mutator/operator/string_literal.rb +18 -22
- data/lib/evilution/mutator/operator/superclass_removal.rb +65 -0
- data/lib/evilution/mutator/operator/symbol_literal.rb +17 -21
- data/lib/evilution/mutator/operator.rb +6 -0
- data/lib/evilution/mutator/registry.rb +56 -56
- data/lib/evilution/mutator.rb +4 -0
- data/lib/evilution/parallel/pool.rb +56 -58
- data/lib/evilution/parallel.rb +4 -0
- data/lib/evilution/reporter/cli.rb +99 -101
- data/lib/evilution/reporter/html.rb +242 -244
- data/lib/evilution/reporter/json.rb +57 -59
- data/lib/evilution/reporter/suggestion.rb +354 -328
- data/lib/evilution/reporter.rb +4 -0
- data/lib/evilution/result/mutation_result.rb +43 -46
- data/lib/evilution/result/summary.rb +80 -81
- data/lib/evilution/result.rb +4 -0
- data/lib/evilution/runner.rb +401 -316
- data/lib/evilution/session/store.rb +147 -0
- data/lib/evilution/session.rb +4 -0
- data/lib/evilution/spec_resolver.rb +49 -47
- data/lib/evilution/subject.rb +14 -16
- data/lib/evilution/version.rb +1 -1
- data/lib/evilution.rb +16 -0
- metadata +24 -2
|
@@ -4,75 +4,73 @@ require "timeout"
|
|
|
4
4
|
require_relative "../memory"
|
|
5
5
|
require_relative "../result/mutation_result"
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
module Isolation
|
|
9
|
-
class InProcess
|
|
10
|
-
def call(mutation:, test_command:, timeout:)
|
|
11
|
-
start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
12
|
-
rss_before = Memory.rss_kb
|
|
13
|
-
result = execute_with_timeout(mutation, test_command, timeout)
|
|
14
|
-
rss_after = Memory.rss_kb
|
|
15
|
-
duration = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
|
|
16
|
-
delta = compute_memory_delta(rss_before, rss_after, result)
|
|
7
|
+
require_relative "../isolation"
|
|
17
8
|
|
|
18
|
-
|
|
19
|
-
|
|
9
|
+
class Evilution::Isolation::InProcess
|
|
10
|
+
def call(mutation:, test_command:, timeout:)
|
|
11
|
+
start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
12
|
+
rss_before = Evilution::Memory.rss_kb
|
|
13
|
+
result = execute_with_timeout(mutation, test_command, timeout)
|
|
14
|
+
rss_after = Evilution::Memory.rss_kb
|
|
15
|
+
duration = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
|
|
16
|
+
delta = compute_memory_delta(rss_before, rss_after, result)
|
|
20
17
|
|
|
21
|
-
|
|
18
|
+
build_mutation_result(mutation, result, duration, rss_after, delta)
|
|
19
|
+
end
|
|
22
20
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def execute_with_timeout(mutation, test_command, timeout)
|
|
24
|
+
result = Timeout.timeout(timeout) do
|
|
25
|
+
suppress_output { test_command.call(mutation) }
|
|
26
|
+
end
|
|
27
|
+
{ timeout: false }.merge(result)
|
|
28
|
+
rescue Timeout::Error
|
|
29
|
+
{ timeout: true }
|
|
30
|
+
rescue StandardError => e
|
|
31
|
+
{ timeout: false, passed: false, error: e.message }
|
|
32
|
+
end
|
|
33
33
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
end
|
|
43
|
-
end
|
|
44
|
-
ensure
|
|
45
|
-
$stdout = saved_stdout
|
|
46
|
-
$stderr = saved_stderr
|
|
34
|
+
def suppress_output
|
|
35
|
+
saved_stdout = $stdout
|
|
36
|
+
saved_stderr = $stderr
|
|
37
|
+
File.open(File::NULL, "w") do |null_out|
|
|
38
|
+
File.open(File::NULL, "w") do |null_err|
|
|
39
|
+
$stdout = null_out
|
|
40
|
+
$stderr = null_err
|
|
41
|
+
yield
|
|
47
42
|
end
|
|
43
|
+
end
|
|
44
|
+
ensure
|
|
45
|
+
$stdout = saved_stdout
|
|
46
|
+
$stderr = saved_stderr
|
|
47
|
+
end
|
|
48
48
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
49
|
+
def compute_memory_delta(rss_before, rss_after, result)
|
|
50
|
+
return nil if result[:timeout]
|
|
51
|
+
return nil unless rss_before && rss_after
|
|
52
52
|
|
|
53
|
-
|
|
54
|
-
|
|
53
|
+
rss_after - rss_before
|
|
54
|
+
end
|
|
55
55
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
56
|
+
def build_mutation_result(mutation, result, duration, rss_after, memory_delta_kb)
|
|
57
|
+
status = if result[:timeout]
|
|
58
|
+
:timeout
|
|
59
|
+
elsif result[:error]
|
|
60
|
+
:error
|
|
61
|
+
elsif result[:passed]
|
|
62
|
+
:survived
|
|
63
|
+
else
|
|
64
|
+
:killed
|
|
65
|
+
end
|
|
66
66
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
end
|
|
76
|
-
end
|
|
67
|
+
Evilution::Result::MutationResult.new(
|
|
68
|
+
mutation: mutation,
|
|
69
|
+
status: status,
|
|
70
|
+
duration: duration,
|
|
71
|
+
test_command: result[:test_command],
|
|
72
|
+
child_rss_kb: rss_after,
|
|
73
|
+
memory_delta_kb: memory_delta_kb
|
|
74
|
+
)
|
|
77
75
|
end
|
|
78
76
|
end
|
|
@@ -5,167 +5,196 @@ require "mcp"
|
|
|
5
5
|
require_relative "../config"
|
|
6
6
|
require_relative "../runner"
|
|
7
7
|
require_relative "../reporter/json"
|
|
8
|
+
require_relative "../reporter/suggestion"
|
|
9
|
+
|
|
10
|
+
require_relative "../mcp"
|
|
11
|
+
|
|
12
|
+
class Evilution::MCP::MutateTool < MCP::Tool
|
|
13
|
+
tool_name "evilution-mutate"
|
|
14
|
+
description "Run mutation testing on Ruby source files. " \
|
|
15
|
+
"Use suggest_tests: true to get concrete RSpec test code for surviving mutants."
|
|
16
|
+
input_schema(
|
|
17
|
+
properties: {
|
|
18
|
+
files: {
|
|
19
|
+
type: "array",
|
|
20
|
+
items: { type: "string" },
|
|
21
|
+
description: "Target files, supports line-range syntax (e.g. lib/foo.rb:15-30)"
|
|
22
|
+
},
|
|
23
|
+
target: {
|
|
24
|
+
type: "string",
|
|
25
|
+
description: "Only mutate the named method (e.g. Foo#bar)"
|
|
26
|
+
},
|
|
27
|
+
timeout: {
|
|
28
|
+
type: "integer",
|
|
29
|
+
description: "Per-mutation timeout in seconds (default: 30)"
|
|
30
|
+
},
|
|
31
|
+
jobs: {
|
|
32
|
+
type: "integer",
|
|
33
|
+
description: "Number of parallel workers (default: 1)"
|
|
34
|
+
},
|
|
35
|
+
fail_fast: {
|
|
36
|
+
type: "integer",
|
|
37
|
+
description: "Stop after N surviving mutants"
|
|
38
|
+
},
|
|
39
|
+
spec: {
|
|
40
|
+
type: "array",
|
|
41
|
+
items: { type: "string" },
|
|
42
|
+
description: "Spec files to run (overrides auto-detection)"
|
|
43
|
+
},
|
|
44
|
+
suggest_tests: {
|
|
45
|
+
type: "boolean",
|
|
46
|
+
description: "When true, suggestions for survived mutants include concrete RSpec test code " \
|
|
47
|
+
"instead of static description text (default: false)"
|
|
48
|
+
},
|
|
49
|
+
verbosity: {
|
|
50
|
+
type: "string",
|
|
51
|
+
enum: %w[full summary minimal],
|
|
52
|
+
description: "Response verbosity: full (all entries, diffs stripped from killed/neutral/equivalent), " \
|
|
53
|
+
"summary (omits killed/neutral/equivalent arrays; default), " \
|
|
54
|
+
"minimal (only summary + survived)"
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
class << self
|
|
60
|
+
# rubocop:disable Metrics/ParameterLists
|
|
61
|
+
def call(server_context:, files: [], target: nil, timeout: nil, jobs: nil,
|
|
62
|
+
fail_fast: nil, spec: nil, suggest_tests: nil, verbosity: nil)
|
|
63
|
+
parsed_files, line_ranges = parse_files(Array(files))
|
|
64
|
+
config_opts = build_config_opts(parsed_files, line_ranges, target, timeout, jobs, fail_fast, spec,
|
|
65
|
+
suggest_tests)
|
|
66
|
+
config = Evilution::Config.new(**config_opts)
|
|
67
|
+
on_result = build_streaming_callback(server_context, suggest_tests)
|
|
68
|
+
runner = Evilution::Runner.new(config: config, on_result: on_result)
|
|
69
|
+
summary = runner.call
|
|
70
|
+
report = Evilution::Reporter::JSON.new(suggest_tests: suggest_tests == true).call(summary)
|
|
71
|
+
compact = trim_report(report, normalize_verbosity(verbosity))
|
|
72
|
+
|
|
73
|
+
::MCP::Tool::Response.new([{ type: "text", text: compact }])
|
|
74
|
+
rescue Evilution::Error => e
|
|
75
|
+
error_payload = build_error_payload(e)
|
|
76
|
+
::MCP::Tool::Response.new([{ type: "text", text: ::JSON.generate(error_payload) }], error: true)
|
|
77
|
+
end
|
|
78
|
+
# rubocop:enable Metrics/ParameterLists
|
|
8
79
|
|
|
9
|
-
|
|
10
|
-
module MCP
|
|
11
|
-
class MutateTool < ::MCP::Tool
|
|
12
|
-
tool_name "evilution-mutate"
|
|
13
|
-
description "Run mutation testing on Ruby source files. " \
|
|
14
|
-
"Use suggest_tests: true to get concrete RSpec test code for surviving mutants."
|
|
15
|
-
input_schema(
|
|
16
|
-
properties: {
|
|
17
|
-
files: {
|
|
18
|
-
type: "array",
|
|
19
|
-
items: { type: "string" },
|
|
20
|
-
description: "Target files, supports line-range syntax (e.g. lib/foo.rb:15-30)"
|
|
21
|
-
},
|
|
22
|
-
target: {
|
|
23
|
-
type: "string",
|
|
24
|
-
description: "Only mutate the named method (e.g. Foo#bar)"
|
|
25
|
-
},
|
|
26
|
-
timeout: {
|
|
27
|
-
type: "integer",
|
|
28
|
-
description: "Per-mutation timeout in seconds (default: 30)"
|
|
29
|
-
},
|
|
30
|
-
jobs: {
|
|
31
|
-
type: "integer",
|
|
32
|
-
description: "Number of parallel workers (default: 1)"
|
|
33
|
-
},
|
|
34
|
-
fail_fast: {
|
|
35
|
-
type: "integer",
|
|
36
|
-
description: "Stop after N surviving mutants"
|
|
37
|
-
},
|
|
38
|
-
spec: {
|
|
39
|
-
type: "array",
|
|
40
|
-
items: { type: "string" },
|
|
41
|
-
description: "Spec files to run (overrides auto-detection)"
|
|
42
|
-
},
|
|
43
|
-
suggest_tests: {
|
|
44
|
-
type: "boolean",
|
|
45
|
-
description: "When true, suggestions for survived mutants include concrete RSpec test code " \
|
|
46
|
-
"instead of static description text (default: false)"
|
|
47
|
-
},
|
|
48
|
-
verbosity: {
|
|
49
|
-
type: "string",
|
|
50
|
-
enum: %w[full summary minimal],
|
|
51
|
-
description: "Response verbosity: full (all entries, diffs stripped from killed/neutral/equivalent), " \
|
|
52
|
-
"summary (omits killed/neutral/equivalent arrays; default), " \
|
|
53
|
-
"minimal (only summary + survived)"
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
)
|
|
57
|
-
|
|
58
|
-
class << self
|
|
59
|
-
# rubocop:disable Lint/UnusedMethodArgument,Metrics/ParameterLists
|
|
60
|
-
def call(server_context:, files: [], target: nil, timeout: nil, jobs: nil,
|
|
61
|
-
fail_fast: nil, spec: nil, suggest_tests: nil, verbosity: nil)
|
|
62
|
-
parsed_files, line_ranges = parse_files(Array(files))
|
|
63
|
-
config_opts = build_config_opts(parsed_files, line_ranges, target, timeout, jobs, fail_fast, spec,
|
|
64
|
-
suggest_tests)
|
|
65
|
-
config = Config.new(**config_opts)
|
|
66
|
-
runner = Runner.new(config: config)
|
|
67
|
-
summary = runner.call
|
|
68
|
-
report = Reporter::JSON.new.call(summary)
|
|
69
|
-
compact = trim_report(report, normalize_verbosity(verbosity))
|
|
70
|
-
|
|
71
|
-
::MCP::Tool::Response.new([{ type: "text", text: compact }])
|
|
72
|
-
rescue Evilution::Error => e
|
|
73
|
-
error_payload = build_error_payload(e)
|
|
74
|
-
::MCP::Tool::Response.new([{ type: "text", text: ::JSON.generate(error_payload) }], error: true)
|
|
75
|
-
end
|
|
76
|
-
# rubocop:enable Lint/UnusedMethodArgument,Metrics/ParameterLists
|
|
80
|
+
VALID_VERBOSITIES = %w[full summary minimal].freeze
|
|
77
81
|
|
|
78
|
-
|
|
82
|
+
private
|
|
79
83
|
|
|
80
|
-
|
|
84
|
+
def parse_files(raw_files)
|
|
85
|
+
files = []
|
|
86
|
+
ranges = {}
|
|
81
87
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
88
|
+
raw_files.each do |arg|
|
|
89
|
+
file, range_str = arg.split(":", 2)
|
|
90
|
+
files << file
|
|
91
|
+
next unless range_str
|
|
85
92
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
files << file
|
|
89
|
-
next unless range_str
|
|
93
|
+
ranges[file] = parse_line_range(range_str)
|
|
94
|
+
end
|
|
90
95
|
|
|
91
|
-
|
|
92
|
-
|
|
96
|
+
[files, ranges]
|
|
97
|
+
end
|
|
93
98
|
|
|
94
|
-
|
|
95
|
-
|
|
99
|
+
def parse_line_range(str)
|
|
100
|
+
if str.include?("-")
|
|
101
|
+
start_str, end_str = str.split("-", 2)
|
|
102
|
+
start_line = Integer(start_str)
|
|
103
|
+
end_line = end_str.empty? ? Float::INFINITY : Integer(end_str)
|
|
104
|
+
start_line..end_line
|
|
105
|
+
else
|
|
106
|
+
line = Integer(str)
|
|
107
|
+
line..line
|
|
108
|
+
end
|
|
109
|
+
rescue ArgumentError, TypeError
|
|
110
|
+
raise Evilution::ParseError, "invalid line range: #{str.inspect}"
|
|
111
|
+
end
|
|
96
112
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
rescue ArgumentError, TypeError
|
|
108
|
-
raise ParseError, "invalid line range: #{str.inspect}"
|
|
109
|
-
end
|
|
113
|
+
def build_config_opts(files, line_ranges, target, timeout, jobs, fail_fast, spec, suggest_tests)
|
|
114
|
+
opts = { target_files: files, line_ranges: line_ranges, format: :json, quiet: true, skip_config_file: true }
|
|
115
|
+
opts[:target] = target if target
|
|
116
|
+
opts[:timeout] = timeout if timeout
|
|
117
|
+
opts[:jobs] = jobs if jobs
|
|
118
|
+
opts[:fail_fast] = fail_fast if fail_fast
|
|
119
|
+
opts[:spec_files] = spec if spec
|
|
120
|
+
opts[:suggest_tests] = true if suggest_tests
|
|
121
|
+
opts
|
|
122
|
+
end
|
|
110
123
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
opts[:jobs] = jobs if jobs
|
|
116
|
-
opts[:fail_fast] = fail_fast if fail_fast
|
|
117
|
-
opts[:spec_files] = spec if spec
|
|
118
|
-
opts[:suggest_tests] = true if suggest_tests
|
|
119
|
-
opts
|
|
120
|
-
end
|
|
124
|
+
def normalize_verbosity(value)
|
|
125
|
+
normalized = value.to_s.strip.downcase
|
|
126
|
+
normalized = "summary" if normalized.empty?
|
|
127
|
+
return normalized if VALID_VERBOSITIES.include?(normalized)
|
|
121
128
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
normalized = "summary" if normalized.empty?
|
|
125
|
-
return normalized if VALID_VERBOSITIES.include?(normalized)
|
|
129
|
+
raise Evilution::ParseError, "invalid verbosity: #{value.inspect} (must be full, summary, or minimal)"
|
|
130
|
+
end
|
|
126
131
|
|
|
127
|
-
|
|
128
|
-
|
|
132
|
+
def trim_report(json_string, verbosity)
|
|
133
|
+
data = ::JSON.parse(json_string)
|
|
134
|
+
case verbosity
|
|
135
|
+
when "full"
|
|
136
|
+
strip_diffs(data, "killed")
|
|
137
|
+
strip_diffs(data, "neutral")
|
|
138
|
+
strip_diffs(data, "equivalent")
|
|
139
|
+
when "summary"
|
|
140
|
+
data.delete("killed")
|
|
141
|
+
data.delete("neutral")
|
|
142
|
+
data.delete("equivalent")
|
|
143
|
+
when "minimal"
|
|
144
|
+
data.delete("killed")
|
|
145
|
+
data.delete("neutral")
|
|
146
|
+
data.delete("equivalent")
|
|
147
|
+
data.delete("timed_out")
|
|
148
|
+
data.delete("errors")
|
|
149
|
+
end
|
|
150
|
+
::JSON.generate(data)
|
|
151
|
+
end
|
|
129
152
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
case verbosity
|
|
133
|
-
when "full"
|
|
134
|
-
strip_diffs(data, "killed")
|
|
135
|
-
strip_diffs(data, "neutral")
|
|
136
|
-
strip_diffs(data, "equivalent")
|
|
137
|
-
when "summary"
|
|
138
|
-
data.delete("killed")
|
|
139
|
-
data.delete("neutral")
|
|
140
|
-
data.delete("equivalent")
|
|
141
|
-
when "minimal"
|
|
142
|
-
data.delete("killed")
|
|
143
|
-
data.delete("neutral")
|
|
144
|
-
data.delete("equivalent")
|
|
145
|
-
data.delete("timed_out")
|
|
146
|
-
data.delete("errors")
|
|
147
|
-
end
|
|
148
|
-
::JSON.generate(data)
|
|
149
|
-
end
|
|
153
|
+
def strip_diffs(data, key)
|
|
154
|
+
return unless data[key]
|
|
150
155
|
|
|
151
|
-
|
|
152
|
-
|
|
156
|
+
data[key].each { |entry| entry.delete("diff") }
|
|
157
|
+
end
|
|
153
158
|
|
|
154
|
-
|
|
155
|
-
|
|
159
|
+
def build_streaming_callback(server_context, suggest_tests)
|
|
160
|
+
return nil unless suggest_tests && server_context.respond_to?(:report_progress)
|
|
161
|
+
|
|
162
|
+
suggestion = Evilution::Reporter::Suggestion.new(suggest_tests: true)
|
|
163
|
+
survivor_index = 0
|
|
156
164
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
when ConfigError then "config_error"
|
|
160
|
-
when ParseError then "parse_error"
|
|
161
|
-
else "runtime_error"
|
|
162
|
-
end
|
|
165
|
+
proc do |result|
|
|
166
|
+
next unless result.survived?
|
|
163
167
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
168
|
+
begin
|
|
169
|
+
survivor_index += 1
|
|
170
|
+
detail = build_suggestion_detail(result.mutation, suggestion)
|
|
171
|
+
server_context.report_progress(survivor_index, message: ::JSON.generate(detail))
|
|
172
|
+
rescue StandardError # rubocop:disable Lint/SuppressedException
|
|
167
173
|
end
|
|
168
174
|
end
|
|
169
175
|
end
|
|
176
|
+
|
|
177
|
+
def build_suggestion_detail(mutation, suggestion)
|
|
178
|
+
{
|
|
179
|
+
operator: mutation.operator_name,
|
|
180
|
+
file: mutation.file_path,
|
|
181
|
+
line: mutation.line,
|
|
182
|
+
subject: mutation.subject.name,
|
|
183
|
+
diff: mutation.diff,
|
|
184
|
+
suggestion: suggestion.suggestion_for(mutation)
|
|
185
|
+
}
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def build_error_payload(error)
|
|
189
|
+
error_type = case error
|
|
190
|
+
when Evilution::ConfigError then "config_error"
|
|
191
|
+
when Evilution::ParseError then "parse_error"
|
|
192
|
+
else "runtime_error"
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
payload = { type: error_type, message: error.message }
|
|
196
|
+
payload[:file] = error.file if error.file
|
|
197
|
+
{ error: payload }
|
|
198
|
+
end
|
|
170
199
|
end
|
|
171
200
|
end
|
data/lib/evilution/mcp/server.rb
CHANGED
|
@@ -3,17 +3,18 @@
|
|
|
3
3
|
require "mcp"
|
|
4
4
|
require_relative "../version"
|
|
5
5
|
require_relative "mutate_tool"
|
|
6
|
+
require_relative "session_list_tool"
|
|
7
|
+
require_relative "session_show_tool"
|
|
8
|
+
require_relative "session_diff_tool"
|
|
6
9
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
end
|
|
17
|
-
end
|
|
10
|
+
require_relative "../mcp"
|
|
11
|
+
|
|
12
|
+
class Evilution::MCP::Server
|
|
13
|
+
def self.build
|
|
14
|
+
::MCP::Server.new(
|
|
15
|
+
name: "evilution",
|
|
16
|
+
version: Evilution::VERSION,
|
|
17
|
+
tools: [Evilution::MCP::MutateTool, Evilution::MCP::SessionListTool, Evilution::MCP::SessionShowTool, Evilution::MCP::SessionDiffTool]
|
|
18
|
+
)
|
|
18
19
|
end
|
|
19
20
|
end
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "mcp"
|
|
5
|
+
require_relative "../session/store"
|
|
6
|
+
|
|
7
|
+
require_relative "../mcp"
|
|
8
|
+
|
|
9
|
+
class Evilution::MCP::SessionDiffTool < MCP::Tool
|
|
10
|
+
tool_name "evilution-session-diff"
|
|
11
|
+
description "Compare two mutation testing sessions and return the diff. " \
|
|
12
|
+
"Shows new regressions, fixed mutations, and persistent survivors."
|
|
13
|
+
input_schema(
|
|
14
|
+
properties: {
|
|
15
|
+
base: {
|
|
16
|
+
type: "string",
|
|
17
|
+
description: "Path to the base (older) session JSON file"
|
|
18
|
+
},
|
|
19
|
+
head: {
|
|
20
|
+
type: "string",
|
|
21
|
+
description: "Path to the head (newer) session JSON file"
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
class << self
|
|
27
|
+
# rubocop:disable Lint/UnusedMethodArgument
|
|
28
|
+
def call(server_context:, base: nil, head: nil)
|
|
29
|
+
return error_response("config_error", "base is required") unless base
|
|
30
|
+
return error_response("config_error", "head is required") unless head
|
|
31
|
+
|
|
32
|
+
store = Evilution::Session::Store.new
|
|
33
|
+
base_data = store.load(base)
|
|
34
|
+
head_data = store.load(head)
|
|
35
|
+
|
|
36
|
+
::MCP::Tool::Response.new([{ type: "text", text: ::JSON.generate(build_diff(base_data, head_data)) }])
|
|
37
|
+
rescue Evilution::Error => e
|
|
38
|
+
error_response("not_found", e.message)
|
|
39
|
+
rescue ::JSON::ParserError => e
|
|
40
|
+
error_response("parse_error", e.message)
|
|
41
|
+
rescue SystemCallError => e
|
|
42
|
+
error_response("runtime_error", e.message)
|
|
43
|
+
end
|
|
44
|
+
# rubocop:enable Lint/UnusedMethodArgument
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def build_diff(base_data, head_data)
|
|
49
|
+
base_survivors = base_data["survived"] || []
|
|
50
|
+
head_survivors = head_data["survived"] || []
|
|
51
|
+
|
|
52
|
+
base_keys = base_survivors.to_set { |m| mutation_key(m) }
|
|
53
|
+
head_keys = head_survivors.to_set { |m| mutation_key(m) }
|
|
54
|
+
|
|
55
|
+
{
|
|
56
|
+
"summary" => build_summary_diff(base_data, head_data),
|
|
57
|
+
"fixed" => base_survivors.reject { |m| head_keys.include?(mutation_key(m)) },
|
|
58
|
+
"new_survivors" => head_survivors.reject { |m| base_keys.include?(mutation_key(m)) },
|
|
59
|
+
"persistent" => head_survivors.select { |m| base_keys.include?(mutation_key(m)) }
|
|
60
|
+
}
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def build_summary_diff(base_data, head_data)
|
|
64
|
+
base_summary = base_data["summary"] || {}
|
|
65
|
+
head_summary = head_data["summary"] || {}
|
|
66
|
+
base_score = base_summary["score"] || 0.0
|
|
67
|
+
head_score = head_summary["score"] || 0.0
|
|
68
|
+
|
|
69
|
+
{
|
|
70
|
+
"base_score" => base_score,
|
|
71
|
+
"head_score" => head_score,
|
|
72
|
+
"score_delta" => (head_score - base_score).round(4),
|
|
73
|
+
"base_survived" => base_summary["survived"] || 0,
|
|
74
|
+
"head_survived" => head_summary["survived"] || 0
|
|
75
|
+
}
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def mutation_key(mutation)
|
|
79
|
+
[mutation["operator"], mutation["file"], mutation["line"], mutation["subject"]]
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def error_response(type, message)
|
|
83
|
+
::MCP::Tool::Response.new(
|
|
84
|
+
[{ type: "text", text: ::JSON.generate({ error: { type: type, message: message } }) }],
|
|
85
|
+
error: true
|
|
86
|
+
)
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "mcp"
|
|
5
|
+
require_relative "../session/store"
|
|
6
|
+
|
|
7
|
+
require_relative "../mcp"
|
|
8
|
+
|
|
9
|
+
class Evilution::MCP::SessionListTool < MCP::Tool
|
|
10
|
+
tool_name "evilution-session-list"
|
|
11
|
+
description "List past mutation testing sessions with summary statistics. " \
|
|
12
|
+
"Returns sessions in reverse chronological order."
|
|
13
|
+
input_schema(
|
|
14
|
+
properties: {
|
|
15
|
+
results_dir: {
|
|
16
|
+
type: "string",
|
|
17
|
+
description: "Session results directory (default: .evilution/results)"
|
|
18
|
+
},
|
|
19
|
+
limit: {
|
|
20
|
+
type: "integer",
|
|
21
|
+
description: "Return only the N most recent sessions"
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
class << self
|
|
27
|
+
# rubocop:disable Lint/UnusedMethodArgument
|
|
28
|
+
def call(server_context:, results_dir: nil, limit: nil)
|
|
29
|
+
store_opts = {}
|
|
30
|
+
store_opts[:results_dir] = results_dir if results_dir
|
|
31
|
+
store = Evilution::Session::Store.new(**store_opts)
|
|
32
|
+
entries = store.list
|
|
33
|
+
entries = entries.first(limit) if limit
|
|
34
|
+
|
|
35
|
+
payload = entries.map { |e| stringify_keys(e) }
|
|
36
|
+
::MCP::Tool::Response.new([{ type: "text", text: ::JSON.generate(payload) }])
|
|
37
|
+
end
|
|
38
|
+
# rubocop:enable Lint/UnusedMethodArgument
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def stringify_keys(hash)
|
|
43
|
+
hash.transform_keys(&:to_s)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|