evilution 1.4.0 → 1.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. checksums.yaml +4 -4
  2. data/.beads/interactions.jsonl +71 -0
  3. data/CHANGELOG.md +60 -0
  4. data/README.md +64 -22
  5. data/docs/architecture.md +56 -12
  6. data/docs/isolation.md +82 -0
  7. data/lib/evilution/ast/class_body.rb +101 -0
  8. data/lib/evilution/ast/included_block.rb +20 -6
  9. data/lib/evilution/ast/never_nil.rb +36 -0
  10. data/lib/evilution/ast/parser.rb +94 -31
  11. data/lib/evilution/ast/round_half_mode.rb +38 -0
  12. data/lib/evilution/ast/source_surgeon.rb +2 -3
  13. data/lib/evilution/ast/syntax_check.rb +56 -0
  14. data/lib/evilution/ast/uncovered_code.rb +7 -7
  15. data/lib/evilution/cache.rb +13 -3
  16. data/lib/evilution/cli/exit_guard.rb +26 -4
  17. data/lib/evilution/integration/base.rb +10 -2
  18. data/lib/evilution/integration/loading/body_call_neutralizer.rb +59 -14
  19. data/lib/evilution/integration/loading/concern_redeclaration.rb +33 -14
  20. data/lib/evilution/integration/loading/concern_state_cleaner.rb +3 -3
  21. data/lib/evilution/integration/loading/mutation_applier.rb +33 -6
  22. data/lib/evilution/integration/loading/own_methods.rb +87 -0
  23. data/lib/evilution/integration/loading/redefinition_recovery.rb +2 -3
  24. data/lib/evilution/integration/loading/source_evaluator.rb +9 -2
  25. data/lib/evilution/integration/loading/superseded_modules.rb +103 -0
  26. data/lib/evilution/integration/loading/syntax_validator.rb +2 -2
  27. data/lib/evilution/isolation/fork.rb +82 -10
  28. data/lib/evilution/isolation/in_process.rb +79 -4
  29. data/lib/evilution/mcp/info_tool/status_glossary.rb +13 -2
  30. data/lib/evilution/mcp/info_tool.rb +3 -2
  31. data/lib/evilution/mutation.rb +18 -11
  32. data/lib/evilution/mutator/base.rb +27 -7
  33. data/lib/evilution/mutator/constant_value_subjects.rb +13 -0
  34. data/lib/evilution/mutator/operator/alias_removal.rb +11 -81
  35. data/lib/evilution/mutator/operator/argument_nil_substitution.rb +2 -2
  36. data/lib/evilution/mutator/operator/argument_removal.rb +2 -2
  37. data/lib/evilution/mutator/operator/array_literal.rb +29 -0
  38. data/lib/evilution/mutator/operator/attr_accessor_swap.rb +68 -0
  39. data/lib/evilution/mutator/operator/boolean_literal_replacement.rb +2 -0
  40. data/lib/evilution/mutator/operator/call_to_nil.rb +12 -3
  41. data/lib/evilution/mutator/operator/complex_literal.rb +39 -0
  42. data/lib/evilution/mutator/operator/compound_assignment.rb +47 -0
  43. data/lib/evilution/mutator/operator/constant_namespace_strip.rb +57 -0
  44. data/lib/evilution/mutator/operator/constant_read_to_nil.rb +58 -0
  45. data/lib/evilution/mutator/operator/constant_write_to_nil.rb +32 -0
  46. data/lib/evilution/mutator/operator/data_struct_member.rb +1 -1
  47. data/lib/evilution/mutator/operator/explicit_super_mutation.rb +2 -2
  48. data/lib/evilution/mutator/operator/float_literal.rb +54 -19
  49. data/lib/evilution/mutator/operator/freeze_removal.rb +86 -0
  50. data/lib/evilution/mutator/operator/hash_literal.rb +51 -0
  51. data/lib/evilution/mutator/operator/index_range_to_drop.rb +67 -0
  52. data/lib/evilution/mutator/operator/index_receiver_to_self.rb +20 -0
  53. data/lib/evilution/mutator/operator/index_to_key_predicate.rb +58 -0
  54. data/lib/evilution/mutator/operator/index_write_to_value.rb +37 -0
  55. data/lib/evilution/mutator/operator/integer_literal.rb +39 -9
  56. data/lib/evilution/mutator/operator/memoization_break.rb +18 -0
  57. data/lib/evilution/mutator/operator/mixin_removal.rb +18 -55
  58. data/lib/evilution/mutator/operator/nil_replacement.rb +2 -0
  59. data/lib/evilution/mutator/operator/operator_write_receiver_promotion.rb +38 -0
  60. data/lib/evilution/mutator/operator/range_replacement.rb +2 -0
  61. data/lib/evilution/mutator/operator/rational_literal.rb +40 -0
  62. data/lib/evilution/mutator/operator/reduce_to_sum.rb +1 -1
  63. data/lib/evilution/mutator/operator/rescue_class_widening.rb +54 -0
  64. data/lib/evilution/mutator/operator/round_half_mode_swap.rb +36 -0
  65. data/lib/evilution/mutator/operator/safe_navigation_insertion.rb +57 -0
  66. data/lib/evilution/mutator/operator/safe_navigation_removal.rb +2 -24
  67. data/lib/evilution/mutator/operator/short_circuit_to_eager.rb +34 -0
  68. data/lib/evilution/mutator/operator/sleep_to_zero.rb +43 -0
  69. data/lib/evilution/mutator/operator/splat_operator.rb +16 -24
  70. data/lib/evilution/mutator/operator/string_literal.rb +29 -0
  71. data/lib/evilution/mutator/operator/superclass_removal.rb +14 -40
  72. data/lib/evilution/mutator/operator/symbol_literal.rb +47 -2
  73. data/lib/evilution/mutator/operator/to_i_to_integer.rb +1 -1
  74. data/lib/evilution/mutator/operator/variable_read_to_nil.rb +113 -0
  75. data/lib/evilution/mutator/operator/visibility_removal.rb +74 -0
  76. data/lib/evilution/mutator/primitives.rb +24 -2
  77. data/lib/evilution/mutator/receiver_positions.rb +79 -0
  78. data/lib/evilution/mutator/registry.rb +29 -2
  79. data/lib/evilution/parallel/work_queue/channel/frame.rb +6 -0
  80. data/lib/evilution/parallel/work_queue/channel.rb +58 -5
  81. data/lib/evilution/parallel/work_queue/dispatcher/reply.rb +41 -0
  82. data/lib/evilution/parallel/work_queue/dispatcher.rb +14 -1
  83. data/lib/evilution/parallel/work_queue/worker/loop.rb +5 -0
  84. data/lib/evilution/parallel/work_queue/worker.rb +13 -4
  85. data/lib/evilution/parallel/work_queue.rb +15 -9
  86. data/lib/evilution/runner/mutation_executor/strategy/parallel.rb +10 -0
  87. data/lib/evilution/runner/subject_pipeline/target.rb +86 -0
  88. data/lib/evilution/runner/subject_pipeline.rb +20 -51
  89. data/lib/evilution/runner.rb +1 -1
  90. data/lib/evilution/subject.rb +18 -1
  91. data/lib/evilution/version.rb +1 -1
  92. data/lib/evilution.rb +62 -0
  93. metadata +32 -2
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../operator"
4
+ require_relative "../../ast/round_half_mode"
5
+
6
+ # Swap the tie-breaking mode of `round`: `amount.round(2, half: :even)`
7
+ # becomes `half: :up` and `half: :down`.
8
+ #
9
+ # The modes differ only for a value sitting exactly on a tie (`2.5`), so a
10
+ # survivor means no example rounds one — the case banker's rounding in money
11
+ # code exists for.
12
+ #
13
+ # Only a literal mode is swapped; one held in a variable is left alone.
14
+ # Integer, Float, Rational and BigDecimal all read `half:` the same way.
15
+ class Evilution::Mutator::Operator::RoundHalfModeSwap < Evilution::Mutator::Base
16
+ def visit_call_node(node)
17
+ mode = Evilution::AST::RoundHalfMode.of(node)
18
+ swap(node, mode) if mode
19
+ super
20
+ end
21
+
22
+ private
23
+
24
+ def swap(node, mode)
25
+ others = Evilution::AST::RoundHalfMode::MODES - [mode.unescaped.to_sym]
26
+
27
+ others.each do |other|
28
+ add_mutation(
29
+ offset: mode.location.start_offset,
30
+ length: mode.location.length,
31
+ replacement: other.inspect,
32
+ node: node
33
+ )
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../operator"
4
+ require_relative "../../ast/never_nil"
5
+
6
+ # Replace a plain call with safe navigation: `user.name` becomes
7
+ # `user&.name`, and the same for the `||=`, `&&=` and operator-write forms.
8
+ #
9
+ # The mutant only differs when the receiver is nil: the call is then skipped
10
+ # and the expression is nil, where the plain call raises NoMethodError or
11
+ # runs a method nil has (`nil.to_a` is `[]`). A survivor means no test
12
+ # reaches the call with a nil receiver and checks what comes of it.
13
+ #
14
+ # It touches nearly every call and survives wherever the receiver is never
15
+ # nil, so it is registered for the strict profile only. Receivers that can
16
+ # never be nil (`self`, literals other than `nil`, constants) are skipped,
17
+ # since the mutant is equivalent there.
18
+ class Evilution::Mutator::Operator::SafeNavigationInsertion < Evilution::Mutator::Base
19
+ # A constant could hold nil, but one that receives a call does not in
20
+ # practice.
21
+ CONSTANTS = [Prism::ConstantReadNode, Prism::ConstantPathNode].freeze
22
+ private_constant :CONSTANTS
23
+
24
+ def visit_call_node(node)
25
+ mutate_plain_call(node)
26
+ super
27
+ end
28
+
29
+ def visit_call_or_write_node(node)
30
+ mutate_plain_call(node)
31
+ super
32
+ end
33
+
34
+ def visit_call_and_write_node(node)
35
+ mutate_plain_call(node)
36
+ super
37
+ end
38
+
39
+ def visit_call_operator_write_node(node)
40
+ mutate_plain_call(node)
41
+ super
42
+ end
43
+
44
+ private
45
+
46
+ def mutate_plain_call(node)
47
+ operator = node.call_operator_loc
48
+ return unless operator && operator.slice == "."
49
+ return if never_nil?(node.receiver)
50
+
51
+ add_mutation(offset: operator.start_offset, length: operator.length, replacement: "&.", node: node)
52
+ end
53
+
54
+ def never_nil?(receiver)
55
+ Evilution::AST::NeverNil.node?(receiver) || CONSTANTS.include?(receiver.class)
56
+ end
57
+ end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "../operator"
4
+ require_relative "../../ast/never_nil"
4
5
 
