evilution 1.1.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 (161) hide show
  1. checksums.yaml +4 -4
  2. data/.beads/interactions.jsonl +58 -0
  3. data/.rubocop_todo.yml +9 -0
  4. data/CHANGELOG.md +86 -0
  5. data/README.md +202 -14
  6. data/docs/architecture.md +54 -5
  7. data/docs/isolation.md +3 -4
  8. data/exe/evil +8 -1
  9. data/exe/evilution +8 -1
  10. data/lib/evilution/ast/local_reads.rb +43 -0
  11. data/lib/evilution/ast/parser.rb +1 -0
  12. data/lib/evilution/ast/pattern/filter.rb +1 -0
  13. data/lib/evilution/ast/pattern/parser.rb +1 -0
  14. data/lib/evilution/ast/pattern.rb +2 -0
  15. data/lib/evilution/ast/sorbet_sig_detector.rb +1 -0
  16. data/lib/evilution/baseline.rb +37 -15
  17. data/lib/evilution/child_output.rb +1 -1
  18. data/lib/evilution/cli/command.rb +1 -0
  19. data/lib/evilution/cli/commands/tests_list.rb +4 -3
  20. data/lib/evilution/cli/commands.rb +2 -0
  21. data/lib/evilution/cli/dispatcher.rb +2 -0
  22. data/lib/evilution/cli/exit_guard.rb +61 -0
  23. data/lib/evilution/cli/parsed_args.rb +2 -0
  24. data/lib/evilution/cli/parser/command_extractor.rb +2 -0
  25. data/lib/evilution/cli/parser/file_args.rb +2 -0
  26. data/lib/evilution/cli/parser/options_builder.rb +4 -0
  27. data/lib/evilution/cli/parser/stdin_reader.rb +1 -0
  28. data/lib/evilution/cli/parser.rb +1 -0
  29. data/lib/evilution/cli/printers/tests_list.rb +5 -4
  30. data/lib/evilution/cli/printers.rb +2 -0
  31. data/lib/evilution/cli/result.rb +2 -0
  32. data/lib/evilution/cli.rb +6 -0
  33. data/lib/evilution/config/builders.rb +2 -0
  34. data/lib/evilution/config/env_loader.rb +2 -0
  35. data/lib/evilution/config/file_loader.rb +1 -0
  36. data/lib/evilution/config/sources.rb +1 -0
  37. data/lib/evilution/config/validators/example_targeting_cache.rb +1 -0
  38. data/lib/evilution/config/validators/example_targeting_fallback.rb +1 -0
  39. data/lib/evilution/config/validators/example_targeting_strategy.rb +1 -0
  40. data/lib/evilution/config/validators/fail_fast.rb +1 -0
  41. data/lib/evilution/config/validators/hooks.rb +1 -0
  42. data/lib/evilution/config/validators/ignore_patterns.rb +1 -0
  43. data/lib/evilution/config/validators/integration.rb +1 -0
  44. data/lib/evilution/config/validators/isolation.rb +1 -0
  45. data/lib/evilution/config/validators/jobs.rb +1 -0
  46. data/lib/evilution/config/validators/preload.rb +1 -0
  47. data/lib/evilution/config/validators/profile.rb +1 -0
  48. data/lib/evilution/config/validators/spec_mappings.rb +1 -0
  49. data/lib/evilution/config/validators/spec_pattern.rb +1 -0
  50. data/lib/evilution/config/validators/warmup.rb +17 -0
  51. data/lib/evilution/config/validators.rb +2 -0
  52. data/lib/evilution/config.rb +7 -5
  53. data/lib/evilution/coverage.rb +1 -1
  54. data/lib/evilution/coverage_example_filter.rb +1 -1
  55. data/lib/evilution/diagnostic.rb +22 -0
  56. data/lib/evilution/example_filter.rb +1 -1
  57. data/lib/evilution/integration/loading/body_call_neutralizer.rb +10 -2
  58. data/lib/evilution/integration/loading/concern_state_cleaner.rb +20 -4
  59. data/lib/evilution/integration/loading/reeval_warning_filter.rb +72 -0
  60. data/lib/evilution/integration/loading/source_evaluator.rb +4 -1
  61. data/lib/evilution/integration/loading/test_load_path.rb +21 -13
  62. data/lib/evilution/integration/minitest.rb +2 -1
  63. data/lib/evilution/integration/rspec/crash_detector_lifecycle.rb +9 -1
  64. data/lib/evilution/integration/rspec/state_guard/configuration_state.rb +1 -0
  65. data/lib/evilution/integration/rspec/state_guard/configuration_streams.rb +5 -1
  66. data/lib/evilution/integration/rspec/state_guard/example_groups_constants.rb +1 -2
  67. data/lib/evilution/integration/rspec/state_guard/internals.rb +1 -2
  68. data/lib/evilution/integration/rspec/state_guard/object_space_example_groups.rb +1 -2
  69. data/lib/evilution/integration/rspec/state_guard/reporter_arrays.rb +1 -0
  70. data/lib/evilution/integration/rspec/state_guard/world_example_groups.rb +1 -0
  71. data/lib/evilution/integration/rspec/state_guard/world_filtered_examples.rb +1 -0
  72. data/lib/evilution/integration/rspec/state_guard/world_sources_by_path.rb +1 -0
  73. data/lib/evilution/integration/rspec/state_guard.rb +6 -0
  74. data/lib/evilution/integration/rspec/unresolved_spec_warner.rb +2 -1
  75. data/lib/evilution/integration/rspec.rb +48 -2
  76. data/lib/evilution/integration/test_unit/test_file_resolver.rb +2 -1
  77. data/lib/evilution/isolation/fork.rb +29 -8
  78. data/lib/evilution/mcp/info_tool/actions/environment.rb +1 -0
  79. data/lib/evilution/mcp/info_tool/actions/feedback.rb +1 -0
  80. data/lib/evilution/mcp/info_tool/actions/statuses.rb +1 -0
  81. data/lib/evilution/mcp/info_tool/actions/subjects.rb +1 -0
  82. data/lib/evilution/mcp/info_tool/actions/tests.rb +1 -0
  83. data/lib/evilution/mutation.rb +1 -1
  84. data/lib/evilution/mutator/operator/argument_list_removal.rb +52 -0
  85. data/lib/evilution/mutator/operator/argument_propagation.rb +54 -0
  86. data/lib/evilution/mutator/operator/array_coercion_to_literal.rb +34 -0
  87. data/lib/evilution/mutator/operator/attribute_write_to_read.rb +34 -0
  88. data/lib/evilution/mutator/operator/bang_method.rb +11 -3
  89. data/lib/evilution/mutator/operator/binary_operand_promotion.rb +63 -0
  90. data/lib/evilution/mutator/operator/block_body_promotion.rb +45 -0
  91. data/lib/evilution/mutator/operator/block_body_to_nil.rb +52 -0
  92. data/lib/evilution/mutator/operator/block_body_to_raise.rb +34 -0
  93. data/lib/evilution/mutator/operator/block_destructuring_expansion.rb +85 -0
  94. data/lib/evilution/mutator/operator/block_parameter_drop.rb +96 -0
  95. data/lib/evilution/mutator/operator/call_to_nil.rb +34 -0
  96. data/lib/evilution/mutator/operator/coercion_emptying.rb +60 -0
  97. data/lib/evilution/mutator/operator/collection_replacement.rb +19 -9
  98. data/lib/evilution/mutator/operator/comparison_replacement.rb +37 -12
  99. data/lib/evilution/mutator/operator/const_get_to_constant_path.rb +50 -0
  100. data/lib/evilution/mutator/operator/dig_to_fetch_chain.rb +45 -0
  101. data/lib/evilution/mutator/operator/double_negation_removal.rb +22 -0
  102. data/lib/evilution/mutator/operator/dynamic_dispatch_resolution.rb +79 -0
  103. data/lib/evilution/mutator/operator/forwarding_super_to_explicit.rb +71 -0
  104. data/lib/evilution/mutator/operator/inequality_to_negated_identity.rb +43 -0
  105. data/lib/evilution/mutator/operator/keyword_argument_removal.rb +47 -0
  106. data/lib/evilution/mutator/operator/method_body_replacement.rb +10 -1
  107. data/lib/evilution/mutator/operator/method_body_to_raise.rb +59 -0
  108. data/lib/evilution/mutator/operator/method_body_to_super.rb +150 -0
  109. data/lib/evilution/mutator/operator/optional_default_injection.rb +71 -0
  110. data/lib/evilution/mutator/operator/optional_parameter_to_required.rb +41 -0
  111. data/lib/evilution/mutator/operator/proc_to_lambda.rb +43 -0
  112. data/lib/evilution/mutator/operator/receiver_constructor_swap.rb +51 -0
  113. data/lib/evilution/mutator/operator/reduce_to_sum.rb +58 -0
  114. data/lib/evilution/mutator/operator/regexp_anchor_to_predicate.rb +136 -0
  115. data/lib/evilution/mutator/operator/safe_navigation_removal.rb +67 -0
  116. data/lib/evilution/mutator/operator/send_mutation.rb +42 -6
  117. data/lib/evilution/mutator/operator/symbol_to_proc_replacement.rb +59 -0
  118. data/lib/evilution/mutator/operator/to_i_to_integer.rb +38 -0
  119. data/lib/evilution/mutator/operator/typed_default_return.rb +84 -0
  120. data/lib/evilution/mutator/primitives.rb +25 -0
  121. data/lib/evilution/mutator/registry.rb +32 -1
  122. data/lib/evilution/parallel/pool.rb +1 -0
  123. data/lib/evilution/parallel_db_warning.rb +1 -1
  124. data/lib/evilution/process_supervisor.rb +20 -8
  125. data/lib/evilution/rails_warmup.rb +61 -0
  126. data/lib/evilution/reporter/cli/item_formatters/neutral_group.rb +23 -0
  127. data/lib/evilution/reporter/cli/item_formatters/subject_score.rb +42 -0
  128. data/lib/evilution/reporter/cli/item_formatters/subject_score_group.rb +16 -0
  129. data/lib/evilution/reporter/cli/line_formatters/infra_retry_notice.rb +19 -0
  130. data/lib/evilution/reporter/cli/line_formatters/result_line.rb +27 -3
  131. data/lib/evilution/reporter/cli/line_formatters/score.rb +19 -1
  132. data/lib/evilution/reporter/cli/line_formatters/unresolved_targets.rb +35 -0
  133. data/lib/evilution/reporter/cli/metrics_block.rb +4 -0
  134. data/lib/evilution/reporter/cli/trailer.rb +11 -7
  135. data/lib/evilution/reporter/cli.rb +20 -4
  136. data/lib/evilution/reporter/html/report.rb +1 -0
  137. data/lib/evilution/reporter/json/subjects.rb +29 -0
  138. data/lib/evilution/reporter/json.rb +23 -1
  139. data/lib/evilution/result/coverage_gap_grouper.rb +1 -0
  140. data/lib/evilution/result/mutation_result.rb +3 -2
  141. data/lib/evilution/result/neutral_reason.rb +35 -0
  142. data/lib/evilution/result/subject_score.rb +28 -0
  143. data/lib/evilution/result/subject_scorer.rb +38 -0
  144. data/lib/evilution/result/summary.rb +44 -2
  145. data/lib/evilution/runner/baseline_runner.rb +17 -7
  146. data/lib/evilution/runner/canary.rb +52 -5
  147. data/lib/evilution/runner/isolation_resolver.rb +23 -2
  148. data/lib/evilution/runner/mutation_executor/infra_retry.rb +54 -0
  149. data/lib/evilution/runner/mutation_executor/neutralizer/baseline_failed.rb +23 -10
  150. data/lib/evilution/runner/mutation_executor/neutralizer/infra_error.rb +18 -1
  151. data/lib/evilution/runner/mutation_executor/result_cache.rb +11 -0
  152. data/lib/evilution/runner/mutation_executor/strategy/parallel.rb +19 -1
  153. data/lib/evilution/runner/mutation_executor.rb +32 -4
  154. data/lib/evilution/runner/report_publisher.rb +31 -9
  155. data/lib/evilution/runner/target_spec_audit.rb +43 -0
  156. data/lib/evilution/runner.rb +10 -1
  157. data/lib/evilution/source_ast_cache.rb +1 -1
  158. data/lib/evilution/spec_ast_cache.rb +1 -1
  159. data/lib/evilution/version.rb +1 -1
  160. data/lib/evilution.rb +35 -0
  161. metadata +50 -2
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ require_relative "../operator"
6
+
7
+ # Reassign an optional parameter to its own default at the top of the body:
8
+ # `def f(a = 1); body; end` becomes `def f(a = 1); a = 1; body; end`.
9
+ #
10
+ # Whatever a caller passed is overwritten, so the mutant survives only where
11
+ # every example already passes the default — or passes a value the assertions
12
+ # never distinguish from it. Where OptionalParameterToRequired asks whether the
13
+ # default is ever used, this asks the opposite question: whether any other value
14
+ # ever is.
15
+ #
16
+ # The parameter must be read somewhere in the body, otherwise overwriting it
17
+ # changes nothing and the mutant would survive every suite. An endless method is
18
+ # left alone: its body is a single expression, and putting a second statement in
19
+ # front of it would change what the method returns rather than what it computes.
20
+ # KeywordArgument owns optional keyword parameters, and a block's optional
21
+ # parameter is left alone for the same reason it is elsewhere — a block ignores
22
+ # arity, so the default is not a contract.
23
+ class Evilution::Mutator::Operator::OptionalDefaultInjection < Evilution::Mutator::Base
24
+ def visit_def_node(node)
25
+ inject_defaults(node)
26
+ super
27
+ end
28
+
29
+ private
30
+
31
+ def inject_defaults(node)
32
+ return if node.equal_loc
33
+ return unless node.parameters
34
+
35
+ statements = body_statements(node.body)
36
+ return if statements.nil?
37
+
38
+ node.parameters.optionals.each { |optional| inject_default(optional, node.body, statements) }
39
+ end
40
+
41
+ # The read is looked for across the whole body, rescue and ensure clauses
42
+ # included, since a parameter read only in a rescue clause is still read. The
43
+ # injection itself goes ahead of the leading statements, which is the only
44
+ # place a statement can be added.
45
+ def inject_default(optional, body, statements)
46
+ name = optional.name.to_s
47
+ return if name.start_with?("_")
48
+ return unless Evilution::AST::LocalReads.new.call(body, name)
49
+
50
+ first = statements.body.first.location
51
+
52
+ add_mutation(
53
+ offset: first.start_offset,
54
+ length: 0,
55
+ replacement: "#{source_of(optional)}; ",
56
+ node: optional
57
+ )
58
+ end
59
+
60
+ # A method-level rescue/ensure (`def foo; stmts; rescue; ...; end`) makes the
61
+ # body a BeginNode whose location spans the entire `def...end`. The injection
62
+ # belongs ahead of the leading statements; returns nil for a rescue/ensure-only
63
+ # body and for an empty method, whose body is nil. A body that holds no
64
+ # statements is always nil rather than an empty StatementsNode, so a present
65
+ # node always has a first statement to inject ahead of.
66
+ def body_statements(body)
67
+ return body unless body.is_a?(Prism::BeginNode)
68
+
69
+ body.statements
70
+ end
71
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ require_relative "../operator"
6
+
7
+ # Drop the default of an optional positional parameter, making it required:
8
+ # `def f(a = 1)` becomes `def f(a)`.
9
+ #
10
+ # A survivor means no example calls the method without that argument, so the
11
+ # default is never exercised and the value it supplies is unasserted. Callers
12
+ # that pass the argument are unaffected, which is what separates this from a
13
+ # mutation that breaks every call site.
14
+ #
15
+ # Ruby allows a required parameter to follow an optional one, so each optional
16
+ # is mutated on its own and the rest of the signature stays as written.
17
+ # KeywordArgument owns optional keyword parameters. Blocks are left alone: a
18
+ # block ignores arity, so a missing argument arrives as nil rather than raising,
19
+ # and making its parameter required would change nothing.
20
+ class Evilution::Mutator::Operator::OptionalParameterToRequired < Evilution::Mutator::Base
21
+ def visit_def_node(node)
22
+ parameters = node.parameters
23
+ parameters.optionals.each { |optional| require_parameter(optional) } if parameters
24
+
25
+ super
26
+ end
27
+
28
+ private
29
+
30
+ def require_parameter(node)
31
+ location = node.location
32
+ name_loc = node.name_loc
33
+
34
+ add_mutation(
35
+ offset: location.start_offset,
36
+ length: location.length,
37
+ replacement: byteslice_source(name_loc.start_offset, name_loc.length),
38
+ node: node
39
+ )
40
+ end
41
+ 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
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../operator"
4
+
5
+ # Replace a lenient `to_i` with the strict `Integer()` conversion:
6
+ # `value.to_i` becomes `Integer(value)`, `value.to_i(16)` becomes
7
+ # `Integer(value, 16)`.
8
+ #
9
+ # `to_i` never fails — malformed input ("abc", "12abc", "") and nil become 0
10
+ # or a leading-digits prefix — where `Integer()` raises. `Integer()` also
11
+ # honours radix prefixes ("0x1A" is 26, "012" is octal 10) that `to_i`
12
+ # ignores. A survivor means no test feeds invalid or prefixed input.
13
+ #
14
+ # Numeric literal receivers are skipped: both conversions agree on them.
15
+ class Evilution::Mutator::Operator::ToIToInteger < Evilution::Mutator::Base
16
+ NUMERIC_LITERALS = [Prism::IntegerNode, Prism::FloatNode, Prism::RationalNode, Prism::ImaginaryNode].freeze
17
+ private_constant :NUMERIC_LITERALS
18
+
19
+ def visit_call_node(node)
20
+ rewrite(node) if node.name == :to_i && convertible?(node)
21
+ super
22
+ end
23
+
24
+ private
25
+
26
+ def convertible?(node)
27
+ receiver = node.receiver
28
+ receiver && node.block.nil? && NUMERIC_LITERALS.none? { |type| receiver.is_a?(type) }
29
+ end
30
+
31
+ def rewrite(node)
32
+ arguments = node.arguments ? node.arguments.arguments : []
33
+ return if arguments.length > 1 || arguments.any?(Prism::SplatNode)
34
+
35
+ parts = [node.receiver, *arguments].map { |part| source_of(part) }
36
+ replace_span(node: node, target: node, replacement: "Integer(#{parts.join(", ")})")
37
+ end
38
+ end