evilution 1.2.0 → 1.3.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 +38 -0
- data/.rubocop_todo.yml +9 -0
- data/CHANGELOG.md +47 -0
- data/README.md +65 -7
- data/docs/architecture.md +35 -1
- data/lib/evilution/ast/parser.rb +1 -0
- data/lib/evilution/ast/pattern/filter.rb +1 -0
- data/lib/evilution/ast/pattern/parser.rb +1 -0
- data/lib/evilution/ast/pattern.rb +2 -0
- data/lib/evilution/ast/sorbet_sig_detector.rb +1 -0
- data/lib/evilution/baseline.rb +37 -15
- data/lib/evilution/child_output.rb +1 -1
- data/lib/evilution/cli/command.rb +1 -0
- data/lib/evilution/cli/commands/tests_list.rb +4 -3
- data/lib/evilution/cli/commands.rb +2 -0
- data/lib/evilution/cli/dispatcher.rb +2 -0
- data/lib/evilution/cli/exit_guard.rb +2 -0
- data/lib/evilution/cli/parsed_args.rb +2 -0
- data/lib/evilution/cli/parser/command_extractor.rb +2 -0
- data/lib/evilution/cli/parser/file_args.rb +2 -0
- data/lib/evilution/cli/parser/options_builder.rb +3 -0
- data/lib/evilution/cli/parser/stdin_reader.rb +1 -0
- data/lib/evilution/cli/parser.rb +1 -0
- data/lib/evilution/cli/printers/tests_list.rb +5 -4
- data/lib/evilution/cli/printers.rb +2 -0
- data/lib/evilution/cli/result.rb +2 -0
- data/lib/evilution/cli.rb +5 -0
- data/lib/evilution/config/builders.rb +2 -0
- data/lib/evilution/config/env_loader.rb +2 -0
- data/lib/evilution/config/file_loader.rb +1 -0
- data/lib/evilution/config/sources.rb +1 -0
- data/lib/evilution/config/validators/example_targeting_cache.rb +1 -0
- data/lib/evilution/config/validators/example_targeting_fallback.rb +1 -0
- data/lib/evilution/config/validators/example_targeting_strategy.rb +1 -0
- data/lib/evilution/config/validators/fail_fast.rb +1 -0
- data/lib/evilution/config/validators/hooks.rb +1 -0
- data/lib/evilution/config/validators/ignore_patterns.rb +1 -0
- data/lib/evilution/config/validators/integration.rb +1 -0
- data/lib/evilution/config/validators/isolation.rb +1 -0
- data/lib/evilution/config/validators/jobs.rb +1 -0
- data/lib/evilution/config/validators/preload.rb +1 -0
- data/lib/evilution/config/validators/profile.rb +1 -0
- data/lib/evilution/config/validators/spec_mappings.rb +1 -0
- data/lib/evilution/config/validators/spec_pattern.rb +1 -0
- data/lib/evilution/config/validators/warmup.rb +17 -0
- data/lib/evilution/config/validators.rb +2 -0
- data/lib/evilution/config.rb +4 -3
- data/lib/evilution/coverage.rb +1 -1
- data/lib/evilution/coverage_example_filter.rb +1 -1
- data/lib/evilution/diagnostic.rb +1 -1
- data/lib/evilution/example_filter.rb +1 -1
- data/lib/evilution/integration/loading/body_call_neutralizer.rb +10 -2
- data/lib/evilution/integration/loading/test_load_path.rb +21 -13
- data/lib/evilution/integration/rspec/state_guard/configuration_state.rb +1 -0
- data/lib/evilution/integration/rspec/state_guard/configuration_streams.rb +1 -0
- data/lib/evilution/integration/rspec/state_guard/example_groups_constants.rb +1 -2
- data/lib/evilution/integration/rspec/state_guard/internals.rb +1 -2
- data/lib/evilution/integration/rspec/state_guard/object_space_example_groups.rb +1 -2
- data/lib/evilution/integration/rspec/state_guard/reporter_arrays.rb +1 -0
- data/lib/evilution/integration/rspec/state_guard/world_example_groups.rb +1 -0
- data/lib/evilution/integration/rspec/state_guard/world_filtered_examples.rb +1 -0
- data/lib/evilution/integration/rspec/state_guard/world_sources_by_path.rb +1 -0
- data/lib/evilution/integration/rspec/state_guard.rb +6 -0
- data/lib/evilution/mcp/info_tool/actions/environment.rb +1 -0
- data/lib/evilution/mcp/info_tool/actions/feedback.rb +1 -0
- data/lib/evilution/mcp/info_tool/actions/statuses.rb +1 -0
- data/lib/evilution/mcp/info_tool/actions/subjects.rb +1 -0
- data/lib/evilution/mcp/info_tool/actions/tests.rb +1 -0
- data/lib/evilution/mutation.rb +1 -1
- data/lib/evilution/mutator/operator/argument_list_removal.rb +52 -0
- data/lib/evilution/mutator/operator/argument_propagation.rb +54 -0
- data/lib/evilution/mutator/operator/array_coercion_to_literal.rb +34 -0
- data/lib/evilution/mutator/operator/attribute_write_to_read.rb +34 -0
- data/lib/evilution/mutator/operator/bang_method.rb +11 -3
- data/lib/evilution/mutator/operator/binary_operand_promotion.rb +63 -0
- data/lib/evilution/mutator/operator/block_body_promotion.rb +45 -0
- data/lib/evilution/mutator/operator/block_body_to_nil.rb +52 -0
- data/lib/evilution/mutator/operator/block_body_to_raise.rb +34 -0
- data/lib/evilution/mutator/operator/call_to_nil.rb +34 -0
- data/lib/evilution/mutator/operator/coercion_emptying.rb +60 -0
- data/lib/evilution/mutator/operator/collection_replacement.rb +19 -9
- data/lib/evilution/mutator/operator/comparison_replacement.rb +37 -12
- data/lib/evilution/mutator/operator/const_get_to_constant_path.rb +50 -0
- data/lib/evilution/mutator/operator/dig_to_fetch_chain.rb +45 -0
- data/lib/evilution/mutator/operator/double_negation_removal.rb +22 -0
- data/lib/evilution/mutator/operator/dynamic_dispatch_resolution.rb +79 -0
- data/lib/evilution/mutator/operator/inequality_to_negated_identity.rb +43 -0
- data/lib/evilution/mutator/operator/keyword_argument_removal.rb +47 -0
- data/lib/evilution/mutator/operator/proc_to_lambda.rb +43 -0
- data/lib/evilution/mutator/operator/receiver_constructor_swap.rb +51 -0
- data/lib/evilution/mutator/operator/reduce_to_sum.rb +58 -0
- data/lib/evilution/mutator/operator/regexp_anchor_to_predicate.rb +136 -0
- data/lib/evilution/mutator/operator/safe_navigation_removal.rb +67 -0
- data/lib/evilution/mutator/operator/send_mutation.rb +42 -6
- data/lib/evilution/mutator/operator/symbol_to_proc_replacement.rb +59 -0
- data/lib/evilution/mutator/operator/to_i_to_integer.rb +38 -0
- data/lib/evilution/mutator/primitives.rb +25 -0
- data/lib/evilution/mutator/registry.rb +24 -1
- data/lib/evilution/parallel/pool.rb +1 -0
- data/lib/evilution/parallel_db_warning.rb +1 -1
- data/lib/evilution/rails_warmup.rb +61 -0
- data/lib/evilution/reporter/html/report.rb +1 -0
- data/lib/evilution/result/coverage_gap_grouper.rb +1 -0
- data/lib/evilution/result/subject_scorer.rb +1 -0
- data/lib/evilution/runner/baseline_runner.rb +17 -7
- data/lib/evilution/runner/isolation_resolver.rb +23 -2
- data/lib/evilution/runner/mutation_executor/neutralizer/baseline_failed.rb +17 -10
- data/lib/evilution/source_ast_cache.rb +1 -1
- data/lib/evilution/spec_ast_cache.rb +1 -1
- data/lib/evilution/version.rb +1 -1
- data/lib/evilution.rb +23 -0
- metadata +27 -2
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../operator"
|
|
4
|
+
|
|
5
|
+
# Resolve a dynamic dispatch with a literal symbol into a direct call:
|
|
6
|
+
# `target.send(:reset)` becomes `target.reset`, and the same for `__send__`
|
|
7
|
+
# and `public_send`. Remaining arguments, a block-pass and a literal block
|
|
8
|
+
# stay attached.
|
|
9
|
+
#
|
|
10
|
+
# The two forms differ only in visibility checks, so the mutant is emitted
|
|
11
|
+
# only where those checks can disagree:
|
|
12
|
+
#
|
|
13
|
+
# - send / __send__ on an explicit receiver other than self: the direct call
|
|
14
|
+
# raises NoMethodError for a private method. With an implicit or self
|
|
15
|
+
# receiver a private method resolves either way (Ruby 2.7+).
|
|
16
|
+
# - public_send with an implicit or self receiver: the direct call reaches a
|
|
17
|
+
# private method that public_send refuses. On any other receiver both
|
|
18
|
+
# refuse it, and only a protected method called from inside its class
|
|
19
|
+
# would differ.
|
|
20
|
+
#
|
|
21
|
+
# method_missing is reached the same way by both forms.
|
|
22
|
+
class Evilution::Mutator::Operator::DynamicDispatchResolution < Evilution::Mutator::Base
|
|
23
|
+
SEND_SELECTORS = %i[send __send__].freeze
|
|
24
|
+
private_constant :SEND_SELECTORS
|
|
25
|
+
|
|
26
|
+
OPERATOR_NAMES = %w[
|
|
27
|
+
+ - * / % ** == != < > <= >= <=> === =~ !~ ! [] []= << >> & | ^ ~ +@ -@
|
|
28
|
+
].to_set.freeze
|
|
29
|
+
private_constant :OPERATOR_NAMES
|
|
30
|
+
|
|
31
|
+
METHOD_NAME = /\A[[:alpha:]_][[:alnum:]_]*[?!=]?\z/
|
|
32
|
+
private_constant :METHOD_NAME
|
|
33
|
+
|
|
34
|
+
def visit_call_node(node)
|
|
35
|
+
selector = literal_selector(node)
|
|
36
|
+
resolve(node, selector) if selector && visibility_can_differ?(node)
|
|
37
|
+
super
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def literal_selector(node)
|
|
43
|
+
return unless node.arguments in Prism::ArgumentsNode[arguments: [Prism::SymbolNode => symbol, *]]
|
|
44
|
+
|
|
45
|
+
name = symbol.unescaped
|
|
46
|
+
name if name.match?(METHOD_NAME) || OPERATOR_NAMES.include?(name)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def visibility_can_differ?(node)
|
|
50
|
+
self_receiver = node.receiver.nil? || node.receiver.is_a?(Prism::SelfNode)
|
|
51
|
+
|
|
52
|
+
if SEND_SELECTORS.include?(node.name)
|
|
53
|
+
!self_receiver
|
|
54
|
+
else
|
|
55
|
+
node.name == :public_send && self_receiver
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def resolve(node, selector)
|
|
60
|
+
start = node.message_loc.start_offset
|
|
61
|
+
following = following_item(node)
|
|
62
|
+
|
|
63
|
+
if following
|
|
64
|
+
replacement = "#{selector}#{node.opening_loc ? "(" : " "}"
|
|
65
|
+
finish = following.start_offset
|
|
66
|
+
else
|
|
67
|
+
replacement = selector
|
|
68
|
+
finish = node.closing_loc ? node.closing_loc.end_offset : node.arguments.end_offset
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
add_mutation(offset: start, length: finish - start, replacement: replacement, node: node)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# The argument after the selector, or the block-pass when the selector is
|
|
75
|
+
# the only argument.
|
|
76
|
+
def following_item(node)
|
|
77
|
+
node.arguments.arguments[1] || (node.block if node.block.is_a?(Prism::BlockArgumentNode))
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../operator"
|
|
4
|
+
|
|
5
|
+
# Rewrite an inequality with stricter comparisons: `a != b` becomes
|
|
6
|
+
# `!a.eql?(b)` and `!a.equal?(b)`.
|
|
7
|
+
#
|
|
8
|
+
# `eql?` adds a type check (`1 != 1.0` is false, `!1.eql?(1.0)` is true) and
|
|
9
|
+
# `equal?` compares identity (two equal strings or arrays are still
|
|
10
|
+
# different objects). A survivor means no test separates value equality from
|
|
11
|
+
# the stricter forms, so the comparison in use is never pinned down.
|
|
12
|
+
#
|
|
13
|
+
# Comparisons against nil, true, false or a symbol literal are skipped: those
|
|
14
|
+
# are singletons, so all three forms always agree.
|
|
15
|
+
class Evilution::Mutator::Operator::InequalityToNegatedIdentity < Evilution::Mutator::Base
|
|
16
|
+
SELECTORS = %i[eql? equal?].freeze
|
|
17
|
+
private_constant :SELECTORS
|
|
18
|
+
|
|
19
|
+
SINGLETON_LITERALS = [Prism::NilNode, Prism::TrueNode, Prism::FalseNode, Prism::SymbolNode].freeze
|
|
20
|
+
private_constant :SINGLETON_LITERALS
|
|
21
|
+
|
|
22
|
+
def visit_call_node(node)
|
|
23
|
+
rewrite(node) if node.name == :!=
|
|
24
|
+
super
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def rewrite(node)
|
|
30
|
+
return unless node.arguments in Prism::ArgumentsNode[arguments: [argument]]
|
|
31
|
+
return if [node.receiver, argument].any? { |operand| singleton_literal?(operand) }
|
|
32
|
+
|
|
33
|
+
left = receiver_source(node.receiver)
|
|
34
|
+
SELECTORS.each do |selector|
|
|
35
|
+
add_mutation(offset: node.start_offset, length: node.location.length,
|
|
36
|
+
replacement: "!#{left}.#{selector}(#{source_of(argument)})", node: node)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def singleton_literal?(operand)
|
|
41
|
+
SINGLETON_LITERALS.any? { |type| operand.is_a?(type) }
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../operator"
|
|
4
|
+
|
|
5
|
+
# Drop one keyword argument at a call site: `build(name: "x", size: 2)`
|
|
6
|
+
# becomes `build(size: 2)` and `build(name: "x")`. Applies to any argument
|
|
7
|
+
# list — calls, `super` and `yield`.
|
|
8
|
+
#
|
|
9
|
+
# A survivor means the callee never consumes that keyword, or its default
|
|
10
|
+
# happens to match. argument_removal and argument_nil_substitution skip
|
|
11
|
+
# keyword arguments entirely.
|
|
12
|
+
#
|
|
13
|
+
# Only `key: value` pairs are dropped; a `**opts` double splat stays, though
|
|
14
|
+
# it still counts as a neighbour when removing the separator. A lone keyword
|
|
15
|
+
# that is the whole argument list is left to argument_list_removal.
|
|
16
|
+
class Evilution::Mutator::Operator::KeywordArgumentRemoval < Evilution::Mutator::Base
|
|
17
|
+
def visit_arguments_node(node)
|
|
18
|
+
node.arguments.each_with_index do |argument, index|
|
|
19
|
+
next unless argument.is_a?(Prism::KeywordHashNode)
|
|
20
|
+
|
|
21
|
+
drop_keywords(argument, index.zero? ? nil : node.arguments[index - 1])
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
super
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def drop_keywords(hash, previous_argument)
|
|
30
|
+
elements = hash.elements
|
|
31
|
+
return if elements.length == 1 && previous_argument.nil?
|
|
32
|
+
|
|
33
|
+
elements.each_with_index do |element, index|
|
|
34
|
+
next unless element.is_a?(Prism::AssocNode)
|
|
35
|
+
|
|
36
|
+
drop_element(element, elements[index + 1], index.zero? ? previous_argument : elements[index - 1])
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Cut from the element to the next one, or — for the last element — from
|
|
41
|
+
# the end of whatever precedes it, so the separating comma goes too.
|
|
42
|
+
def drop_element(element, following, preceding)
|
|
43
|
+
start, finish = following ? [element.start_offset, following.start_offset] : [preceding.end_offset, element.end_offset]
|
|
44
|
+
|
|
45
|
+
add_mutation(offset: start, length: finish - start, replacement: "", node: element, skip_unparseable: true)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../operator"
|
|
4
|
+
|
|
5
|
+
# Turn a proc into a lambda: `proc { |a, b| ... }` and `Proc.new { ... }`
|
|
6
|
+
# become `lambda { ... }`, with the block itself untouched.
|
|
7
|
+
#
|
|
8
|
+
# A lambda checks arity (wrong argument counts raise ArgumentError instead
|
|
9
|
+
# of padding with nil or dropping extras), does not auto-splat a single
|
|
10
|
+
# array argument, and `return` inside it returns from the lambda rather than
|
|
11
|
+
# from the enclosing method. A survivor means no test relies on any of that.
|
|
12
|
+
#
|
|
13
|
+
# Only a literal block with no arguments qualifies: `proc(&blk)` and
|
|
14
|
+
# `Proc.new(x) { }` are skipped.
|
|
15
|
+
class Evilution::Mutator::Operator::ProcToLambda < Evilution::Mutator::Base
|
|
16
|
+
def visit_call_node(node)
|
|
17
|
+
rewrite(node) if proc_constructor?(node) && node.block.is_a?(Prism::BlockNode) && node.arguments.nil?
|
|
18
|
+
super
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def proc_constructor?(node)
|
|
24
|
+
case node.name
|
|
25
|
+
when :proc then node.receiver.nil?
|
|
26
|
+
when :new then proc_constant?(node.receiver)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def proc_constant?(receiver)
|
|
31
|
+
case receiver
|
|
32
|
+
when Prism::ConstantReadNode then receiver.name == :Proc
|
|
33
|
+
when Prism::ConstantPathNode then receiver.parent.nil? && receiver.name == :Proc
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Replace the call head — `proc`, `Proc.new` or `Proc.new()` — leaving the
|
|
38
|
+
# block in place.
|
|
39
|
+
def rewrite(node)
|
|
40
|
+
head_end = (node.closing_loc || node.message_loc).end_offset
|
|
41
|
+
add_mutation(offset: node.start_offset, length: head_end - node.start_offset, replacement: "lambda", node: node)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../operator"
|
|
4
|
+
|
|
5
|
+
# Swap a constructor for a sibling on the same receiver constant:
|
|
6
|
+
# `Date.parse(value)` becomes `Date.iso8601(value)`, `Date.strptime(value)`,
|
|
7
|
+
# `Date.rfc2822(value)` and the rest. Keyed on receiver and selector, which
|
|
8
|
+
# the plain selector tables cannot express — `JSON.parse` is untouched.
|
|
9
|
+
#
|
|
10
|
+
# `parse` accepts almost any date-like string; the siblings accept one
|
|
11
|
+
# format (strptime's default is ISO 8601) or, like `jd` / `civil`, numbers
|
|
12
|
+
# only. A survivor means the tests only ever feed input every parser agrees
|
|
13
|
+
# on, so an overly lenient parse goes unnoticed.
|
|
14
|
+
#
|
|
15
|
+
# Only the constant itself (`Date`, `::Date`) matches, not a namespaced
|
|
16
|
+
# `Acme::Date`.
|
|
17
|
+
class Evilution::Mutator::Operator::ReceiverConstructorSwap < Evilution::Mutator::Base
|
|
18
|
+
DATE_PARSE_SIBLINGS = %i[jd civil strptime iso8601 rfc3339 xmlschema rfc2822 rfc822 httpdate jisx0301].freeze
|
|
19
|
+
private_constant :DATE_PARSE_SIBLINGS
|
|
20
|
+
|
|
21
|
+
# receiver constant => { selector => replacements }. Time has no jd / civil
|
|
22
|
+
# / rfc3339 / jisx0301, and its strptime requires a format argument.
|
|
23
|
+
REPLACEMENTS = {
|
|
24
|
+
Date: { parse: DATE_PARSE_SIBLINGS },
|
|
25
|
+
DateTime: { parse: DATE_PARSE_SIBLINGS },
|
|
26
|
+
Time: { parse: %i[iso8601 xmlschema rfc2822 rfc822 httpdate] }
|
|
27
|
+
}.freeze
|
|
28
|
+
private_constant :REPLACEMENTS
|
|
29
|
+
|
|
30
|
+
def visit_call_node(node)
|
|
31
|
+
replacements_for(node).each do |replacement|
|
|
32
|
+
location = node.message_loc
|
|
33
|
+
add_mutation(offset: location.start_offset, length: location.length, replacement: replacement.to_s, node: node)
|
|
34
|
+
end
|
|
35
|
+
super
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def replacements_for(node)
|
|
41
|
+
selectors = REPLACEMENTS[receiver_constant(node.receiver)]
|
|
42
|
+
selectors ? selectors.fetch(node.name, []) : []
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def receiver_constant(receiver)
|
|
46
|
+
case receiver
|
|
47
|
+
when Prism::ConstantReadNode then receiver.name
|
|
48
|
+
when Prism::ConstantPathNode then receiver.name if receiver.parent.nil?
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../operator"
|
|
4
|
+
|
|
5
|
+
# Rewrite a `+` reduction into `sum`: `values.reduce(:+)` becomes
|
|
6
|
+
# `values.sum`, `values.inject(10, :+)` becomes `values.sum(10)`, and the
|
|
7
|
+
# same for the block-pass form `inject(&:+)`.
|
|
8
|
+
#
|
|
9
|
+
# The two differ on an empty collection (nil vs 0), on non-numeric elements
|
|
10
|
+
# (`reduce` concatenates strings and arrays, `sum` raises TypeError without a
|
|
11
|
+
# matching initial value) and on float precision (`sum` compensates rounding
|
|
12
|
+
# error). A survivor means none of those edges is tested.
|
|
13
|
+
class Evilution::Mutator::Operator::ReduceToSum < Evilution::Mutator::Base
|
|
14
|
+
SELECTORS = %i[reduce inject].freeze
|
|
15
|
+
private_constant :SELECTORS
|
|
16
|
+
|
|
17
|
+
NON_VALUE_ARGUMENTS = [Prism::SplatNode, Prism::KeywordHashNode].freeze
|
|
18
|
+
private_constant :NON_VALUE_ARGUMENTS
|
|
19
|
+
|
|
20
|
+
def visit_call_node(node)
|
|
21
|
+
rewrite(node) if node.receiver && SELECTORS.include?(node.name)
|
|
22
|
+
super
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def rewrite(node)
|
|
28
|
+
initial = initial_arguments(node)
|
|
29
|
+
return unless initial && initial.length <= 1
|
|
30
|
+
return if initial.any? { |argument| NON_VALUE_ARGUMENTS.any? { |type| argument.is_a?(type) } }
|
|
31
|
+
|
|
32
|
+
# A literal block never reaches here, so the call ends at its argument
|
|
33
|
+
# list or block-pass: cut from the selector to the end of the call.
|
|
34
|
+
start = node.message_loc.start_offset
|
|
35
|
+
replacement = initial.empty? ? "sum" : "sum(#{source_of(initial.first)})"
|
|
36
|
+
add_mutation(offset: start, length: node.end_offset - start, replacement: replacement, node: node)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# The arguments besides the `+` operand — at most an initial value — when
|
|
40
|
+
# the call is a `+` reduction; nil otherwise.
|
|
41
|
+
def initial_arguments(node)
|
|
42
|
+
arguments = node.arguments ? node.arguments.arguments : []
|
|
43
|
+
|
|
44
|
+
if plus_block_pass?(node.block)
|
|
45
|
+
arguments unless plus_symbol?(arguments.last)
|
|
46
|
+
elsif node.block.nil? && plus_symbol?(arguments.last)
|
|
47
|
+
arguments[...-1]
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def plus_block_pass?(block)
|
|
52
|
+
block.is_a?(Prism::BlockArgumentNode) && plus_symbol?(block.expression)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def plus_symbol?(node)
|
|
56
|
+
node.is_a?(Prism::SymbolNode) && node.unescaped == "+"
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../operator"
|
|
4
|
+
|
|
5
|
+
# Rewrite a match against an anchored literal regexp into a prefix or suffix
|
|
6
|
+
# predicate: `line =~ /^foo/` becomes `line.start_with?("foo")`, and
|
|
7
|
+
# `line.match?(/bar$/)` becomes `line.end_with?("bar")`. Handles `=~`,
|
|
8
|
+
# `match` and `match?`, with the regexp on either side.
|
|
9
|
+
#
|
|
10
|
+
# Line anchors carry the signal: `^` also matches after any newline, and `$`
|
|
11
|
+
# / `\Z` before a trailing one, where the predicates do not — a survivor
|
|
12
|
+
# means no test feeds multi-line input, or the anchor should have been `\A` /
|
|
13
|
+
# `\z`. With the string anchors `\A` / `\z` the truth value is identical, so
|
|
14
|
+
# only `=~` and `match` are rewritten there (their return value — an index
|
|
15
|
+
# or MatchData — still differs from a boolean); `match?` would be an exact
|
|
16
|
+
# equivalent and is skipped.
|
|
17
|
+
#
|
|
18
|
+
# Only a flag-free pattern of one anchor plus literal characters qualifies,
|
|
19
|
+
# and only on a call without a block: `match` yields its MatchData to one,
|
|
20
|
+
# which the predicate would silently drop.
|
|
21
|
+
class Evilution::Mutator::Operator::RegexpAnchorToPredicate < Evilution::Mutator::Base
|
|
22
|
+
SELECTORS = %i[=~ match match?].freeze
|
|
23
|
+
private_constant :SELECTORS
|
|
24
|
+
|
|
25
|
+
START_ANCHORS = { "^" => :line, "\\A" => :string }.freeze
|
|
26
|
+
private_constant :START_ANCHORS
|
|
27
|
+
|
|
28
|
+
END_ANCHORS = { "$" => :line, "\\Z" => :line, "\\z" => :string }.freeze
|
|
29
|
+
private_constant :END_ANCHORS
|
|
30
|
+
|
|
31
|
+
METACHARACTERS = ".*+?()[]{}|^$".chars.to_set.freeze
|
|
32
|
+
private_constant :METACHARACTERS
|
|
33
|
+
|
|
34
|
+
# Operands that need no parentheses in front of `.start_with?`.
|
|
35
|
+
SIMPLE_OPERANDS = [
|
|
36
|
+
Prism::LocalVariableReadNode,
|
|
37
|
+
Prism::InstanceVariableReadNode,
|
|
38
|
+
Prism::ClassVariableReadNode,
|
|
39
|
+
Prism::GlobalVariableReadNode,
|
|
40
|
+
Prism::ConstantReadNode,
|
|
41
|
+
Prism::ConstantPathNode,
|
|
42
|
+
Prism::SelfNode,
|
|
43
|
+
Prism::StringNode,
|
|
44
|
+
Prism::ParenthesesNode
|
|
45
|
+
].freeze
|
|
46
|
+
private_constant :SIMPLE_OPERANDS
|
|
47
|
+
|
|
48
|
+
# A backslash pair or a single character. A literal cannot end in a lone
|
|
49
|
+
# backslash, since that would escape its closing delimiter.
|
|
50
|
+
TOKEN = /\\.|./m
|
|
51
|
+
private_constant :TOKEN
|
|
52
|
+
|
|
53
|
+
IDENTIFIER = /\A[[:alpha:]_]/
|
|
54
|
+
private_constant :IDENTIFIER
|
|
55
|
+
|
|
56
|
+
def visit_call_node(node)
|
|
57
|
+
rewrite(node) if SELECTORS.include?(node.name)
|
|
58
|
+
super
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
def rewrite(node)
|
|
64
|
+
return unless node.arguments in Prism::ArgumentsNode[arguments: [argument]]
|
|
65
|
+
return if node.receiver.nil? || node.block
|
|
66
|
+
|
|
67
|
+
regexp, operand = split_operands(node.receiver, argument)
|
|
68
|
+
return if regexp.nil?
|
|
69
|
+
|
|
70
|
+
predicate, literal = anchored_literal(regexp, node.name)
|
|
71
|
+
return if predicate.nil?
|
|
72
|
+
|
|
73
|
+
replacement = "#{operand_source(operand)}#{call_operator(node)}#{predicate}(#{literal.inspect})"
|
|
74
|
+
replace_span(node: node, target: node, replacement: replacement)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def split_operands(receiver, argument)
|
|
78
|
+
if argument.is_a?(Prism::RegularExpressionNode)
|
|
79
|
+
[argument, receiver]
|
|
80
|
+
elsif receiver.is_a?(Prism::RegularExpressionNode)
|
|
81
|
+
[receiver, argument]
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# [predicate, literal] when the pattern is one anchor plus literal
|
|
86
|
+
# characters and the rewrite is not an exact equivalent; nil otherwise.
|
|
87
|
+
def anchored_literal(regexp, selector)
|
|
88
|
+
return if regexp.closing_loc.length > 1
|
|
89
|
+
|
|
90
|
+
predicate, kind, body = split_anchor(regexp.content.scan(TOKEN))
|
|
91
|
+
return if predicate.nil? || (kind == :string && selector == :match?)
|
|
92
|
+
|
|
93
|
+
literal = literal_text(body)
|
|
94
|
+
[predicate, literal] if literal && !literal.empty?
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# A pattern anchored at both ends needs no check here: the other anchor
|
|
98
|
+
# (`\z`, `\Z`, `$`, `^`, `\A`) is never a literal character, so
|
|
99
|
+
# literal_text rejects the body.
|
|
100
|
+
def split_anchor(tokens)
|
|
101
|
+
if (start_kind = START_ANCHORS[tokens.first])
|
|
102
|
+
[:start_with?, start_kind, tokens.drop(1)]
|
|
103
|
+
elsif (end_kind = END_ANCHORS[tokens.last])
|
|
104
|
+
[:end_with?, end_kind, tokens[...-1]]
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def literal_text(tokens)
|
|
109
|
+
chars = tokens.map { |token| literal_char(token) }
|
|
110
|
+
chars.join if chars.all?
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def literal_char(token)
|
|
114
|
+
if token.start_with?("\\")
|
|
115
|
+
escaped = token[1]
|
|
116
|
+
escaped unless escaped.match?(/[[:alnum:]]/)
|
|
117
|
+
elsif !METACHARACTERS.include?(token)
|
|
118
|
+
token
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def operand_source(operand)
|
|
123
|
+
source = source_of(operand)
|
|
124
|
+
simple_operand?(operand) ? source : "(#{source})"
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def simple_operand?(operand)
|
|
128
|
+
return true if SIMPLE_OPERANDS.any? { |type| operand.is_a?(type) }
|
|
129
|
+
|
|
130
|
+
operand.is_a?(Prism::CallNode) && (operand.name.match?(IDENTIFIER) || operand.name == :[])
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def call_operator(node)
|
|
134
|
+
node.call_operator_loc ? node.call_operator_loc.slice : "."
|
|
135
|
+
end
|
|
136
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../operator"
|
|
4
|
+
|
|
5
|
+
# Replace safe navigation with a plain call: `user&.name` becomes
|
|
6
|
+
# `user.name`, and the same for the `||=`, `&&=` and operator-write forms.
|
|
7
|
+
#
|
|
8
|
+
# The mutant only differs when the receiver is nil, where it raises
|
|
9
|
+
# NoMethodError instead of short-circuiting. A survivor means no test ever
|
|
10
|
+
# reaches the call with a nil receiver, so the `&.` is unexercised.
|
|
11
|
+
#
|
|
12
|
+
# Receivers that can never be nil — `self` and literals — are skipped, since
|
|
13
|
+
# the mutant is equivalent there.
|
|
14
|
+
class Evilution::Mutator::Operator::SafeNavigationRemoval < Evilution::Mutator::Base
|
|
15
|
+
NEVER_NIL_RECEIVERS = [
|
|
16
|
+
Prism::SelfNode,
|
|
17
|
+
Prism::StringNode,
|
|
18
|
+
Prism::InterpolatedStringNode,
|
|
19
|
+
Prism::XStringNode,
|
|
20
|
+
Prism::InterpolatedXStringNode,
|
|
21
|
+
Prism::SymbolNode,
|
|
22
|
+
Prism::InterpolatedSymbolNode,
|
|
23
|
+
Prism::IntegerNode,
|
|
24
|
+
Prism::FloatNode,
|
|
25
|
+
Prism::RationalNode,
|
|
26
|
+
Prism::ImaginaryNode,
|
|
27
|
+
Prism::ArrayNode,
|
|
28
|
+
Prism::HashNode,
|
|
29
|
+
Prism::RangeNode,
|
|
30
|
+
Prism::RegularExpressionNode,
|
|
31
|
+
Prism::InterpolatedRegularExpressionNode,
|
|
32
|
+
Prism::TrueNode,
|
|
33
|
+
Prism::FalseNode,
|
|
34
|
+
Prism::LambdaNode
|
|
35
|
+
].freeze
|
|
36
|
+
private_constant :NEVER_NIL_RECEIVERS
|
|
37
|
+
|
|
38
|
+
def visit_call_node(node)
|
|
39
|
+
mutate_safe_navigation(node)
|
|
40
|
+
super
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def visit_call_or_write_node(node)
|
|
44
|
+
mutate_safe_navigation(node)
|
|
45
|
+
super
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def visit_call_and_write_node(node)
|
|
49
|
+
mutate_safe_navigation(node)
|
|
50
|
+
super
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def visit_call_operator_write_node(node)
|
|
54
|
+
mutate_safe_navigation(node)
|
|
55
|
+
super
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
def mutate_safe_navigation(node)
|
|
61
|
+
return unless node.safe_navigation?
|
|
62
|
+
return if NEVER_NIL_RECEIVERS.any? { |type| node.receiver.is_a?(type) }
|
|
63
|
+
|
|
64
|
+
operator = node.call_operator_loc
|
|
65
|
+
add_mutation(offset: operator.start_offset, length: operator.length, replacement: ".", node: node)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -25,22 +25,50 @@ class Evilution::Mutator::Operator::SendMutation < Evilution::Mutator::Base
|
|
|
25
25
|
count: [:size],
|
|
26
26
|
select: [:filter],
|
|
27
27
|
filter: [:select],
|
|
28
|
-
to_s: [
|
|
29
|
-
to_i: [
|
|
28
|
+
to_s: %i[to_i to_str],
|
|
29
|
+
to_i: %i[to_s to_int],
|
|
30
30
|
to_f: [:to_i],
|
|
31
|
-
to_a: [
|
|
32
|
-
to_h: [
|
|
31
|
+
to_a: %i[to_h to_ary],
|
|
32
|
+
to_h: %i[to_a to_hash],
|
|
33
33
|
downcase: [:upcase],
|
|
34
34
|
upcase: [:downcase],
|
|
35
35
|
strip: %i[lstrip rstrip],
|
|
36
36
|
lstrip: [:strip],
|
|
37
37
|
rstrip: [:strip],
|
|
38
38
|
chomp: [:chop],
|
|
39
|
-
chop: [:chomp]
|
|
39
|
+
chop: [:chomp],
|
|
40
|
+
bytes: [:chars],
|
|
41
|
+
chars: [:bytes],
|
|
42
|
+
start_with?: [:end_with?],
|
|
43
|
+
end_with?: [:start_with?],
|
|
44
|
+
ceil: [:floor],
|
|
45
|
+
floor: [:ceil],
|
|
46
|
+
transform_keys: [:transform_values],
|
|
47
|
+
transform_values: [:transform_keys],
|
|
48
|
+
append: [:prepend],
|
|
49
|
+
prepend: [:append],
|
|
50
|
+
reverse_merge: [:merge],
|
|
51
|
+
even?: [:odd?],
|
|
52
|
+
odd?: [:even?],
|
|
53
|
+
zero?: [:nonzero?],
|
|
54
|
+
negative?: [:positive?],
|
|
55
|
+
positive?: [:negative?],
|
|
56
|
+
pred: [:succ],
|
|
57
|
+
succ: [:pred],
|
|
58
|
+
is_a?: [:instance_of?],
|
|
59
|
+
kind_of?: [:instance_of?]
|
|
60
|
+
}.freeze
|
|
61
|
+
|
|
62
|
+
# Swapped only when called with exactly one argument. Kept out of
|
|
63
|
+
# REPLACEMENTS, which symbol_to_proc_replacement also reads: `&:method`
|
|
64
|
+
# always calls with none, and a zero-argument `method` is usually an
|
|
65
|
+
# accessor such as `request.method` (the HTTP verb), not Object#method.
|
|
66
|
+
ONE_ARGUMENT_REPLACEMENTS = {
|
|
67
|
+
method: [:public_method]
|
|
40
68
|
}.freeze
|
|
41
69
|
|
|
42
70
|
def visit_call_node(node)
|
|
43
|
-
replacements =
|
|
71
|
+
replacements = replacements_for(node)
|
|
44
72
|
return super unless replacements
|
|
45
73
|
return super unless node.receiver
|
|
46
74
|
|
|
@@ -58,4 +86,12 @@ class Evilution::Mutator::Operator::SendMutation < Evilution::Mutator::Base
|
|
|
58
86
|
|
|
59
87
|
super
|
|
60
88
|
end
|
|
89
|
+
|
|
90
|
+
private
|
|
91
|
+
|
|
92
|
+
def replacements_for(node)
|
|
93
|
+
REPLACEMENTS.fetch(node.name) do
|
|
94
|
+
ONE_ARGUMENT_REPLACEMENTS[node.name] if node.arguments && node.arguments.arguments.length == 1
|
|
95
|
+
end
|
|
96
|
+
end
|
|
61
97
|
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../operator"
|
|
4
|
+
require_relative "send_mutation"
|
|
5
|
+
require_relative "collection_replacement"
|
|
6
|
+
|
|
7
|
+
# Apply the call-site selector tables to a symbol block-pass: `map(&:to_s)`
|
|
8
|
+
# becomes `map(&:to_i)`, `map(&:first)` becomes `map(&:last)`.
|
|
9
|
+
#
|
|
10
|
+
# send_mutation and collection_replacement only see explicit calls, so a
|
|
11
|
+
# selector passed as `&:sym` was never swapped — only removed. This reads
|
|
12
|
+
# both tables live, so selectors added to them apply here too.
|
|
13
|
+
#
|
|
14
|
+
# Replacements that are Ruby aliases of the original (`collect` / `map`,
|
|
15
|
+
# `find` / `detect`, `select` / `filter`, `length` / `size` / `count`) are
|
|
16
|
+
# skipped: on the receivers these selectors are normally mapped over, the
|
|
17
|
+
# mutant behaves identically.
|
|
18
|
+
class Evilution::Mutator::Operator::SymbolToProcReplacement < Evilution::Mutator::Base
|
|
19
|
+
TABLES = [
|
|
20
|
+
Evilution::Mutator::Operator::SendMutation::REPLACEMENTS,
|
|
21
|
+
Evilution::Mutator::Operator::CollectionReplacement::REPLACEMENTS
|
|
22
|
+
].freeze
|
|
23
|
+
private_constant :TABLES
|
|
24
|
+
|
|
25
|
+
ALIAS_GROUPS = [
|
|
26
|
+
%i[map collect],
|
|
27
|
+
%i[find detect],
|
|
28
|
+
%i[select filter],
|
|
29
|
+
%i[length size count]
|
|
30
|
+
].freeze
|
|
31
|
+
private_constant :ALIAS_GROUPS
|
|
32
|
+
|
|
33
|
+
def visit_block_argument_node(node)
|
|
34
|
+
symbol = node.expression
|
|
35
|
+
replace_selector(node, symbol) if symbol.is_a?(Prism::SymbolNode)
|
|
36
|
+
super
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def replace_selector(node, symbol)
|
|
42
|
+
selector = symbol.unescaped.to_sym
|
|
43
|
+
location = symbol.value_loc
|
|
44
|
+
|
|
45
|
+
replacements_for(selector).each do |replacement|
|
|
46
|
+
add_mutation(
|
|
47
|
+
offset: location.start_offset,
|
|
48
|
+
length: location.length,
|
|
49
|
+
replacement: replacement.to_s,
|
|
50
|
+
node: node
|
|
51
|
+
)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def replacements_for(selector)
|
|
56
|
+
aliases = ALIAS_GROUPS.find { |group| group.include?(selector) } || []
|
|
57
|
+
TABLES.flat_map { |table| table.fetch(selector, []) }.uniq - aliases
|
|
58
|
+
end
|
|
59
|
+
end
|