evilution 0.23.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 +210 -0
- data/CHANGELOG.md +51 -0
- data/README.md +81 -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 +78 -0
- data/lib/evilution/cli/parser/file_args.rb +41 -0
- data/lib/evilution/cli/parser/options_builder.rb +123 -0
- data/lib/evilution/cli/parser/stdin_reader.rb +28 -0
- data/lib/evilution/cli/parser.rb +27 -196
- 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 +178 -3
- data/lib/evilution/example_filter.rb +143 -0
- data/lib/evilution/integration/base.rb +11 -57
- data/lib/evilution/integration/crash_detector.rb +5 -2
- data/lib/evilution/integration/minitest.rb +25 -7
- data/lib/evilution/integration/minitest_crash_detector.rb +5 -2
- data/lib/evilution/integration/rspec.rb +99 -12
- data/lib/evilution/isolation/fork.rb +26 -0
- data/lib/evilution/isolation/in_process.rb +1 -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 +38 -11
- data/lib/evilution/reporter/html/assets/style.css +85 -0
- data/lib/evilution/reporter/html/baseline_keys.rb +28 -0
- data/lib/evilution/reporter/html/diff_formatter.rb +27 -0
- data/lib/evilution/reporter/html/escape.rb +12 -0
- data/lib/evilution/reporter/html/namespace.rb +11 -0
- data/lib/evilution/reporter/html/report.rb +68 -0
- data/lib/evilution/reporter/html/section.rb +21 -0
- data/lib/evilution/reporter/html/sections/baseline_comparison.rb +46 -0
- data/lib/evilution/reporter/html/sections/error_details.rb +30 -0
- data/lib/evilution/reporter/html/sections/error_entry.rb +22 -0
- data/lib/evilution/reporter/html/sections/file_section.rb +62 -0
- data/lib/evilution/reporter/html/sections/header.rb +29 -0
- data/lib/evilution/reporter/html/sections/mutation_map.rb +32 -0
- data/lib/evilution/reporter/html/sections/neutral_details.rb +25 -0
- data/lib/evilution/reporter/html/sections/summary_cards.rb +11 -0
- data/lib/evilution/reporter/html/sections/survived_details.rb +35 -0
- data/lib/evilution/reporter/html/sections/survived_entry.rb +36 -0
- data/lib/evilution/reporter/html/sections/truncation_notice.rb +17 -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/sections.rb +4 -0
- data/lib/evilution/reporter/html/stylesheet.rb +14 -0
- data/lib/evilution/reporter/html/templates/baseline_comparison.html.erb +8 -0
- data/lib/evilution/reporter/html/templates/error_details.html.erb +6 -0
- data/lib/evilution/reporter/html/templates/error_entry.html.erb +10 -0
- data/lib/evilution/reporter/html/templates/file_section.html.erb +12 -0
- data/lib/evilution/reporter/html/templates/header.html.erb +4 -0
- data/lib/evilution/reporter/html/templates/mutation_map.html.erb +6 -0
- data/lib/evilution/reporter/html/templates/neutral_details.html.erb +14 -0
- data/lib/evilution/reporter/html/templates/report.html.erb +17 -0
- data/lib/evilution/reporter/html/templates/summary_cards.html.erb +26 -0
- data/lib/evilution/reporter/html/templates/survived_details.html.erb +21 -0
- data/lib/evilution/reporter/html/templates/survived_entry.html.erb +8 -0
- data/lib/evilution/reporter/html/templates/truncation_notice.html.erb +1 -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/html.rb +11 -390
- data/lib/evilution/reporter/json.rb +19 -9
- 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 +9 -1
- data/lib/evilution/result/summary.rb +21 -1
- data/lib/evilution/runner/baseline_runner.rb +92 -0
- data/lib/evilution/runner/diagnostics.rb +105 -0
- data/lib/evilution/runner/isolation_resolver.rb +134 -0
- data/lib/evilution/runner/mutation_executor.rb +325 -0
- data/lib/evilution/runner/mutation_planner.rb +126 -0
- data/lib/evilution/runner/report_publisher.rb +60 -0
- data/lib/evilution/runner/subject_pipeline.rb +121 -0
- data/lib/evilution/runner.rb +61 -692
- 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 +75 -2
|
@@ -4,6 +4,7 @@ require "stringio"
|
|
|
4
4
|
require_relative "base"
|
|
5
5
|
require_relative "minitest_crash_detector"
|
|
6
6
|
require_relative "../spec_resolver"
|
|
7
|
+
require_relative "../spec_selector"
|
|
7
8
|
|
|
8
9
|
require_relative "../integration"
|
|
9
10
|
|
|
@@ -35,10 +36,13 @@ class Evilution::Integration::Minitest < Evilution::Integration::Base
|
|
|
35
36
|
}
|
|
36
37
|
end
|
|
37
38
|
|
|
38
|
-
def initialize(test_files: nil, hooks: nil)
|
|
39
|
+
def initialize(test_files: nil, hooks: nil, fallback_to_full_suite: false, spec_selector: nil)
|
|
39
40
|
@test_files = test_files
|
|
40
41
|
@minitest_loaded = false
|
|
41
|
-
@
|
|
42
|
+
@spec_selector = spec_selector || Evilution::SpecSelector.new(
|
|
43
|
+
spec_resolver: Evilution::SpecResolver.new(test_dir: "test", test_suffix: "_test.rb", request_dir: "integration")
|
|
44
|
+
)
|
|
45
|
+
@fallback_to_full_suite = fallback_to_full_suite
|
|
42
46
|
@crash_detector = nil
|
|
43
47
|
@warned_files = Set.new
|
|
44
48
|
super(hooks: hooks)
|
|
@@ -62,6 +66,8 @@ class Evilution::Integration::Minitest < Evilution::Integration::Base
|
|
|
62
66
|
def run_tests(mutation)
|
|
63
67
|
reset_state
|
|
64
68
|
files = resolve_test_files(mutation)
|
|
69
|
+
return unresolved_result(mutation) if files.nil?
|
|
70
|
+
|
|
65
71
|
command = "ruby -Itest #{files.join(" ")}"
|
|
66
72
|
|
|
67
73
|
files.each { |f| load(File.expand_path(f)) }
|
|
@@ -75,6 +81,15 @@ class Evilution::Integration::Minitest < Evilution::Integration::Base
|
|
|
75
81
|
{ passed: false, error: e.message, test_command: command }
|
|
76
82
|
end
|
|
77
83
|
|
|
84
|
+
def unresolved_result(mutation)
|
|
85
|
+
{
|
|
86
|
+
passed: false,
|
|
87
|
+
unresolved: true,
|
|
88
|
+
error: "no matching test resolved for #{mutation.file_path}",
|
|
89
|
+
test_command: "ruby -Itest (skipped: no test resolved for #{mutation.file_path})"
|
|
90
|
+
}
|
|
91
|
+
end
|
|
92
|
+
|
|
78
93
|
def build_args(_mutation)
|
|
79
94
|
["--seed", "0"]
|
|
80
95
|
end
|
|
@@ -112,10 +127,12 @@ class Evilution::Integration::Minitest < Evilution::Integration::Base
|
|
|
112
127
|
if passed
|
|
113
128
|
{ passed: true, test_command: command }
|
|
114
129
|
elsif detector.only_crashes?
|
|
130
|
+
classes = detector.unique_crash_classes
|
|
115
131
|
{
|
|
116
132
|
passed: false,
|
|
117
133
|
test_crashed: true,
|
|
118
134
|
error: "test crashes: #{detector.crash_summary}",
|
|
135
|
+
error_class: (classes.first if classes.length == 1),
|
|
119
136
|
test_command: command
|
|
120
137
|
}
|
|
121
138
|
else
|
|
@@ -126,13 +143,13 @@ class Evilution::Integration::Minitest < Evilution::Integration::Base
|
|
|
126
143
|
def resolve_test_files(mutation)
|
|
127
144
|
return test_files if test_files
|
|
128
145
|
|
|
129
|
-
resolved = @
|
|
130
|
-
|
|
146
|
+
resolved = Array(@spec_selector.call(mutation.file_path))
|
|
147
|
+
if resolved.empty?
|
|
131
148
|
warn_unresolved_test(mutation.file_path)
|
|
132
|
-
return glob_test_files
|
|
149
|
+
return @fallback_to_full_suite ? glob_test_files : nil
|
|
133
150
|
end
|
|
134
151
|
|
|
135
|
-
|
|
152
|
+
resolved
|
|
136
153
|
end
|
|
137
154
|
|
|
138
155
|
def glob_test_files
|
|
@@ -144,7 +161,8 @@ class Evilution::Integration::Minitest < Evilution::Integration::Base
|
|
|
144
161
|
return if @warned_files.include?(file_path)
|
|
145
162
|
|
|
146
163
|
@warned_files << file_path
|
|
147
|
-
|
|
164
|
+
action = @fallback_to_full_suite ? "running full suite" : "marking mutation unresolved"
|
|
165
|
+
warn "[evilution] No matching test found for #{file_path}, #{action}. " \
|
|
148
166
|
"Use --spec to specify the test file."
|
|
149
167
|
end
|
|
150
168
|
end
|
|
@@ -49,7 +49,10 @@ class Evilution::Integration::MinitestCrashDetector
|
|
|
49
49
|
def crash_summary
|
|
50
50
|
return nil if @crashes.empty?
|
|
51
51
|
|
|
52
|
-
|
|
53
|
-
|
|
52
|
+
"#{unique_crash_classes.join(", ")} (#{@crashes.length} crash#{"es" unless @crashes.length == 1})"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def unique_crash_classes
|
|
56
|
+
@crashes.map { |e| e.class.name }.uniq
|
|
54
57
|
end
|
|
55
58
|
end
|
|
@@ -4,6 +4,7 @@ require "stringio"
|
|
|
4
4
|
require_relative "base"
|
|
5
5
|
require_relative "crash_detector"
|
|
6
6
|
require_relative "../spec_resolver"
|
|
7
|
+
require_relative "../spec_selector"
|
|
7
8
|
require_relative "../related_spec_heuristic"
|
|
8
9
|
|
|
9
10
|
require_relative "../integration"
|
|
@@ -26,12 +27,15 @@ class Evilution::Integration::RSpec < Evilution::Integration::Base
|
|
|
26
27
|
{ runner: baseline_runner }
|
|
27
28
|
end
|
|
28
29
|
|
|
29
|
-
def initialize(test_files: nil, hooks: nil, related_specs_heuristic: false
|
|
30
|
+
def initialize(test_files: nil, hooks: nil, related_specs_heuristic: false, fallback_to_full_suite: false,
|
|
31
|
+
spec_selector: nil, example_filter: nil)
|
|
30
32
|
@test_files = test_files
|
|
31
33
|
@rspec_loaded = false
|
|
32
|
-
@
|
|
34
|
+
@spec_selector = spec_selector || Evilution::SpecSelector.new
|
|
33
35
|
@related_spec_heuristic = Evilution::RelatedSpecHeuristic.new
|
|
34
36
|
@related_specs_heuristic_enabled = related_specs_heuristic
|
|
37
|
+
@fallback_to_full_suite = fallback_to_full_suite
|
|
38
|
+
@example_filter = example_filter
|
|
35
39
|
@crash_detector = nil
|
|
36
40
|
@warned_files = Set.new
|
|
37
41
|
super(hooks: hooks)
|
|
@@ -57,14 +61,21 @@ class Evilution::Integration::RSpec < Evilution::Integration::Base
|
|
|
57
61
|
def run_tests(mutation)
|
|
58
62
|
reset_state
|
|
59
63
|
|
|
64
|
+
files = resolve_test_files(mutation)
|
|
65
|
+
return unresolved_result(mutation) if files.nil?
|
|
66
|
+
|
|
67
|
+
targets = apply_example_filter(mutation, files)
|
|
68
|
+
return unresolved_example_result(mutation) if targets.nil?
|
|
69
|
+
|
|
60
70
|
out = StringIO.new
|
|
61
71
|
err = StringIO.new
|
|
62
|
-
|
|
63
|
-
args = build_args(mutation)
|
|
72
|
+
args = build_args(targets)
|
|
64
73
|
command = "rspec #{args.join(" ")}"
|
|
65
74
|
|
|
66
75
|
detector = reset_crash_detector
|
|
67
76
|
eg_before = snapshot_example_groups
|
|
77
|
+
fe_before = snapshot_filtered_examples_keys
|
|
78
|
+
rep_before = snapshot_reporter_lengths
|
|
68
79
|
status = ::RSpec::Core::Runner.run(args, out, err)
|
|
69
80
|
|
|
70
81
|
build_rspec_result(status, command, detector)
|
|
@@ -72,13 +83,38 @@ class Evilution::Integration::RSpec < Evilution::Integration::Base
|
|
|
72
83
|
{ passed: false, error: e.message, test_command: command }
|
|
73
84
|
ensure
|
|
74
85
|
release_rspec_state(eg_before)
|
|
86
|
+
release_filtered_examples(fe_before)
|
|
87
|
+
release_reporter_state(rep_before)
|
|
75
88
|
end
|
|
76
89
|
|
|
77
|
-
def build_args(
|
|
78
|
-
files = resolve_test_files(mutation)
|
|
90
|
+
def build_args(files)
|
|
79
91
|
["--format", "progress", "--no-color", "--order", "defined", *files]
|
|
80
92
|
end
|
|
81
93
|
|
|
94
|
+
def apply_example_filter(mutation, files)
|
|
95
|
+
return files unless @example_filter
|
|
96
|
+
|
|
97
|
+
@example_filter.call(mutation, files)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def unresolved_result(mutation)
|
|
101
|
+
{
|
|
102
|
+
passed: false,
|
|
103
|
+
unresolved: true,
|
|
104
|
+
error: "no matching spec resolved for #{mutation.file_path}",
|
|
105
|
+
test_command: "rspec (skipped: no spec resolved for #{mutation.file_path})"
|
|
106
|
+
}
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def unresolved_example_result(mutation)
|
|
110
|
+
{
|
|
111
|
+
passed: false,
|
|
112
|
+
unresolved: true,
|
|
113
|
+
error: "no matching example found for #{mutation.file_path}",
|
|
114
|
+
test_command: "rspec (skipped: no matching example for #{mutation.file_path})"
|
|
115
|
+
}
|
|
116
|
+
end
|
|
117
|
+
|
|
82
118
|
def reset_state
|
|
83
119
|
if ::RSpec.respond_to?(:clear_examples)
|
|
84
120
|
::RSpec.clear_examples
|
|
@@ -127,6 +163,54 @@ class Evilution::Integration::RSpec < Evilution::Integration::Base
|
|
|
127
163
|
world.instance_variable_set(:@sources_by_path, {}) if world.instance_variable_defined?(:@sources_by_path)
|
|
128
164
|
end
|
|
129
165
|
|
|
166
|
+
def snapshot_filtered_examples_keys
|
|
167
|
+
fe = rspec_world_ivar(:@filtered_examples)
|
|
168
|
+
fe ? Set.new(fe.keys.map(&:object_id)) : nil
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def snapshot_reporter_lengths
|
|
172
|
+
reporter = rspec_config_ivar(:@reporter)
|
|
173
|
+
return nil unless reporter
|
|
174
|
+
|
|
175
|
+
%i[@examples @failed_examples @pending_examples].each_with_object({}) do |ivar, acc|
|
|
176
|
+
next unless reporter.instance_variable_defined?(ivar)
|
|
177
|
+
|
|
178
|
+
arr = reporter.instance_variable_get(ivar)
|
|
179
|
+
acc[ivar] = arr.length if arr.is_a?(Array)
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def release_filtered_examples(snapshot_keys)
|
|
184
|
+
fe = rspec_world_ivar(:@filtered_examples)
|
|
185
|
+
return unless fe && snapshot_keys
|
|
186
|
+
|
|
187
|
+
fe.each_key.to_a.each do |k|
|
|
188
|
+
fe.delete(k) unless snapshot_keys.include?(k.object_id)
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def release_reporter_state(lengths)
|
|
193
|
+
return unless lengths
|
|
194
|
+
|
|
195
|
+
reporter = rspec_config_ivar(:@reporter)
|
|
196
|
+
return unless reporter
|
|
197
|
+
|
|
198
|
+
lengths.each do |ivar, length|
|
|
199
|
+
arr = reporter.instance_variable_get(ivar)
|
|
200
|
+
arr.slice!(length..) if arr.is_a?(Array) && arr.length > length
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def rspec_world_ivar(ivar)
|
|
205
|
+
world = ::RSpec.world
|
|
206
|
+
world.instance_variable_defined?(ivar) ? world.instance_variable_get(ivar) : nil
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def rspec_config_ivar(ivar)
|
|
210
|
+
config = ::RSpec.configuration
|
|
211
|
+
config.instance_variable_defined?(ivar) ? config.instance_variable_get(ivar) : nil
|
|
212
|
+
end
|
|
213
|
+
|
|
130
214
|
def reset_crash_detector
|
|
131
215
|
if @crash_detector
|
|
132
216
|
@crash_detector.reset
|
|
@@ -141,10 +225,12 @@ class Evilution::Integration::RSpec < Evilution::Integration::Base
|
|
|
141
225
|
if status.zero?
|
|
142
226
|
{ passed: true, test_command: command }
|
|
143
227
|
elsif detector.only_crashes?
|
|
228
|
+
classes = detector.unique_crash_classes
|
|
144
229
|
{
|
|
145
230
|
passed: false,
|
|
146
231
|
test_crashed: true,
|
|
147
232
|
error: "test crashes: #{detector.crash_summary}",
|
|
233
|
+
error_class: (classes.first if classes.length == 1),
|
|
148
234
|
test_command: command
|
|
149
235
|
}
|
|
150
236
|
else
|
|
@@ -155,23 +241,24 @@ class Evilution::Integration::RSpec < Evilution::Integration::Base
|
|
|
155
241
|
def resolve_test_files(mutation)
|
|
156
242
|
return test_files if test_files
|
|
157
243
|
|
|
158
|
-
resolved = @
|
|
159
|
-
|
|
244
|
+
resolved = Array(@spec_selector.call(mutation.file_path))
|
|
245
|
+
if resolved.empty?
|
|
160
246
|
warn_unresolved_spec(mutation.file_path)
|
|
161
|
-
return ["spec"]
|
|
247
|
+
return @fallback_to_full_suite ? ["spec"] : nil
|
|
162
248
|
end
|
|
163
249
|
|
|
164
|
-
return
|
|
250
|
+
return resolved unless @related_specs_heuristic_enabled
|
|
165
251
|
|
|
166
252
|
related = @related_spec_heuristic.call(mutation)
|
|
167
|
-
(
|
|
253
|
+
(resolved + related).uniq
|
|
168
254
|
end
|
|
169
255
|
|
|
170
256
|
def warn_unresolved_spec(file_path)
|
|
171
257
|
return if @warned_files.include?(file_path)
|
|
172
258
|
|
|
173
259
|
@warned_files << file_path
|
|
174
|
-
|
|
260
|
+
action = @fallback_to_full_suite ? "running full suite" : "marking mutation unresolved"
|
|
261
|
+
warn "[evilution] No matching spec found for #{file_path}, #{action}. " \
|
|
175
262
|
"Use --spec to specify the spec file."
|
|
176
263
|
end
|
|
177
264
|
|
|
@@ -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)
|
|
@@ -98,6 +123,7 @@ class Evilution::Isolation::Fork
|
|
|
98
123
|
def classify_status(result)
|
|
99
124
|
return :timeout if result[:timeout]
|
|
100
125
|
return :killed if result[:test_crashed]
|
|
126
|
+
return :unresolved if result[:unresolved]
|
|
101
127
|
return :error if result[:error]
|
|
102
128
|
return :survived if result[:passed]
|
|
103
129
|
|
|
@@ -65,6 +65,7 @@ class Evilution::Isolation::InProcess
|
|
|
65
65
|
def classify_status(result)
|
|
66
66
|
return :timeout if result[:timeout]
|
|
67
67
|
return :killed if result[:test_crashed]
|
|
68
|
+
return :unresolved if result[:unresolved]
|
|
68
69
|
return :error if result[:error]
|
|
69
70
|
return :survived if result[:passed]
|
|
70
71
|
|
|
@@ -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
|