5
6
  # Replace safe navigation with a plain call: `user&.name` becomes
6
7
  # `user.name`, and the same for the `||=`, `&&=` and operator-write forms.
@@ -12,29 +13,6 @@ require_relative "../operator"
12
13
  # Receivers that can never be nil — `self` and literals — are skipped, since
13
14
  # the mutant is equivalent there.
14
15
  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
16
  def visit_call_node(node)
39
17
  mutate_safe_navigation(node)
40
18
  super
@@ -59,7 +37,7 @@ class Evilution::Mutator::Operator::SafeNavigationRemoval < Evilution::Mutator::
59
37
 
60
38
  def mutate_safe_navigation(node)
61
39
  return unless node.safe_navigation?
62
- return if NEVER_NIL_RECEIVERS.any? { |type| node.receiver.is_a?(type) }
40
+ return if Evilution::AST::NeverNil.node?(node.receiver)
63
41
 
64
42
  operator = node.call_operator_loc
65
43
  add_mutation(offset: operator.start_offset, length: operator.length, replacement: ".", node: node)
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../operator"
4
+
5
+ # Make a short-circuit operator evaluate both sides: `a && b` becomes
6
+ # `a & b`, `a || b` becomes `a | b`.
7
+ #
8
+ # A survivor means no example depends on the right side being skipped: it
9
+ # guards nothing (a nil receiver, a side effect), and both sides are plain
10
+ # booleans, since `&` and `|` give another value for anything else.
11
+ #
12
+ # `&` and `|` bind tighter than `&&` and `||`, so an operand that would
13
+ # regroup (`a == 1 && b` is not `a == 1 & b`) is parenthesized.
14
+ class Evilution::Mutator::Operator::ShortCircuitToEager < Evilution::Mutator::Base
15
+ def visit_and_node(node)
16
+ mutate_to_eager(node, "&")
17
+ super
18
+ end
19
+
20
+ def visit_or_node(node)
21
+ mutate_to_eager(node, "|")
22
+ super
23
+ end
24
+
25
+ private
26
+
27
+ # An operand that only jumps (`a || return`) has no value to pass on; the
28
+ # result does not parse and is dropped.
29
+ def mutate_to_eager(node, operator)
30
+ replacement = "#{receiver_source(node.left)} #{operator} #{receiver_source(node.right)}"
31
+
32
+ replace_span(node:, target: node, replacement:)
33
+ end
34
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../operator"
4
+
5
+ # Take the wait out of a sleep whose duration is not a literal:
6
+ # `sleep delay` becomes `sleep 0`.
7
+ #
8
+ # A survivor means the pause decides nothing an example observes. A killed
9
+ # mutant means the suite depends on wall-clock time, which is worth knowing
10
+ # too.
11
+ #
12
+ # A literal duration (`sleep 5`, `sleep 0.5`) is left to the literal
13
+ # operators, which already take it to zero. Only `Kernel#sleep` is touched: a
14
+ # `sleep` sent to another receiver may be something else.
15
+ class Evilution::Mutator::Operator::SleepToZero < Evilution::Mutator::Base
16
+ LITERAL_DURATIONS = [Prism::IntegerNode, Prism::FloatNode, Prism::RationalNode].freeze
17
+ KERNEL = %w[Kernel ::Kernel].freeze
18
+
19
+ def visit_call_node(node)
20
+ duration = duration_of(node)
21
+ add_mutation(offset: duration.location.start_offset, length: duration.location.length, replacement: "0", node:) if duration
22
+
23
+ super
24
+ end
25
+
26
+ private
27
+
28
+ # The single argument of a `sleep` call, when it is one this operator
29
+ # replaces.
30
+ def duration_of(node)
31
+ return unless node.name == :sleep && kernel_call?(node) && node.arguments
32
+
33
+ arguments = node.arguments.arguments
34
+ return unless arguments.one?
35
+
36
+ duration = arguments.first
37
+ duration unless duration.is_a?(Prism::SplatNode) || LITERAL_DURATIONS.include?(duration.class)
38
+ end
39
+
40
+ def kernel_call?(node)
41
+ node.receiver.nil? || KERNEL.include?(node.receiver.slice)
42
+ end
43
+ end
@@ -4,13 +4,15 @@ require_relative "../operator"
4
4
 
