evilution 0.21.0 → 0.22.1
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/.gitignore +4 -0
- data/.beads/interactions.jsonl +16 -0
- data/.beads/issues.jsonl +9 -6
- data/.claude/settings.json +5 -0
- data/CHANGELOG.md +35 -0
- data/README.md +28 -13
- data/comparison_results/baseline_2026-04-09.md +35 -0
- data/comparison_results/operator_classification.md +79 -0
- data/comparison_results/operator_prioritization.md +68 -0
- data/docs/mutation_density_benchmark.md +91 -0
- data/lib/evilution/ast/parser.rb +2 -1
- data/lib/evilution/baseline.rb +14 -11
- data/lib/evilution/cli.rb +2 -1
- data/lib/evilution/config.rb +15 -3
- data/lib/evilution/disable_comment.rb +2 -1
- data/lib/evilution/integration/base.rb +124 -1
- data/lib/evilution/integration/minitest.rb +145 -0
- data/lib/evilution/integration/minitest_crash_detector.rb +55 -0
- data/lib/evilution/integration/rspec.rb +33 -100
- data/lib/evilution/isolation/fork.rb +11 -3
- data/lib/evilution/isolation/in_process.rb +12 -3
- data/lib/evilution/mcp/mutate_tool.rb +6 -6
- data/lib/evilution/mutator/base.rb +4 -0
- data/lib/evilution/mutator/operator/bitwise_complement.rb +1 -1
- data/lib/evilution/mutator/operator/block_pass_removal.rb +30 -0
- data/lib/evilution/mutator/operator/ensure_removal.rb +1 -1
- data/lib/evilution/mutator/operator/index_to_at.rb +30 -0
- data/lib/evilution/mutator/operator/index_to_dig.rb +2 -2
- data/lib/evilution/mutator/operator/index_to_fetch.rb +2 -2
- data/lib/evilution/mutator/operator/keyword_argument.rb +1 -1
- data/lib/evilution/mutator/operator/regex_simplification.rb +169 -0
- data/lib/evilution/mutator/operator/rescue_body_replacement.rb +1 -1
- data/lib/evilution/mutator/operator/rescue_removal.rb +1 -1
- data/lib/evilution/mutator/operator/symbol_literal.rb +9 -0
- data/lib/evilution/mutator/registry.rb +3 -0
- data/lib/evilution/reporter/cli.rb +19 -0
- data/lib/evilution/reporter/html.rb +12 -3
- data/lib/evilution/reporter/json.rb +14 -3
- data/lib/evilution/reporter/suggestion.rb +659 -2
- data/lib/evilution/result/mutation_result.rb +9 -2
- data/lib/evilution/runner.rb +56 -17
- data/lib/evilution/spec_resolver.rb +24 -16
- data/lib/evilution/version.rb +1 -1
- data/lib/evilution.rb +4 -0
- data/script/memory_check +5 -5
- data/scripts/benchmark_density +261 -0
- data/scripts/benchmark_density.yml +19 -0
- data/scripts/compare_mutations +404 -0
- data/scripts/compare_mutations.yml +24 -0
- data/scripts/mutant_json_adapter +224 -0
- metadata +17 -2
|
@@ -1,17 +1,29 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "fileutils"
|
|
4
3
|
require "stringio"
|
|
5
|
-
require "tmpdir"
|
|
6
4
|
require_relative "base"
|
|
7
5
|
require_relative "crash_detector"
|
|
8
6
|
require_relative "../spec_resolver"
|
|
9
7
|
require_relative "../related_spec_heuristic"
|
|
10
|
-
require_relative "../temp_dir_tracker"
|
|
11
8
|
|
|
12
9
|
require_relative "../integration"
|
|
13
10
|
|
|
14
11
|
class Evilution::Integration::RSpec < Evilution::Integration::Base
|
|
12
|
+
def self.baseline_runner
|
|
13
|
+
lambda { |spec_file|
|
|
14
|
+
require "rspec/core"
|
|
15
|
+
::RSpec.reset
|
|
16
|
+
status = ::RSpec::Core::Runner.run(
|
|
17
|
+
["--format", "progress", "--no-color", "--order", "defined", spec_file]
|
|
18
|
+
)
|
|
19
|
+
status.zero?
|
|
20
|
+
}
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def self.baseline_options
|
|
24
|
+
{ runner: baseline_runner }
|
|
25
|
+
end
|
|
26
|
+
|
|
15
27
|
def initialize(test_files: nil, hooks: nil)
|
|
16
28
|
@test_files = test_files
|
|
17
29
|
@rspec_loaded = false
|
|
@@ -22,105 +34,24 @@ class Evilution::Integration::RSpec < Evilution::Integration::Base
|
|
|
22
34
|
super(hooks: hooks)
|
|
23
35
|
end
|
|
24
36
|
|
|
25
|
-
def call(mutation)
|
|
26
|
-
@temp_dir = nil
|
|
27
|
-
ensure_rspec_loaded
|
|
28
|
-
@hooks.fire(:mutation_insert_pre, mutation: mutation, file_path: mutation.file_path) if @hooks
|
|
29
|
-
apply_mutation(mutation)
|
|
30
|
-
@hooks.fire(:mutation_insert_post, mutation: mutation, file_path: mutation.file_path) if @hooks
|
|
31
|
-
run_rspec(mutation)
|
|
32
|
-
ensure
|
|
33
|
-
restore_original(mutation)
|
|
34
|
-
end
|
|
35
|
-
|
|
36
37
|
private
|
|
37
38
|
|
|
38
39
|
attr_reader :test_files
|
|
39
40
|
|
|
40
|
-
def
|
|
41
|
+
def ensure_framework_loaded
|
|
41
42
|
return if @rspec_loaded
|
|
42
43
|
|
|
43
|
-
|
|
44
|
+
fire_hook(:setup_integration_pre, integration: :rspec)
|
|
44
45
|
require "rspec/core"
|
|
45
46
|
Evilution::Integration::CrashDetector.register_with_rspec
|
|
46
47
|
@rspec_loaded = true
|
|
47
|
-
|
|
48
|
+
fire_hook(:setup_integration_post, integration: :rspec)
|
|
48
49
|
rescue LoadError => e
|
|
49
50
|
raise Evilution::Error, "rspec-core is required but not available: #{e.message}"
|
|
50
51
|
end
|
|
51
52
|
|
|
52
|
-
def
|
|
53
|
-
|
|
54
|
-
Evilution::TempDirTracker.register(@temp_dir)
|
|
55
|
-
@displaced_feature = nil
|
|
56
|
-
subpath = resolve_require_subpath(mutation.file_path)
|
|
57
|
-
|
|
58
|
-
if subpath
|
|
59
|
-
dest = File.join(@temp_dir, subpath)
|
|
60
|
-
FileUtils.mkdir_p(File.dirname(dest))
|
|
61
|
-
File.write(dest, mutation.mutated_source)
|
|
62
|
-
$LOAD_PATH.unshift(@temp_dir)
|
|
63
|
-
displace_loaded_feature(mutation.file_path)
|
|
64
|
-
else
|
|
65
|
-
absolute = File.expand_path(mutation.file_path)
|
|
66
|
-
dest = File.join(@temp_dir, absolute)
|
|
67
|
-
FileUtils.mkdir_p(File.dirname(dest))
|
|
68
|
-
File.write(dest, mutation.mutated_source)
|
|
69
|
-
load(dest)
|
|
70
|
-
end
|
|
71
|
-
end
|
|
72
|
-
|
|
73
|
-
def restore_original(mutation) # rubocop:disable Lint/UnusedMethodArgument
|
|
74
|
-
return unless @temp_dir
|
|
75
|
-
|
|
76
|
-
$LOAD_PATH.delete(@temp_dir)
|
|
77
|
-
$LOADED_FEATURES.reject! { |f| f.start_with?(@temp_dir) }
|
|
78
|
-
$LOADED_FEATURES << @displaced_feature if @displaced_feature && !$LOADED_FEATURES.include?(@displaced_feature)
|
|
79
|
-
@displaced_feature = nil
|
|
80
|
-
FileUtils.rm_rf(@temp_dir)
|
|
81
|
-
Evilution::TempDirTracker.unregister(@temp_dir)
|
|
82
|
-
@temp_dir = nil
|
|
83
|
-
end
|
|
84
|
-
|
|
85
|
-
def resolve_require_subpath(file_path)
|
|
86
|
-
absolute = File.expand_path(file_path)
|
|
87
|
-
best_subpath = nil
|
|
88
|
-
|
|
89
|
-
$LOAD_PATH.each do |entry|
|
|
90
|
-
dir = File.expand_path(entry)
|
|
91
|
-
prefix = dir.end_with?("/") ? dir : "#{dir}/"
|
|
92
|
-
next unless absolute.start_with?(prefix)
|
|
93
|
-
|
|
94
|
-
candidate = absolute.delete_prefix(prefix)
|
|
95
|
-
best_subpath = candidate if best_subpath.nil? || candidate.length < best_subpath.length
|
|
96
|
-
end
|
|
97
|
-
|
|
98
|
-
best_subpath
|
|
99
|
-
end
|
|
100
|
-
|
|
101
|
-
def displace_loaded_feature(file_path)
|
|
102
|
-
absolute = File.expand_path(file_path)
|
|
103
|
-
return unless $LOADED_FEATURES.include?(absolute)
|
|
104
|
-
|
|
105
|
-
@displaced_feature = absolute
|
|
106
|
-
$LOADED_FEATURES.delete(absolute)
|
|
107
|
-
end
|
|
108
|
-
|
|
109
|
-
def run_rspec(mutation)
|
|
110
|
-
# When used via the Runner with Isolation::Fork, each mutation is executed
|
|
111
|
-
# in its own forked child process, so RSpec state (loaded example groups,
|
|
112
|
-
# world, configuration) cannot accumulate across mutation runs — the child
|
|
113
|
-
# process exits after each run.
|
|
114
|
-
#
|
|
115
|
-
# This integration can also be invoked directly (e.g. in specs or alternative
|
|
116
|
-
# runners) without fork isolation. clear_examples reuses the existing World
|
|
117
|
-
# and Configuration (avoiding per-run instance growth) while clearing loaded
|
|
118
|
-
# example groups, constants, and configuration state.
|
|
119
|
-
if ::RSpec.respond_to?(:clear_examples)
|
|
120
|
-
::RSpec.clear_examples
|
|
121
|
-
else
|
|
122
|
-
::RSpec.reset
|
|
123
|
-
end
|
|
53
|
+
def run_tests(mutation)
|
|
54
|
+
reset_state
|
|
124
55
|
|
|
125
56
|
out = StringIO.new
|
|
126
57
|
err = StringIO.new
|
|
@@ -139,6 +70,19 @@ class Evilution::Integration::RSpec < Evilution::Integration::Base
|
|
|
139
70
|
release_rspec_state(eg_before)
|
|
140
71
|
end
|
|
141
72
|
|
|
73
|
+
def build_args(mutation)
|
|
74
|
+
files = resolve_test_files(mutation)
|
|
75
|
+
["--format", "progress", "--no-color", "--order", "defined", *files]
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def reset_state
|
|
79
|
+
if ::RSpec.respond_to?(:clear_examples)
|
|
80
|
+
::RSpec.clear_examples
|
|
81
|
+
else
|
|
82
|
+
::RSpec.reset
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
142
86
|
def snapshot_example_groups
|
|
143
87
|
groups = Set.new
|
|
144
88
|
ObjectSpace.each_object(Class) do |klass|
|
|
@@ -150,11 +94,6 @@ class Evilution::Integration::RSpec < Evilution::Integration::Base
|
|
|
150
94
|
|
|
151
95
|
def release_rspec_state(eg_before)
|
|
152
96
|
release_example_groups(eg_before)
|
|
153
|
-
# Remove ExampleGroups constants so the named reference is dropped.
|
|
154
|
-
# We avoid a full RSpec.reset here because it creates new World and
|
|
155
|
-
# Configuration instances each call; the pre-run reset already handles
|
|
156
|
-
# that. Instead, clear the world's example_groups array (which holds
|
|
157
|
-
# direct class references) and the source cache.
|
|
158
97
|
::RSpec::ExampleGroups.remove_all_constants if defined?(::RSpec::ExampleGroups)
|
|
159
98
|
release_world_example_groups
|
|
160
99
|
end
|
|
@@ -166,7 +105,6 @@ class Evilution::Integration::RSpec < Evilution::Integration::Base
|
|
|
166
105
|
next unless klass < ::RSpec::Core::ExampleGroup
|
|
167
106
|
next if eg_before.include?(klass.object_id)
|
|
168
107
|
|
|
169
|
-
# Remove nested module constants (LetDefinitions, NamedSubjectPreventSuper)
|
|
170
108
|
klass.constants(false).each do |const|
|
|
171
109
|
klass.send(:remove_const, const)
|
|
172
110
|
rescue NameError # rubocop:disable Lint/SuppressedException
|
|
@@ -205,11 +143,6 @@ class Evilution::Integration::RSpec < Evilution::Integration::Base
|
|
|
205
143
|
end
|
|
206
144
|
end
|
|
207
145
|
|
|
208
|
-
def build_args(mutation)
|
|
209
|
-
files = resolve_test_files(mutation)
|
|
210
|
-
["--format", "progress", "--no-color", "--order", "defined", *files]
|
|
211
|
-
end
|
|
212
|
-
|
|
213
146
|
def resolve_test_files(mutation)
|
|
214
147
|
return test_files if test_files
|
|
215
148
|
|
|
@@ -57,8 +57,13 @@ class Evilution::Isolation::Fork
|
|
|
57
57
|
def execute_in_child(mutation, test_command)
|
|
58
58
|
result = test_command.call(mutation)
|
|
59
59
|
{ child_rss_kb: Evilution::Memory.rss_kb }.merge(result)
|
|
60
|
-
rescue StandardError => e
|
|
61
|
-
{
|
|
60
|
+
rescue ScriptError, StandardError => e
|
|
61
|
+
{
|
|
62
|
+
passed: false,
|
|
63
|
+
error: e.message,
|
|
64
|
+
error_class: e.class.name,
|
|
65
|
+
error_backtrace: Array(e.backtrace).first(5)
|
|
66
|
+
}
|
|
62
67
|
end
|
|
63
68
|
|
|
64
69
|
def wait_for_result(pid, read_io, timeout)
|
|
@@ -107,7 +112,10 @@ class Evilution::Isolation::Fork
|
|
|
107
112
|
duration: duration,
|
|
108
113
|
test_command: result[:test_command],
|
|
109
114
|
child_rss_kb: result[:child_rss_kb],
|
|
110
|
-
parent_rss_kb: parent_rss_kb
|
|
115
|
+
parent_rss_kb: parent_rss_kb,
|
|
116
|
+
error_message: result[:error],
|
|
117
|
+
error_class: result[:error_class],
|
|
118
|
+
error_backtrace: result[:error_backtrace]
|
|
111
119
|
)
|
|
112
120
|
end
|
|
113
121
|
end
|
|
@@ -34,8 +34,14 @@ class Evilution::Isolation::InProcess
|
|
|
34
34
|
{ timeout: false }.merge(result)
|
|
35
35
|
rescue Timeout::Error
|
|
36
36
|
{ timeout: true }
|
|
37
|
-
rescue StandardError => e
|
|
38
|
-
{
|
|
37
|
+
rescue ScriptError, StandardError => e
|
|
38
|
+
{
|
|
39
|
+
timeout: false,
|
|
40
|
+
passed: false,
|
|
41
|
+
error: e.message,
|
|
42
|
+
error_class: e.class.name,
|
|
43
|
+
error_backtrace: Array(e.backtrace).first(5)
|
|
44
|
+
}
|
|
39
45
|
end
|
|
40
46
|
|
|
41
47
|
def suppress_output
|
|
@@ -74,7 +80,10 @@ class Evilution::Isolation::InProcess
|
|
|
74
80
|
test_command: result[:test_command],
|
|
75
81
|
child_rss_kb: rss_after,
|
|
76
82
|
memory_delta_kb: memory_delta_kb,
|
|
77
|
-
parent_rss_kb: rss_before
|
|
83
|
+
parent_rss_kb: rss_before,
|
|
84
|
+
error_message: result[:error],
|
|
85
|
+
error_class: result[:error_class],
|
|
86
|
+
error_backtrace: result[:error_backtrace]
|
|
78
87
|
)
|
|
79
88
|
end
|
|
80
89
|
end
|
|
@@ -12,7 +12,7 @@ require_relative "../mcp"
|
|
|
12
12
|
class Evilution::MCP::MutateTool < MCP::Tool
|
|
13
13
|
tool_name "evilution-mutate"
|
|
14
14
|
description "Run mutation testing on Ruby source files. " \
|
|
15
|
-
"Use suggest_tests: true to get concrete
|
|
15
|
+
"Use suggest_tests: true to get concrete test code (RSpec or Minitest) for surviving mutants."
|
|
16
16
|
input_schema(
|
|
17
17
|
properties: {
|
|
18
18
|
files: {
|
|
@@ -43,7 +43,7 @@ class Evilution::MCP::MutateTool < MCP::Tool
|
|
|
43
43
|
},
|
|
44
44
|
suggest_tests: {
|
|
45
45
|
type: "boolean",
|
|
46
|
-
description: "When true, suggestions for survived mutants include concrete
|
|
46
|
+
description: "When true, suggestions for survived mutants include concrete test code " \
|
|
47
47
|
"instead of static description text (default: false)"
|
|
48
48
|
},
|
|
49
49
|
verbosity: {
|
|
@@ -64,10 +64,10 @@ class Evilution::MCP::MutateTool < MCP::Tool
|
|
|
64
64
|
config_opts = build_config_opts(parsed_files, line_ranges, target, timeout, jobs, fail_fast, spec,
|
|
65
65
|
suggest_tests)
|
|
66
66
|
config = Evilution::Config.new(**config_opts)
|
|
67
|
-
on_result = build_streaming_callback(server_context, suggest_tests)
|
|
67
|
+
on_result = build_streaming_callback(server_context, suggest_tests, config.integration)
|
|
68
68
|
runner = Evilution::Runner.new(config: config, on_result: on_result)
|
|
69
69
|
summary = runner.call
|
|
70
|
-
report = Evilution::Reporter::JSON.new(suggest_tests: suggest_tests == true).call(summary)
|
|
70
|
+
report = Evilution::Reporter::JSON.new(suggest_tests: suggest_tests == true, integration: config.integration).call(summary)
|
|
71
71
|
compact = trim_report(report, normalize_verbosity(verbosity))
|
|
72
72
|
|
|
73
73
|
::MCP::Tool::Response.new([{ type: "text", text: compact }])
|
|
@@ -156,10 +156,10 @@ class Evilution::MCP::MutateTool < MCP::Tool
|
|
|
156
156
|
data[key].each { |entry| entry.delete("diff") }
|
|
157
157
|
end
|
|
158
158
|
|
|
159
|
-
def build_streaming_callback(server_context, suggest_tests)
|
|
159
|
+
def build_streaming_callback(server_context, suggest_tests, integration)
|
|
160
160
|
return nil unless suggest_tests && server_context.respond_to?(:report_progress)
|
|
161
161
|
|
|
162
|
-
suggestion = Evilution::Reporter::Suggestion.new(suggest_tests: true)
|
|
162
|
+
suggestion = Evilution::Reporter::Suggestion.new(suggest_tests: true, integration: integration)
|
|
163
163
|
survivor_index = 0
|
|
164
164
|
|
|
165
165
|
proc do |result|
|
|
@@ -45,6 +45,10 @@ class Evilution::Mutator::Base < Prism::Visitor
|
|
|
45
45
|
)
|
|
46
46
|
end
|
|
47
47
|
|
|
48
|
+
def byteslice_source(offset, length)
|
|
49
|
+
@file_source.byteslice(offset, length).force_encoding(@file_source.encoding)
|
|
50
|
+
end
|
|
51
|
+
|
|
48
52
|
def self.operator_name
|
|
49
53
|
class_name = name || "anonymous"
|
|
50
54
|
class_name.split("::").last
|
|
@@ -9,7 +9,7 @@ class Evilution::Mutator::Operator::BitwiseComplement < Evilution::Mutator::Base
|
|
|
9
9
|
receiver_loc = node.receiver.location
|
|
10
10
|
|
|
11
11
|
# Remove ~: replace entire ~expr with just the receiver expression
|
|
12
|
-
receiver_source =
|
|
12
|
+
receiver_source = byteslice_source(receiver_loc.start_offset, receiver_loc.length)
|
|
13
13
|
add_mutation(
|
|
14
14
|
offset: node.location.start_offset,
|
|
15
15
|
length: node.location.length,
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../operator"
|
|
4
|
+
|
|
5
|
+
class Evilution::Mutator::Operator::BlockPassRemoval < Evilution::Mutator::Base
|
|
6
|
+
def visit_call_node(node)
|
|
7
|
+
if node.block.is_a?(Prism::BlockArgumentNode)
|
|
8
|
+
block_node = node.block
|
|
9
|
+
call_start = node.location.start_offset
|
|
10
|
+
node_end = call_start + node.location.length
|
|
11
|
+
block_end = block_node.location.start_offset + block_node.location.length
|
|
12
|
+
|
|
13
|
+
prefix = @file_source.byteslice(call_start...block_node.location.start_offset).rstrip
|
|
14
|
+
suffix = @file_source.byteslice(block_end...node_end)
|
|
15
|
+
|
|
16
|
+
# Clean up: remove trailing comma from prefix, remove empty parens
|
|
17
|
+
prefix = prefix.sub(/,\s*\z/, "")
|
|
18
|
+
replacement = "#{prefix}#{suffix}".sub(/\(\s*\)/, "")
|
|
19
|
+
|
|
20
|
+
add_mutation(
|
|
21
|
+
offset: call_start,
|
|
22
|
+
length: node.location.length,
|
|
23
|
+
replacement: replacement,
|
|
24
|
+
node: node
|
|
25
|
+
)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
super
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -21,7 +21,7 @@ class Evilution::Mutator::Operator::EnsureRemoval < Evilution::Mutator::Base
|
|
|
21
21
|
|
|
22
22
|
def line_start_after_newline(offset)
|
|
23
23
|
pos = offset
|
|
24
|
-
pos -= 1 while pos.positive? && @file_source
|
|
24
|
+
pos -= 1 while pos.positive? && @file_source.getbyte(pos - 1) != 0x0A
|
|
25
25
|
pos
|
|
26
26
|
end
|
|
27
27
|
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../operator"
|
|
4
|
+
|
|
5
|
+
class Evilution::Mutator::Operator::IndexToAt < Evilution::Mutator::Base
|
|
6
|
+
def visit_call_node(node)
|
|
7
|
+
if indexable?(node)
|
|
8
|
+
receiver_source = @file_source.byteslice(node.receiver.location.start_offset, node.receiver.location.length)
|
|
9
|
+
arg_source = @file_source.byteslice(node.arguments.location.start_offset, node.arguments.location.length)
|
|
10
|
+
|
|
11
|
+
add_mutation(
|
|
12
|
+
offset: node.location.start_offset,
|
|
13
|
+
length: node.location.length,
|
|
14
|
+
replacement: "#{receiver_source}.at(#{arg_source})",
|
|
15
|
+
node: node
|
|
16
|
+
)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
super
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def indexable?(node)
|
|
25
|
+
node.name == :[] &&
|
|
26
|
+
node.receiver &&
|
|
27
|
+
node.arguments &&
|
|
28
|
+
node.arguments.arguments.length == 1
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -11,8 +11,8 @@ class Evilution::Mutator::Operator::IndexToDig < Evilution::Mutator::Base
|
|
|
11
11
|
def visit_call_node(node)
|
|
12
12
|
if chain_head?(node)
|
|
13
13
|
root, args = collect_chain(node)
|
|
14
|
-
root_source =
|
|
15
|
-
arg_sources = args.map { |a|
|
|
14
|
+
root_source = byteslice_source(root.location.start_offset, root.location.length)
|
|
15
|
+
arg_sources = args.map { |a| byteslice_source(a.location.start_offset, a.location.length) }
|
|
16
16
|
|
|
17
17
|
add_mutation(
|
|
18
18
|
offset: node.location.start_offset,
|
|
@@ -5,8 +5,8 @@ require_relative "../operator"
|
|
|
5
5
|
class Evilution::Mutator::Operator::IndexToFetch < Evilution::Mutator::Base
|
|
6
6
|
def visit_call_node(node)
|
|
7
7
|
if indexable?(node)
|
|
8
|
-
receiver_source =
|
|
9
|
-
arg_source =
|
|
8
|
+
receiver_source = byteslice_source(node.receiver.location.start_offset, node.receiver.location.length)
|
|
9
|
+
arg_source = byteslice_source(node.arguments.location.start_offset, node.arguments.location.length)
|
|
10
10
|
|
|
11
11
|
add_mutation(
|
|
12
12
|
offset: node.location.start_offset,
|
|
@@ -26,7 +26,7 @@ class Evilution::Mutator::Operator::KeywordArgument < Evilution::Mutator::Base
|
|
|
26
26
|
add_mutation(
|
|
27
27
|
offset: kw_loc.start_offset,
|
|
28
28
|
length: kw_loc.length,
|
|
29
|
-
replacement:
|
|
29
|
+
replacement: byteslice_source(name_loc.start_offset, name_loc.end_offset - name_loc.start_offset),
|
|
30
30
|
node: kw
|
|
31
31
|
)
|
|
32
32
|
end
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../operator"
|
|
4
|
+
|
|
5
|
+
class Evilution::Mutator::Operator::RegexSimplification < Evilution::Mutator::Base
|
|
6
|
+
def visit_regular_expression_node(node)
|
|
7
|
+
content = node.content
|
|
8
|
+
return super if content.empty?
|
|
9
|
+
|
|
10
|
+
content_offset = node.content_loc.start_offset
|
|
11
|
+
|
|
12
|
+
remove_quantifiers(node, content, content_offset)
|
|
13
|
+
remove_anchors(node, content, content_offset)
|
|
14
|
+
remove_character_class_ranges(node, content, content_offset)
|
|
15
|
+
|
|
16
|
+
super
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def remove_quantifiers(node, content, content_offset)
|
|
22
|
+
i = 0
|
|
23
|
+
while i < content.length
|
|
24
|
+
if content[i] == "\\"
|
|
25
|
+
i += 2
|
|
26
|
+
next
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
if content[i] == "["
|
|
30
|
+
i = skip_character_class(content, i)
|
|
31
|
+
next
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
match = match_quantifier(content, i)
|
|
35
|
+
if match
|
|
36
|
+
add_mutation(
|
|
37
|
+
offset: content_offset + i,
|
|
38
|
+
length: match.length,
|
|
39
|
+
replacement: "",
|
|
40
|
+
node: node
|
|
41
|
+
)
|
|
42
|
+
i += match.length
|
|
43
|
+
else
|
|
44
|
+
i += 1
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def match_quantifier(content, pos)
|
|
50
|
+
case content[pos]
|
|
51
|
+
when "+", "*", "?"
|
|
52
|
+
content[pos]
|
|
53
|
+
when "{"
|
|
54
|
+
if (m = content[pos..].match(/\A\{\d+(?:,\d*)?\}/))
|
|
55
|
+
m[0]
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def remove_anchors(node, content, content_offset)
|
|
61
|
+
i = 0
|
|
62
|
+
while i < content.length
|
|
63
|
+
if content[i] == "\\"
|
|
64
|
+
anchor = match_backslash_anchor(content, i)
|
|
65
|
+
if anchor
|
|
66
|
+
add_mutation(
|
|
67
|
+
offset: content_offset + i,
|
|
68
|
+
length: anchor.length,
|
|
69
|
+
replacement: "",
|
|
70
|
+
node: node
|
|
71
|
+
)
|
|
72
|
+
i += anchor.length
|
|
73
|
+
else
|
|
74
|
+
i += 2
|
|
75
|
+
end
|
|
76
|
+
next
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
if content[i] == "["
|
|
80
|
+
i = skip_character_class(content, i)
|
|
81
|
+
next
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
if %w[^ $].include?(content[i])
|
|
85
|
+
add_mutation(
|
|
86
|
+
offset: content_offset + i,
|
|
87
|
+
length: 1,
|
|
88
|
+
replacement: "",
|
|
89
|
+
node: node
|
|
90
|
+
)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
i += 1
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def match_backslash_anchor(content, pos)
|
|
98
|
+
return nil unless content[pos] == "\\"
|
|
99
|
+
|
|
100
|
+
two_char = content[pos, 2]
|
|
101
|
+
return two_char if %w[\\A \\z \\Z].include?(two_char)
|
|
102
|
+
|
|
103
|
+
nil
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def remove_character_class_ranges(node, content, content_offset)
|
|
107
|
+
i = 0
|
|
108
|
+
while i < content.length
|
|
109
|
+
if content[i] == "\\"
|
|
110
|
+
i += 2
|
|
111
|
+
next
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
if content[i] == "["
|
|
115
|
+
scan_ranges_in_class(node, content, content_offset, i)
|
|
116
|
+
i = skip_character_class(content, i)
|
|
117
|
+
else
|
|
118
|
+
i += 1
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def scan_ranges_in_class(node, content, content_offset, class_start)
|
|
124
|
+
first_item = skip_class_prefix(content, class_start)
|
|
125
|
+
i = first_item
|
|
126
|
+
|
|
127
|
+
while i < content.length && content[i] != "]"
|
|
128
|
+
if content[i] == "\\"
|
|
129
|
+
i += 2
|
|
130
|
+
next
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
emit_range_removal(node, content, content_offset, first_item, i) if content[i] == "-"
|
|
134
|
+
i += 1
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def skip_class_prefix(content, class_start)
|
|
139
|
+
i = class_start + 1
|
|
140
|
+
i += 1 if i < content.length && content[i] == "^"
|
|
141
|
+
i += 1 if i < content.length && content[i] == "]"
|
|
142
|
+
i
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def emit_range_removal(node, content, content_offset, first_item, pos)
|
|
146
|
+
return unless pos > first_item && pos + 1 < content.length && content[pos + 1] != "]"
|
|
147
|
+
|
|
148
|
+
add_mutation(
|
|
149
|
+
offset: content_offset + pos,
|
|
150
|
+
length: 1,
|
|
151
|
+
replacement: "",
|
|
152
|
+
node: node
|
|
153
|
+
)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def skip_character_class(content, pos)
|
|
157
|
+
i = pos + 1
|
|
158
|
+
i += 1 if i < content.length && content[i] == "^"
|
|
159
|
+
i += 1 if i < content.length && content[i] == "]"
|
|
160
|
+
|
|
161
|
+
while i < content.length
|
|
162
|
+
return i + 1 if content[i] == "]"
|
|
163
|
+
|
|
164
|
+
i += content[i] == "\\" ? 2 : 1
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
i
|
|
168
|
+
end
|
|
169
|
+
end
|
|
@@ -85,7 +85,7 @@ class Evilution::Mutator::Operator::RescueBodyReplacement < Evilution::Mutator::
|
|
|
85
85
|
def indentation_of(offset)
|
|
86
86
|
pos = offset - 1
|
|
87
87
|
col = 0
|
|
88
|
-
while pos >= 0 && @file_source
|
|
88
|
+
while pos >= 0 && @file_source.getbyte(pos) != 0x0A
|
|
89
89
|
col += 1
|
|
90
90
|
pos -= 1
|
|
91
91
|
end
|
|
@@ -31,7 +31,7 @@ class Evilution::Mutator::Operator::RescueRemoval < Evilution::Mutator::Base
|
|
|
31
31
|
|
|
32
32
|
def line_start_before(offset)
|
|
33
33
|
pos = offset - 1
|
|
34
|
-
pos -= 1 while pos.positive? && @file_source
|
|
34
|
+
pos -= 1 while pos.positive? && @file_source.getbyte(pos) != 0x0A
|
|
35
35
|
pos
|
|
36
36
|
end
|
|
37
37
|
end
|
|
@@ -4,6 +4,8 @@ require_relative "../operator"
|
|
|
4
4
|
|
|
5
5
|
class Evilution::Mutator::Operator::SymbolLiteral < Evilution::Mutator::Base
|
|
6
6
|
def visit_symbol_node(node)
|
|
7
|
+
return super if label_form?(node)
|
|
8
|
+
|
|
7
9
|
add_mutation(
|
|
8
10
|
offset: node.location.start_offset,
|
|
9
11
|
length: node.location.length,
|
|
@@ -20,4 +22,11 @@ class Evilution::Mutator::Operator::SymbolLiteral < Evilution::Mutator::Base
|
|
|
20
22
|
|
|
21
23
|
super
|
|
22
24
|
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def label_form?(node)
|
|
29
|
+
closing = node.closing_loc
|
|
30
|
+
!closing.nil? && closing.slice == ":"
|
|
31
|
+
end
|
|
23
32
|
end
|
|
@@ -27,9 +27,11 @@ class Evilution::Mutator::Registry
|
|
|
27
27
|
Evilution::Mutator::Operator::MethodCallRemoval,
|
|
28
28
|
Evilution::Mutator::Operator::ArgumentRemoval,
|
|
29
29
|
Evilution::Mutator::Operator::BlockRemoval,
|
|
30
|
+
Evilution::Mutator::Operator::BlockPassRemoval,
|
|
30
31
|
Evilution::Mutator::Operator::ConditionalFlip,
|
|
31
32
|
Evilution::Mutator::Operator::RangeReplacement,
|
|
32
33
|
Evilution::Mutator::Operator::RegexpMutation,
|
|
34
|
+
Evilution::Mutator::Operator::RegexSimplification,
|
|
33
35
|
Evilution::Mutator::Operator::ReceiverReplacement,
|
|
34
36
|
Evilution::Mutator::Operator::SendMutation,
|
|
35
37
|
Evilution::Mutator::Operator::ArgumentNilSubstitution,
|
|
@@ -52,6 +54,7 @@ class Evilution::Mutator::Registry
|
|
|
52
54
|
Evilution::Mutator::Operator::BitwiseComplement,
|
|
53
55
|
Evilution::Mutator::Operator::ZsuperRemoval,
|
|
54
56
|
Evilution::Mutator::Operator::ExplicitSuperMutation,
|
|
57
|
+
Evilution::Mutator::Operator::IndexToAt,
|
|
55
58
|
Evilution::Mutator::Operator::IndexToFetch,
|
|
56
59
|
Evilution::Mutator::Operator::IndexToDig,
|
|
57
60
|
Evilution::Mutator::Operator::IndexAssignmentRemoval,
|
|
@@ -19,6 +19,7 @@ class Evilution::Reporter::CLI
|
|
|
19
19
|
append_survived(lines, summary)
|
|
20
20
|
append_neutral(lines, summary)
|
|
21
21
|
append_equivalent(lines, summary)
|
|
22
|
+
append_errors(lines, summary)
|
|
22
23
|
append_disabled(lines, summary)
|
|
23
24
|
lines << ""
|
|
24
25
|
lines << "[TRUNCATED] Stopped early due to --fail-fast" if summary.truncated?
|
|
@@ -54,6 +55,24 @@ class Evilution::Reporter::CLI
|
|
|
54
55
|
summary.equivalent_results.each { |result| lines << format_neutral(result) }
|
|
55
56
|
end
|
|
56
57
|
|
|
58
|
+
def append_errors(lines, summary)
|
|
59
|
+
errored = summary.results.select(&:error?)
|
|
60
|
+
return if errored.empty?
|
|
61
|
+
|
|
62
|
+
lines << ""
|
|
63
|
+
lines << "Errored mutations:"
|
|
64
|
+
errored.each { |result| lines << format_error(result) }
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def format_error(result)
|
|
68
|
+
mutation = result.mutation
|
|
69
|
+
header = " #{mutation.operator_name}: #{mutation.file_path}:#{mutation.line}"
|
|
70
|
+
return header unless result.error_message
|
|
71
|
+
|
|
72
|
+
indented = result.error_message.lines.map { |line| " #{line.chomp}" }.join("\n")
|
|
73
|
+
"#{header}\n#{indented}"
|
|
74
|
+
end
|
|
75
|
+
|
|
57
76
|
def append_disabled(lines, summary)
|
|
58
77
|
return unless summary.disabled_mutations.any?
|
|
59
78
|
|