evilution 0.16.1 → 0.18.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/.migration-hint-ts +1 -1
- data/.beads/issues.jsonl +47 -46
- data/CHANGELOG.md +48 -0
- data/README.md +143 -50
- data/docs/ast_pattern_syntax.md +210 -0
- data/lib/evilution/ast/pattern/filter.rb +25 -0
- data/lib/evilution/ast/pattern/matcher.rb +107 -0
- data/lib/evilution/ast/pattern/parser.rb +185 -0
- data/lib/evilution/ast/pattern.rb +4 -0
- data/lib/evilution/ast/sorbet_sig_detector.rb +52 -0
- data/lib/evilution/cli.rb +400 -24
- data/lib/evilution/config.rb +43 -2
- data/lib/evilution/disable_comment.rb +90 -0
- data/lib/evilution/hooks/loader.rb +35 -0
- data/lib/evilution/hooks/registry.rb +60 -0
- data/lib/evilution/hooks.rb +58 -0
- data/lib/evilution/integration/base.rb +4 -0
- data/lib/evilution/integration/rspec.rb +6 -2
- data/lib/evilution/isolation/fork.rb +5 -0
- data/lib/evilution/mcp/session_diff_tool.rb +5 -35
- data/lib/evilution/mutator/base.rb +4 -1
- data/lib/evilution/mutator/operator/collection_return.rb +33 -0
- data/lib/evilution/mutator/operator/defined_check.rb +16 -0
- data/lib/evilution/mutator/operator/index_assignment_removal.rb +18 -0
- data/lib/evilution/mutator/operator/index_to_dig.rb +58 -0
- data/lib/evilution/mutator/operator/index_to_fetch.rb +30 -0
- data/lib/evilution/mutator/operator/keyword_argument.rb +91 -0
- data/lib/evilution/mutator/operator/mixin_removal.rb +2 -1
- data/lib/evilution/mutator/operator/multiple_assignment.rb +47 -0
- data/lib/evilution/mutator/operator/pattern_matching_alternative.rb +46 -0
- data/lib/evilution/mutator/operator/pattern_matching_array.rb +97 -0
- data/lib/evilution/mutator/operator/pattern_matching_guard.rb +44 -0
- data/lib/evilution/mutator/operator/regex_capture.rb +43 -0
- data/lib/evilution/mutator/operator/scalar_return.rb +37 -0
- data/lib/evilution/mutator/operator/splat_operator.rb +46 -0
- data/lib/evilution/mutator/operator/superclass_removal.rb +2 -1
- data/lib/evilution/mutator/operator/yield_statement.rb +51 -0
- data/lib/evilution/mutator/registry.rb +17 -3
- data/lib/evilution/parallel/pool.rb +7 -51
- data/lib/evilution/parallel/work_queue.rb +224 -0
- data/lib/evilution/reporter/cli.rb +22 -1
- data/lib/evilution/reporter/html.rb +76 -3
- data/lib/evilution/reporter/json.rb +23 -2
- data/lib/evilution/reporter/suggestion.rb +115 -1
- data/lib/evilution/result/summary.rb +20 -2
- data/lib/evilution/runner.rb +133 -13
- data/lib/evilution/session/diff.rb +85 -0
- data/lib/evilution/session/store.rb +5 -2
- data/lib/evilution/version.rb +1 -1
- data/lib/evilution.rb +23 -0
- metadata +28 -2
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../hooks"
|
|
4
|
+
|
|
5
|
+
class Evilution::Hooks::Loader
|
|
6
|
+
def self.call(registry, config_hooks = nil)
|
|
7
|
+
return registry if config_hooks.nil?
|
|
8
|
+
|
|
9
|
+
unless config_hooks.is_a?(Hash)
|
|
10
|
+
raise Evilution::ConfigError, "hooks must be a mapping of event names to file paths, got #{config_hooks.class}"
|
|
11
|
+
end
|
|
12
|
+
return registry if config_hooks.empty?
|
|
13
|
+
|
|
14
|
+
config_hooks.each do |event, paths|
|
|
15
|
+
event = event.to_sym
|
|
16
|
+
Array(paths).each do |path|
|
|
17
|
+
handler = load_hook_file(path)
|
|
18
|
+
registry.register(event) { |payload| handler.call(payload) }
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
registry
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def self.load_hook_file(path)
|
|
26
|
+
raise Evilution::ConfigError, "hook file not found: #{path}" unless File.exist?(path)
|
|
27
|
+
|
|
28
|
+
result = Module.new.module_eval(File.read(path), path, 1)
|
|
29
|
+
raise Evilution::ConfigError, "hook file #{path} must return a Proc, got #{result.class}" unless result.is_a?(Proc)
|
|
30
|
+
|
|
31
|
+
result
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private_class_method :load_hook_file
|
|
35
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../hooks"
|
|
4
|
+
|
|
5
|
+
class Evilution::Hooks::Registry
|
|
6
|
+
def initialize(on_error: nil)
|
|
7
|
+
@handlers = Evilution::Hooks::EVENTS.to_h { |event| [event, []] }
|
|
8
|
+
@on_error = on_error
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def register(event, &block)
|
|
12
|
+
validate_event!(event)
|
|
13
|
+
raise ArgumentError, "a block must be provided when registering handler for #{event}" unless block
|
|
14
|
+
|
|
15
|
+
@handlers[event] << block
|
|
16
|
+
self
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def fire(event, **payload)
|
|
20
|
+
validate_event!(event)
|
|
21
|
+
errors = []
|
|
22
|
+
|
|
23
|
+
@handlers[event].each do |handler|
|
|
24
|
+
handler.call(payload)
|
|
25
|
+
rescue StandardError => e
|
|
26
|
+
errors << e
|
|
27
|
+
report_error(event, e)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
errors
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def clear(event = nil)
|
|
34
|
+
if event
|
|
35
|
+
validate_event!(event)
|
|
36
|
+
@handlers[event].clear
|
|
37
|
+
else
|
|
38
|
+
@handlers.each_value(&:clear)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def handlers_for(event)
|
|
43
|
+
validate_event!(event)
|
|
44
|
+
@handlers[event].dup
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def validate_event!(event)
|
|
50
|
+
raise ArgumentError, "unknown hook event: #{event}" unless Evilution::Hooks::EVENTS.include?(event)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def report_error(event, error)
|
|
54
|
+
if @on_error
|
|
55
|
+
@on_error.call(event, error)
|
|
56
|
+
else
|
|
57
|
+
warn "[evilution] hook error in #{event}: #{error.message}"
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class Evilution::Hooks
|
|
4
|
+
EVENTS = %i[
|
|
5
|
+
worker_process_start
|
|
6
|
+
mutation_insert_pre
|
|
7
|
+
mutation_insert_post
|
|
8
|
+
setup_integration_pre
|
|
9
|
+
setup_integration_post
|
|
10
|
+
].freeze
|
|
11
|
+
|
|
12
|
+
def initialize
|
|
13
|
+
@handlers = EVENTS.to_h { |event| [event, []] }
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def register(event, &block)
|
|
17
|
+
validate_event!(event)
|
|
18
|
+
raise ArgumentError, "a block must be provided when registering handler for #{event}" unless block
|
|
19
|
+
|
|
20
|
+
@handlers[event] << block
|
|
21
|
+
self
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def fire(event, **payload)
|
|
25
|
+
validate_event!(event)
|
|
26
|
+
@handlers[event].each { |handler| handler.call(payload) }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def clear(event = nil)
|
|
30
|
+
if event
|
|
31
|
+
validate_event!(event)
|
|
32
|
+
@handlers[event].clear
|
|
33
|
+
else
|
|
34
|
+
@handlers.each_value(&:clear)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def handlers_for(event)
|
|
39
|
+
validate_event!(event)
|
|
40
|
+
@handlers[event].dup
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def self.from_config(config_hooks)
|
|
44
|
+
hooks = new
|
|
45
|
+
return hooks if config_hooks.nil? || config_hooks.empty?
|
|
46
|
+
|
|
47
|
+
config_hooks.each do |event, callables|
|
|
48
|
+
Array(callables).each { |callable| hooks.register(event) { |payload| callable.call(payload) } }
|
|
49
|
+
end
|
|
50
|
+
hooks
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def validate_event!(event)
|
|
56
|
+
raise ArgumentError, "unknown hook event: #{event}" unless EVENTS.include?(event)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -9,10 +9,10 @@ require_relative "../spec_resolver"
|
|
|
9
9
|
require_relative "../integration"
|
|
10
10
|
|
|
11
11
|
class Evilution::Integration::RSpec < Evilution::Integration::Base
|
|
12
|
-
def initialize(test_files: nil)
|
|
12
|
+
def initialize(test_files: nil, hooks: nil)
|
|
13
13
|
@test_files = test_files
|
|
14
14
|
@rspec_loaded = false
|
|
15
|
-
super()
|
|
15
|
+
super(hooks: hooks)
|
|
16
16
|
end
|
|
17
17
|
|
|
18
18
|
def call(mutation)
|
|
@@ -20,7 +20,9 @@ class Evilution::Integration::RSpec < Evilution::Integration::Base
|
|
|
20
20
|
@temp_dir = nil
|
|
21
21
|
@lock_file = nil
|
|
22
22
|
ensure_rspec_loaded
|
|
23
|
+
@hooks.fire(:mutation_insert_pre, mutation: mutation, file_path: mutation.file_path) if @hooks
|
|
23
24
|
apply_mutation(mutation)
|
|
25
|
+
@hooks.fire(:mutation_insert_post, mutation: mutation, file_path: mutation.file_path) if @hooks
|
|
24
26
|
run_rspec(mutation)
|
|
25
27
|
ensure
|
|
26
28
|
restore_original(mutation)
|
|
@@ -33,8 +35,10 @@ class Evilution::Integration::RSpec < Evilution::Integration::Base
|
|
|
33
35
|
def ensure_rspec_loaded
|
|
34
36
|
return if @rspec_loaded
|
|
35
37
|
|
|
38
|
+
@hooks.fire(:setup_integration_pre, integration: :rspec) if @hooks
|
|
36
39
|
require "rspec/core"
|
|
37
40
|
@rspec_loaded = true
|
|
41
|
+
@hooks.fire(:setup_integration_post, integration: :rspec) if @hooks
|
|
38
42
|
rescue LoadError => e
|
|
39
43
|
raise Evilution::Error, "rspec-core is required but not available: #{e.message}"
|
|
40
44
|
end
|
|
@@ -9,6 +9,10 @@ require_relative "../isolation"
|
|
|
9
9
|
class Evilution::Isolation::Fork
|
|
10
10
|
GRACE_PERIOD = 2
|
|
11
11
|
|
|
12
|
+
def initialize(hooks: nil)
|
|
13
|
+
@hooks = hooks
|
|
14
|
+
end
|
|
15
|
+
|
|
12
16
|
def call(mutation:, test_command:, timeout:)
|
|
13
17
|
sandbox_dir = Dir.mktmpdir("evilution-run")
|
|
14
18
|
start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
@@ -18,6 +22,7 @@ class Evilution::Isolation::Fork
|
|
|
18
22
|
ENV["TMPDIR"] = sandbox_dir
|
|
19
23
|
read_io.close
|
|
20
24
|
suppress_child_output
|
|
25
|
+
@hooks.fire(:worker_process_start, mutation: mutation) if @hooks
|
|
21
26
|
result = execute_in_child(mutation, test_command)
|
|
22
27
|
Marshal.dump(result, write_io)
|
|
23
28
|
write_io.close
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require "json"
|
|
4
4
|
require "mcp"
|
|
5
5
|
require_relative "../session/store"
|
|
6
|
+
require_relative "../session/diff"
|
|
6
7
|
|
|
7
8
|
require_relative "../mcp"
|
|
8
9
|
|
|
@@ -33,7 +34,10 @@ class Evilution::MCP::SessionDiffTool < MCP::Tool
|
|
|
33
34
|
base_data = store.load(base)
|
|
34
35
|
head_data = store.load(head)
|
|
35
36
|
|
|
36
|
-
::
|
|
37
|
+
diff = Evilution::Session::Diff.new
|
|
38
|
+
result = diff.call(base_data, head_data)
|
|
39
|
+
|
|
40
|
+
::MCP::Tool::Response.new([{ type: "text", text: ::JSON.generate(result.to_h) }])
|
|
37
41
|
rescue Evilution::Error => e
|
|
38
42
|
error_response("not_found", e.message)
|
|
39
43
|
rescue ::JSON::ParserError => e
|
|
@@ -45,40 +49,6 @@ class Evilution::MCP::SessionDiffTool < MCP::Tool
|
|
|
45
49
|
|
|
46
50
|
private
|
|
47
51
|
|
|
48
|
-
def build_diff(base_data, head_data)
|
|
49
|
-
base_survivors = base_data["survived"] || []
|
|
50
|
-
head_survivors = head_data["survived"] || []
|
|
51
|
-
|
|
52
|
-
base_keys = base_survivors.to_set { |m| mutation_key(m) }
|
|
53
|
-
head_keys = head_survivors.to_set { |m| mutation_key(m) }
|
|
54
|
-
|
|
55
|
-
{
|
|
56
|
-
"summary" => build_summary_diff(base_data, head_data),
|
|
57
|
-
"fixed" => base_survivors.reject { |m| head_keys.include?(mutation_key(m)) },
|
|
58
|
-
"new_survivors" => head_survivors.reject { |m| base_keys.include?(mutation_key(m)) },
|
|
59
|
-
"persistent" => head_survivors.select { |m| base_keys.include?(mutation_key(m)) }
|
|
60
|
-
}
|
|
61
|
-
end
|
|
62
|
-
|
|
63
|
-
def build_summary_diff(base_data, head_data)
|
|
64
|
-
base_summary = base_data["summary"] || {}
|
|
65
|
-
head_summary = head_data["summary"] || {}
|
|
66
|
-
base_score = base_summary["score"] || 0.0
|
|
67
|
-
head_score = head_summary["score"] || 0.0
|
|
68
|
-
|
|
69
|
-
{
|
|
70
|
-
"base_score" => base_score,
|
|
71
|
-
"head_score" => head_score,
|
|
72
|
-
"score_delta" => (head_score - base_score).round(4),
|
|
73
|
-
"base_survived" => base_summary["survived"] || 0,
|
|
74
|
-
"head_survived" => head_summary["survived"] || 0
|
|
75
|
-
}
|
|
76
|
-
end
|
|
77
|
-
|
|
78
|
-
def mutation_key(mutation)
|
|
79
|
-
[mutation["operator"], mutation["file"], mutation["line"], mutation["subject"]]
|
|
80
|
-
end
|
|
81
|
-
|
|
82
52
|
def error_response(type, message)
|
|
83
53
|
::MCP::Tool::Response.new(
|
|
84
54
|
[{ type: "text", text: ::JSON.generate({ error: { type: type, message: message } }) }],
|
|
@@ -13,10 +13,11 @@ class Evilution::Mutator::Base < Prism::Visitor
|
|
|
13
13
|
@file_source = nil
|
|
14
14
|
end
|
|
15
15
|
|
|
16
|
-
def call(subject)
|
|
16
|
+
def call(subject, filter: nil)
|
|
17
17
|
@subject = subject
|
|
18
18
|
@file_source = File.read(subject.file_path)
|
|
19
19
|
@mutations = []
|
|
20
|
+
@filter = filter
|
|
20
21
|
visit(subject.node)
|
|
21
22
|
@mutations
|
|
22
23
|
end
|
|
@@ -24,6 +25,8 @@ class Evilution::Mutator::Base < Prism::Visitor
|
|
|
24
25
|
private
|
|
25
26
|
|
|
26
27
|
def add_mutation(offset:, length:, replacement:, node:)
|
|
28
|
+
return if @filter && @filter.skip?(node)
|
|
29
|
+
|
|
27
30
|
mutated_source = Evilution::AST::SourceSurgeon.apply(
|
|
28
31
|
@file_source,
|
|
29
32
|
offset: offset,
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../operator"
|
|
4
|
+
|
|
5
|
+
class Evilution::Mutator::Operator::CollectionReturn < Evilution::Mutator::Base
|
|
6
|
+
def visit_def_node(node)
|
|
7
|
+
body = node.body
|
|
8
|
+
if body.is_a?(Prism::StatementsNode) && body.body.length > 1
|
|
9
|
+
return_node = body.body.last
|
|
10
|
+
replacement = collection_replacement(return_node)
|
|
11
|
+
|
|
12
|
+
if replacement
|
|
13
|
+
add_mutation(
|
|
14
|
+
offset: body.location.start_offset,
|
|
15
|
+
length: body.location.length,
|
|
16
|
+
replacement: replacement,
|
|
17
|
+
node: node
|
|
18
|
+
)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
super
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def collection_replacement(node)
|
|
26
|
+
case node
|
|
27
|
+
when Prism::ArrayNode
|
|
28
|
+
"[]" if node.elements.any?
|
|
29
|
+
when Prism::HashNode
|
|
30
|
+
"{}" if node.elements.any?
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../operator"
|
|
4
|
+
|
|
5
|
+
class Evilution::Mutator::Operator::DefinedCheck < Evilution::Mutator::Base
|
|
6
|
+
def visit_defined_node(node)
|
|
7
|
+
add_mutation(
|
|
8
|
+
offset: node.location.start_offset,
|
|
9
|
+
length: node.location.length,
|
|
10
|
+
replacement: "true",
|
|
11
|
+
node: node
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
super
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../operator"
|
|
4
|
+
|
|
5
|
+
class Evilution::Mutator::Operator::IndexAssignmentRemoval < Evilution::Mutator::Base
|
|
6
|
+
def visit_call_node(node)
|
|
7
|
+
if node.name == :[]= && node.receiver
|
|
8
|
+
add_mutation(
|
|
9
|
+
offset: node.location.start_offset,
|
|
10
|
+
length: node.location.length,
|
|
11
|
+
replacement: "nil",
|
|
12
|
+
node: node
|
|
13
|
+
)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
super
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../operator"
|
|
4
|
+
|
|
5
|
+
class Evilution::Mutator::Operator::IndexToDig < Evilution::Mutator::Base
|
|
6
|
+
def initialize
|
|
7
|
+
super
|
|
8
|
+
@consumed = Set.new
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def visit_call_node(node)
|
|
12
|
+
if chain_head?(node)
|
|
13
|
+
root, args = collect_chain(node)
|
|
14
|
+
root_source = @file_source[root.location.start_offset, root.location.length]
|
|
15
|
+
arg_sources = args.map { |a| @file_source[a.location.start_offset, a.location.length] }
|
|
16
|
+
|
|
17
|
+
add_mutation(
|
|
18
|
+
offset: node.location.start_offset,
|
|
19
|
+
length: node.location.length,
|
|
20
|
+
replacement: "#{root_source}.dig(#{arg_sources.join(", ")})",
|
|
21
|
+
node: node
|
|
22
|
+
)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
super
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def chain_head?(node)
|
|
31
|
+
return false if @consumed.include?(node.object_id)
|
|
32
|
+
return false unless single_arg_index?(node)
|
|
33
|
+
return false unless single_arg_index?(node.receiver)
|
|
34
|
+
|
|
35
|
+
true
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def single_arg_index?(node)
|
|
39
|
+
node.is_a?(Prism::CallNode) &&
|
|
40
|
+
node.name == :[] &&
|
|
41
|
+
node.receiver &&
|
|
42
|
+
node.arguments &&
|
|
43
|
+
node.arguments.arguments.length == 1
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def collect_chain(node)
|
|
47
|
+
args = []
|
|
48
|
+
current = node
|
|
49
|
+
|
|
50
|
+
while single_arg_index?(current)
|
|
51
|
+
@consumed.add(current.object_id)
|
|
52
|
+
args.unshift(current.arguments.arguments.first)
|
|
53
|
+
current = current.receiver
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
[current, args]
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../operator"
|
|
4
|
+
|
|
5
|
+
class Evilution::Mutator::Operator::IndexToFetch < Evilution::Mutator::Base
|
|
6
|
+
def visit_call_node(node)
|
|
7
|
+
if indexable?(node)
|
|
8
|
+
receiver_source = @file_source[node.receiver.location.start_offset, node.receiver.location.length]
|
|
9
|
+
arg_source = @file_source[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}.fetch(#{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
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../operator"
|
|
4
|
+
|
|
5
|
+
class Evilution::Mutator::Operator::KeywordArgument < Evilution::Mutator::Base
|
|
6
|
+
def visit_def_node(node)
|
|
7
|
+
params = node.parameters
|
|
8
|
+
if params
|
|
9
|
+
mutate_optional_keyword_defaults(params)
|
|
10
|
+
mutate_optional_keyword_removal(params)
|
|
11
|
+
mutate_keyword_rest_removal(params)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
super
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
|
|
19
|
+
def mutate_optional_keyword_defaults(params)
|
|
20
|
+
params.keywords.each do |kw|
|
|
21
|
+
next unless kw.is_a?(Prism::OptionalKeywordParameterNode)
|
|
22
|
+
|
|
23
|
+
name_loc = kw.name_loc
|
|
24
|
+
kw_loc = kw.location
|
|
25
|
+
|
|
26
|
+
add_mutation(
|
|
27
|
+
offset: kw_loc.start_offset,
|
|
28
|
+
length: kw_loc.length,
|
|
29
|
+
replacement: @file_source[name_loc.start_offset...name_loc.end_offset],
|
|
30
|
+
node: kw
|
|
31
|
+
)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def mutate_optional_keyword_removal(params)
|
|
36
|
+
all_params = collect_all_params(params)
|
|
37
|
+
return if all_params.length < 2
|
|
38
|
+
|
|
39
|
+
params.keywords.each do |kw|
|
|
40
|
+
next unless kw.is_a?(Prism::OptionalKeywordParameterNode)
|
|
41
|
+
|
|
42
|
+
remaining = all_params.reject { |p| p.equal?(kw) }
|
|
43
|
+
replacement = remaining.map(&:slice).join(", ")
|
|
44
|
+
|
|
45
|
+
add_mutation(
|
|
46
|
+
offset: params.location.start_offset,
|
|
47
|
+
length: params.location.length,
|
|
48
|
+
replacement: replacement,
|
|
49
|
+
node: kw
|
|
50
|
+
)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def mutate_keyword_rest_removal(params)
|
|
55
|
+
kr = params.keyword_rest
|
|
56
|
+
return unless kr.is_a?(Prism::KeywordRestParameterNode)
|
|
57
|
+
|
|
58
|
+
all_params = collect_all_params(params)
|
|
59
|
+
|
|
60
|
+
if all_params.length < 2
|
|
61
|
+
add_mutation(
|
|
62
|
+
offset: kr.location.start_offset,
|
|
63
|
+
length: kr.location.length,
|
|
64
|
+
replacement: "",
|
|
65
|
+
node: kr
|
|
66
|
+
)
|
|
67
|
+
else
|
|
68
|
+
remaining = all_params.reject { |p| p.equal?(kr) }
|
|
69
|
+
replacement = remaining.map(&:slice).join(", ")
|
|
70
|
+
|
|
71
|
+
add_mutation(
|
|
72
|
+
offset: params.location.start_offset,
|
|
73
|
+
length: params.location.length,
|
|
74
|
+
replacement: replacement,
|
|
75
|
+
node: kr
|
|
76
|
+
)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def collect_all_params(params)
|
|
81
|
+
result = []
|
|
82
|
+
result.concat(params.requireds)
|
|
83
|
+
result.concat(params.optionals)
|
|
84
|
+
result << params.rest if params.rest
|
|
85
|
+
result.concat(params.posts)
|
|
86
|
+
result.concat(params.keywords)
|
|
87
|
+
result << params.keyword_rest if params.keyword_rest
|
|
88
|
+
result << params.block if params.block
|
|
89
|
+
result
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -7,10 +7,11 @@ require_relative "../operator"
|
|
|
7
7
|
class Evilution::Mutator::Operator::MixinRemoval < Evilution::Mutator::Base
|
|
8
8
|
MIXIN_METHODS = %i[include extend prepend].freeze
|
|
9
9
|
|
|
10
|
-
def call(subject)
|
|
10
|
+
def call(subject, filter: nil)
|
|
11
11
|
@subject = subject
|
|
12
12
|
@file_source = File.read(subject.file_path)
|
|
13
13
|
@mutations = []
|
|
14
|
+
@filter = filter
|
|
14
15
|
|
|
15
16
|
tree = self.class.parsed_tree_for(subject.file_path, @file_source)
|
|
16
17
|
enclosing = find_enclosing_scope(tree, subject.line_number)
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../operator"
|
|
4
|
+
|
|
5
|
+
class Evilution::Mutator::Operator::MultipleAssignment < Evilution::Mutator::Base
|
|
6
|
+
def visit_multi_write_node(node)
|
|
7
|
+
lefts = node.lefts
|
|
8
|
+
values = node.value.is_a?(Prism::ArrayNode) ? node.value.elements : nil
|
|
9
|
+
|
|
10
|
+
if values && lefts.length == values.length && lefts.length >= 2 && node.rest.nil?
|
|
11
|
+
mutate_target_removal(node, lefts, values)
|
|
12
|
+
mutate_swap(node, lefts, values) if lefts.length == 2
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
super
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
def mutate_target_removal(node, lefts, values)
|
|
21
|
+
lefts.each_index do |i|
|
|
22
|
+
remaining_lefts = lefts.each_with_index.filter_map { |l, j| l.slice if j != i }
|
|
23
|
+
remaining_values = values.each_with_index.filter_map { |v, j| v.slice if j != i }
|
|
24
|
+
|
|
25
|
+
replacement = "#{remaining_lefts.join(", ")} = #{remaining_values.join(", ")}"
|
|
26
|
+
|
|
27
|
+
add_mutation(
|
|
28
|
+
offset: node.location.start_offset,
|
|
29
|
+
length: node.location.length,
|
|
30
|
+
replacement: replacement,
|
|
31
|
+
node: node
|
|
32
|
+
)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def mutate_swap(node, lefts, values)
|
|
37
|
+
swapped_lefts = "#{lefts[1].slice}, #{lefts[0].slice}"
|
|
38
|
+
replacement = "#{swapped_lefts} = #{values.map(&:slice).join(", ")}"
|
|
39
|
+
|
|
40
|
+
add_mutation(
|
|
41
|
+
offset: node.location.start_offset,
|
|
42
|
+
length: node.location.length,
|
|
43
|
+
replacement: replacement,
|
|
44
|
+
node: node
|
|
45
|
+
)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../operator"
|
|
4
|
+
|
|
5
|
+
class Evilution::Mutator::Operator::PatternMatchingAlternative < Evilution::Mutator::Base
|
|
6
|
+
def visit_alternation_pattern_node(node)
|
|
7
|
+
remove_left(node)
|
|
8
|
+
remove_right(node)
|
|
9
|
+
swap_order(node)
|
|
10
|
+
super
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
private
|
|
14
|
+
|
|
15
|
+
def remove_left(node)
|
|
16
|
+
add_mutation(
|
|
17
|
+
offset: node.location.start_offset,
|
|
18
|
+
length: node.location.length,
|
|
19
|
+
replacement: source_for(node.right),
|
|
20
|
+
node: node
|
|
21
|
+
)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def remove_right(node)
|
|
25
|
+
add_mutation(
|
|
26
|
+
offset: node.location.start_offset,
|
|
27
|
+
length: node.location.length,
|
|
28
|
+
replacement: source_for(node.left),
|
|
29
|
+
node: node
|
|
30
|
+
)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def swap_order(node)
|
|
34
|
+
operator = @file_source.byteslice(node.operator_loc.start_offset, node.operator_loc.length)
|
|
35
|
+
add_mutation(
|
|
36
|
+
offset: node.location.start_offset,
|
|
37
|
+
length: node.location.length,
|
|
38
|
+
replacement: "#{source_for(node.right)} #{operator} #{source_for(node.left)}",
|
|
39
|
+
node: node
|
|
40
|
+
)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def source_for(node)
|
|
44
|
+
@file_source.byteslice(node.location.start_offset, node.location.length)
|
|
45
|
+
end
|
|
46
|
+
end
|