5
5
  class Evilution::Mutator::Operator::SplatOperator < Evilution::Mutator::Base
6
6
  def visit_splat_node(node)
7
- mutate_remove_splat(node) if node.expression && !pattern_rests.include?(node)
7
+ mutate_remove_splat(node) if node.expression && !exempt_splats.include?(node)
8
8
 
9
9
  super
10
10
  end
11
11
 
12
+ # Inside a hash literal `**opts` merges a hash in; a bare `opts` is not an
13
+ # element (`{ opts }` is a syntax error), so these splats are skipped.
12
14
  def visit_hash_node(node)
13
- node.elements.each { |el| hash_elements.add(el) }
15
+ exempt_splats.merge(node.elements.grep(Prism::AssocSplatNode))
14
16
  super
15
17
  end
16
18
 
@@ -23,18 +25,18 @@ class Evilution::Mutator::Operator::SplatOperator < Evilution::Mutator::Base
23
25
  # (`a, *b = x`) is still mutated: `a, b = x` parses and changes what `b`
24
26
  # binds.
25
27
  def visit_hash_pattern_node(node)
26
- pattern_rests.add(node.rest) if node.rest
28
+ exempt_splats.add(node.rest) if node.rest
27
29
  super
28
30
  end
29
31
 
30
32
  def visit_array_pattern_node(node)
