evilution 0.22.6 → 0.23.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 +3 -0
- data/CHANGELOG.md +18 -0
- data/README.md +36 -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.rb +257 -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 +18 -3
- data/lib/evilution/integration/base.rb +59 -2
- data/lib/evilution/integration/minitest.rb +6 -1
- data/lib/evilution/integration/rspec.rb +10 -2
- data/lib/evilution/isolation/fork.rb +10 -9
- data/lib/evilution/isolation/in_process.rb +10 -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/html.rb +41 -0
- data/lib/evilution/runner.rb +3 -1
- data/lib/evilution/version.rb +1 -1
- metadata +30 -2
data/lib/evilution/config.rb
CHANGED
|
@@ -27,6 +27,7 @@ class Evilution::Config
|
|
|
27
27
|
show_disabled: false,
|
|
28
28
|
baseline_session: nil,
|
|
29
29
|
skip_heredoc_literals: false,
|
|
30
|
+
related_specs_heuristic: false,
|
|
30
31
|
preload: nil
|
|
31
32
|
}.freeze
|
|
32
33
|
|
|
@@ -35,7 +36,7 @@ class Evilution::Config
|
|
|
35
36
|
:jobs, :fail_fast, :baseline, :isolation, :incremental, :suggest_tests,
|
|
36
37
|
:progress, :save_session, :line_ranges, :spec_files, :hooks,
|
|
37
38
|
:ignore_patterns, :show_disabled, :baseline_session,
|
|
38
|
-
:skip_heredoc_literals, :preload
|
|
39
|
+
:skip_heredoc_literals, :related_specs_heuristic, :preload
|
|
39
40
|
|
|
40
41
|
def initialize(**options)
|
|
41
42
|
file_options = options.delete(:skip_config_file) ? {} : load_config_file
|
|
@@ -96,6 +97,10 @@ class Evilution::Config
|
|
|
96
97
|
skip_heredoc_literals
|
|
97
98
|
end
|
|
98
99
|
|
|
100
|
+
def related_specs_heuristic?
|
|
101
|
+
related_specs_heuristic
|
|
102
|
+
end
|
|
103
|
+
|
|
99
104
|
def self.file_options
|
|
100
105
|
CONFIG_FILES.each do |path|
|
|
101
106
|
next unless File.exist?(path)
|
|
@@ -138,8 +143,17 @@ class Evilution::Config
|
|
|
138
143
|
# Generate concrete test code in suggestions, matching integration (default: false)
|
|
139
144
|
# suggest_tests: false
|
|
140
145
|
|
|
141
|
-
# Skip all string literal mutations inside heredocs (default: false)
|
|
142
|
-
#
|
|
146
|
+
# Skip all string literal mutations inside heredocs (default: false).
|
|
147
|
+
# Useful for Rails apps where heredoc content (SQL, templates, fixtures)
|
|
148
|
+
# rarely has meaningful test coverage and produces noisy survivors.
|
|
149
|
+
# skip_heredoc_literals: true
|
|
150
|
+
|
|
151
|
+
# Opt into the RelatedSpecHeuristic, which appends request/integration/
|
|
152
|
+
# feature/system specs for mutations that touch `.includes(...)` calls
|
|
153
|
+
# (default: false). Off by default because the fan-out can be heavy and
|
|
154
|
+
# push runs over the per-mutation timeout. Enable if you need coverage
|
|
155
|
+
# of N+1 regressions that only surface in higher-level specs.
|
|
156
|
+
# related_specs_heuristic: true
|
|
143
157
|
|
|
144
158
|
# Preload file required in the parent process before forking workers.
|
|
145
159
|
# For Rails projects, spec/rails_helper.rb or test/test_helper.rb is
|
|
@@ -195,6 +209,7 @@ class Evilution::Config
|
|
|
195
209
|
@show_disabled = merged[:show_disabled]
|
|
196
210
|
@baseline_session = merged[:baseline_session]
|
|
197
211
|
@skip_heredoc_literals = merged[:skip_heredoc_literals]
|
|
212
|
+
@related_specs_heuristic = merged[:related_specs_heuristic]
|
|
198
213
|
@hooks = validate_hooks(merged[:hooks])
|
|
199
214
|
@preload = validate_preload(merged[:preload])
|
|
200
215
|
end
|
|
@@ -55,6 +55,9 @@ class Evilution::Integration::Base
|
|
|
55
55
|
end
|
|
56
56
|
|
|
57
57
|
def apply_mutation(mutation)
|
|
58
|
+
prism_error = validate_mutated_syntax(mutation.mutated_source)
|
|
59
|
+
return prism_error if prism_error
|
|
60
|
+
|
|
58
61
|
@temp_dir = Dir.mktmpdir("evilution")
|
|
59
62
|
Evilution::TempDirTracker.register(@temp_dir)
|
|
60
63
|
@displaced_feature = nil
|
|
@@ -82,6 +85,17 @@ class Evilution::Integration::Base
|
|
|
82
85
|
}
|
|
83
86
|
end
|
|
84
87
|
|
|
88
|
+
def validate_mutated_syntax(source)
|
|
89
|
+
return nil if Prism.parse(source).success?
|
|
90
|
+
|
|
91
|
+
{
|
|
92
|
+
passed: false,
|
|
93
|
+
error: "mutated source has syntax errors",
|
|
94
|
+
error_class: "SyntaxError",
|
|
95
|
+
error_backtrace: []
|
|
96
|
+
}
|
|
97
|
+
end
|
|
98
|
+
|
|
85
99
|
def apply_via_require(mutation, subpath)
|
|
86
100
|
dest = File.join(@temp_dir, subpath)
|
|
87
101
|
FileUtils.mkdir_p(File.dirname(dest))
|
|
@@ -90,7 +104,9 @@ class Evilution::Integration::Base
|
|
|
90
104
|
displace_loaded_feature(mutation.file_path)
|
|
91
105
|
pin_autoloaded_constants(mutation.original_source)
|
|
92
106
|
clear_concern_state(mutation.file_path)
|
|
93
|
-
|
|
107
|
+
with_redefinition_recovery(mutation.original_source) do
|
|
108
|
+
require(subpath.delete_suffix(".rb"))
|
|
109
|
+
end
|
|
94
110
|
end
|
|
95
111
|
|
|
96
112
|
def apply_via_load(mutation)
|
|
@@ -100,7 +116,22 @@ class Evilution::Integration::Base
|
|
|
100
116
|
File.write(dest, mutation.mutated_source)
|
|
101
117
|
pin_autoloaded_constants(mutation.original_source)
|
|
102
118
|
clear_concern_state(mutation.file_path)
|
|
103
|
-
|
|
119
|
+
with_redefinition_recovery(mutation.original_source) do
|
|
120
|
+
load(dest)
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def with_redefinition_recovery(original_source)
|
|
125
|
+
yield
|
|
126
|
+
rescue ArgumentError => e
|
|
127
|
+
raise unless redefinition_conflict?(e)
|
|
128
|
+
|
|
129
|
+
remove_defined_constants(original_source)
|
|
130
|
+
yield
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def redefinition_conflict?(error)
|
|
134
|
+
error.message.include?("already defined")
|
|
104
135
|
end
|
|
105
136
|
|
|
106
137
|
def restore_original(_mutation)
|
|
@@ -139,6 +170,32 @@ class Evilution::Integration::Base
|
|
|
139
170
|
names
|
|
140
171
|
end
|
|
141
172
|
|
|
173
|
+
def remove_defined_constants(source)
|
|
174
|
+
collect_constant_names(Prism.parse(source).value).reverse_each do |name|
|
|
175
|
+
parent_name, _, local_name = name.rpartition("::")
|
|
176
|
+
parent = resolve_loaded_constant_parent(parent_name)
|
|
177
|
+
next unless parent
|
|
178
|
+
next unless parent.const_defined?(local_name, false)
|
|
179
|
+
next if parent.autoload?(local_name)
|
|
180
|
+
|
|
181
|
+
parent.send(:remove_const, local_name.to_sym)
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def resolve_loaded_constant_parent(parent_name)
|
|
186
|
+
return Object if parent_name.empty?
|
|
187
|
+
|
|
188
|
+
parent_name.split("::").reduce(Object) do |mod, part|
|
|
189
|
+
return nil unless mod.const_defined?(part, false)
|
|
190
|
+
return nil if mod.autoload?(part)
|
|
191
|
+
|
|
192
|
+
resolved = mod.const_get(part, false)
|
|
193
|
+
return nil unless resolved.is_a?(Module)
|
|
194
|
+
|
|
195
|
+
resolved
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
|
|
142
199
|
def clear_concern_state(file_path)
|
|
143
200
|
return unless defined?(ActiveSupport::Concern)
|
|
144
201
|
|
|
@@ -112,7 +112,12 @@ class Evilution::Integration::Minitest < Evilution::Integration::Base
|
|
|
112
112
|
if passed
|
|
113
113
|
{ passed: true, test_command: command }
|
|
114
114
|
elsif detector.only_crashes?
|
|
115
|
-
{
|
|
115
|
+
{
|
|
116
|
+
passed: false,
|
|
117
|
+
test_crashed: true,
|
|
118
|
+
error: "test crashes: #{detector.crash_summary}",
|
|
119
|
+
test_command: command
|
|
120
|
+
}
|
|
116
121
|
else
|
|
117
122
|
{ passed: false, test_command: command }
|
|
118
123
|
end
|
|
@@ -26,11 +26,12 @@ 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)
|
|
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
|
|
34
35
|
@crash_detector = nil
|
|
35
36
|
@warned_files = Set.new
|
|
36
37
|
super(hooks: hooks)
|
|
@@ -140,7 +141,12 @@ class Evilution::Integration::RSpec < Evilution::Integration::Base
|
|
|
140
141
|
if status.zero?
|
|
141
142
|
{ passed: true, test_command: command }
|
|
142
143
|
elsif detector.only_crashes?
|
|
143
|
-
{
|
|
144
|
+
{
|
|
145
|
+
passed: false,
|
|
146
|
+
test_crashed: true,
|
|
147
|
+
error: "test crashes: #{detector.crash_summary}",
|
|
148
|
+
test_command: command
|
|
149
|
+
}
|
|
144
150
|
else
|
|
145
151
|
{ passed: false, test_command: command }
|
|
146
152
|
end
|
|
@@ -155,6 +161,8 @@ class Evilution::Integration::RSpec < Evilution::Integration::Base
|
|
|
155
161
|
return ["spec"]
|
|
156
162
|
end
|
|
157
163
|
|
|
164
|
+
return [resolved] unless @related_specs_heuristic_enabled
|
|
165
|
+
|
|
158
166
|
related = @related_spec_heuristic.call(mutation)
|
|
159
167
|
([resolved] + related).uniq
|
|
160
168
|
end
|
|
@@ -95,16 +95,17 @@ 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 :error if result[:error]
|
|
102
|
+
return :survived if result[:passed]
|
|
103
|
+
|
|
104
|
+
:killed
|
|
105
|
+
end
|
|
106
|
+
|
|
98
107
|
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
|
|
108
|
+
status = classify_status(result)
|
|
108
109
|
|
|
109
110
|
Evilution::Result::MutationResult.new(
|
|
110
111
|
mutation: mutation,
|
|
@@ -62,16 +62,17 @@ 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 :error if result[:error]
|
|
69
|
+
return :survived if result[:passed]
|
|
70
|
+
|
|
71
|
+
:killed
|
|
72
|
+
end
|
|
73
|
+
|
|
65
74
|
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
|
|
75
|
+
status = classify_status(result)
|
|
75
76
|
|
|
76
77
|
Evilution::Result::MutationResult.new(
|
|
77
78
|
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
|