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.
Files changed (113) hide show
  1. checksums.yaml +4 -4
  2. data/.beads/interactions.jsonl +38 -0
  3. data/.rubocop_todo.yml +9 -0
  4. data/CHANGELOG.md +47 -0
  5. data/README.md +65 -7
  6. data/docs/architecture.md +35 -1
  7. data/lib/evilution/ast/parser.rb +1 -0
  8. data/lib/evilution/ast/pattern/filter.rb +1 -0
  9. data/lib/evilution/ast/pattern/parser.rb +1 -0
  10. data/lib/evilution/ast/pattern.rb +2 -0
  11. data/lib/evilution/ast/sorbet_sig_detector.rb +1 -0
  12. data/lib/evilution/baseline.rb +37 -15
  13. data/lib/evilution/child_output.rb +1 -1
  14. data/lib/evilution/cli/command.rb +1 -0
  15. data/lib/evilution/cli/commands/tests_list.rb +4 -3
  16. data/lib/evilution/cli/commands.rb +2 -0
  17. data/lib/evilution/cli/dispatcher.rb +2 -0
  18. data/lib/evilution/cli/exit_guard.rb +2 -0
  19. data/lib/evilution/cli/parsed_args.rb +2 -0
  20. data/lib/evilution/cli/parser/command_extractor.rb +2 -0
  21. data/lib/evilution/cli/parser/file_args.rb +2 -0
  22. data/lib/evilution/cli/parser/options_builder.rb +3 -0
  23. data/lib/evilution/cli/parser/stdin_reader.rb +1 -0
  24. data/lib/evilution/cli/parser.rb +1 -0
  25. data/lib/evilution/cli/printers/tests_list.rb +5 -4
  26. data/lib/evilution/cli/printers.rb +2 -0
  27. data/lib/evilution/cli/result.rb +2 -0
  28. data/lib/evilution/cli.rb +5 -0
  29. data/lib/evilution/config/builders.rb +2 -0
  30. data/lib/evilution/config/env_loader.rb +2 -0
  31. data/lib/evilution/config/file_loader.rb +1 -0
  32. data/lib/evilution/config/sources.rb +1 -0
  33. data/lib/evilution/config/validators/example_targeting_cache.rb +1 -0
  34. data/lib/evilution/config/validators/example_targeting_fallback.rb +1 -0
  35. data/lib/evilution/config/validators/example_targeting_strategy.rb +1 -0
  36. data/lib/evilution/config/validators/fail_fast.rb +1 -0
  37. data/lib/evilution/config/validators/hooks.rb +1 -0
  38. data/lib/evilution/config/validators/ignore_patterns.rb +1 -0
  39. data/lib/evilution/config/validators/integration.rb +1 -0
  40. data/lib/evilution/config/validators/isolation.rb +1 -0
  41. data/lib/evilution/config/validators/jobs.rb +1 -0
  42. data/lib/evilution/config/validators/preload.rb +1 -0
  43. data/lib/evilution/config/validators/profile.rb +1 -0
  44. data/lib/evilution/config/validators/spec_mappings.rb +1 -0
  45. data/lib/evilution/config/validators/spec_pattern.rb +1 -0
  46. data/lib/evilution/config/validators/warmup.rb +17 -0
  47. data/lib/evilution/config/validators.rb +2 -0
  48. data/lib/evilution/config.rb +4 -3
  49. data/lib/evilution/coverage.rb +1 -1
  50. data/lib/evilution/coverage_example_filter.rb +1 -1
  51. data/lib/evilution/diagnostic.rb +1 -1
  52. data/lib/evilution/example_filter.rb +1 -1
  53. data/lib/evilution/integration/loading/body_call_neutralizer.rb +10 -2
  54. data/lib/evilution/integration/loading/test_load_path.rb +21 -13
  55. data/lib/evilution/integration/rspec/state_guard/configuration_state.rb +1 -0
  56. data/lib/evilution/integration/rspec/state_guard/configuration_streams.rb +1 -0
  57. data/lib/evilution/integration/rspec/state_guard/example_groups_constants.rb +1 -2
  58. data/lib/evilution/integration/rspec/state_guard/internals.rb +1 -2
  59. data/lib/evilution/integration/rspec/state_guard/object_space_example_groups.rb +1 -2
  60. data/lib/evilution/integration/rspec/state_guard/reporter_arrays.rb +1 -0
  61. data/lib/evilution/integration/rspec/state_guard/world_example_groups.rb +1 -0
  62. data/lib/evilution/integration/rspec/state_guard/world_filtered_examples.rb +1 -0
  63. data/lib/evilution/integration/rspec/state_guard/world_sources_by_path.rb +1 -0
  64. data/lib/evilution/integration/rspec/state_guard.rb +6 -0
  65. data/lib/evilution/mcp/info_tool/actions/environment.rb +1 -0
  66. data/lib/evilution/mcp/info_tool/actions/feedback.rb +1 -0
  67. data/lib/evilution/mcp/info_tool/actions/statuses.rb +1 -0
  68. data/lib/evilution/mcp/info_tool/actions/subjects.rb +1 -0
  69. data/lib/evilution/mcp/info_tool/actions/tests.rb +1 -0
  70. data/lib/evilution/mutation.rb +1 -1
  71. data/lib/evilution/mutator/operator/argument_list_removal.rb +52 -0
  72. data/lib/evilution/mutator/operator/argument_propagation.rb +54 -0
  73. data/lib/evilution/mutator/operator/array_coercion_to_literal.rb +34 -0
  74. data/lib/evilution/mutator/operator/attribute_write_to_read.rb +34 -0
  75. data/lib/evilution/mutator/operator/bang_method.rb +11 -3
  76. data/lib/evilution/mutator/operator/binary_operand_promotion.rb +63 -0
  77. data/lib/evilution/mutator/operator/block_body_promotion.rb +45 -0
  78. data/lib/evilution/mutator/operator/block_body_to_nil.rb +52 -0
  79. data/lib/evilution/mutator/operator/block_body_to_raise.rb +34 -0
  80. data/lib/evilution/mutator/operator/call_to_nil.rb +34 -0
  81. data/lib/evilution/mutator/operator/coercion_emptying.rb +60 -0
  82. data/lib/evilution/mutator/operator/collection_replacement.rb +19 -9
  83. data/lib/evilution/mutator/operator/comparison_replacement.rb +37 -12
  84. data/lib/evilution/mutator/operator/const_get_to_constant_path.rb +50 -0
  85. data/lib/evilution/mutator/operator/dig_to_fetch_chain.rb +45 -0
  86. data/lib/evilution/mutator/operator/double_negation_removal.rb +22 -0
  87. data/lib/evilution/mutator/operator/dynamic_dispatch_resolution.rb +79 -0
  88. data/lib/evilution/mutator/operator/inequality_to_negated_identity.rb +43 -0
  89. data/lib/evilution/mutator/operator/keyword_argument_removal.rb +47 -0
  90. data/lib/evilution/mutator/operator/proc_to_lambda.rb +43 -0
  91. data/lib/evilution/mutator/operator/receiver_constructor_swap.rb +51 -0
  92. data/lib/evilution/mutator/operator/reduce_to_sum.rb +58 -0
  93. data/lib/evilution/mutator/operator/regexp_anchor_to_predicate.rb +136 -0
  94. data/lib/evilution/mutator/operator/safe_navigation_removal.rb +67 -0
  95. data/lib/evilution/mutator/operator/send_mutation.rb +42 -6
  96. data/lib/evilution/mutator/operator/symbol_to_proc_replacement.rb +59 -0
  97. data/lib/evilution/mutator/operator/to_i_to_integer.rb +38 -0
  98. data/lib/evilution/mutator/primitives.rb +25 -0
  99. data/lib/evilution/mutator/registry.rb +24 -1
  100. data/lib/evilution/parallel/pool.rb +1 -0
  101. data/lib/evilution/parallel_db_warning.rb +1 -1
  102. data/lib/evilution/rails_warmup.rb +61 -0
  103. data/lib/evilution/reporter/html/report.rb +1 -0
  104. data/lib/evilution/result/coverage_gap_grouper.rb +1 -0
  105. data/lib/evilution/result/subject_scorer.rb +1 -0
  106. data/lib/evilution/runner/baseline_runner.rb +17 -7
  107. data/lib/evilution/runner/isolation_resolver.rb +23 -2
  108. data/lib/evilution/runner/mutation_executor/neutralizer/baseline_failed.rb +17 -10
  109. data/lib/evilution/source_ast_cache.rb +1 -1
  110. data/lib/evilution/spec_ast_cache.rb +1 -1
  111. data/lib/evilution/version.rb +1 -1
  112. data/lib/evilution.rb +23 -0
  113. 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: [:to_i],
29
- to_i: [:to_s],
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: [:to_h],
32
- to_h: [:to_a],
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 = REPLACEMENTS[node.name]
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