31
- pattern_rests.add(node.rest) if node.rest
33
+ exempt_splats.add(node.rest) if node.rest
32
34
  super
33
35
  end
34
36
 
35
37
  def visit_find_pattern_node(node)
36
- pattern_rests.add(node.left)
37
- pattern_rests.add(node.right)
38
+ exempt_splats.add(node.left)
39
+ exempt_splats.add(node.right)
38
40
  super
39
41
  end
40
42
 
@@ -42,22 +44,17 @@ class Evilution::Mutator::Operator::SplatOperator < Evilution::Mutator::Base
42
44
  # `k: v` or another `**splat` precedes a `**opts` splat in the same call,
43
45
  # demoting `**opts` to bare `opts` puts a positional after a keyword
44
46
  # argument and Ruby rejects it (`bar(k: v, opts)` and `bar(**a, opts)` are
45
- # syntax errors). Mark such splats so `visit_assoc_splat_node` skips them.
47
+ # syntax errors), so such splats are skipped.
46
48
  # A splat that comes first (`bar(**opts, k: v)`) is still safe —
47
49
  # positional-before-keyword is fine.
48
50
  def visit_keyword_hash_node(node)
49
- node.elements.drop(1).each do |el|
50
- kwarg_preceded_splats.add(el) if el.is_a?(Prism::AssocSplatNode)
51
- end
52
-
51
+ exempt_splats.merge(node.elements.drop(1).grep(Prism::AssocSplatNode))
53
52
  super
