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.
Files changed (90) hide show
  1. checksums.yaml +4 -4
  2. data/.beads/.migration-hint-ts +1 -1
  3. data/.beads/issues.jsonl +17 -17
  4. data/CHANGELOG.md +39 -0
  5. data/lib/evilution/ast/inheritance_scanner.rb +70 -0
  6. data/lib/evilution/ast/parser.rb +73 -68
  7. data/lib/evilution/ast/source_surgeon.rb +7 -9
  8. data/lib/evilution/ast.rb +4 -0
  9. data/lib/evilution/baseline.rb +73 -75
  10. data/lib/evilution/cache.rb +75 -77
  11. data/lib/evilution/cli.rb +412 -173
  12. data/lib/evilution/config.rb +141 -136
  13. data/lib/evilution/equivalent/detector.rb +29 -27
  14. data/lib/evilution/equivalent/heuristic/alias_swap.rb +32 -33
  15. data/lib/evilution/equivalent/heuristic/arithmetic_identity.rb +30 -0
  16. data/lib/evilution/equivalent/heuristic/comment_marking.rb +21 -0
  17. data/lib/evilution/equivalent/heuristic/dead_code.rb +41 -45
  18. data/lib/evilution/equivalent/heuristic/method_body_nil.rb +11 -15
  19. data/lib/evilution/equivalent/heuristic/noop_source.rb +5 -9
  20. data/lib/evilution/equivalent/heuristic.rb +6 -0
  21. data/lib/evilution/equivalent.rb +4 -0
  22. data/lib/evilution/git/changed_files.rb +35 -37
  23. data/lib/evilution/git.rb +4 -0
  24. data/lib/evilution/integration/base.rb +5 -7
  25. data/lib/evilution/integration/rspec.rb +114 -116
  26. data/lib/evilution/integration.rb +4 -0
  27. data/lib/evilution/isolation/fork.rb +98 -100
  28. data/lib/evilution/isolation/in_process.rb +59 -61
  29. data/lib/evilution/isolation.rb +4 -0
  30. data/lib/evilution/mcp/mutate_tool.rb +172 -143
  31. data/lib/evilution/mcp/server.rb +12 -11
  32. data/lib/evilution/mcp/session_diff_tool.rb +89 -0
  33. data/lib/evilution/mcp/session_list_tool.rb +46 -0
  34. data/lib/evilution/mcp/session_show_tool.rb +53 -0
  35. data/lib/evilution/mcp.rb +4 -0
  36. data/lib/evilution/memory/leak_check.rb +80 -84
  37. data/lib/evilution/memory.rb +34 -36
  38. data/lib/evilution/mutation.rb +40 -42
  39. data/lib/evilution/mutator/base.rb +62 -48
  40. data/lib/evilution/mutator/operator/argument_nil_substitution.rb +32 -36
  41. data/lib/evilution/mutator/operator/argument_removal.rb +32 -36
  42. data/lib/evilution/mutator/operator/arithmetic_replacement.rb +26 -30
  43. data/lib/evilution/mutator/operator/array_literal.rb +18 -22
  44. data/lib/evilution/mutator/operator/block_removal.rb +16 -20
  45. data/lib/evilution/mutator/operator/boolean_literal_replacement.rb +38 -42
  46. data/lib/evilution/mutator/operator/boolean_operator_replacement.rb +41 -45
  47. data/lib/evilution/mutator/operator/collection_replacement.rb +32 -36
  48. data/lib/evilution/mutator/operator/comparison_replacement.rb +24 -28
  49. data/lib/evilution/mutator/operator/compound_assignment.rb +114 -118
  50. data/lib/evilution/mutator/operator/conditional_branch.rb +25 -29
  51. data/lib/evilution/mutator/operator/conditional_flip.rb +26 -30
  52. data/lib/evilution/mutator/operator/conditional_negation.rb +25 -29
  53. data/lib/evilution/mutator/operator/float_literal.rb +22 -26
  54. data/lib/evilution/mutator/operator/hash_literal.rb +18 -22
  55. data/lib/evilution/mutator/operator/integer_literal.rb +2 -0
  56. data/lib/evilution/mutator/operator/method_body_replacement.rb +12 -16
  57. data/lib/evilution/mutator/operator/method_call_removal.rb +12 -16
  58. data/lib/evilution/mutator/operator/mixin_removal.rb +80 -0
  59. data/lib/evilution/mutator/operator/negation_insertion.rb +12 -16
  60. data/lib/evilution/mutator/operator/nil_replacement.rb +13 -17
  61. data/lib/evilution/mutator/operator/range_replacement.rb +12 -16
  62. data/lib/evilution/mutator/operator/receiver_replacement.rb +16 -20
  63. data/lib/evilution/mutator/operator/regexp_mutation.rb +15 -19
  64. data/lib/evilution/mutator/operator/return_value_removal.rb +12 -16
  65. data/lib/evilution/mutator/operator/send_mutation.rb +36 -40
  66. data/lib/evilution/mutator/operator/statement_deletion.rb +13 -17
  67. data/lib/evilution/mutator/operator/string_literal.rb +18 -22
  68. data/lib/evilution/mutator/operator/superclass_removal.rb +65 -0
  69. data/lib/evilution/mutator/operator/symbol_literal.rb +17 -21
  70. data/lib/evilution/mutator/operator.rb +6 -0
  71. data/lib/evilution/mutator/registry.rb +56 -56
  72. data/lib/evilution/mutator.rb +4 -0
  73. data/lib/evilution/parallel/pool.rb +56 -58
  74. data/lib/evilution/parallel.rb +4 -0
  75. data/lib/evilution/reporter/cli.rb +99 -101
  76. data/lib/evilution/reporter/html.rb +242 -244
  77. data/lib/evilution/reporter/json.rb +57 -59
  78. data/lib/evilution/reporter/suggestion.rb +354 -328
  79. data/lib/evilution/reporter.rb +4 -0
  80. data/lib/evilution/result/mutation_result.rb +43 -46
  81. data/lib/evilution/result/summary.rb +80 -81
  82. data/lib/evilution/result.rb +4 -0
  83. data/lib/evilution/runner.rb +401 -316
  84. data/lib/evilution/session/store.rb +147 -0
  85. data/lib/evilution/session.rb +4 -0
  86. data/lib/evilution/spec_resolver.rb +49 -47
  87. data/lib/evilution/subject.rb +14 -16
  88. data/lib/evilution/version.rb +1 -1
  89. data/lib/evilution.rb +16 -0
  90. metadata +24 -2
