evilution 0.28.0 → 0.29.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 +52 -0
- data/CHANGELOG.md +7 -0
- data/lib/evilution/ast/constant_names.rb +28 -11
- data/lib/evilution/ast/pattern/parser.rb +29 -17
- data/lib/evilution/cli/commands/session_diff.rb +6 -4
- data/lib/evilution/cli/commands/subjects.rb +6 -3
- data/lib/evilution/cli/commands/util_mutation.rb +24 -19
- data/lib/evilution/cli/parser/command_extractor.rb +9 -11
- data/lib/evilution/cli/parser/file_args.rb +3 -1
- data/lib/evilution/cli/parser/options_builder.rb +29 -1
- data/lib/evilution/cli/parser/stdin_reader.rb +2 -2
- data/lib/evilution/cli/parser.rb +18 -20
- data/lib/evilution/cli/printers/environment.rb +19 -19
- data/lib/evilution/cli/printers/session_diff.rb +8 -8
- data/lib/evilution/compare/normalizer.rb +10 -5
- data/lib/evilution/config.rb +10 -10
- data/lib/evilution/disable_comment.rb +21 -12
- data/lib/evilution/integration/loading/mutation_applier.rb +17 -12
- data/lib/evilution/integration/minitest.rb +25 -16
- data/lib/evilution/integration/rspec.rb +4 -0
- data/lib/evilution/isolation/fork.rb +27 -17
- data/lib/evilution/mcp/info_tool/actions/subjects.rb +32 -23
- data/lib/evilution/mcp/info_tool/actions/tests.rb +22 -12
- data/lib/evilution/mcp/info_tool/request_parser.rb +3 -1
- data/lib/evilution/mcp/info_tool.rb +7 -1
- data/lib/evilution/mcp/mutate_tool/option_parser.rb +3 -1
- data/lib/evilution/mcp/mutate_tool/survived_enricher.rb +19 -9
- data/lib/evilution/mcp/mutate_tool.rb +27 -14
- data/lib/evilution/mcp/session_tool.rb +27 -18
- data/lib/evilution/mutation.rb +13 -15
- data/lib/evilution/mutator/base.rb +17 -15
- data/lib/evilution/mutator/operator/argument_nil_substitution.rb +11 -14
- data/lib/evilution/mutator/operator/argument_removal.rb +11 -14
- data/lib/evilution/mutator/operator/begin_unwrap.rb +17 -5
- data/lib/evilution/mutator/operator/bitwise_complement.rb +26 -19
- data/lib/evilution/mutator/operator/block_param_removal.rb +18 -8
- data/lib/evilution/mutator/operator/block_pass_removal.rb +19 -15
- data/lib/evilution/mutator/operator/case_when.rb +7 -5
- data/lib/evilution/mutator/operator/conditional_branch.rb +22 -22
- data/lib/evilution/mutator/operator/equality_to_identity.rb +8 -3
- data/lib/evilution/mutator/operator/explicit_super_mutation.rb +17 -13
- data/lib/evilution/mutator/operator/index_to_at.rb +5 -4
- data/lib/evilution/mutator/operator/index_to_dig.rb +12 -6
- data/lib/evilution/mutator/operator/index_to_fetch.rb +5 -4
- data/lib/evilution/mutator/operator/keyword_argument.rb +30 -25
- data/lib/evilution/mutator/operator/mixin_removal.rb +20 -14
- data/lib/evilution/mutator/operator/multiple_assignment.rb +12 -13
- data/lib/evilution/mutator/operator/receiver_replacement.rb +9 -6
- data/lib/evilution/mutator/operator/regex_simplification.rb +62 -67
- data/lib/evilution/mutator/operator/rescue_body_replacement.rb +9 -8
- data/lib/evilution/mutator/operator/rescue_removal.rb +4 -7
- data/lib/evilution/mutator/operator/superclass_removal.rb +21 -15
- data/lib/evilution/parallel/work_queue/dispatcher.rb +15 -8
- data/lib/evilution/parallel/work_queue/worker.rb +10 -7
- data/lib/evilution/parallel/work_queue.rb +35 -18
- data/lib/evilution/reporter/cli/item_formatters/coverage_gap.rb +13 -8
- data/lib/evilution/reporter/cli/line_formatters/mutations.rb +17 -8
- data/lib/evilution/reporter/json.rb +52 -18
- data/lib/evilution/reporter/suggestion/diff_helpers.rb +0 -13
- data/lib/evilution/reporter/suggestion/diff_lines.rb +28 -0
- data/lib/evilution/reporter/suggestion/templates/minitest.rb +20 -14
- data/lib/evilution/reporter/suggestion/templates/rspec.rb +19 -13
- data/lib/evilution/runner/baseline_runner.rb +15 -8
- data/lib/evilution/runner/diagnostics.rb +13 -9
- data/lib/evilution/runner/isolation_resolver.rb +11 -9
- data/lib/evilution/runner/mutation_executor/result_cache.rb +3 -1
- data/lib/evilution/runner/mutation_executor/strategy/parallel.rb +32 -10
- data/lib/evilution/runner/mutation_executor/strategy/sequential.rb +1 -1
- data/lib/evilution/runner/mutation_executor.rb +2 -0
- data/lib/evilution/runner/mutation_planner.rb +37 -17
- data/lib/evilution/runner/subject_pipeline.rb +21 -11
- data/lib/evilution/runner.rb +3 -3
- data/lib/evilution/session/diff.rb +15 -6
- data/lib/evilution/spec_ast_cache.rb +26 -12
- data/lib/evilution/version.rb +1 -1
- data/script/memory_check +11 -5
- data/scripts/benchmark_density +10 -9
- data/scripts/compare_mutations +38 -21
- data/scripts/mutant_json_adapter +7 -4
- metadata +3 -2
|
@@ -57,11 +57,7 @@ class Evilution::Compare::Normalizer
|
|
|
57
57
|
private
|
|
58
58
|
|
|
59
59
|
def build_evilution_record(entry, index:)
|
|
60
|
-
file_path
|
|
61
|
-
line = entry["line"] or raise Evilution::Compare::InvalidInput.new("missing 'line' in record", index: index)
|
|
62
|
-
diff = entry["diff"].to_s
|
|
63
|
-
status = EVILUTION_STATUS_MAP[entry["status"]] ||
|
|
64
|
-
raise(Evilution::Compare::InvalidInput.new("unknown status #{entry["status"].inspect}", index: index))
|
|
60
|
+
file_path, line, diff, status = extract_evilution_fields(entry, index)
|
|
65
61
|
Evilution::Compare::Record.new(
|
|
66
62
|
source: :evilution,
|
|
67
63
|
file_path: file_path,
|
|
@@ -74,6 +70,15 @@ class Evilution::Compare::Normalizer
|
|
|
74
70
|
)
|
|
75
71
|
end
|
|
76
72
|
|
|
73
|
+
def extract_evilution_fields(entry, index)
|
|
74
|
+
file_path = entry["file"] or raise Evilution::Compare::InvalidInput.new("missing 'file' in record", index: index)
|
|
75
|
+
line = entry["line"] or raise Evilution::Compare::InvalidInput.new("missing 'line' in record", index: index)
|
|
76
|
+
diff = entry["diff"].to_s
|
|
77
|
+
status = EVILUTION_STATUS_MAP[entry["status"]] ||
|
|
78
|
+
raise(Evilution::Compare::InvalidInput.new("unknown status #{entry["status"].inspect}", index: index))
|
|
79
|
+
[file_path, line, diff, status]
|
|
80
|
+
end
|
|
81
|
+
|
|
77
82
|
def build_mutant_record(cov, source_path:, index:)
|
|
78
83
|
mr = cov["mutation_result"] or raise Evilution::Compare::InvalidInput.new("missing mutation_result", index: index)
|
|
79
84
|
cr = cov["criteria_result"] or raise Evilution::Compare::InvalidInput.new("missing criteria_result", index: index)
|
data/lib/evilution/config.rb
CHANGED
|
@@ -239,17 +239,17 @@ class Evilution::Config
|
|
|
239
239
|
end
|
|
240
240
|
end
|
|
241
241
|
|
|
242
|
+
VALIDATED_ATTRS = %i[
|
|
243
|
+
integration jobs fail_fast isolation ignore_patterns
|
|
244
|
+
hooks preload spec_mappings spec_pattern profile
|
|
245
|
+
].freeze
|
|
246
|
+
private_constant :VALIDATED_ATTRS
|
|
247
|
+
|
|
242
248
|
def assign_validated_attributes(merged)
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
@ignore_patterns = Validators::IgnorePatterns.call(merged[:ignore_patterns])
|
|
248
|
-
@hooks = Validators::Hooks.call(merged[:hooks])
|
|
249
|
-
@preload = Validators::Preload.call(merged[:preload])
|
|
250
|
-
@spec_mappings = Validators::SpecMappings.call(merged[:spec_mappings])
|
|
251
|
-
@spec_pattern = Validators::SpecPattern.call(merged[:spec_pattern])
|
|
252
|
-
@profile = Validators::Profile.call(merged[:profile])
|
|
249
|
+
VALIDATED_ATTRS.each do |key|
|
|
250
|
+
validator = Validators.const_get(key.to_s.split("_").map(&:capitalize).join)
|
|
251
|
+
instance_variable_set(:"@#{key}", validator.call(merged[key]))
|
|
252
|
+
end
|
|
253
253
|
end
|
|
254
254
|
|
|
255
255
|
def assign_example_targeting(merged)
|
|
@@ -20,21 +20,30 @@ class Evilution::DisableComment
|
|
|
20
20
|
private
|
|
21
21
|
|
|
22
22
|
def classify_comments(parse_result, source)
|
|
23
|
-
parse_result.comments.filter_map
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
end
|
|
23
|
+
parse_result.comments.filter_map { |comment| classify_comment(comment, source) }
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def classify_comment(comment, source)
|
|
27
|
+
loc = comment.location
|
|
28
|
+
text = comment_text(loc, source)
|
|
29
|
+
|
|
30
|
+
if text.match?(DISABLE_MARKER)
|
|
31
|
+
disable_entry(loc, text, source)
|
|
32
|
+
elsif text.match?(ENABLE_MARKER)
|
|
33
|
+
{ type: :enable, line: loc.start_line }
|
|
35
34
|
end
|
|
36
35
|
end
|
|
37
36
|
|
|
37
|
+
def comment_text(loc, source)
|
|
38
|
+
source.byteslice(loc.start_offset, loc.end_offset - loc.start_offset)
|
|
39
|
+
.force_encoding(source.encoding)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def disable_entry(loc, text, source)
|
|
43
|
+
standalone = source.lines[loc.start_line - 1].strip == text.strip
|
|
44
|
+
{ type: :disable, line: loc.start_line, standalone: standalone }
|
|
45
|
+
end
|
|
46
|
+
|
|
38
47
|
def scan_comments(comments, method_ranges, total_lines)
|
|
39
48
|
disabled = []
|
|
40
49
|
range_start = nil
|
|
@@ -28,25 +28,30 @@ class Evilution::Integration::Loading::MutationApplier
|
|
|
28
28
|
syntax_error = @syntax_validator.call(mutation.mutated_source)
|
|
29
29
|
return syntax_error if syntax_error
|
|
30
30
|
|
|
31
|
+
apply(mutation)
|
|
32
|
+
nil
|
|
33
|
+
rescue SyntaxError => e
|
|
34
|
+
failure_result(e, "syntax error in mutated source: #{e.message}")
|
|
35
|
+
rescue ScriptError, StandardError => e
|
|
36
|
+
failure_result(e, "#{e.class}: #{e.message}")
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def apply(mutation)
|
|
31
42
|
@constant_pinner.call(mutation.original_source)
|
|
32
43
|
@concern_state_cleaner.call(mutation.file_path)
|
|
33
44
|
@redefinition_recovery.call(mutation.original_source) do
|
|
34
45
|
@source_evaluator.call(mutation.mutated_source, mutation.file_path)
|
|
35
46
|
end
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
passed: false,
|
|
40
|
-
error: "syntax error in mutated source: #{e.message}",
|
|
41
|
-
error_class: e.class.name,
|
|
42
|
-
error_backtrace: Array(e.backtrace).first(5)
|
|
43
|
-
}
|
|
44
|
-
rescue ScriptError, StandardError => e
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def failure_result(error, message)
|
|
45
50
|
{
|
|
46
51
|
passed: false,
|
|
47
|
-
error:
|
|
48
|
-
error_class:
|
|
49
|
-
error_backtrace: Array(
|
|
52
|
+
error: message,
|
|
53
|
+
error_class: error.class.name,
|
|
54
|
+
error_backtrace: Array(error.backtrace).first(5)
|
|
50
55
|
}
|
|
51
56
|
end
|
|
52
57
|
end
|
|
@@ -10,22 +10,31 @@ require_relative "../integration"
|
|
|
10
10
|
|
|
11
11
|
class Evilution::Integration::Minitest < Evilution::Integration::Base
|
|
12
12
|
def self.baseline_runner
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
13
|
+
->(test_file) { run_baseline_test_file(test_file) }
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def self.run_baseline_test_file(test_file)
|
|
17
|
+
require "minitest"
|
|
18
|
+
require "stringio"
|
|
19
|
+
::Minitest::Runnable.runnables.clear
|
|
20
|
+
baseline_test_files(test_file).each { |f| load(File.expand_path(f)) }
|
|
21
|
+
run_baseline_minitest
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def self.baseline_test_files(test_file)
|
|
25
|
+
File.directory?(test_file) ? Dir.glob(File.join(test_file, "**/*_test.rb")) : [test_file]
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def self.run_baseline_minitest
|
|
29
|
+
out = StringIO.new
|
|
30
|
+
options = ::Minitest.process_args(["--seed", "0"])
|
|
31
|
+
options[:io] = out
|
|
32
|
+
reporter = ::Minitest::CompositeReporter.new
|
|
33
|
+
reporter << ::Minitest::SummaryReporter.new(out, options)
|
|
34
|
+
reporter.start
|
|
35
|
+
::Minitest.__run(reporter, options)
|
|
36
|
+
reporter.report
|
|
37
|
+
reporter.passed?
|
|
29
38
|
end
|
|
30
39
|
|
|
31
40
|
def self.baseline_options
|
|
@@ -74,6 +74,10 @@ class Evilution::Integration::RSpec < Evilution::Integration::Base
|
|
|
74
74
|
command = "rspec #{args.join(" ")}"
|
|
75
75
|
|
|
76
76
|
reset_examples
|
|
77
|
+
execute_run(args, command)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def execute_run(args, command)
|
|
77
81
|
detector = @crash_detector_lifecycle.current
|
|
78
82
|
snapshot = @state_guard.snapshot
|
|
79
83
|
begin
|
|
@@ -21,41 +21,51 @@ class Evilution::Isolation::Fork
|
|
|
21
21
|
sandbox_dir = Dir.mktmpdir("evilution-run")
|
|
22
22
|
start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
23
23
|
parent_rss = Evilution::Memory.rss_kb
|
|
24
|
+
read_io, write_io = binary_pipe
|
|
25
|
+
pid = fork_child(read_io, write_io, sandbox_dir, mutation, test_command)
|
|
26
|
+
write_io.close
|
|
27
|
+
result = wait_for_result(pid, read_io, timeout)
|
|
28
|
+
duration = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
|
|
29
|
+
build_mutation_result(mutation, result, duration, parent_rss)
|
|
30
|
+
ensure
|
|
31
|
+
cleanup_resources(read_io, write_io, pid, sandbox_dir)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
# Marshal result payload is ASCII-8BIT; pipes default to text mode and may
|
|
37
|
+
# transcode according to their external/internal encodings (influenced by
|
|
38
|
+
# Encoding.default_external and/or Encoding.default_internal — Rails sets
|
|
39
|
+
# the latter to UTF-8), failing on bytes with no mapping. Force binmode on
|
|
40
|
+
# both ends.
|
|
41
|
+
def binary_pipe
|
|
24
42
|
read_io, write_io = IO.pipe
|
|
25
|
-
# Marshal result payload is ASCII-8BIT; pipes default to text mode and may
|
|
26
|
-
# transcode according to their external/internal encodings (influenced by
|
|
27
|
-
# Encoding.default_external and/or Encoding.default_internal — Rails sets
|
|
28
|
-
# the latter to UTF-8), failing on bytes with no mapping. Force binmode on
|
|
29
|
-
# both ends.
|
|
30
43
|
read_io.binmode
|
|
31
44
|
write_io.binmode
|
|
45
|
+
[read_io, write_io]
|
|
46
|
+
end
|
|
32
47
|
|
|
33
|
-
|
|
48
|
+
def fork_child(read_io, write_io, sandbox_dir, mutation, test_command)
|
|
49
|
+
::Process.fork do
|
|
34
50
|
ENV["TMPDIR"] = sandbox_dir
|
|
35
51
|
read_io.close
|
|
36
52
|
suppress_child_output
|
|
37
|
-
@hooks.fire(:worker_process_start, mutation:
|
|
53
|
+
@hooks.fire(:worker_process_start, mutation:) if @hooks
|
|
38
54
|
result = execute_in_child(mutation, test_command)
|
|
39
55
|
Marshal.dump(result, write_io)
|
|
40
56
|
write_io.close
|
|
41
57
|
exit!(result[:passed] ? 0 : 1)
|
|
42
58
|
end
|
|
59
|
+
end
|
|
43
60
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
build_mutation_result(mutation, result, duration, parent_rss)
|
|
49
|
-
ensure
|
|
50
|
-
read_io&.close
|
|
51
|
-
write_io&.close
|
|
61
|
+
def cleanup_resources(read_io, write_io, pid, sandbox_dir)
|
|
62
|
+
read_io.close unless read_io.nil?
|
|
63
|
+
write_io.close unless write_io.nil?
|
|
52
64
|
ensure_reaped(pid)
|
|
53
65
|
restore_original_source
|
|
54
66
|
FileUtils.rm_rf(sandbox_dir) if sandbox_dir
|
|
55
67
|
end
|
|
56
68
|
|
|
57
|
-
private
|
|
58
|
-
|
|
59
69
|
def restore_original_source
|
|
60
70
|
Evilution::TempDirTracker.cleanup_all
|
|
61
71
|
end
|
|
@@ -10,34 +10,43 @@ class Evilution::MCP::InfoTool::Actions::Subjects < Evilution::MCP::InfoTool::Ac
|
|
|
10
10
|
def self.call(files: nil, line_ranges: nil, target: nil, integration: nil, skip_config: nil, **)
|
|
11
11
|
return config_error("files is required") if files.nil? || files.empty?
|
|
12
12
|
|
|
13
|
-
config =
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
)
|
|
17
|
-
runner = Evilution::Runner.new(config: config)
|
|
18
|
-
subjects = runner.parse_and_filter_subjects
|
|
19
|
-
|
|
20
|
-
registry = Evilution::Mutator::Registry.default
|
|
21
|
-
filter = build_subject_filter(config)
|
|
22
|
-
operator_options = { skip_heredoc_literals: config.skip_heredoc_literals? }
|
|
23
|
-
|
|
24
|
-
entries = subjects.map do |subj|
|
|
25
|
-
count = registry.mutations_for(subj, filter: filter, operator_options: operator_options).length
|
|
26
|
-
{ "name" => subj.name, "file" => subj.file_path, "line" => subj.line_number, "mutations" => count }
|
|
27
|
-
ensure
|
|
28
|
-
subj.release_node!
|
|
29
|
-
end
|
|
30
|
-
|
|
31
|
-
success(
|
|
32
|
-
"subjects" => entries,
|
|
33
|
-
"total_subjects" => entries.length,
|
|
34
|
-
"total_mutations" => entries.sum { |e| e["mutations"] }
|
|
35
|
-
)
|
|
13
|
+
config = build_config(files, line_ranges, target, integration, skip_config)
|
|
14
|
+
subjects = Evilution::Runner.new(config: config).parse_and_filter_subjects
|
|
15
|
+
entries = subject_entries(subjects, config)
|
|
16
|
+
success_response(entries)
|
|
36
17
|
end
|
|
37
18
|
|
|
38
19
|
class << self
|
|
39
20
|
private
|
|
40
21
|
|
|
22
|
+
def build_config(files, line_ranges, target, integration, skip_config)
|
|
23
|
+
Evilution::MCP::InfoTool::ConfigFactory.subjects(
|
|
24
|
+
files: files, line_ranges: line_ranges,
|
|
25
|
+
target: target, integration: integration, skip_config: skip_config
|
|
26
|
+
)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def subject_entries(subjects, config)
|
|
30
|
+
registry = Evilution::Mutator::Registry.default
|
|
31
|
+
filter = build_subject_filter(config)
|
|
32
|
+
operator_options = { skip_heredoc_literals: config.skip_heredoc_literals? }
|
|
33
|
+
|
|
34
|
+
subjects.map do |subj|
|
|
35
|
+
count = registry.mutations_for(subj, filter: filter, operator_options: operator_options).length
|
|
36
|
+
{ "name" => subj.name, "file" => subj.file_path, "line" => subj.line_number, "mutations" => count }
|
|
37
|
+
ensure
|
|
38
|
+
subj.release_node!
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def success_response(entries)
|
|
43
|
+
success(
|
|
44
|
+
"subjects" => entries,
|
|
45
|
+
"total_subjects" => entries.length,
|
|
46
|
+
"total_mutations" => entries.sum { |e| e["mutations"] }
|
|
47
|
+
)
|
|
48
|
+
end
|
|
49
|
+
|
|
41
50
|
def build_subject_filter(config)
|
|
42
51
|
return nil if config.ignore_patterns.empty?
|
|
43
52
|
|
|
@@ -6,27 +6,37 @@ require_relative "../../../runner"
|
|
|
6
6
|
require_relative "../../../spec_resolver"
|
|
7
7
|
|
|
8
8
|
class Evilution::MCP::InfoTool::Actions::Tests < Evilution::MCP::InfoTool::Actions::Base
|
|
9
|
+
ResolveResult = Data.define(:resolved, :unresolved)
|
|
10
|
+
private_constant :ResolveResult
|
|
11
|
+
|
|
9
12
|
def self.call(files: nil, spec: nil, integration: nil, skip_config: nil, **)
|
|
10
13
|
return config_error("files is required") if files.nil? || files.empty?
|
|
11
14
|
|
|
12
|
-
config =
|
|
13
|
-
files: files, spec: spec, integration: integration, skip_config: skip_config
|
|
14
|
-
)
|
|
15
|
+
config = build_config(files, spec, integration, skip_config)
|
|
15
16
|
return explicit_specs_response(files, config.spec_files) if config.spec_files.any?
|
|
16
17
|
|
|
17
|
-
|
|
18
|
-
resolved, unresolved = resolve_specs(files, resolver)
|
|
19
|
-
success(
|
|
20
|
-
"specs" => resolved,
|
|
21
|
-
"unresolved" => unresolved,
|
|
22
|
-
"total_sources" => files.length,
|
|
23
|
-
"total_specs" => resolved.map { |r| r["spec"] }.uniq.length
|
|
24
|
-
)
|
|
18
|
+
resolved_specs_response(files, resolver_for(config.integration))
|
|
25
19
|
end
|
|
26
20
|
|
|
27
21
|
class << self
|
|
28
22
|
private
|
|
29
23
|
|
|
24
|
+
def build_config(files, spec, integration, skip_config)
|
|
25
|
+
Evilution::MCP::InfoTool::ConfigFactory.tests(
|
|
26
|
+
files: files, spec: spec, integration: integration, skip_config: skip_config
|
|
27
|
+
)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def resolved_specs_response(files, resolver)
|
|
31
|
+
result = resolve_specs(files, resolver)
|
|
32
|
+
success(
|
|
33
|
+
"specs" => result.resolved,
|
|
34
|
+
"unresolved" => result.unresolved,
|
|
35
|
+
"total_sources" => files.length,
|
|
36
|
+
"total_specs" => result.resolved.map { |r| r["spec"] }.uniq.length
|
|
37
|
+
)
|
|
38
|
+
end
|
|
39
|
+
|
|
30
40
|
def resolver_for(integration)
|
|
31
41
|
integration_class = Evilution::Runner::INTEGRATIONS[integration.to_sym]
|
|
32
42
|
return Evilution::SpecResolver.new unless integration_class
|
|
@@ -54,7 +64,7 @@ class Evilution::MCP::InfoTool::Actions::Tests < Evilution::MCP::InfoTool::Actio
|
|
|
54
64
|
unresolved << source
|
|
55
65
|
end
|
|
56
66
|
end
|
|
57
|
-
|
|
67
|
+
ResolveResult.new(resolved: resolved, unresolved: unresolved)
|
|
58
68
|
end
|
|
59
69
|
end
|
|
60
70
|
end
|
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
require_relative "../info_tool"
|
|
4
4
|
|
|
5
5
|
module Evilution::MCP::InfoTool::RequestParser
|
|
6
|
+
ParsedPaths = Data.define(:files, :ranges)
|
|
7
|
+
|
|
6
8
|
module_function
|
|
7
9
|
|
|
8
10
|
def parse_files(raw_files)
|
|
@@ -15,7 +17,7 @@ module Evilution::MCP::InfoTool::RequestParser
|
|
|
15
17
|
ranges[file] = parse_line_range(range_str) if range_str
|
|
16
18
|
end
|
|
17
19
|
|
|
18
|
-
|
|
20
|
+
ParsedPaths.new(files: files, ranges: ranges)
|
|
19
21
|
end
|
|
20
22
|
|
|
21
23
|
def parse_line_range(str)
|
|
@@ -74,7 +74,13 @@ class Evilution::MCP::InfoTool < MCP::Tool
|
|
|
74
74
|
return ResponseFormatter.error("config_error", "action is required") unless action
|
|
75
75
|
return ResponseFormatter.error("config_error", "unknown action: #{action}") unless ACTIONS.key?(action)
|
|
76
76
|
|
|
77
|
-
parsed_files
|
|
77
|
+
parsed_files = nil
|
|
78
|
+
line_ranges = nil
|
|
79
|
+
if files
|
|
80
|
+
parsed = RequestParser.parse_files(Array(files))
|
|
81
|
+
parsed_files = parsed.files
|
|
82
|
+
line_ranges = parsed.ranges
|
|
83
|
+
end
|
|
78
84
|
|
|
79
85
|
ACTIONS[action].call(
|
|
80
86
|
files: parsed_files, line_ranges: line_ranges, target: target, spec: spec,
|
|
@@ -8,6 +8,8 @@ module Evilution::MCP::MutateTool::OptionParser
|
|
|
8
8
|
isolation baseline save_session].freeze
|
|
9
9
|
ALLOWED_OPT_KEYS = (PASSTHROUGH_KEYS + %i[spec skip_config]).freeze
|
|
10
10
|
|
|
11
|
+
ParsedPaths = Data.define(:files, :ranges)
|
|
12
|
+
|
|
11
13
|
def self.parse_files(raw_files)
|
|
12
14
|
files = []
|
|
13
15
|
ranges = {}
|
|
@@ -20,7 +22,7 @@ module Evilution::MCP::MutateTool::OptionParser
|
|
|
20
22
|
ranges[file] = parse_line_range(range_str)
|
|
21
23
|
end
|
|
22
24
|
|
|
23
|
-
|
|
25
|
+
ParsedPaths.new(files: files, ranges: ranges)
|
|
24
26
|
end
|
|
25
27
|
|
|
26
28
|
def self.parse_line_range(str)
|
|
@@ -9,23 +9,33 @@ module Evilution::MCP::MutateTool::SurvivedEnricher
|
|
|
9
9
|
entries = data["survived"]
|
|
10
10
|
return unless entries.is_a?(Array)
|
|
11
11
|
|
|
12
|
-
explicit_spec =
|
|
13
|
-
resolver = explicit_spec ? nil : resolver_for_integration(config.integration)
|
|
12
|
+
explicit_spec, resolver = build_resolver(config)
|
|
14
13
|
cache = {}
|
|
15
14
|
|
|
16
15
|
entries.each_with_index do |entry, index|
|
|
17
16
|
result = survived_results[index]
|
|
18
17
|
next unless result
|
|
19
18
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
19
|
+
enrich_entry(entry, result.mutation, explicit_spec, resolver, cache)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def self.build_resolver(config)
|
|
24
|
+
explicit_spec = explicit_spec_override(config)
|
|
25
|
+
resolver = explicit_spec ? nil : resolver_for_integration(config.integration)
|
|
26
|
+
[explicit_spec, resolver]
|
|
27
|
+
end
|
|
28
|
+
private_class_method :build_resolver
|
|
29
|
+
|
|
30
|
+
def self.enrich_entry(entry, mutation, explicit_spec, resolver, cache)
|
|
31
|
+
entry["subject"] = mutation.subject.name
|
|
32
|
+
spec_file = explicit_spec || cache.fetch(mutation.file_path) do
|
|
33
|
+
cache[mutation.file_path] = resolver.call(mutation.file_path)
|
|
27
34
|
end
|
|
35
|
+
entry["spec_file"] = spec_file if spec_file
|
|
36
|
+
entry["next_step"] = build_next_step(mutation, spec_file)
|
|
28
37
|
end
|
|
38
|
+
private_class_method :enrich_entry
|
|
29
39
|
|
|
30
40
|
def self.explicit_spec_override(config)
|
|
31
41
|
return nil unless config.respond_to?(:spec_files)
|
|
@@ -105,37 +105,50 @@ class Evilution::MCP::MutateTool < MCP::Tool
|
|
|
105
105
|
|
|
106
106
|
class << self
|
|
107
107
|
def call(server_context:, files: [], verbosity: nil, **opts)
|
|
108
|
+
config = build_config(files, opts)
|
|
109
|
+
on_result = build_progress_streamer(server_context, opts, config)
|
|
110
|
+
summary = Evilution::Runner.new(config: config, on_result: on_result).call
|
|
111
|
+
compact = build_compact_report(summary, verbosity, opts, config)
|
|
112
|
+
|
|
113
|
+
::MCP::Tool::Response.new([{ type: "text", text: compact }])
|
|
114
|
+
rescue Evilution::Error => e
|
|
115
|
+
payload = Evilution::MCP::MutateTool::ErrorPayload.build(e)
|
|
116
|
+
::MCP::Tool::Response.new([{ type: "text", text: ::JSON.generate(payload) }], error: true)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
private
|
|
120
|
+
|
|
121
|
+
def build_config(files, opts)
|
|
108
122
|
Evilution::MCP::MutateTool::OptionParser.validate!(opts)
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
files:
|
|
112
|
-
line_ranges:
|
|
123
|
+
parsed = Evilution::MCP::MutateTool::OptionParser.parse_files(Array(files))
|
|
124
|
+
Evilution::MCP::MutateTool::ConfigBuilder.build(
|
|
125
|
+
files: parsed.files,
|
|
126
|
+
line_ranges: parsed.ranges,
|
|
113
127
|
params: opts
|
|
114
128
|
)
|
|
115
|
-
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def build_progress_streamer(server_context, opts, config)
|
|
132
|
+
Evilution::MCP::MutateTool::ProgressStreamer.build(
|
|
116
133
|
server_context: server_context,
|
|
117
134
|
suggest_tests: opts[:suggest_tests],
|
|
118
135
|
integration: config.integration
|
|
119
136
|
)
|
|
120
|
-
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def build_compact_report(summary, verbosity, opts, config)
|
|
121
140
|
report = Evilution::Reporter::JSON.new(
|
|
122
141
|
suggest_tests: opts[:suggest_tests] == true,
|
|
123
142
|
integration: config.integration
|
|
124
143
|
).call(summary)
|
|
125
|
-
|
|
126
|
-
compact = Evilution::MCP::MutateTool::ReportTrimmer.call(
|
|
144
|
+
Evilution::MCP::MutateTool::ReportTrimmer.call(
|
|
127
145
|
report,
|
|
128
|
-
verbosity:
|
|
146
|
+
verbosity: Evilution::MCP::MutateTool::OptionParser.normalize_verbosity(verbosity),
|
|
129
147
|
survived_results: summary.survived_results,
|
|
130
148
|
config: config,
|
|
131
149
|
enricher: Evilution::MCP::MutateTool::SurvivedEnricher,
|
|
132
150
|
summary: summary
|
|
133
151
|
)
|
|
134
|
-
|
|
135
|
-
::MCP::Tool::Response.new([{ type: "text", text: compact }])
|
|
136
|
-
rescue Evilution::Error => e
|
|
137
|
-
payload = Evilution::MCP::MutateTool::ErrorPayload.build(e)
|
|
138
|
-
::MCP::Tool::Response.new([{ type: "text", text: ::JSON.generate(payload) }], error: true)
|
|
139
152
|
end
|
|
140
153
|
end
|
|
141
154
|
end
|
|
@@ -50,6 +50,9 @@ class Evilution::MCP::SessionTool < MCP::Tool
|
|
|
50
50
|
|
|
51
51
|
VALID_ACTIONS = %w[list show diff].freeze
|
|
52
52
|
|
|
53
|
+
LimitResult = Data.define(:limit, :error)
|
|
54
|
+
private_constant :LimitResult
|
|
55
|
+
|
|
53
56
|
class << self
|
|
54
57
|
def call(server_context:, action: nil, results_dir: nil, limit: nil, path: nil, base: nil, head: nil)
|
|
55
58
|
return error_response("config_error", "action is required") unless action
|
|
@@ -65,28 +68,28 @@ class Evilution::MCP::SessionTool < MCP::Tool
|
|
|
65
68
|
private
|
|
66
69
|
|
|
67
70
|
def list_action(results_dir:, limit:)
|
|
68
|
-
|
|
69
|
-
return error_response("config_error",
|
|
71
|
+
result = normalize_limit(limit)
|
|
72
|
+
return error_response("config_error", result.error) if result.error
|
|
70
73
|
|
|
71
74
|
store_opts = {}
|
|
72
75
|
store_opts[:results_dir] = results_dir if results_dir
|
|
73
76
|
store = Evilution::Session::Store.new(**store_opts)
|
|
74
77
|
entries = store.list
|
|
75
|
-
entries = entries.first(
|
|
78
|
+
entries = entries.first(result.limit) unless result.limit.nil?
|
|
76
79
|
|
|
77
80
|
payload = entries.map { |e| e.transform_keys(&:to_s) }
|
|
78
81
|
success_response(payload)
|
|
79
82
|
end
|
|
80
83
|
|
|
81
84
|
def normalize_limit(limit)
|
|
82
|
-
return
|
|
85
|
+
return LimitResult.new(limit: nil, error: nil) if limit.nil?
|
|
83
86
|
|
|
84
87
|
coerced = Integer(limit)
|
|
85
|
-
return
|
|
88
|
+
return LimitResult.new(limit: nil, error: "limit must be a non-negative integer") if coerced.negative?
|
|
86
89
|
|
|
87
|
-
|
|
90
|
+
LimitResult.new(limit: coerced, error: nil)
|
|
88
91
|
rescue ArgumentError, TypeError
|
|
89
|
-
|
|
92
|
+
LimitResult.new(limit: nil, error: "limit must be a non-negative integer")
|
|
90
93
|
end
|
|
91
94
|
|
|
92
95
|
def show_action(path:, results_dir:)
|
|
@@ -107,19 +110,11 @@ class Evilution::MCP::SessionTool < MCP::Tool
|
|
|
107
110
|
end
|
|
108
111
|
|
|
109
112
|
def diff_action(base:, head:, results_dir:)
|
|
110
|
-
return error_response("config_error", "base is required") unless base
|
|
111
|
-
return error_response("config_error", "head is required") unless head
|
|
112
|
-
|
|
113
113
|
dir = results_dir || Evilution::Session::Store::DEFAULT_DIR
|
|
114
|
-
|
|
115
|
-
return
|
|
116
|
-
|
|
117
|
-
store = Evilution::Session::Store.new(results_dir: dir)
|
|
118
|
-
base_data = store.load(base)
|
|
119
|
-
head_data = store.load(head)
|
|
114
|
+
validation = validate_diff_args(base, head, dir)
|
|
115
|
+
return validation if validation
|
|
120
116
|
|
|
121
|
-
|
|
122
|
-
result = diff.call(base_data, head_data)
|
|
117
|
+
result = load_and_diff(base, head, dir)
|
|
123
118
|
success_response(result.to_h)
|
|
124
119
|
rescue Evilution::Error => e
|
|
125
120
|
error_response("not_found", e.message)
|
|
@@ -129,6 +124,20 @@ class Evilution::MCP::SessionTool < MCP::Tool
|
|
|
129
124
|
error_response("runtime_error", e.message)
|
|
130
125
|
end
|
|
131
126
|
|
|
127
|
+
def validate_diff_args(base, head, dir)
|
|
128
|
+
return error_response("config_error", "base is required") unless base
|
|
129
|
+
return error_response("config_error", "head is required") unless head
|
|
130
|
+
return error_response("config_error", "base must be under results directory") unless within?(base, dir)
|
|
131
|
+
return error_response("config_error", "head must be under results directory") unless within?(head, dir)
|
|
132
|
+
|
|
133
|
+
nil
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def load_and_diff(base, head, dir)
|
|
137
|
+
store = Evilution::Session::Store.new(results_dir: dir)
|
|
138
|
+
Evilution::Session::Diff.new.call(store.load(base), store.load(head))
|
|
139
|
+
end
|
|
140
|
+
|
|
132
141
|
def within?(path, results_dir)
|
|
133
142
|
resolved_root = canonical_path(results_dir)
|
|
134
143
|
resolved_path = canonical_path(path)
|