54
53
  end
55
54
 
56
55
  def visit_assoc_splat_node(node)
57
56
  return super if node.value.nil?
58
- return super if hash_elements.include?(node)
59
- return super if kwarg_preceded_splats.include?(node)
60
- return super if pattern_rests.include?(node)
57
+ return super if exempt_splats.include?(node)
61
58
 
62
59
  mutate_remove_double_splat(node)
63
60
 
@@ -66,16 +63,11 @@ class Evilution::Mutator::Operator::SplatOperator < Evilution::Mutator::Base
66
63
 
67
64
  private
68
65
 
69
- def hash_elements
70
- @hash_elements ||= Set.new.compare_by_identity
71
- end
72
-
73
- def kwarg_preceded_splats
74
- @kwarg_preceded_splats ||= Set.new.compare_by_identity
75
- end
76
-
77
- def pattern_rests
78
- @pattern_rests ||= Set.new.compare_by_identity
66
+ # Splats that are left as written: removing the `*` or `**` would not
67
+ # parse, or would change what a pattern matches. Each visitor that fills
68
+ # the set says why.
69
+ def exempt_splats
70
+ @exempt_splats ||= Set.new.compare_by_identity
79
71
  end
80
72
 
81
73
  def mutate_remove_splat(node)
@@ -3,6 +3,8 @@
3
3
  require_relative "../operator"
4
4
 
5
5
  class Evilution::Mutator::Operator::StringLiteral < Evilution::Mutator::Base
6
+ extend Evilution::Mutator::ConstantValueSubjects
7
+
6
8
  def initialize(skip_heredoc_literals: false, **rest)
7
9
  super(**rest)
