evilution 0.22.7 → 0.24.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 +8 -0
- data/CHANGELOG.md +28 -0
- data/README.md +37 -7
- data/lib/evilution/cli/command.rb +37 -0
- data/lib/evilution/cli/commands/environment_show.rb +20 -0
- data/lib/evilution/cli/commands/init.rb +24 -0
- data/lib/evilution/cli/commands/mcp.rb +19 -0
- data/lib/evilution/cli/commands/run.rb +68 -0
- data/lib/evilution/cli/commands/session_diff.rb +30 -0
- data/lib/evilution/cli/commands/session_gc.rb +46 -0
- data/lib/evilution/cli/commands/session_list.rb +51 -0
- data/lib/evilution/cli/commands/session_show.rb +27 -0
- data/lib/evilution/cli/commands/subjects.rb +50 -0
- data/lib/evilution/cli/commands/tests_list.rb +43 -0
- data/lib/evilution/cli/commands/util_mutation.rb +66 -0
- data/lib/evilution/cli/commands/version.rb +17 -0
- data/lib/evilution/cli/commands.rb +4 -0
- data/lib/evilution/cli/dispatcher.rb +23 -0
- data/lib/evilution/cli/parsed_args.rb +12 -0
- data/lib/evilution/cli/parser/command_extractor.rb +77 -0
- data/lib/evilution/cli/parser/file_args.rb +41 -0
- data/lib/evilution/cli/parser/options_builder.rb +103 -0
- data/lib/evilution/cli/parser/stdin_reader.rb +28 -0
- data/lib/evilution/cli/parser.rb +88 -0
- data/lib/evilution/cli/printers/environment.rb +53 -0
- data/lib/evilution/cli/printers/session_detail.rb +76 -0
- data/lib/evilution/cli/printers/session_diff.rb +57 -0
- data/lib/evilution/cli/printers/session_list.rb +48 -0
- data/lib/evilution/cli/printers/subjects.rb +35 -0
- data/lib/evilution/cli/printers/tests_list.rb +45 -0
- data/lib/evilution/cli/printers/util_mutation.rb +35 -0
- data/lib/evilution/cli/printers.rb +4 -0
- data/lib/evilution/cli/result.rb +9 -0
- data/lib/evilution/cli.rb +30 -850
- data/lib/evilution/config.rb +31 -3
- data/lib/evilution/integration/base.rb +23 -55
- data/lib/evilution/integration/minitest.rb +22 -4
- data/lib/evilution/integration/rspec.rb +28 -8
- data/lib/evilution/isolation/fork.rb +11 -9
- data/lib/evilution/isolation/in_process.rb +11 -9
- data/lib/evilution/mcp/info_tool.rb +261 -0
- data/lib/evilution/mcp/mutate_tool.rb +112 -19
- data/lib/evilution/mcp/server.rb +3 -4
- data/lib/evilution/mcp/session_diff_tool.rb +5 -1
- data/lib/evilution/mcp/session_list_tool.rb +5 -1
- data/lib/evilution/mcp/session_show_tool.rb +5 -1
- data/lib/evilution/mcp/session_tool.rb +157 -0
- data/lib/evilution/reporter/cli.rb +2 -1
- data/lib/evilution/reporter/html/assets/style.css +68 -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 +47 -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/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.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 +9 -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/report.html.erb +17 -0
- data/lib/evilution/reporter/html/templates/summary_cards.html.erb +23 -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.rb +11 -349
- data/lib/evilution/reporter/json.rb +12 -8
- data/lib/evilution/result/mutation_result.rb +5 -1
- data/lib/evilution/result/summary.rb +9 -1
- data/lib/evilution/runner/baseline_runner.rb +71 -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 +255 -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 +57 -692
- data/lib/evilution/version.rb +1 -1
- metadata +71 -2
data/lib/evilution/config.rb
CHANGED
|
@@ -27,6 +27,8 @@ class Evilution::Config
|
|
|
27
27
|
show_disabled: false,
|
|
28
28
|
baseline_session: nil,
|
|
29
29
|
skip_heredoc_literals: false,
|
|
30
|
+
related_specs_heuristic: false,
|
|
31
|
+
fallback_to_full_suite: false,
|
|
30
32
|
preload: nil
|
|
31
33
|
}.freeze
|
|
32
34
|
|
|
@@ -35,7 +37,8 @@ class Evilution::Config
|
|
|
35
37
|
:jobs, :fail_fast, :baseline, :isolation, :incremental, :suggest_tests,
|
|
36
38
|
:progress, :save_session, :line_ranges, :spec_files, :hooks,
|
|
37
39
|
:ignore_patterns, :show_disabled, :baseline_session,
|
|
38
|
-
:skip_heredoc_literals, :
|
|
40
|
+
:skip_heredoc_literals, :related_specs_heuristic,
|
|
41
|
+
:fallback_to_full_suite, :preload
|
|
39
42
|
|
|
40
43
|
def initialize(**options)
|
|
41
44
|
file_options = options.delete(:skip_config_file) ? {} : load_config_file
|
|
@@ -96,6 +99,14 @@ class Evilution::Config
|
|
|
96
99
|
skip_heredoc_literals
|
|
97
100
|
end
|
|
98
101
|
|
|
102
|
+
def related_specs_heuristic?
|
|
103
|
+
related_specs_heuristic
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def fallback_to_full_suite?
|
|
107
|
+
fallback_to_full_suite
|
|
108
|
+
end
|
|
109
|
+
|
|
99
110
|
def self.file_options
|
|
100
111
|
CONFIG_FILES.each do |path|
|
|
101
112
|
next unless File.exist?(path)
|
|
@@ -138,8 +149,23 @@ class Evilution::Config
|
|
|
138
149
|
# Generate concrete test code in suggestions, matching integration (default: false)
|
|
139
150
|
# suggest_tests: false
|
|
140
151
|
|
|
141
|
-
# Skip all string literal mutations inside heredocs (default: false)
|
|
142
|
-
#
|
|
152
|
+
# Skip all string literal mutations inside heredocs (default: false).
|
|
153
|
+
# Useful for Rails apps where heredoc content (SQL, templates, fixtures)
|
|
154
|
+
# rarely has meaningful test coverage and produces noisy survivors.
|
|
155
|
+
# skip_heredoc_literals: true
|
|
156
|
+
|
|
157
|
+
# Opt into the RelatedSpecHeuristic, which appends request/integration/
|
|
158
|
+
# feature/system specs for mutations that touch `.includes(...)` calls
|
|
159
|
+
# (default: false). Off by default because the fan-out can be heavy and
|
|
160
|
+
# push runs over the per-mutation timeout. Enable if you need coverage
|
|
161
|
+
# of N+1 regressions that only surface in higher-level specs.
|
|
162
|
+
# related_specs_heuristic: true
|
|
163
|
+
|
|
164
|
+
# When no matching spec resolves for a mutation's source file, the
|
|
165
|
+
# default is to skip that mutation and mark it :unresolved in the
|
|
166
|
+
# report (a coverage gap signal). Set to true to fall back to running
|
|
167
|
+
# the entire test suite for such mutations instead (slow, high memory).
|
|
168
|
+
# fallback_to_full_suite: false
|
|
143
169
|
|
|
144
170
|
# Preload file required in the parent process before forking workers.
|
|
145
171
|
# For Rails projects, spec/rails_helper.rb or test/test_helper.rb is
|
|
@@ -195,6 +221,8 @@ class Evilution::Config
|
|
|
195
221
|
@show_disabled = merged[:show_disabled]
|
|
196
222
|
@baseline_session = merged[:baseline_session]
|
|
197
223
|
@skip_heredoc_literals = merged[:skip_heredoc_literals]
|
|
224
|
+
@related_specs_heuristic = merged[:related_specs_heuristic]
|
|
225
|
+
@fallback_to_full_suite = merged[:fallback_to_full_suite]
|
|
198
226
|
@hooks = validate_hooks(merged[:hooks])
|
|
199
227
|
@preload = validate_preload(merged[:preload])
|
|
200
228
|
end
|
|
@@ -1,10 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "fileutils"
|
|
4
3
|
require "prism"
|
|
5
|
-
require "tmpdir"
|
|
6
4
|
require_relative "../integration"
|
|
7
|
-
require_relative "../temp_dir_tracker"
|
|
8
5
|
|
|
9
6
|
class Evilution::Integration::Base
|
|
10
7
|
def self.baseline_runner
|
|
@@ -20,7 +17,6 @@ class Evilution::Integration::Base
|
|
|
20
17
|
end
|
|
21
18
|
|
|
22
19
|
def call(mutation)
|
|
23
|
-
@temp_dir = nil
|
|
24
20
|
ensure_framework_loaded
|
|
25
21
|
fire_hook(:mutation_insert_pre, mutation: mutation, file_path: mutation.file_path)
|
|
26
22
|
load_error = apply_mutation(mutation)
|
|
@@ -28,8 +24,6 @@ class Evilution::Integration::Base
|
|
|
28
24
|
|
|
29
25
|
fire_hook(:mutation_insert_post, mutation: mutation, file_path: mutation.file_path)
|
|
30
26
|
run_tests(mutation)
|
|
31
|
-
ensure
|
|
32
|
-
restore_original(mutation)
|
|
33
27
|
end
|
|
34
28
|
|
|
35
29
|
private
|
|
@@ -55,15 +49,13 @@ class Evilution::Integration::Base
|
|
|
55
49
|
end
|
|
56
50
|
|
|
57
51
|
def apply_mutation(mutation)
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
else
|
|
66
|
-
apply_via_load(mutation)
|
|
52
|
+
prism_error = validate_mutated_syntax(mutation.mutated_source)
|
|
53
|
+
return prism_error if prism_error
|
|
54
|
+
|
|
55
|
+
pin_autoloaded_constants(mutation.original_source)
|
|
56
|
+
clear_concern_state(mutation.file_path)
|
|
57
|
+
with_redefinition_recovery(mutation.original_source) do
|
|
58
|
+
eval_mutated_source(mutation)
|
|
67
59
|
end
|
|
68
60
|
nil
|
|
69
61
|
rescue SyntaxError => e
|
|
@@ -82,29 +74,25 @@ class Evilution::Integration::Base
|
|
|
82
74
|
}
|
|
83
75
|
end
|
|
84
76
|
|
|
85
|
-
def
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
require(subpath.delete_suffix(".rb"))
|
|
95
|
-
end
|
|
77
|
+
def validate_mutated_syntax(source)
|
|
78
|
+
return nil if Prism.parse(source).success?
|
|
79
|
+
|
|
80
|
+
{
|
|
81
|
+
passed: false,
|
|
82
|
+
error: "mutated source has syntax errors",
|
|
83
|
+
error_class: "SyntaxError",
|
|
84
|
+
error_backtrace: []
|
|
85
|
+
}
|
|
96
86
|
end
|
|
97
87
|
|
|
98
|
-
|
|
88
|
+
# Evaluate the mutated source with __FILE__ set to the original path so
|
|
89
|
+
# that `require_relative` and `__dir__` resolve against the real source
|
|
90
|
+
# tree, where sibling files actually exist.
|
|
91
|
+
def eval_mutated_source(mutation)
|
|
99
92
|
absolute = File.expand_path(mutation.file_path)
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
pin_autoloaded_constants(mutation.original_source)
|
|
104
|
-
clear_concern_state(mutation.file_path)
|
|
105
|
-
with_redefinition_recovery(mutation.original_source) do
|
|
106
|
-
load(dest)
|
|
107
|
-
end
|
|
93
|
+
# rubocop:disable Security/Eval
|
|
94
|
+
eval(mutation.mutated_source, TOPLEVEL_BINDING, absolute, 1)
|
|
95
|
+
# rubocop:enable Security/Eval
|
|
108
96
|
end
|
|
109
97
|
|
|
110
98
|
def with_redefinition_recovery(original_source)
|
|
@@ -120,18 +108,6 @@ class Evilution::Integration::Base
|
|
|
120
108
|
error.message.include?("already defined")
|
|
121
109
|
end
|
|
122
110
|
|
|
123
|
-
def restore_original(_mutation)
|
|
124
|
-
return unless @temp_dir
|
|
125
|
-
|
|
126
|
-
$LOAD_PATH.delete(@temp_dir)
|
|
127
|
-
$LOADED_FEATURES.reject! { |f| f.start_with?(@temp_dir) }
|
|
128
|
-
$LOADED_FEATURES << @displaced_feature if @displaced_feature && !$LOADED_FEATURES.include?(@displaced_feature)
|
|
129
|
-
@displaced_feature = nil
|
|
130
|
-
FileUtils.rm_rf(@temp_dir)
|
|
131
|
-
Evilution::TempDirTracker.unregister(@temp_dir)
|
|
132
|
-
@temp_dir = nil
|
|
133
|
-
end
|
|
134
|
-
|
|
135
111
|
def pin_autoloaded_constants(source)
|
|
136
112
|
collect_constant_names(Prism.parse(source).value).each do |name|
|
|
137
113
|
Object.const_get(name) if Object.const_defined?(name, false)
|
|
@@ -223,12 +199,4 @@ class Evilution::Integration::Base
|
|
|
223
199
|
|
|
224
200
|
best_subpath
|
|
225
201
|
end
|
|
226
|
-
|
|
227
|
-
def displace_loaded_feature(file_path)
|
|
228
|
-
absolute = File.expand_path(file_path)
|
|
229
|
-
return unless $LOADED_FEATURES.include?(absolute)
|
|
230
|
-
|
|
231
|
-
@displaced_feature = absolute
|
|
232
|
-
$LOADED_FEATURES.delete(absolute)
|
|
233
|
-
end
|
|
234
202
|
end
|
|
@@ -35,10 +35,11 @@ class Evilution::Integration::Minitest < Evilution::Integration::Base
|
|
|
35
35
|
}
|
|
36
36
|
end
|
|
37
37
|
|
|
38
|
-
def initialize(test_files: nil, hooks: nil)
|
|
38
|
+
def initialize(test_files: nil, hooks: nil, fallback_to_full_suite: false)
|
|
39
39
|
@test_files = test_files
|
|
40
40
|
@minitest_loaded = false
|
|
41
41
|
@spec_resolver = Evilution::SpecResolver.new(test_dir: "test", test_suffix: "_test.rb", request_dir: "integration")
|
|
42
|
+
@fallback_to_full_suite = fallback_to_full_suite
|
|
42
43
|
@crash_detector = nil
|
|
43
44
|
@warned_files = Set.new
|
|
44
45
|
super(hooks: hooks)
|
|
@@ -62,6 +63,8 @@ class Evilution::Integration::Minitest < Evilution::Integration::Base
|
|
|
62
63
|
def run_tests(mutation)
|
|
63
64
|
reset_state
|
|
64
65
|
files = resolve_test_files(mutation)
|
|
66
|
+
return unresolved_result(mutation) if files.nil?
|
|
67
|
+
|
|
65
68
|
command = "ruby -Itest #{files.join(" ")}"
|
|
66
69
|
|
|
67
70
|
files.each { |f| load(File.expand_path(f)) }
|
|
@@ -75,6 +78,15 @@ class Evilution::Integration::Minitest < Evilution::Integration::Base
|
|
|
75
78
|
{ passed: false, error: e.message, test_command: command }
|
|
76
79
|
end
|
|
77
80
|
|
|
81
|
+
def unresolved_result(mutation)
|
|
82
|
+
{
|
|
83
|
+
passed: false,
|
|
84
|
+
unresolved: true,
|
|
85
|
+
error: "no matching test resolved for #{mutation.file_path}",
|
|
86
|
+
test_command: "ruby -Itest (skipped: no test resolved for #{mutation.file_path})"
|
|
87
|
+
}
|
|
88
|
+
end
|
|
89
|
+
|
|
78
90
|
def build_args(_mutation)
|
|
79
91
|
["--seed", "0"]
|
|
80
92
|
end
|
|
@@ -112,7 +124,12 @@ class Evilution::Integration::Minitest < Evilution::Integration::Base
|
|
|
112
124
|
if passed
|
|
113
125
|
{ passed: true, test_command: command }
|
|
114
126
|
elsif detector.only_crashes?
|
|
115
|
-
{
|
|
127
|
+
{
|
|
128
|
+
passed: false,
|
|
129
|
+
test_crashed: true,
|
|
130
|
+
error: "test crashes: #{detector.crash_summary}",
|
|
131
|
+
test_command: command
|
|
132
|
+
}
|
|
116
133
|
else
|
|
117
134
|
{ passed: false, test_command: command }
|
|
118
135
|
end
|
|
@@ -124,7 +141,7 @@ class Evilution::Integration::Minitest < Evilution::Integration::Base
|
|
|
124
141
|
resolved = @spec_resolver.call(mutation.file_path)
|
|
125
142
|
unless resolved
|
|
126
143
|
warn_unresolved_test(mutation.file_path)
|
|
127
|
-
return glob_test_files
|
|
144
|
+
return @fallback_to_full_suite ? glob_test_files : nil
|
|
128
145
|
end
|
|
129
146
|
|
|
130
147
|
[resolved]
|
|
@@ -139,7 +156,8 @@ class Evilution::Integration::Minitest < Evilution::Integration::Base
|
|
|
139
156
|
return if @warned_files.include?(file_path)
|
|
140
157
|
|
|
141
158
|
@warned_files << file_path
|
|
142
|
-
|
|
159
|
+
action = @fallback_to_full_suite ? "running full suite" : "marking mutation unresolved"
|
|
160
|
+
warn "[evilution] No matching test found for #{file_path}, #{action}. " \
|
|
143
161
|
"Use --spec to specify the test file."
|
|
144
162
|
end
|
|
145
163
|
end
|
|
@@ -26,11 +26,13 @@ class Evilution::Integration::RSpec < Evilution::Integration::Base
|
|
|
26
26
|
{ runner: baseline_runner }
|
|
27
27
|
end
|
|
28
28
|
|
|
29
|
-
def initialize(test_files: nil, hooks: nil)
|
|
29
|
+
def initialize(test_files: nil, hooks: nil, related_specs_heuristic: false, fallback_to_full_suite: false)
|
|
30
30
|
@test_files = test_files
|
|
31
31
|
@rspec_loaded = false
|
|
32
32
|
@spec_resolver = Evilution::SpecResolver.new
|
|
33
33
|
@related_spec_heuristic = Evilution::RelatedSpecHeuristic.new
|
|
34
|
+
@related_specs_heuristic_enabled = related_specs_heuristic
|
|
35
|
+
@fallback_to_full_suite = fallback_to_full_suite
|
|
34
36
|
@crash_detector = nil
|
|
35
37
|
@warned_files = Set.new
|
|
36
38
|
super(hooks: hooks)
|
|
@@ -56,10 +58,12 @@ class Evilution::Integration::RSpec < Evilution::Integration::Base
|
|
|
56
58
|
def run_tests(mutation)
|
|
57
59
|
reset_state
|
|
58
60
|
|
|
61
|
+
files = resolve_test_files(mutation)
|
|
62
|
+
return unresolved_result(mutation) if files.nil?
|
|
63
|
+
|
|
59
64
|
out = StringIO.new
|
|
60
65
|
err = StringIO.new
|
|
61
|
-
|
|
62
|
-
args = build_args(mutation)
|
|
66
|
+
args = build_args(files)
|
|
63
67
|
command = "rspec #{args.join(" ")}"
|
|
64
68
|
|
|
65
69
|
detector = reset_crash_detector
|
|
@@ -73,11 +77,19 @@ class Evilution::Integration::RSpec < Evilution::Integration::Base
|
|
|
73
77
|
release_rspec_state(eg_before)
|
|
74
78
|
end
|
|
75
79
|
|
|
76
|
-
def build_args(
|
|
77
|
-
files = resolve_test_files(mutation)
|
|
80
|
+
def build_args(files)
|
|
78
81
|
["--format", "progress", "--no-color", "--order", "defined", *files]
|
|
79
82
|
end
|
|
80
83
|
|
|
84
|
+
def unresolved_result(mutation)
|
|
85
|
+
{
|
|
86
|
+
passed: false,
|
|
87
|
+
unresolved: true,
|
|
88
|
+
error: "no matching spec resolved for #{mutation.file_path}",
|
|
89
|
+
test_command: "rspec (skipped: no spec resolved for #{mutation.file_path})"
|
|
90
|
+
}
|
|
91
|
+
end
|
|
92
|
+
|
|
81
93
|
def reset_state
|
|
82
94
|
if ::RSpec.respond_to?(:clear_examples)
|
|
83
95
|
::RSpec.clear_examples
|
|
@@ -140,7 +152,12 @@ class Evilution::Integration::RSpec < Evilution::Integration::Base
|
|
|
140
152
|
if status.zero?
|
|
141
153
|
{ passed: true, test_command: command }
|
|
142
154
|
elsif detector.only_crashes?
|
|
143
|
-
{
|
|
155
|
+
{
|
|
156
|
+
passed: false,
|
|
157
|
+
test_crashed: true,
|
|
158
|
+
error: "test crashes: #{detector.crash_summary}",
|
|
159
|
+
test_command: command
|
|
160
|
+
}
|
|
144
161
|
else
|
|
145
162
|
{ passed: false, test_command: command }
|
|
146
163
|
end
|
|
@@ -152,9 +169,11 @@ class Evilution::Integration::RSpec < Evilution::Integration::Base
|
|
|
152
169
|
resolved = @spec_resolver.call(mutation.file_path)
|
|
153
170
|
unless resolved
|
|
154
171
|
warn_unresolved_spec(mutation.file_path)
|
|
155
|
-
return ["spec"]
|
|
172
|
+
return @fallback_to_full_suite ? ["spec"] : nil
|
|
156
173
|
end
|
|
157
174
|
|
|
175
|
+
return [resolved] unless @related_specs_heuristic_enabled
|
|
176
|
+
|
|
158
177
|
related = @related_spec_heuristic.call(mutation)
|
|
159
178
|
([resolved] + related).uniq
|
|
160
179
|
end
|
|
@@ -163,7 +182,8 @@ class Evilution::Integration::RSpec < Evilution::Integration::Base
|
|
|
163
182
|
return if @warned_files.include?(file_path)
|
|
164
183
|
|
|
165
184
|
@warned_files << file_path
|
|
166
|
-
|
|
185
|
+
action = @fallback_to_full_suite ? "running full suite" : "marking mutation unresolved"
|
|
186
|
+
warn "[evilution] No matching spec found for #{file_path}, #{action}. " \
|
|
167
187
|
"Use --spec to specify the spec file."
|
|
168
188
|
end
|
|
169
189
|
|
|
@@ -95,16 +95,18 @@ class Evilution::Isolation::Fork
|
|
|
95
95
|
::Process.wait(pid) rescue nil # rubocop:disable Style/RescueModifier
|
|
96
96
|
end
|
|
97
97
|
|
|
98
|
+
def classify_status(result)
|
|
99
|
+
return :timeout if result[:timeout]
|
|
100
|
+
return :killed if result[:test_crashed]
|
|
101
|
+
return :unresolved if result[:unresolved]
|
|
102
|
+
return :error if result[:error]
|
|
103
|
+
return :survived if result[:passed]
|
|
104
|
+
|
|
105
|
+
:killed
|
|
106
|
+
end
|
|
107
|
+
|
|
98
108
|
def build_mutation_result(mutation, result, duration, parent_rss_kb)
|
|
99
|
-
status =
|
|
100
|
-
:timeout
|
|
101
|
-
elsif result[:error]
|
|
102
|
-
:error
|
|
103
|
-
elsif result[:passed]
|
|
104
|
-
:survived
|
|
105
|
-
else
|
|
106
|
-
:killed
|
|
107
|
-
end
|
|
109
|
+
status = classify_status(result)
|
|
108
110
|
|
|
109
111
|
Evilution::Result::MutationResult.new(
|
|
110
112
|
mutation: mutation,
|
|
@@ -62,16 +62,18 @@ class Evilution::Isolation::InProcess
|
|
|
62
62
|
rss_after - rss_before
|
|
63
63
|
end
|
|
64
64
|
|
|
65
|
+
def classify_status(result)
|
|
66
|
+
return :timeout if result[:timeout]
|
|
67
|
+
return :killed if result[:test_crashed]
|
|
68
|
+
return :unresolved if result[:unresolved]
|
|
69
|
+
return :error if result[:error]
|
|
70
|
+
return :survived if result[:passed]
|
|
71
|
+
|
|
72
|
+
:killed
|
|
73
|
+
end
|
|
74
|
+
|
|
65
75
|
def build_mutation_result(mutation, result, duration, rss_before, rss_after, memory_delta_kb)
|
|
66
|
-
status =
|
|
67
|
-
:timeout
|
|
68
|
-
elsif result[:error]
|
|
69
|
-
:error
|
|
70
|
-
elsif result[:passed]
|
|
71
|
-
:survived
|
|
72
|
-
else
|
|
73
|
-
:killed
|
|
74
|
-
end
|
|
76
|
+
status = classify_status(result)
|
|
75
77
|
|
|
76
78
|
Evilution::Result::MutationResult.new(
|
|
77
79
|
mutation: mutation,
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "mcp"
|
|
5
|
+
require_relative "../config"
|
|
6
|
+
require_relative "../runner"
|
|
7
|
+
require_relative "../mutator/registry"
|
|
8
|
+
require_relative "../spec_resolver"
|
|
9
|
+
require_relative "../ast/pattern/filter"
|
|
10
|
+
require_relative "../version"
|
|
11
|
+
|
|
12
|
+
require_relative "../mcp"
|
|
13
|
+
|
|
14
|
+
class Evilution::MCP::InfoTool < MCP::Tool
|
|
15
|
+
tool_name "evilution-info"
|
|
16
|
+
description "Discover what evilution sees before running any mutations. " \
|
|
17
|
+
"One tool, three actions: " \
|
|
18
|
+
"'subjects' lists every mutatable method in the target files with its file, line, and mutation count; " \
|
|
19
|
+
"'tests' resolves which spec/test files cover the given sources (so you pick the right --spec before mutating); " \
|
|
20
|
+
"'environment' dumps the effective config (version, ruby, config file, timeout, " \
|
|
21
|
+
"integration, isolation, and every other setting). " \
|
|
22
|
+
"Use this instead of shelling out to 'evilution subjects', 'evilution tests list', or 'evilution environment show' — " \
|
|
23
|
+
"the response is structured JSON so you can plan the next mutation run without parsing CLI text."
|
|
24
|
+
input_schema(
|
|
25
|
+
properties: {
|
|
26
|
+
action: {
|
|
27
|
+
type: "string",
|
|
28
|
+
enum: %w[subjects tests environment],
|
|
29
|
+
description: "Which discovery operation to perform. " \
|
|
30
|
+
"'subjects' lists mutatable methods; 'tests' resolves specs for sources; 'environment' dumps effective config."
|
|
31
|
+
},
|
|
32
|
+
files: {
|
|
33
|
+
type: "array",
|
|
34
|
+
items: { type: "string" },
|
|
35
|
+
description: "[subjects, tests] Target source files. Supports line-range syntax " \
|
|
36
|
+
"(lib/foo.rb:15-30, lib/foo.rb:15, lib/foo.rb:15-); for 'tests' the range is " \
|
|
37
|
+
"stripped before spec resolution."
|
|
38
|
+
},
|
|
39
|
+
target: {
|
|
40
|
+
type: "string",
|
|
41
|
+
description: "[subjects] Filter expression: method (Foo#bar), type (Foo#/Foo.), namespace (Foo*), class (Foo)"
|
|
42
|
+
},
|
|
43
|
+
spec: {
|
|
44
|
+
type: "array",
|
|
45
|
+
items: { type: "string" },
|
|
46
|
+
description: "[tests] Explicit spec files to return instead of auto-resolving from sources"
|
|
47
|
+
},
|
|
48
|
+
integration: {
|
|
49
|
+
type: "string",
|
|
50
|
+
description: "[subjects, tests] Test integration (rspec, minitest) — 'tests' selects " \
|
|
51
|
+
"the matching spec resolver (spec/*_spec.rb for rspec, test/*_test.rb for minitest)"
|
|
52
|
+
},
|
|
53
|
+
skip_config: {
|
|
54
|
+
type: "boolean",
|
|
55
|
+
description: "[subjects, tests] When true, ignore .evilution.yml / config/evilution.yml; " \
|
|
56
|
+
"explicit tool parameters still apply. " \
|
|
57
|
+
"Default: false — project config is loaded so the result reflects what `evilution` CLI would see."
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
required: ["action"]
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
VALID_ACTIONS = %w[subjects tests environment].freeze
|
|
64
|
+
|
|
65
|
+
class << self
|
|
66
|
+
# rubocop:disable Lint/UnusedMethodArgument
|
|
67
|
+
def call(server_context:, action: nil, files: nil, target: nil, spec: nil, integration: nil, skip_config: nil)
|
|
68
|
+
return error_response("config_error", "action is required") unless action
|
|
69
|
+
return error_response("config_error", "unknown action: #{action}") unless VALID_ACTIONS.include?(action)
|
|
70
|
+
|
|
71
|
+
parsed_files, line_ranges = parse_files(Array(files)) if files
|
|
72
|
+
|
|
73
|
+
case action
|
|
74
|
+
when "subjects"
|
|
75
|
+
subjects_action(files: parsed_files, line_ranges: line_ranges, target: target,
|
|
76
|
+
integration: integration, skip_config: skip_config)
|
|
77
|
+
when "tests"
|
|
78
|
+
tests_action(files: parsed_files, spec: spec, integration: integration, skip_config: skip_config)
|
|
79
|
+
when "environment"
|
|
80
|
+
environment_action
|
|
81
|
+
end
|
|
82
|
+
rescue Evilution::Error => e
|
|
83
|
+
error_response_for(e)
|
|
84
|
+
end
|
|
85
|
+
# rubocop:enable Lint/UnusedMethodArgument
|
|
86
|
+
|
|
87
|
+
private
|
|
88
|
+
|
|
89
|
+
def parse_files(raw_files)
|
|
90
|
+
files = []
|
|
91
|
+
ranges = {}
|
|
92
|
+
|
|
93
|
+
raw_files.each do |arg|
|
|
94
|
+
file, range_str = arg.split(":", 2)
|
|
95
|
+
files << file
|
|
96
|
+
ranges[file] = parse_line_range(range_str) if range_str
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
[files, ranges]
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def parse_line_range(str)
|
|
103
|
+
if str.include?("-")
|
|
104
|
+
start_str, end_str = str.split("-", 2)
|
|
105
|
+
start_line = Integer(start_str)
|
|
106
|
+
end_line = end_str.empty? ? Float::INFINITY : Integer(end_str)
|
|
107
|
+
start_line..end_line
|
|
108
|
+
else
|
|
109
|
+
line = Integer(str)
|
|
110
|
+
line..line
|
|
111
|
+
end
|
|
112
|
+
rescue ArgumentError, TypeError
|
|
113
|
+
raise Evilution::ParseError, "invalid line range: #{str.inspect}"
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def subjects_action(files:, line_ranges:, target:, integration:, skip_config:)
|
|
117
|
+
return error_response("config_error", "files is required") if files.nil? || files.empty?
|
|
118
|
+
|
|
119
|
+
config = build_subjects_config(files: files, line_ranges: line_ranges,
|
|
120
|
+
target: target, integration: integration, skip_config: skip_config)
|
|
121
|
+
runner = Evilution::Runner.new(config: config)
|
|
122
|
+
subjects = runner.parse_and_filter_subjects
|
|
123
|
+
|
|
124
|
+
registry = Evilution::Mutator::Registry.default
|
|
125
|
+
filter = build_subject_filter(config)
|
|
126
|
+
operator_options = { skip_heredoc_literals: config.skip_heredoc_literals? }
|
|
127
|
+
|
|
128
|
+
entries = subjects.map do |subj|
|
|
129
|
+
count = registry.mutations_for(subj, filter: filter, operator_options: operator_options).length
|
|
130
|
+
{ "name" => subj.name, "file" => subj.file_path, "line" => subj.line_number, "mutations" => count }
|
|
131
|
+
ensure
|
|
132
|
+
subj.release_node!
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
success_response(
|
|
136
|
+
"subjects" => entries,
|
|
137
|
+
"total_subjects" => entries.length,
|
|
138
|
+
"total_mutations" => entries.sum { |e| e["mutations"] }
|
|
139
|
+
)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def tests_action(files:, spec:, integration:, skip_config:)
|
|
143
|
+
return error_response("config_error", "files is required") if files.nil? || files.empty?
|
|
144
|
+
|
|
145
|
+
config = build_tests_config(files: files, spec: spec, integration: integration, skip_config: skip_config)
|
|
146
|
+
return explicit_specs_response(files, config.spec_files) if config.spec_files.any?
|
|
147
|
+
|
|
148
|
+
resolver = resolver_for_integration(config.integration)
|
|
149
|
+
resolved, unresolved = resolve_specs(files, resolver)
|
|
150
|
+
success_response(
|
|
151
|
+
"specs" => resolved,
|
|
152
|
+
"unresolved" => unresolved,
|
|
153
|
+
"total_sources" => files.length,
|
|
154
|
+
"total_specs" => resolved.map { |r| r["spec"] }.uniq.length
|
|
155
|
+
)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def build_subjects_config(files:, line_ranges:, target:, integration:, skip_config:)
|
|
159
|
+
opts = { target_files: files, line_ranges: line_ranges || {} }
|
|
160
|
+
opts[:skip_config_file] = true if skip_config
|
|
161
|
+
opts[:target] = target if target
|
|
162
|
+
opts[:integration] = integration if integration
|
|
163
|
+
Evilution::Config.new(**opts)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def build_tests_config(files:, spec:, integration:, skip_config:)
|
|
167
|
+
opts = { target_files: files }
|
|
168
|
+
opts[:skip_config_file] = true if skip_config
|
|
169
|
+
opts[:spec_files] = spec if spec
|
|
170
|
+
opts[:integration] = integration if integration
|
|
171
|
+
Evilution::Config.new(**opts)
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def resolver_for_integration(integration)
|
|
175
|
+
integration_class = Evilution::Runner::INTEGRATIONS[integration.to_sym]
|
|
176
|
+
return Evilution::SpecResolver.new unless integration_class
|
|
177
|
+
|
|
178
|
+
integration_class.baseline_options[:spec_resolver] || Evilution::SpecResolver.new
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def explicit_specs_response(files, spec_files)
|
|
182
|
+
success_response(
|
|
183
|
+
"specs" => spec_files.map { |f| { "source" => nil, "spec" => f } },
|
|
184
|
+
"unresolved" => [],
|
|
185
|
+
"total_sources" => files.length,
|
|
186
|
+
"total_specs" => spec_files.length
|
|
187
|
+
)
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def resolve_specs(files, resolver)
|
|
191
|
+
resolved = []
|
|
192
|
+
unresolved = []
|
|
193
|
+
files.each do |source|
|
|
194
|
+
found = resolver.call(source)
|
|
195
|
+
if found
|
|
196
|
+
resolved << { "source" => source, "spec" => found }
|
|
197
|
+
else
|
|
198
|
+
unresolved << source
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
[resolved, unresolved]
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def environment_action
|
|
205
|
+
config = Evilution::Config.new(skip_config_file: false)
|
|
206
|
+
config_file = Evilution::Config::CONFIG_FILES.find { |path| File.exist?(path) }
|
|
207
|
+
|
|
208
|
+
success_response(
|
|
209
|
+
"version" => Evilution::VERSION,
|
|
210
|
+
"ruby" => RUBY_VERSION,
|
|
211
|
+
"config_file" => config_file,
|
|
212
|
+
"settings" => environment_settings(config)
|
|
213
|
+
)
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def error_response_for(error)
|
|
217
|
+
type = case error
|
|
218
|
+
when Evilution::ConfigError then "config_error"
|
|
219
|
+
when Evilution::ParseError then "parse_error"
|
|
220
|
+
else "runtime_error"
|
|
221
|
+
end
|
|
222
|
+
error_response(type, error.message)
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def environment_settings(config)
|
|
226
|
+
{
|
|
227
|
+
"timeout" => config.timeout,
|
|
228
|
+
"format" => config.format,
|
|
229
|
+
"integration" => config.integration,
|
|
230
|
+
"jobs" => config.jobs,
|
|
231
|
+
"isolation" => config.isolation,
|
|
232
|
+
"baseline" => config.baseline,
|
|
233
|
+
"incremental" => config.incremental,
|
|
234
|
+
"fail_fast" => config.fail_fast,
|
|
235
|
+
"min_score" => config.min_score,
|
|
236
|
+
"suggest_tests" => config.suggest_tests,
|
|
237
|
+
"save_session" => config.save_session,
|
|
238
|
+
"target" => config.target,
|
|
239
|
+
"skip_heredoc_literals" => config.skip_heredoc_literals,
|
|
240
|
+
"ignore_patterns" => config.ignore_patterns
|
|
241
|
+
}
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def build_subject_filter(config)
|
|
245
|
+
return nil if config.ignore_patterns.empty?
|
|
246
|
+
|
|
247
|
+
Evilution::AST::Pattern::Filter.new(config.ignore_patterns)
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def success_response(payload)
|
|
251
|
+
::MCP::Tool::Response.new([{ type: "text", text: ::JSON.generate(payload) }])
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def error_response(type, message)
|
|
255
|
+
::MCP::Tool::Response.new(
|
|
256
|
+
[{ type: "text", text: ::JSON.generate({ error: { type: type, message: message } }) }],
|
|
257
|
+
error: true
|
|
258
|
+
)
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
end
|