evilution 0.27.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 +65 -0
- data/.rubocop_todo.yml +0 -1
- data/CHANGELOG.md +39 -0
- data/README.md +19 -0
- data/lib/evilution/ast/constant_names.rb +28 -11
- data/lib/evilution/ast/pattern/parser.rb +29 -17
- data/lib/evilution/baseline.rb +5 -4
- 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 +36 -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/diff_extractor/evilution.rb +22 -0
- data/lib/evilution/compare/diff_extractor/mutant.rb +30 -0
- data/lib/evilution/compare/diff_extractor.rb +6 -0
- data/lib/evilution/compare/fingerprint.rb +15 -72
- data/lib/evilution/compare/line_normalizer.rb +72 -0
- data/lib/evilution/compare/normalizer.rb +27 -9
- data/lib/evilution/config/validators/profile.rb +11 -0
- data/lib/evilution/config.rb +49 -32
- data/lib/evilution/disable_comment.rb +21 -12
- data/lib/evilution/integration/crash_detector.rb +2 -2
- data/lib/evilution/integration/loading/mutation_applier.rb +17 -12
- data/lib/evilution/integration/loading/source_evaluator.rb +6 -2
- data/lib/evilution/integration/minitest.rb +25 -16
- data/lib/evilution/integration/minitest_crash_detector.rb +2 -2
- data/lib/evilution/integration/rspec/state_guard/object_space_example_groups.rb +11 -3
- data/lib/evilution/integration/rspec.rb +4 -0
- data/lib/evilution/isolation/fork.rb +43 -28
- data/lib/evilution/isolation/in_process.rb +10 -6
- 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 -3
- data/lib/evilution/mcp/mutate_tool/option_parser.rb +3 -1
- data/lib/evilution/mcp/mutate_tool/progress_streamer.rb +5 -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 -20
- data/lib/evilution/mutation.rb +60 -42
- data/lib/evilution/mutator/base.rb +23 -21
- 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/predicate_to_nil.rb +20 -0
- 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/mutator/registry.rb +20 -0
- data/lib/evilution/parallel/work_queue/channel/frame.rb +5 -1
- data/lib/evilution/parallel/work_queue/dispatcher.rb +15 -8
- data/lib/evilution/parallel/work_queue/worker/loop.rb +1 -1
- data/lib/evilution/parallel/work_queue/worker.rb +10 -7
- data/lib/evilution/parallel/work_queue.rb +35 -18
- data/lib/evilution/process_cleanup.rb +19 -0
- 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/html/baseline_keys.rb +1 -1
- data/lib/evilution/reporter/html/diff_formatter.rb +1 -1
- data/lib/evilution/reporter/html/escape.rb +1 -1
- data/lib/evilution/reporter/html/section.rb +1 -1
- data/lib/evilution/reporter/html/sections.rb +4 -2
- data/lib/evilution/reporter/html/stylesheet.rb +1 -1
- data/lib/evilution/reporter/html.rb +8 -3
- 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/registry.rb +1 -5
- data/lib/evilution/reporter/suggestion/templates/generic.rb +1 -1
- data/lib/evilution/reporter/suggestion/templates/minitest.rb +361 -649
- data/lib/evilution/reporter/suggestion/templates/rspec.rb +362 -603
- data/lib/evilution/reporter/suggestion/templates.rb +6 -0
- data/lib/evilution/result/error_info.rb +20 -0
- data/lib/evilution/result/memory_stats.rb +20 -0
- data/lib/evilution/result/mutation_result.rb +30 -14
- data/lib/evilution/runner/baseline_runner.rb +16 -10
- data/lib/evilution/runner/diagnostics.rb +14 -11
- data/lib/evilution/runner/isolation_resolver.rb +12 -11
- data/lib/evilution/runner/mutation_executor/mutation_runner.rb +1 -3
- data/lib/evilution/runner/mutation_executor/neutralization_pipeline.rb +1 -2
- data/lib/evilution/runner/mutation_executor/neutralizer/baseline_failed.rb +3 -10
- data/lib/evilution/runner/mutation_executor/neutralizer/infra_error.rb +3 -10
- data/lib/evilution/runner/mutation_executor/neutralizer.rb +11 -0
- data/lib/evilution/runner/mutation_executor/result_cache.rb +4 -4
- data/lib/evilution/runner/mutation_executor/result_notifier.rb +1 -3
- data/lib/evilution/runner/mutation_executor/result_packer.rb +11 -9
- data/lib/evilution/runner/mutation_executor/strategy/parallel.rb +33 -13
- data/lib/evilution/runner/mutation_executor/strategy/sequential.rb +2 -4
- data/lib/evilution/runner/mutation_executor/strategy.rb +11 -0
- data/lib/evilution/runner/mutation_executor.rb +14 -20
- data/lib/evilution/runner/mutation_planner.rb +38 -19
- data/lib/evilution/runner/report_publisher.rb +1 -2
- data/lib/evilution/runner/subject_pipeline.rb +22 -13
- data/lib/evilution/runner.rb +36 -34
- 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/lib/evilution.rb +1 -0
- data/script/memory_check +14 -6
- data/scripts/benchmark_density +10 -9
- data/scripts/compare_mutations +38 -21
- data/scripts/mutant_json_adapter +7 -4
- metadata +15 -3
- data/lib/evilution/reporter/html/namespace.rb +0 -11
|
@@ -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
|
|
@@ -5,11 +5,15 @@ require_relative "../loading"
|
|
|
5
5
|
# Evaluate source with __FILE__ set to the absolute original path so that
|
|
6
6
|
# `require_relative` and `__dir__` resolve against the real source tree, where
|
|
7
7
|
# sibling files actually exist.
|
|
8
|
+
#
|
|
9
|
+
# Trust boundary: `source` is never user-supplied. It is always the original
|
|
10
|
+
# on-disk source from a file the user already pointed Evilution at, with
|
|
11
|
+
# byte-level mutations applied by AST::SourceSurgeon. The only difference
|
|
12
|
+
# between this eval path and a plain `require` of the same file is that we
|
|
13
|
+
# substitute the mutated bytes — the privilege level is identical.
|
|
8
14
|
class Evilution::Integration::Loading::SourceEvaluator
|
|
9
15
|
def call(source, file_path)
|
|
10
16
|
absolute = File.expand_path(file_path)
|
|
11
|
-
# rubocop:disable Security/Eval
|
|
12
17
|
eval(source, TOPLEVEL_BINDING, absolute, 1)
|
|
13
|
-
# rubocop:enable Security/Eval
|
|
14
18
|
end
|
|
15
19
|
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
|
|
@@ -34,11 +34,11 @@ class Evilution::Integration::MinitestCrashDetector
|
|
|
34
34
|
end
|
|
35
35
|
end
|
|
36
36
|
|
|
37
|
-
def
|
|
37
|
+
def assertion_failure?
|
|
38
38
|
@assertion_failures.positive?
|
|
39
39
|
end
|
|
40
40
|
|
|
41
|
-
def
|
|
41
|
+
def crashed?
|
|
42
42
|
@crashes.any?
|
|
43
43
|
end
|
|
44
44
|
|
|
@@ -9,7 +9,10 @@ class Evilution::Integration::RSpec::StateGuard::ObjectSpaceExampleGroups
|
|
|
9
9
|
groups = Set.new
|
|
10
10
|
ObjectSpace.each_object(Class) do |klass|
|
|
11
11
|
groups << klass.object_id if klass < ::RSpec::Core::ExampleGroup
|
|
12
|
-
rescue TypeError
|
|
12
|
+
rescue TypeError
|
|
13
|
+
# ObjectSpace iteration may surface partially-initialized or anonymous
|
|
14
|
+
# classes whose `<` comparison raises. Skipping them is safe — they
|
|
15
|
+
# cannot be ExampleGroup descendants we need to track.
|
|
13
16
|
end
|
|
14
17
|
groups
|
|
15
18
|
end
|
|
@@ -23,13 +26,18 @@ class Evilution::Integration::RSpec::StateGuard::ObjectSpaceExampleGroups
|
|
|
23
26
|
|
|
24
27
|
klass.constants(false).each do |const|
|
|
25
28
|
klass.send(:remove_const, const)
|
|
26
|
-
rescue NameError
|
|
29
|
+
rescue NameError
|
|
30
|
+
# Constant may have been removed concurrently (e.g. via autoload
|
|
31
|
+
# reload) between #constants(false) and #remove_const. Best-effort
|
|
32
|
+
# cleanup — nothing to do if it's already gone.
|
|
27
33
|
end
|
|
28
34
|
|
|
29
35
|
klass.instance_variables.each do |ivar|
|
|
30
36
|
klass.remove_instance_variable(ivar)
|
|
31
37
|
end
|
|
32
|
-
rescue TypeError
|
|
38
|
+
rescue TypeError
|
|
39
|
+
# Same defensive case as #snapshot: skip classes whose `<` raises
|
|
40
|
+
# mid-iteration.
|
|
33
41
|
end
|
|
34
42
|
end
|
|
35
43
|
end
|
|
@@ -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
|
|
@@ -5,6 +5,7 @@ require "tmpdir"
|
|
|
5
5
|
require_relative "../memory"
|
|
6
6
|
require_relative "../temp_dir_tracker"
|
|
7
7
|
require_relative "../child_output"
|
|
8
|
+
require_relative "../process_cleanup"
|
|
8
9
|
|
|
9
10
|
require_relative "../isolation"
|
|
10
11
|
|
|
@@ -20,42 +21,52 @@ class Evilution::Isolation::Fork
|
|
|
20
21
|
sandbox_dir = Dir.mktmpdir("evilution-run")
|
|
21
22
|
start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
22
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
|
|
23
42
|
read_io, write_io = IO.pipe
|
|
24
|
-
# Marshal result payload is ASCII-8BIT; pipes default to text mode and may
|
|
25
|
-
# transcode according to their external/internal encodings (influenced by
|
|
26
|
-
# Encoding.default_external and/or Encoding.default_internal — Rails sets
|
|
27
|
-
# the latter to UTF-8), failing on bytes with no mapping. Force binmode on
|
|
28
|
-
# both ends.
|
|
29
43
|
read_io.binmode
|
|
30
44
|
write_io.binmode
|
|
45
|
+
[read_io, write_io]
|
|
46
|
+
end
|
|
31
47
|
|
|
32
|
-
|
|
48
|
+
def fork_child(read_io, write_io, sandbox_dir, mutation, test_command)
|
|
49
|
+
::Process.fork do
|
|
33
50
|
ENV["TMPDIR"] = sandbox_dir
|
|
34
51
|
read_io.close
|
|
35
52
|
suppress_child_output
|
|
36
|
-
@hooks.fire(:worker_process_start, mutation:
|
|
53
|
+
@hooks.fire(:worker_process_start, mutation:) if @hooks
|
|
37
54
|
result = execute_in_child(mutation, test_command)
|
|
38
55
|
Marshal.dump(result, write_io)
|
|
39
56
|
write_io.close
|
|
40
57
|
exit!(result[:passed] ? 0 : 1)
|
|
41
58
|
end
|
|
59
|
+
end
|
|
42
60
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
build_mutation_result(mutation, result, duration, parent_rss)
|
|
48
|
-
ensure
|
|
49
|
-
read_io&.close
|
|
50
|
-
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?
|
|
51
64
|
ensure_reaped(pid)
|
|
52
|
-
restore_original_source
|
|
65
|
+
restore_original_source
|
|
53
66
|
FileUtils.rm_rf(sandbox_dir) if sandbox_dir
|
|
54
67
|
end
|
|
55
68
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
def restore_original_source(mutation) # rubocop:disable Lint/UnusedMethodArgument
|
|
69
|
+
def restore_original_source
|
|
59
70
|
Evilution::TempDirTracker.cleanup_all
|
|
60
71
|
end
|
|
61
72
|
|
|
@@ -88,7 +99,7 @@ class Evilution::Isolation::Fork
|
|
|
88
99
|
if data.empty?
|
|
89
100
|
{ timeout: false, passed: false, error: "empty result from child" }
|
|
90
101
|
else
|
|
91
|
-
{ timeout: false }.merge(Marshal.load(data))
|
|
102
|
+
{ timeout: false }.merge(Marshal.load(data))
|
|
92
103
|
end
|
|
93
104
|
else
|
|
94
105
|
terminate_child(pid)
|
|
@@ -113,7 +124,7 @@ class Evilution::Isolation::Fork
|
|
|
113
124
|
end
|
|
114
125
|
|
|
115
126
|
def terminate_child(pid)
|
|
116
|
-
::
|
|
127
|
+
Evilution::ProcessCleanup.safe_kill("TERM", pid)
|
|
117
128
|
_, status = ::Process.waitpid2(pid, ::Process::WNOHANG)
|
|
118
129
|
return if status
|
|
119
130
|
|
|
@@ -121,8 +132,8 @@ class Evilution::Isolation::Fork
|
|
|
121
132
|
_, status = ::Process.waitpid2(pid, ::Process::WNOHANG)
|
|
122
133
|
return if status
|
|
123
134
|
|
|
124
|
-
::
|
|
125
|
-
::
|
|
135
|
+
Evilution::ProcessCleanup.safe_kill("KILL", pid)
|
|
136
|
+
Evilution::ProcessCleanup.safe_wait(pid)
|
|
126
137
|
end
|
|
127
138
|
|
|
128
139
|
def classify_status(result)
|
|
@@ -143,11 +154,15 @@ class Evilution::Isolation::Fork
|
|
|
143
154
|
status: status,
|
|
144
155
|
duration: duration,
|
|
145
156
|
test_command: result[:test_command],
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
157
|
+
memory: Evilution::Result::MemoryStats.from_fields(
|
|
158
|
+
child_rss_kb: result[:child_rss_kb],
|
|
159
|
+
parent_rss_kb: parent_rss_kb
|
|
160
|
+
),
|
|
161
|
+
error: Evilution::Result::ErrorInfo.from_fields(
|
|
162
|
+
message: result[:error],
|
|
163
|
+
klass: result[:error_class],
|
|
164
|
+
backtrace: result[:error_backtrace]
|
|
165
|
+
)
|
|
151
166
|
)
|
|
152
167
|
end
|
|
153
168
|
end
|
|
@@ -80,12 +80,16 @@ class Evilution::Isolation::InProcess
|
|
|
80
80
|
status: status,
|
|
81
81
|
duration: duration,
|
|
82
82
|
test_command: result[:test_command],
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
83
|
+
memory: Evilution::Result::MemoryStats.from_fields(
|
|
84
|
+
child_rss_kb: rss_after,
|
|
85
|
+
memory_delta_kb: memory_delta_kb,
|
|
86
|
+
parent_rss_kb: rss_before
|
|
87
|
+
),
|
|
88
|
+
error: Evilution::Result::ErrorInfo.from_fields(
|
|
89
|
+
message: result[:error],
|
|
90
|
+
klass: result[:error_class],
|
|
91
|
+
backtrace: result[:error_backtrace]
|
|
92
|
+
)
|
|
89
93
|
)
|
|
90
94
|
end
|
|
91
95
|
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)
|
|
@@ -70,12 +70,17 @@ class Evilution::MCP::InfoTool < MCP::Tool
|
|
|
70
70
|
)
|
|
71
71
|
|
|
72
72
|
class << self
|
|
73
|
-
# rubocop:disable Lint/UnusedMethodArgument
|
|
74
73
|
def call(server_context:, action: nil, files: nil, target: nil, spec: nil, integration: nil, skip_config: nil)
|
|
75
74
|
return ResponseFormatter.error("config_error", "action is required") unless action
|
|
76
75
|
return ResponseFormatter.error("config_error", "unknown action: #{action}") unless ACTIONS.key?(action)
|
|
77
76
|
|
|
78
|
-
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
|
|
79
84
|
|
|
80
85
|
ACTIONS[action].call(
|
|
81
86
|
files: parsed_files, line_ranges: line_ranges, target: target, spec: spec,
|
|
@@ -84,7 +89,6 @@ class Evilution::MCP::InfoTool < MCP::Tool
|
|
|
84
89
|
rescue Evilution::Error => e
|
|
85
90
|
ResponseFormatter.error_for(e)
|
|
86
91
|
end
|
|
87
|
-
# rubocop:enable Lint/UnusedMethodArgument
|
|
88
92
|
end
|
|
89
93
|
end
|
|
90
94
|
|
|
@@ -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)
|
|
@@ -10,15 +10,19 @@ module Evilution::MCP::MutateTool::ProgressStreamer
|
|
|
10
10
|
|
|
11
11
|
suggestion = Evilution::Reporter::Suggestion.new(suggest_tests: true, integration: integration)
|
|
12
12
|
survivor_index = 0
|
|
13
|
+
disabled = false
|
|
13
14
|
|
|
14
15
|
proc do |result|
|
|
15
16
|
next unless result.survived?
|
|
17
|
+
next if disabled
|
|
16
18
|
|
|
17
19
|
begin
|
|
18
20
|
survivor_index += 1
|
|
19
21
|
detail = build_suggestion_detail(result.mutation, suggestion)
|
|
20
22
|
server_context.report_progress(survivor_index, message: ::JSON.generate(detail))
|
|
21
|
-
rescue StandardError
|
|
23
|
+
rescue StandardError => e
|
|
24
|
+
warn "[evilution] progress stream disabled after error: #{e.class}: #{e.message}"
|
|
25
|
+
disabled = true
|
|
22
26
|
end
|
|
23
27
|
end
|
|
24
28
|
end
|
|
@@ -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
|