evilution 0.20.0 → 0.22.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/.gitignore +4 -0
- data/.beads/.migration-hint-ts +1 -1
- data/.beads/interactions.jsonl +12 -0
- data/.beads/issues.jsonl +22 -19
- data/CHANGELOG.md +35 -0
- data/README.md +17 -11
- 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 +13 -3
- data/lib/evilution/config.rb +27 -5
- data/lib/evilution/disable_comment.rb +2 -1
- data/lib/evilution/integration/base.rb +98 -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 -92
- data/lib/evilution/isolation/fork.rb +3 -6
- data/lib/evilution/mcp/mutate_tool.rb +6 -6
- data/lib/evilution/mutator/base.rb +5 -1
- 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 +3 -3
- 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/string_literal.rb +18 -0
- data/lib/evilution/mutator/registry.rb +12 -2
- data/lib/evilution/reporter/html.rb +2 -2
- data/lib/evilution/reporter/json.rb +2 -2
- data/lib/evilution/reporter/suggestion.rb +659 -2
- data/lib/evilution/runner.rb +59 -13
- data/lib/evilution/spec_resolver.rb +24 -16
- data/lib/evilution/temp_dir_tracker.rb +39 -0
- data/lib/evilution/version.rb +1 -1
- data/lib/evilution.rb +4 -0
- 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
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../integration"
|
|
4
|
+
|
|
5
|
+
class Evilution::Integration::MinitestCrashDetector
|
|
6
|
+
def initialize
|
|
7
|
+
reset
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def start
|
|
11
|
+
# Required by Minitest reporter interface
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def report
|
|
15
|
+
# Required by Minitest reporter interface
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def passed?
|
|
19
|
+
@crashes.empty?
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def reset
|
|
23
|
+
@assertion_failures = 0
|
|
24
|
+
@crashes = []
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def record(result)
|
|
28
|
+
result.failures.each do |failure|
|
|
29
|
+
if failure.is_a?(::Minitest::UnexpectedError)
|
|
30
|
+
@crashes << failure.error
|
|
31
|
+
elsif failure.is_a?(::Minitest::Assertion)
|
|
32
|
+
@assertion_failures += 1
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def has_assertion_failure? # rubocop:disable Naming/PredicatePrefix
|
|
38
|
+
@assertion_failures.positive?
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def has_crash? # rubocop:disable Naming/PredicatePrefix
|
|
42
|
+
@crashes.any?
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def only_crashes?
|
|
46
|
+
@crashes.any? && @assertion_failures.zero?
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def crash_summary
|
|
50
|
+
return nil if @crashes.empty?
|
|
51
|
+
|
|
52
|
+
types = @crashes.map { |e| e.class.name }.uniq
|
|
53
|
+
"#{types.join(", ")} (#{@crashes.length} crash#{"es" unless @crashes.length == 1})"
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -1,8 +1,6 @@
|
|
|
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"
|
|
@@ -11,6 +9,21 @@ require_relative "../related_spec_heuristic"
|
|
|
11
9
|
require_relative "../integration"
|
|
12
10
|
|
|
13
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
|
+
|
|
14
27
|
def initialize(test_files: nil, hooks: nil)
|
|
15
28
|
@test_files = test_files
|
|
16
29
|
@rspec_loaded = false
|
|
@@ -21,98 +34,24 @@ class Evilution::Integration::RSpec < Evilution::Integration::Base
|
|
|
21
34
|
super(hooks: hooks)
|
|
22
35
|
end
|
|
23
36
|
|
|
24
|
-
def call(mutation)
|
|
25
|
-
@original_content = nil
|
|
26
|
-
@temp_dir = nil
|
|
27
|
-
@lock_file = nil
|
|
28
|
-
ensure_rspec_loaded
|
|
29
|
-
@hooks.fire(:mutation_insert_pre, mutation: mutation, file_path: mutation.file_path) if @hooks
|
|
30
|
-
apply_mutation(mutation)
|
|
31
|
-
@hooks.fire(:mutation_insert_post, mutation: mutation, file_path: mutation.file_path) if @hooks
|
|
32
|
-
run_rspec(mutation)
|
|
33
|
-
ensure
|
|
34
|
-
restore_original(mutation)
|
|
35
|
-
end
|
|
36
|
-
|
|
37
37
|
private
|
|
38
38
|
|
|
39
39
|
attr_reader :test_files
|
|
40
40
|
|
|
41
|
-
def
|
|
41
|
+
def ensure_framework_loaded
|
|
42
42
|
return if @rspec_loaded
|
|
43
43
|
|
|
44
|
-
|
|
44
|
+
fire_hook(:setup_integration_pre, integration: :rspec)
|
|
45
45
|
require "rspec/core"
|
|
46
46
|
Evilution::Integration::CrashDetector.register_with_rspec
|
|
47
47
|
@rspec_loaded = true
|
|
48
|
-
|
|
48
|
+
fire_hook(:setup_integration_post, integration: :rspec)
|
|
49
49
|
rescue LoadError => e
|
|
50
50
|
raise Evilution::Error, "rspec-core is required but not available: #{e.message}"
|
|
51
51
|
end
|
|
52
52
|
|
|
53
|
-
def
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
if subpath
|
|
57
|
-
@temp_dir = Dir.mktmpdir("evilution")
|
|
58
|
-
dest = File.join(@temp_dir, subpath)
|
|
59
|
-
FileUtils.mkdir_p(File.dirname(dest))
|
|
60
|
-
File.write(dest, mutation.mutated_source)
|
|
61
|
-
$LOAD_PATH.unshift(@temp_dir)
|
|
62
|
-
else
|
|
63
|
-
# Fallback: direct write when file isn't under any $LOAD_PATH entry.
|
|
64
|
-
# Acquire an exclusive lock to prevent concurrent workers from corrupting the file.
|
|
65
|
-
lock_path = File.join(Dir.tmpdir, "evilution-#{File.expand_path(mutation.file_path).hash.abs}.lock")
|
|
66
|
-
@lock_file = File.open(lock_path, File::CREAT | File::RDWR)
|
|
67
|
-
@lock_file.flock(File::LOCK_EX)
|
|
68
|
-
@original_content = File.read(mutation.file_path)
|
|
69
|
-
File.write(mutation.file_path, mutation.mutated_source)
|
|
70
|
-
end
|
|
71
|
-
end
|
|
72
|
-
|
|
73
|
-
def restore_original(mutation)
|
|
74
|
-
if @temp_dir
|
|
75
|
-
$LOAD_PATH.delete(@temp_dir)
|
|
76
|
-
$LOADED_FEATURES.reject! { |f| f.start_with?(@temp_dir) }
|
|
77
|
-
FileUtils.rm_rf(@temp_dir)
|
|
78
|
-
@temp_dir = nil
|
|
79
|
-
elsif @original_content
|
|
80
|
-
File.write(mutation.file_path, @original_content)
|
|
81
|
-
@lock_file&.flock(File::LOCK_UN)
|
|
82
|
-
@lock_file&.close
|
|
83
|
-
@lock_file = nil
|
|
84
|
-
end
|
|
85
|
-
end
|
|
86
|
-
|
|
87
|
-
def resolve_require_subpath(file_path)
|
|
88
|
-
absolute = File.expand_path(file_path)
|
|
89
|
-
|
|
90
|
-
$LOAD_PATH.each do |entry|
|
|
91
|
-
dir = File.expand_path(entry)
|
|
92
|
-
prefix = dir.end_with?("/") ? dir : "#{dir}/"
|
|
93
|
-
next unless absolute.start_with?(prefix)
|
|
94
|
-
|
|
95
|
-
return absolute.delete_prefix(prefix)
|
|
96
|
-
end
|
|
97
|
-
|
|
98
|
-
nil
|
|
99
|
-
end
|
|
100
|
-
|
|
101
|
-
def run_rspec(mutation)
|
|
102
|
-
# When used via the Runner with Isolation::Fork, each mutation is executed
|
|
103
|
-
# in its own forked child process, so RSpec state (loaded example groups,
|
|
104
|
-
# world, configuration) cannot accumulate across mutation runs — the child
|
|
105
|
-
# process exits after each run.
|
|
106
|
-
#
|
|
107
|
-
# This integration can also be invoked directly (e.g. in specs or alternative
|
|
108
|
-
# runners) without fork isolation. clear_examples reuses the existing World
|
|
109
|
-
# and Configuration (avoiding per-run instance growth) while clearing loaded
|
|
110
|
-
# example groups, constants, and configuration state.
|
|
111
|
-
if ::RSpec.respond_to?(:clear_examples)
|
|
112
|
-
::RSpec.clear_examples
|
|
113
|
-
else
|
|
114
|
-
::RSpec.reset
|
|
115
|
-
end
|
|
53
|
+
def run_tests(mutation)
|
|
54
|
+
reset_state
|
|
116
55
|
|
|
117
56
|
out = StringIO.new
|
|
118
57
|
err = StringIO.new
|
|
@@ -131,6 +70,19 @@ class Evilution::Integration::RSpec < Evilution::Integration::Base
|
|
|
131
70
|
release_rspec_state(eg_before)
|
|
132
71
|
end
|
|
133
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
|
+
|
|
134
86
|
def snapshot_example_groups
|
|
135
87
|
groups = Set.new
|
|
136
88
|
ObjectSpace.each_object(Class) do |klass|
|
|
@@ -142,11 +94,6 @@ class Evilution::Integration::RSpec < Evilution::Integration::Base
|
|
|
142
94
|
|
|
143
95
|
def release_rspec_state(eg_before)
|
|
144
96
|
release_example_groups(eg_before)
|
|
145
|
-
# Remove ExampleGroups constants so the named reference is dropped.
|
|
146
|
-
# We avoid a full RSpec.reset here because it creates new World and
|
|
147
|
-
# Configuration instances each call; the pre-run reset already handles
|
|
148
|
-
# that. Instead, clear the world's example_groups array (which holds
|
|
149
|
-
# direct class references) and the source cache.
|
|
150
97
|
::RSpec::ExampleGroups.remove_all_constants if defined?(::RSpec::ExampleGroups)
|
|
151
98
|
release_world_example_groups
|
|
152
99
|
end
|
|
@@ -158,7 +105,6 @@ class Evilution::Integration::RSpec < Evilution::Integration::Base
|
|
|
158
105
|
next unless klass < ::RSpec::Core::ExampleGroup
|
|
159
106
|
next if eg_before.include?(klass.object_id)
|
|
160
107
|
|
|
161
|
-
# Remove nested module constants (LetDefinitions, NamedSubjectPreventSuper)
|
|
162
108
|
klass.constants(false).each do |const|
|
|
163
109
|
klass.send(:remove_const, const)
|
|
164
110
|
rescue NameError # rubocop:disable Lint/SuppressedException
|
|
@@ -197,11 +143,6 @@ class Evilution::Integration::RSpec < Evilution::Integration::Base
|
|
|
197
143
|
end
|
|
198
144
|
end
|
|
199
145
|
|
|
200
|
-
def build_args(mutation)
|
|
201
|
-
files = resolve_test_files(mutation)
|
|
202
|
-
["--format", "progress", "--no-color", "--order", "defined", *files]
|
|
203
|
-
end
|
|
204
|
-
|
|
205
146
|
def resolve_test_files(mutation)
|
|
206
147
|
return test_files if test_files
|
|
207
148
|
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require "fileutils"
|
|
4
4
|
require "tmpdir"
|
|
5
5
|
require_relative "../memory"
|
|
6
|
+
require_relative "../temp_dir_tracker"
|
|
6
7
|
|
|
7
8
|
require_relative "../isolation"
|
|
8
9
|
|
|
@@ -44,12 +45,8 @@ class Evilution::Isolation::Fork
|
|
|
44
45
|
|
|
45
46
|
private
|
|
46
47
|
|
|
47
|
-
def restore_original_source(mutation)
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
File.write(mutation.file_path, mutation.original_source)
|
|
51
|
-
rescue StandardError => e
|
|
52
|
-
warn("Warning: failed to restore #{mutation.file_path}: #{e.message}")
|
|
48
|
+
def restore_original_source(mutation) # rubocop:disable Lint/UnusedMethodArgument
|
|
49
|
+
Evilution::TempDirTracker.cleanup_all
|
|
53
50
|
end
|
|
54
51
|
|
|
55
52
|
def suppress_child_output
|
|
@@ -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|
|
|
@@ -7,7 +7,7 @@ require_relative "../mutator"
|
|
|
7
7
|
class Evilution::Mutator::Base < Prism::Visitor
|
|
8
8
|
attr_reader :mutations
|
|
9
9
|
|
|
10
|
-
def initialize
|
|
10
|
+
def initialize(**_options)
|
|
11
11
|
@mutations = []
|
|
12
12
|
@subject = nil
|
|
13
13
|
@file_source = nil
|
|
@@ -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
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
require_relative "../operator"
|
|
4
4
|
|
|
5
5
|
class Evilution::Mutator::Operator::IndexToDig < Evilution::Mutator::Base
|
|
6
|
-
def initialize
|
|
6
|
+
def initialize(**options)
|
|
7
7
|
super
|
|
8
8
|
@consumed = Set.new
|
|
9
9
|
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
|
|
@@ -3,7 +3,25 @@
|
|
|
3
3
|
require_relative "../operator"
|
|
4
4
|
|
|
5
5
|
class Evilution::Mutator::Operator::StringLiteral < Evilution::Mutator::Base
|
|
6
|
+
def initialize(skip_heredoc_literals: false, **rest)
|
|
7
|
+
super(**rest)
|
|
8
|
+
@skip_heredoc_literals = skip_heredoc_literals
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def visit_interpolated_string_node(node)
|
|
12
|
+
return super unless node.heredoc?
|
|
13
|
+
return if @skip_heredoc_literals
|
|
14
|
+
|
|
15
|
+
node.parts.each do |part|
|
|
16
|
+
next if part.is_a?(Prism::StringNode)
|
|
17
|
+
|
|
18
|
+
visit(part)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
6
22
|
def visit_string_node(node)
|
|
23
|
+
return super if node.heredoc?
|
|
24
|
+
|
|
7
25
|
replacement = node.content.empty? ? '"mutation"' : '""'
|
|
8
26
|
|
|
9
27
|
add_mutation(
|