8
10
  @skip_heredoc_literals = skip_heredoc_literals
@@ -32,6 +34,11 @@ class Evilution::Mutator::Operator::StringLiteral < Evilution::Mutator::Base
32
34
  return
33
35
  end
34
36
 
37
+ # The composed value as a whole, next to the chunks and interpolations
38
+ # inside it. A word of `%W[]` has no quotes of its own and is not a
39
+ # literal: `nil` there would be the word "nil".
40
+ emit_string_mutations(node) if node.opening_loc
41
+
35
42
  super
36
43
  end
37
44
 
@@ -59,11 +66,33 @@ class Evilution::Mutator::Operator::StringLiteral < Evilution::Mutator::Base
59
66
  end
60
67
 
61
68
  def visit_interpolated_x_string_node(node)
69
+ return if node.heredoc? && @skip_heredoc_literals
70
+
71
+ mutate_command_to_nil(node)
62
72
  visit_non_string_parts(node)
63
73
  end
64
74
 
75
+ def visit_x_string_node(node)
76
+ mutate_command_to_nil(node)
77
+ super
78
+ end
79
+
65
80
  private
66
81
 
82
+ # A command literal (`` `ls` ``, `%x(ls)`) is only ever replaced as a whole,
83
+ # by `nil`: the command then does not run. Its text is never rewritten, as
84
+ # a changed command would run for real wherever an example runs it.
85
+ def mutate_command_to_nil(node)
86
+ return if node.heredoc?
87
+
88
+ add_mutation(
89
+ offset: node.location.start_offset,
90
+ length: node.location.length,
91
+ replacement: "nil",
92
+ node: node
93
+ )
94
+ end
95
+
67
96
  def visit_non_string_parts(node)
68
97
  node.parts.each do |part|
69
98
  next if part.is_a?(Prism::StringNode)
@@ -3,7 +3,13 @@
3
3
  require "prism"
4
4
 
5
5
  require_relative "../operator"
6
+ require_relative "../../ast/class_body"
6
7
 
8
+ # Drop the superclass from a class definition: `class Admin < User` becomes
9
+ # `class Admin`.
10
+ #
11
+ # The definition line belongs to no method, so the mutant is attributed to
12
+ # the method anchoring the class body (see Evilution::AST::ClassBody).
7
13
  class Evilution::Mutator::Operator::SuperclassRemoval < Evilution::Mutator::Base
8
14
  def call(subject, filter: nil)
9
15
  @subject = subject
@@ -11,24 +17,22 @@ class Evilution::Mutator::Operator::SuperclassRemoval < Evilution::Mutator::Base
11
17
  @mutations = []
12
18
  @filter = filter
13
19
 
14
- enclosing = find_target_class(subject)
15
- return @mutations unless enclosing
16
-
17
- offset, length = superclass_range(enclosing)
18
- add_mutation(offset: offset, length: length, replacement: "", node: enclosing)
20
+ classes_with_superclass_for(subject).each do |class_node|
21
+ offset, length = superclass_range(class_node)
22
+ add_mutation(offset: offset, length: length, replacement: "", node: class_node)
23
+ end
19
24
 
20
25
  @mutations
21
26
  end
22
27
 
23
28
  private
24
29
 
25
- def find_target_class(subject)
30
+ def classes_with_superclass_for(subject)
26
31
  tree = self.class.parsed_tree_for(subject.file_path, @file_source)
27
- enclosing = find_enclosing_class(tree, subject.line_number)
28
- return nil unless enclosing && enclosing.superclass
29
- return nil unless find_first_method_line(enclosing) == subject.line_number
30
32
 
31
- enclosing
33
+ Evilution::AST::ClassBody.anchored_at(tree, subject.line_number).map(&:node).select do |scope|
34
+ scope.is_a?(Prism::ClassNode) && scope.superclass
35
+ end
32
36
  end
33
37
 
34
38
  def superclass_range(class_node)