@@ -4,75 +4,73 @@ require "timeout"
4
4
  require_relative "../memory"
5
5
  require_relative "../result/mutation_result"
6
6
 
7
- module Evilution
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
- build_mutation_result(mutation, result, duration, rss_after, delta)
19
- end
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
- private
18
+ build_mutation_result(mutation, result, duration, rss_after, delta)
19
+ end
22
20
 
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
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
- 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
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
- def compute_memory_delta(rss_before, rss_after, result)
50
- return nil if result[:timeout]
51
- return nil unless rss_before && rss_after
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
- rss_after - rss_before
54
- end
53
+ rss_after - rss_before
54
+ end
55
55
 
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
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
- 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
- )
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
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Evilution::Isolation
4
+ 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
- module Evilution
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
- VALID_VERBOSITIES = %w[full summary minimal].freeze
82
+ private
79
83
 
80
- private
84
+ def parse_files(raw_files)
85
+ files = []
86
+ ranges = {}
81
87
 
82
- def parse_files(raw_files)
83
- files = []
84
- ranges = {}
88
+ raw_files.each do |arg|
89
+ file, range_str = arg.split(":", 2)
90
+ files << file
91
+ next unless range_str
85
92
 
86
- raw_files.each do |arg|
87
- file, range_str = arg.split(":", 2)
88
- files << file
89
- next unless range_str
93
+ ranges[file] = parse_line_range(range_str)
94
+ end
90
95
 
91
- ranges[file] = parse_line_range(range_str)
92
- end
96
+ [files, ranges]
97
+ end
93
98
 
94
- [files, ranges]
95
- end
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
- def parse_line_range(str)
98
- if str.include?("-")
99
- start_str, end_str = str.split("-", 2)
100
- start_line = Integer(start_str)
101
- end_line = end_str.empty? ? Float::INFINITY : Integer(end_str)
102
- start_line..end_line
103
- else
104
- line = Integer(str)
105
- line..line
106
- end
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
- def build_config_opts(files, line_ranges, target, timeout, jobs, fail_fast, spec, suggest_tests)
112
- opts = { target_files: files, line_ranges: line_ranges, format: :json, quiet: true, skip_config_file: true }
113
- opts[:target] = target if target
114
- opts[:timeout] = timeout if timeout
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
- def normalize_verbosity(value)
123
- normalized = value.to_s.strip.downcase
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
- raise ParseError, "invalid verbosity: #{value.inspect} (must be full, summary, or minimal)"
128
- end
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
- def trim_report(json_string, verbosity)
131
- data = ::JSON.parse(json_string)
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
- def strip_diffs(data, key)
152
- return unless data[key]
156
+ data[key].each { |entry| entry.delete("diff") }
157
+ end
153
158
 
154
- data[key].each { |entry| entry.delete("diff") }
155
- end
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
- def build_error_payload(error)
158
- error_type = case error
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
- payload = { type: error_type, message: error.message }
165
- payload[:file] = error.file if error.file
166
- { error: payload }
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
@@ -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
- module Evilution
8
- module MCP
9
- class Server
10
- def self.build
11
- ::MCP::Server.new(
12
- name: "evilution",
13
- version: Evilution::VERSION,
14
- tools: [MutateTool]
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