@@ -39,34 +43,4 @@ class Evilution::Mutator::Operator::SuperclassRemoval < Evilution::Mutator::Base
39
43
 
40
44
  [name_end, superclass_end - name_end]
41
45
  end
42
-
43
- def find_enclosing_class(tree, target_line)
44
- finder = ClassFinder.new(target_line)
45
- finder.visit(tree)
46
- finder.result
47
- end
48
-
49
- def find_first_method_line(class_node)
50
- return nil unless class_node.body
51
-
52
- class_node.body.body.each do |node|
53
- return node.location.start_line if node.is_a?(Prism::DefNode)
54
- end
55
- nil
56
- end
57
-
58
- # Visitor to find the ClassNode enclosing a given line number.
59
- class ClassFinder < Prism::Visitor
60
- attr_reader :result
61
-
62
- def initialize(target_line)
63
- @target_line = target_line
64
- @result = nil
65
- end
66
-
67
- def visit_class_node(node)
68
- @result = node if @target_line.between?(node.location.start_line, node.location.end_line)
69
- super
70
- end
71
- end
72
46
  end
@@ -1,10 +1,27 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "../operator"
4
+ require_relative "../../ast/round_half_mode"
4
5
 
5
6
  class Evilution::Mutator::Operator::SymbolLiteral < Evilution::Mutator::Base
7
+ extend Evilution::Mutator::ConstantValueSubjects
8
+
9
+ def call(subject, filter: nil)
10
+ @round_half_modes = []
11
+ super
12
+ end
13
+
14
+ # The tie-breaking mode of `round` (`half: :even`) is left to
15
+ # RoundHalfModeSwap: a made-up symbol there only raises, and nil rounds
16
+ # like `:up`.
17
+ def visit_call_node(node)
18
+ mode = Evilution::AST::RoundHalfMode.of(node)
19
+ @round_half_modes << mode if mode
20
+ super
21
+ end
22
+
6
23
  def visit_symbol_node(node)
7
- return super if label_form?(node)
24
+ return super if label_form?(node) || round_half_mode?(node)
8
25
 
9
26
  add_mutation(
10
27
  offset: node.location.start_offset,
@@ -23,10 +40,38 @@ class Evilution::Mutator::Operator::SymbolLiteral < Evilution::Mutator::Base
23
40
  super
24
41
  end
25
42
 
43
+ # An interpolated symbol (`:"visit_#{type}"`) as a whole. A label key
44
+ # (`"a#{x}": 1`) cannot be replaced by a value, and a word of `%I[]` has no
45
+ # quotes of its own: `nil` there would be the symbol `:nil`.
46
+ def visit_interpolated_symbol_node(node)
47
+ return super if node.opening_loc.nil? || label_form?(node)
48
+
49
+ add_mutation(
50
+ offset: node.location.start_offset,
51
+ length: node.location.length,
52
+ replacement: ':""',
53
+ node: node
54
+ )
55
+
56
+ add_mutation(
57
+ offset: node.location.start_offset,
58
+ length: node.location.length,
59
+ replacement: "nil",
60
+ node: node
61
+ )
62
+
63
+ super
64
+ end
65
+
26
66
  private
27
67
 
68
+ def round_half_mode?(node)
69
+ @round_half_modes.any? { |mode| mode.equal?(node) }
70
+ end
71
+
72
+ # `a:` closes with `:`, a quoted label (`"a b":`) with `":`.
28
73
  def label_form?(node)
29
74
  closing = node.closing_loc
30
- !closing.nil? && closing.slice == ":"
75
+ !closing.nil? && closing.slice.end_with?(":")
31
76
  end
32
77
  end
@@ -29,7 +29,7 @@ class Evilution::Mutator::Operator::ToIToInteger < Evilution::Mutator::Base
29
29
  end
30
30
 
31
31
  def rewrite(node)
32
- arguments = node.arguments ? node.arguments.arguments : []
32
+ arguments = argument_nodes(node)
33
33
  return if arguments.length > 1 || arguments.any?(Prism::SplatNode)
34
34
 
35
35
  parts = [node.receiver, *arguments].map { |part| source_of(part) }
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../operator"
4
+
5
+ # Replace a variable read with `nil`: a local, an instance, class or global
6
+ # variable, or `self`.
7
+ #
8
+ # A survivor means the value read never reaches an assertion — the tests
9
+ # would pass whatever the variable held. Where the read is passed as a
10
+ # positional argument or interpolated into a string, the mutant is the one
11
+ # argument_nil_substitution or string_interpolation already emits, and the
12
+ # two are counted once.
13
+ #
14
+ # Positions where `nil` says nothing new are skipped:
15
+ #
16
+ # - the receiver of a call, which turns into `nil.name` — a NoMethodError
17
+ # any test that reaches it kills — and likewise the receiver of a write
18
+ # (`counter.total += 1`, `cache[key] ||= value`);
19
+ # - a void statement (every statement of a body except the last), which
20
+ # statement_deletion deletes;
21
+ # - the condition of an `if`, `unless`, `while` or `until`, where `nil` is
22
+ # the `false` conditional_negation already tries;
23
+ # - the left operand of `&&` or `||`, where `nil && b` is the whole
24
+ # expression turned to nil and `nil || b` is `b` promoted;
25
+ # - a block-pass argument, `&nil` being the call without its block;
26
+ # - a double-splatted read, `**nil` raising before Ruby 3.4;
27
+ # - the value a shorthand hash (`name:`) leaves unwritten.
28
+ class Evilution::Mutator::Operator::VariableReadToNil < Evilution::Mutator::Base
29
+ include Evilution::Mutator::ReceiverPositions
30
+
31
+ def call(subject, **)
32
+ @skipped_nodes = Set.new
33
+ super
34
+ end
35
+
36
+ def visit_statements_node(node)
37
+ @skipped_nodes.merge(node.body[...-1])
38
+ super
39
+ end
40
+
41
+ def visit_if_node(node)
42
+ @skipped_nodes.add(node.predicate)
43
+ super
44
+ end
45
+
46
+ def visit_unless_node(node)
47
+ @skipped_nodes.add(node.predicate)
48
+ super
49
+ end
50
+
51
+ def visit_while_node(node)
52
+ @skipped_nodes.add(node.predicate)
53
+ super
54
+ end
55
+
56
+ def visit_until_node(node)
57
+ @skipped_nodes.add(node.predicate)
58
+ super
59
+ end
60
+
61
+ def visit_and_node(node)
62
+ @skipped_nodes.add(node.left)
63
+ super
64
+ end
65
+
66
+ def visit_or_node(node)
67
+ @skipped_nodes.add(node.left)
68
+ super
69
+ end
70
+
71
+ def visit_block_argument_node(node)
72
+ @skipped_nodes.add(node.expression)
73
+ super
74
+ end
75
+
76
+ def visit_assoc_splat_node(node)
77
+ @skipped_nodes.add(node.value)
78
+ super
79
+ end
80
+
81
+ # The read inside has no source of its own: it shares the key's.
82
+ def visit_implicit_node(_node); end
83
+
84
+ def visit_local_variable_read_node(node)
85
+ nilify(node)
86
+ end
87
+
88
+ def visit_instance_variable_read_node(node)
89
+ nilify(node)
90
+ end
91
+
92
+ def visit_class_variable_read_node(node)
93
+ nilify(node)
94
+ end
95
+
96
+ def visit_global_variable_read_node(node)
97
+ nilify(node)
98
+ end
99
+
100
+ def visit_self_node(node)
101
+ nilify(node)
102
+ end
103
+
104
+ private
105
+
106
+ def receiver_found(node)
107
+ @skipped_nodes.add(node)
108
+ end
109
+
110
+ def nilify(node)
111
+ mutate_to_nil(node) unless @skipped_nodes.include?(node)
112
+ end
113
+ end