evilution 0.27.0 → 0.29.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 (125) hide show
  1. checksums.yaml +4 -4
  2. data/.beads/interactions.jsonl +65 -0
  3. data/.rubocop_todo.yml +0 -1
  4. data/CHANGELOG.md +39 -0
  5. data/README.md +19 -0
  6. data/lib/evilution/ast/constant_names.rb +28 -11
  7. data/lib/evilution/ast/pattern/parser.rb +29 -17
  8. data/lib/evilution/baseline.rb +5 -4
  9. data/lib/evilution/cli/commands/session_diff.rb +6 -4
  10. data/lib/evilution/cli/commands/subjects.rb +6 -3
  11. data/lib/evilution/cli/commands/util_mutation.rb +24 -19
  12. data/lib/evilution/cli/parser/command_extractor.rb +9 -11
  13. data/lib/evilution/cli/parser/file_args.rb +3 -1
  14. data/lib/evilution/cli/parser/options_builder.rb +36 -1
  15. data/lib/evilution/cli/parser/stdin_reader.rb +2 -2
  16. data/lib/evilution/cli/parser.rb +18 -20
  17. data/lib/evilution/cli/printers/environment.rb +19 -19
  18. data/lib/evilution/cli/printers/session_diff.rb +8 -8
  19. data/lib/evilution/compare/diff_extractor/evilution.rb +22 -0
  20. data/lib/evilution/compare/diff_extractor/mutant.rb +30 -0
  21. data/lib/evilution/compare/diff_extractor.rb +6 -0
  22. data/lib/evilution/compare/fingerprint.rb +15 -72
  23. data/lib/evilution/compare/line_normalizer.rb +72 -0
  24. data/lib/evilution/compare/normalizer.rb +27 -9
  25. data/lib/evilution/config/validators/profile.rb +11 -0
  26. data/lib/evilution/config.rb +49 -32
  27. data/lib/evilution/disable_comment.rb +21 -12
  28. data/lib/evilution/integration/crash_detector.rb +2 -2
  29. data/lib/evilution/integration/loading/mutation_applier.rb +17 -12
  30. data/lib/evilution/integration/loading/source_evaluator.rb +6 -2
  31. data/lib/evilution/integration/minitest.rb +25 -16
  32. data/lib/evilution/integration/minitest_crash_detector.rb +2 -2
  33. data/lib/evilution/integration/rspec/state_guard/object_space_example_groups.rb +11 -3
  34. data/lib/evilution/integration/rspec.rb +4 -0
  35. data/lib/evilution/isolation/fork.rb +43 -28
  36. data/lib/evilution/isolation/in_process.rb +10 -6
  37. data/lib/evilution/mcp/info_tool/actions/subjects.rb +32 -23
  38. data/lib/evilution/mcp/info_tool/actions/tests.rb +22 -12
  39. data/lib/evilution/mcp/info_tool/request_parser.rb +3 -1
  40. data/lib/evilution/mcp/info_tool.rb +7 -3
  41. data/lib/evilution/mcp/mutate_tool/option_parser.rb +3 -1
  42. data/lib/evilution/mcp/mutate_tool/progress_streamer.rb +5 -1
  43. data/lib/evilution/mcp/mutate_tool/survived_enricher.rb +19 -9
  44. data/lib/evilution/mcp/mutate_tool.rb +27 -14
  45. data/lib/evilution/mcp/session_tool.rb +27 -20
  46. data/lib/evilution/mutation.rb +60 -42
  47. data/lib/evilution/mutator/base.rb +23 -21
  48. data/lib/evilution/mutator/operator/argument_nil_substitution.rb +11 -14
  49. data/lib/evilution/mutator/operator/argument_removal.rb +11 -14
  50. data/lib/evilution/mutator/operator/begin_unwrap.rb +17 -5
  51. data/lib/evilution/mutator/operator/bitwise_complement.rb +26 -19
  52. data/lib/evilution/mutator/operator/block_param_removal.rb +18 -8
  53. data/lib/evilution/mutator/operator/block_pass_removal.rb +19 -15
  54. data/lib/evilution/mutator/operator/case_when.rb +7 -5
  55. data/lib/evilution/mutator/operator/conditional_branch.rb +22 -22
  56. data/lib/evilution/mutator/operator/equality_to_identity.rb +8 -3
  57. data/lib/evilution/mutator/operator/explicit_super_mutation.rb +17 -13
  58. data/lib/evilution/mutator/operator/index_to_at.rb +5 -4
  59. data/lib/evilution/mutator/operator/index_to_dig.rb +12 -6
  60. data/lib/evilution/mutator/operator/index_to_fetch.rb +5 -4
  61. data/lib/evilution/mutator/operator/keyword_argument.rb +30 -25
  62. data/lib/evilution/mutator/operator/mixin_removal.rb +20 -14
  63. data/lib/evilution/mutator/operator/multiple_assignment.rb +12 -13
  64. data/lib/evilution/mutator/operator/predicate_to_nil.rb +20 -0
  65. data/lib/evilution/mutator/operator/receiver_replacement.rb +9 -6
  66. data/lib/evilution/mutator/operator/regex_simplification.rb +62 -67
  67. data/lib/evilution/mutator/operator/rescue_body_replacement.rb +9 -8
  68. data/lib/evilution/mutator/operator/rescue_removal.rb +4 -7
  69. data/lib/evilution/mutator/operator/superclass_removal.rb +21 -15
  70. data/lib/evilution/mutator/registry.rb +20 -0
  71. data/lib/evilution/parallel/work_queue/channel/frame.rb +5 -1
  72. data/lib/evilution/parallel/work_queue/dispatcher.rb +15 -8
  73. data/lib/evilution/parallel/work_queue/worker/loop.rb +1 -1
  74. data/lib/evilution/parallel/work_queue/worker.rb +10 -7
  75. data/lib/evilution/parallel/work_queue.rb +35 -18
  76. data/lib/evilution/process_cleanup.rb +19 -0
  77. data/lib/evilution/reporter/cli/item_formatters/coverage_gap.rb +13 -8
  78. data/lib/evilution/reporter/cli/line_formatters/mutations.rb +17 -8
  79. data/lib/evilution/reporter/html/baseline_keys.rb +1 -1
  80. data/lib/evilution/reporter/html/diff_formatter.rb +1 -1
  81. data/lib/evilution/reporter/html/escape.rb +1 -1
  82. data/lib/evilution/reporter/html/section.rb +1 -1
  83. data/lib/evilution/reporter/html/sections.rb +4 -2
  84. data/lib/evilution/reporter/html/stylesheet.rb +1 -1
  85. data/lib/evilution/reporter/html.rb +8 -3
  86. data/lib/evilution/reporter/json.rb +52 -18
  87. data/lib/evilution/reporter/suggestion/diff_helpers.rb +0 -13
  88. data/lib/evilution/reporter/suggestion/diff_lines.rb +28 -0
  89. data/lib/evilution/reporter/suggestion/registry.rb +1 -5
  90. data/lib/evilution/reporter/suggestion/templates/generic.rb +1 -1
  91. data/lib/evilution/reporter/suggestion/templates/minitest.rb +361 -649
  92. data/lib/evilution/reporter/suggestion/templates/rspec.rb +362 -603
  93. data/lib/evilution/reporter/suggestion/templates.rb +6 -0
  94. data/lib/evilution/result/error_info.rb +20 -0
  95. data/lib/evilution/result/memory_stats.rb +20 -0
  96. data/lib/evilution/result/mutation_result.rb +30 -14
  97. data/lib/evilution/runner/baseline_runner.rb +16 -10
  98. data/lib/evilution/runner/diagnostics.rb +14 -11
  99. data/lib/evilution/runner/isolation_resolver.rb +12 -11
  100. data/lib/evilution/runner/mutation_executor/mutation_runner.rb +1 -3
  101. data/lib/evilution/runner/mutation_executor/neutralization_pipeline.rb +1 -2
  102. data/lib/evilution/runner/mutation_executor/neutralizer/baseline_failed.rb +3 -10
  103. data/lib/evilution/runner/mutation_executor/neutralizer/infra_error.rb +3 -10
  104. data/lib/evilution/runner/mutation_executor/neutralizer.rb +11 -0
  105. data/lib/evilution/runner/mutation_executor/result_cache.rb +4 -4
  106. data/lib/evilution/runner/mutation_executor/result_notifier.rb +1 -3
  107. data/lib/evilution/runner/mutation_executor/result_packer.rb +11 -9
  108. data/lib/evilution/runner/mutation_executor/strategy/parallel.rb +33 -13
  109. data/lib/evilution/runner/mutation_executor/strategy/sequential.rb +2 -4
  110. data/lib/evilution/runner/mutation_executor/strategy.rb +11 -0
  111. data/lib/evilution/runner/mutation_executor.rb +14 -20
  112. data/lib/evilution/runner/mutation_planner.rb +38 -19
  113. data/lib/evilution/runner/report_publisher.rb +1 -2
  114. data/lib/evilution/runner/subject_pipeline.rb +22 -13
  115. data/lib/evilution/runner.rb +36 -34
  116. data/lib/evilution/session/diff.rb +15 -6
  117. data/lib/evilution/spec_ast_cache.rb +26 -12
  118. data/lib/evilution/version.rb +1 -1
  119. data/lib/evilution.rb +1 -0
  120. data/script/memory_check +14 -6
  121. data/scripts/benchmark_density +10 -9
  122. data/scripts/compare_mutations +38 -21
  123. data/scripts/mutant_json_adapter +7 -4
  124. metadata +15 -3
  125. data/lib/evilution/reporter/html/namespace.rb +0 -11
@@ -23,25 +23,29 @@ class Evilution::Mutator::Operator::ExplicitSuperMutation < Evilution::Mutator::
23
23
  end
24
24
 
25
25
  def mutate_arguments(node, args)
26
- # Remove all arguments: super(a, b) -> super()
26
+ emit_remove_all_args(node)
27
+ return unless args.length >= 2
28
+
29
+ args.each_index { |i| emit_remove_arg_at(node, args, i) }
30
+ end
31
+
32
+ # super(a, b) -> super()
33
+ def emit_remove_all_args(node)
27
34
  add_mutation(
28
35
  offset: node.arguments.location.start_offset,
29
36
  length: node.arguments.location.length,
30
37
  replacement: "",
31
38
  node: node
32
39
  )
40
+ end
33
41
 
34
- return unless args.length >= 2
35
-
36
- # Remove individual arguments
37
- args.each_index do |i|
38
- remaining = args.each_with_index.filter_map { |a, j| a.slice if j != i }
39
- add_mutation(
40
- offset: node.arguments.location.start_offset,
41
- length: node.arguments.location.length,
42
- replacement: remaining.join(", "),
43
- node: node
44
- )
45
- end
42
+ def emit_remove_arg_at(node, args, i)
43
+ remaining = args.each_with_index.filter_map { |a, j| a.slice if j != i }
44
+ add_mutation(
45
+ offset: node.arguments.location.start_offset,
46
+ length: node.arguments.location.length,
47
+ replacement: remaining.join(", "),
48
+ node: node
49
+ )
46
50
  end
47
51
  end
@@ -5,13 +5,10 @@ require_relative "../operator"
5
5
  class Evilution::Mutator::Operator::IndexToAt < Evilution::Mutator::Base
6
6
  def visit_call_node(node)
7
7
  if indexable?(node)
8
- receiver_source = @file_source.byteslice(node.receiver.location.start_offset, node.receiver.location.length)
9
- arg_source = @file_source.byteslice(node.arguments.location.start_offset, node.arguments.location.length)
10
-
11
8
  add_mutation(
12
9
  offset: node.location.start_offset,
13
10
  length: node.location.length,
14
- replacement: "#{receiver_source}.at(#{arg_source})",
11
+ replacement: "#{loc_text(node.receiver.location)}.at(#{loc_text(node.arguments.location)})",
15
12
  node: node
16
13
  )
17
14
  end
@@ -21,6 +18,10 @@ class Evilution::Mutator::Operator::IndexToAt < Evilution::Mutator::Base
21
18
 
22
19
  private
23
20
 
21
+ def loc_text(loc)
22
+ @file_source.byteslice(loc.start_offset, loc.length)
23
+ end
24
+
24
25
  def indexable?(node)
25
26
  node.name == :[] &&
26
27
  node.receiver &&
@@ -3,6 +3,9 @@
3
3
  require_relative "../operator"
4
4
 
5
5
  class Evilution::Mutator::Operator::IndexToDig < Evilution::Mutator::Base
6
+ Chain = Data.define(:root, :args)
7
+ private_constant :Chain
8
+
6
9
  def initialize(**options)
7
10
  super
8
11
  @consumed = Set.new
@@ -10,14 +13,11 @@ class Evilution::Mutator::Operator::IndexToDig < Evilution::Mutator::Base
10
13
 
11
14
  def visit_call_node(node)
12
15
  if chain_head?(node)
13
- root, args = collect_chain(node)
14
- root_source = byteslice_source(root.location.start_offset, root.location.length)
15
- arg_sources = args.map { |a| byteslice_source(a.location.start_offset, a.location.length) }
16
-
16
+ chain = collect_chain(node)
17
17
  add_mutation(
18
18
  offset: node.location.start_offset,
19
19
  length: node.location.length,
20
- replacement: "#{root_source}.dig(#{arg_sources.join(", ")})",
20
+ replacement: dig_replacement(chain),
21
21
  node: node
22
22
  )
23
23
  end
@@ -27,6 +27,12 @@ class Evilution::Mutator::Operator::IndexToDig < Evilution::Mutator::Base
27
27
 
28
28
  private
29
29
 
30
+ def dig_replacement(chain)
31
+ root_source = byteslice_source(chain.root.location.start_offset, chain.root.location.length)
32
+ arg_sources = chain.args.map { |a| byteslice_source(a.location.start_offset, a.location.length) }
33
+ "#{root_source}.dig(#{arg_sources.join(", ")})"
34
+ end
35
+
30
36
  def chain_head?(node)
31
37
  return false if @consumed.include?(node.object_id)
32
38
  return false unless single_arg_index?(node)
@@ -53,6 +59,6 @@ class Evilution::Mutator::Operator::IndexToDig < Evilution::Mutator::Base
53
59
  current = current.receiver
54
60
  end
55
61
 
56
- [current, args]
62
+ Chain.new(root: current, args: args)
57
63
  end
58
64
  end
@@ -5,13 +5,10 @@ require_relative "../operator"
5
5
  class Evilution::Mutator::Operator::IndexToFetch < Evilution::Mutator::Base
6
6
  def visit_call_node(node)
7
7
  if indexable?(node)
8
- receiver_source = byteslice_source(node.receiver.location.start_offset, node.receiver.location.length)
9
- arg_source = byteslice_source(node.arguments.location.start_offset, node.arguments.location.length)
10
-
11
8
  add_mutation(
12
9
  offset: node.location.start_offset,
13
10
  length: node.location.length,
14
- replacement: "#{receiver_source}.fetch(#{arg_source})",
11
+ replacement: "#{loc_text(node.receiver.location)}.fetch(#{loc_text(node.arguments.location)})",
15
12
  node: node
16
13
  )
17
14
  end
@@ -21,6 +18,10 @@ class Evilution::Mutator::Operator::IndexToFetch < Evilution::Mutator::Base
21
18
 
22
19
  private
23
20
 
21
+ def loc_text(loc)
22
+ byteslice_source(loc.start_offset, loc.length)
23
+ end
24
+
24
25
  def indexable?(node)
25
26
  node.name == :[] &&
26
27
  node.receiver &&
@@ -56,36 +56,41 @@ class Evilution::Mutator::Operator::KeywordArgument < Evilution::Mutator::Base
56
56
  return unless kr.is_a?(Prism::KeywordRestParameterNode)
57
57
 
58
58
  all_params = collect_all_params(params)
59
-
60
59
  if all_params.length < 2
61
- add_mutation(
62
- offset: kr.location.start_offset,
63
- length: kr.location.length,
64
- replacement: "",
65
- node: kr
66
- )
60
+ emit_remove_only_kr(kr)
67
61
  else
68
- remaining = all_params.reject { |p| p.equal?(kr) }
69
- replacement = remaining.map(&:slice).join(", ")
70
-
71
- add_mutation(
72
- offset: params.location.start_offset,
73
- length: params.location.length,
74
- replacement: replacement,
75
- node: kr
76
- )
62
+ emit_remove_kr_with_remaining(params, all_params, kr)
77
63
  end
78
64
  end
79
65
 
66
+ def emit_remove_only_kr(kr)
67
+ add_mutation(
68
+ offset: kr.location.start_offset,
69
+ length: kr.location.length,
70
+ replacement: "",
71
+ node: kr
72
+ )
73
+ end
74
+
75
+ def emit_remove_kr_with_remaining(params, all_params, kr)
76
+ remaining = all_params.reject { |p| p.equal?(kr) }
77
+ add_mutation(
78
+ offset: params.location.start_offset,
79
+ length: params.location.length,
80
+ replacement: remaining.map(&:slice).join(", "),
81
+ node: kr
82
+ )
83
+ end
84
+
80
85
  def collect_all_params(params)
81
- result = []
82
- result.concat(params.requireds)
83
- result.concat(params.optionals)
84
- result << params.rest if params.rest
85
- result.concat(params.posts)
86
- result.concat(params.keywords)
87
- result << params.keyword_rest if params.keyword_rest
88
- result << params.block if params.block
89
- result
86
+ [
87
+ *params.requireds,
88
+ *params.optionals,
89
+ params.rest,
90
+ *params.posts,
91
+ *params.keywords,
92
+ params.keyword_rest,
93
+ params.block
94
+ ].compact
90
95
  end
91
96
  end
@@ -13,27 +13,33 @@ class Evilution::Mutator::Operator::MixinRemoval < Evilution::Mutator::Base
13
13
  @mutations = []
14
14
  @filter = filter
15
15
 
16
- tree = self.class.parsed_tree_for(subject.file_path, @file_source)
17
- enclosing = find_enclosing_scope(tree, subject.line_number)
16
+ enclosing = find_target_scope(subject)
18
17
  return @mutations unless enclosing
19
18
 
20
- first_method_line = find_first_method_line(enclosing)
21
- return @mutations unless first_method_line == subject.line_number
22
-
23
- find_mixin_calls(enclosing).each do |call_node|
24
- add_mutation(
25
- offset: call_node.location.start_offset,
26
- length: call_node.location.length,
27
- replacement: "",
28
- node: call_node
29
- )
30
- end
31
-
19
+ find_mixin_calls(enclosing).each { |call_node| emit_mixin_removal(call_node) }
32
20
  @mutations
33
21
  end
34
22
 
35
23
  private
36
24
 
25
+ def find_target_scope(subject)
26
+ tree = self.class.parsed_tree_for(subject.file_path, @file_source)
27
+ enclosing = find_enclosing_scope(tree, subject.line_number)
28
+ return nil unless enclosing
29
+ return nil unless find_first_method_line(enclosing) == subject.line_number
30
+
31
+ enclosing
32
+ end
33
+
34
+ def emit_mixin_removal(call_node)
35
+ add_mutation(
36
+ offset: call_node.location.start_offset,
37
+ length: call_node.location.length,
38
+ replacement: "",
39
+ node: call_node
40
+ )
41
+ end
42
+
37
43
  def find_enclosing_scope(tree, target_line)
38
44
  finder = ScopeFinder.new(target_line)
39
45
  finder.visit(tree)
@@ -18,19 +18,18 @@ class Evilution::Mutator::Operator::MultipleAssignment < Evilution::Mutator::Bas
18
18
  private
19
19
 
20
20
  def mutate_target_removal(node, lefts, values)
21
- lefts.each_index do |i|
22
- remaining_lefts = lefts.each_with_index.filter_map { |l, j| l.slice if j != i }
23
- remaining_values = values.each_with_index.filter_map { |v, j| v.slice if j != i }
24
-
25
- replacement = "#{remaining_lefts.join(", ")} = #{remaining_values.join(", ")}"
26
-
27
- add_mutation(
28
- offset: node.location.start_offset,
29
- length: node.location.length,
30
- replacement: replacement,
31
- node: node
32
- )
33
- end
21
+ lefts.each_index { |i| emit_target_removal_at(node, lefts, values, i) }
22
+ end
23
+
24
+ def emit_target_removal_at(node, lefts, values, i)
25
+ remaining_lefts = lefts.each_with_index.filter_map { |l, j| l.slice if j != i }
26
+ remaining_values = values.each_with_index.filter_map { |v, j| v.slice if j != i }
27
+ add_mutation(
28
+ offset: node.location.start_offset,
29
+ length: node.location.length,
30
+ replacement: "#{remaining_lefts.join(", ")} = #{remaining_values.join(", ")}",
31
+ node: node
32
+ )
34
33
  end
35
34
 
36
35
  def mutate_swap(node, lefts, values)
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../operator"
4
+
5
+ class Evilution::Mutator::Operator::PredicateToNil < Evilution::Mutator::Base
6
+ def visit_call_node(node)
7
+ if node.name.to_s.end_with?("?")
8
+ loc = node.location
9
+
10
+ add_mutation(
11
+ offset: loc.start_offset,
12
+ length: loc.length,
13
+ replacement: "nil",
14
+ node: node
15
+ )
16
+ end
17
+
18
+ super
19
+ end
20
+ end
@@ -5,19 +5,22 @@ require_relative "../operator"
5
5
  class Evilution::Mutator::Operator::ReceiverReplacement < Evilution::Mutator::Base
6
6
  def visit_call_node(node)
7
7
  if node.receiver.is_a?(Prism::SelfNode)
8
- call_without_self = @file_source.byteslice(
9
- node.message_loc.start_offset,
10
- node.location.start_offset + node.location.length - node.message_loc.start_offset
11
- )
12
-
13
8
  add_mutation(
14
9
  offset: node.location.start_offset,
15
10
  length: node.location.length,
16
- replacement: call_without_self,
11
+ replacement: call_without_self_text(node),
17
12
  node: node
18
13
  )
19
14
  end
20
15
 
21
16
  super
22
17
  end
18
+
19
+ private
20
+
21
+ def call_without_self_text(node)
22
+ message_start = node.message_loc.start_offset
23
+ call_end = node.location.start_offset + node.location.length
24
+ @file_source.byteslice(message_start, call_end - message_start)
25
+ end
23
26
  end
@@ -19,31 +19,21 @@ class Evilution::Mutator::Operator::RegexSimplification < Evilution::Mutator::Ba
19
19
  private
20
20
 
21
21
  def remove_quantifiers(node, content, content_offset)
22
- i = 0
23
- while i < content.length
24
- if content[i] == "\\"
25
- i += 2
26
- next
22
+ scan_regex_positions(content) do |kind, i|
23
+ case kind
24
+ when :backslash then 2
25
+ when :class_open then class_skip(content, i)
26
+ when :char then emit_quantifier_at(node, content, content_offset, i)
27
27
  end
28
+ end
29
+ end
28
30
 
29
- if content[i] == "["
30
- i = skip_character_class(content, i)
31
- next
32
- end
31
+ def emit_quantifier_at(node, content, content_offset, i)
32
+ match = match_quantifier(content, i)
33
+ return 1 if match.nil?
33
34
 
34
- match = match_quantifier(content, i)
35
- if match
36
- add_mutation(
37
- offset: content_offset + i,
38
- length: match.length,
39
- replacement: "",
40
- node: node
41
- )
42
- i += match.length
43
- else
44
- i += 1
45
- end
46
- end
35
+ add_mutation(offset: content_offset + i, length: match.length, replacement: "", node: node)
36
+ match.length
47
37
  end
48
38
 
49
39
  def match_quantifier(content, pos)
@@ -58,40 +48,29 @@ class Evilution::Mutator::Operator::RegexSimplification < Evilution::Mutator::Ba
58
48
  end
59
49
 
60
50
  def remove_anchors(node, content, content_offset)
61
- i = 0
62
- while i < content.length
63
- if content[i] == "\\"
64
- anchor = match_backslash_anchor(content, i)
65
- if anchor
66
- add_mutation(
67
- offset: content_offset + i,
68
- length: anchor.length,
69
- replacement: "",
70
- node: node
71
- )
72
- i += anchor.length
73
- else
74
- i += 2
75
- end
76
- next
51
+ scan_regex_positions(content) do |kind, i|
52
+ case kind
53
+ when :backslash then try_emit_backslash_anchor(node, content, content_offset, i)
54
+ when :class_open then class_skip(content, i)
55
+ when :char
56
+ try_emit_caret_dollar(node, content, content_offset, i)
57
+ 1
77
58
  end
59
+ end
60
+ end
78
61
 
79
- if content[i] == "["
80
- i = skip_character_class(content, i)
81
- next
82
- end
62
+ def try_emit_backslash_anchor(node, content, content_offset, i)
63
+ anchor = match_backslash_anchor(content, i)
64
+ return 2 if anchor.nil?
83
65
 
84
- if %w[^ $].include?(content[i])
85
- add_mutation(
86
- offset: content_offset + i,
87
- length: 1,
88
- replacement: "",
89
- node: node
90
- )
91
- end
66
+ add_mutation(offset: content_offset + i, length: anchor.length, replacement: "", node: node)
67
+ anchor.length
68
+ end
92
69
 
93
- i += 1
94
- end
70
+ def try_emit_caret_dollar(node, content, content_offset, i)
71
+ return unless %w[^ $].include?(content[i])
72
+
73
+ add_mutation(offset: content_offset + i, length: 1, replacement: "", node: node)
95
74
  end
96
75
 
97
76
  def match_backslash_anchor(content, pos)
@@ -104,18 +83,13 @@ class Evilution::Mutator::Operator::RegexSimplification < Evilution::Mutator::Ba
104
83
  end
105
84
 
106
85
  def remove_character_class_ranges(node, content, content_offset)
107
- i = 0
108
- while i < content.length
109
- if content[i] == "\\"
110
- i += 2
111
- next
112
- end
113
-
114
- if content[i] == "["
86
+ scan_regex_positions(content) do |kind, i|
87
+ case kind
88
+ when :backslash then 2
89
+ when :class_open
115
90
  scan_ranges_in_class(node, content, content_offset, i)
116
- i = skip_character_class(content, i)
117
- else
118
- i += 1
91
+ class_skip(content, i)
92
+ when :char then 1
119
93
  end
120
94
  end
121
95
  end
@@ -153,17 +127,38 @@ class Evilution::Mutator::Operator::RegexSimplification < Evilution::Mutator::Ba
153
127
  )
154
128
  end
155
129
 
130
+ # Walks `content` yielding (kind, position) for each significant token:
131
+ # :backslash for an escape sequence, :class_open for `[`, :char for any
132
+ # other byte. The block returns the number of characters to advance from
133
+ # `position` — callers decide how to handle each case (skip, emit a
134
+ # mutation, descend into a character class, etc.).
135
+ def scan_regex_positions(content)
136
+ i = 0
137
+ while i < content.length
138
+ advance = case content[i]
139
+ when "\\" then yield(:backslash, i)
140
+ when "[" then yield(:class_open, i)
141
+ else yield(:char, i)
142
+ end
143
+ i += advance
144
+ end
145
+ end
146
+
147
+ def class_skip(content, pos)
148
+ skip_character_class(content, pos) - pos
149
+ end
150
+
156
151
  def skip_character_class(content, pos)
157
- i = pos + 1
158
- i += 1 if i < content.length && content[i] == "^"
159
- i += 1 if i < content.length && content[i] == "]"
152
+ scan_to_class_close(content, skip_class_prefix(content, pos))
153
+ end
160
154
 
155
+ def scan_to_class_close(content, start)
156
+ i = start
161
157
  while i < content.length
162
158
  return i + 1 if content[i] == "]"
163
159
 
164
160
  i += content[i] == "\\" ? 2 : 1
165
161
  end
166
-
167
162
  i
168
163
  end
169
164
  end
@@ -72,14 +72,15 @@ class Evilution::Mutator::Operator::RescueBodyReplacement < Evilution::Mutator::
72
72
  end
73
73
 
74
74
  def rescue_line_end(node)
75
- if node.reference
76
- node.reference.location.start_offset + node.reference.location.length
77
- elsif node.exceptions.any?
78
- last_exc = node.exceptions.last
79
- last_exc.location.start_offset + last_exc.location.length
80
- else
81
- node.keyword_loc.start_offset + node.keyword_loc.length
82
- end
75
+ loc = if node.reference
76
+ node.reference.location
77
+ elsif node.exceptions.any?
78
+ node.exceptions.last.location
79
+ else
80
+ node.keyword_loc
81
+ end
82
+
83
+ loc.start_offset + loc.length
83
84
  end
84
85
 
85
86
  def indentation_of(offset)
@@ -20,13 +20,10 @@ class Evilution::Mutator::Operator::RescueRemoval < Evilution::Mutator::Base
20
20
  private
21
21
 
22
22
  def rescue_end_offset(node)
23
- if node.subsequent
24
- line_start_before(node.subsequent.keyword_loc.start_offset)
25
- elsif node.statements
26
- node.statements.location.start_offset + node.statements.location.length
27
- else
28
- node.keyword_loc.start_offset + node.keyword_loc.length
29
- end
23
+ return line_start_before(node.subsequent.keyword_loc.start_offset) if node.subsequent
24
+
25
+ loc = node.statements ? node.statements.location : node.keyword_loc
26
+ loc.start_offset + loc.length
30
27
  end
31
28
 
32
29
  def line_start_before(offset)
@@ -11,29 +11,35 @@ class Evilution::Mutator::Operator::SuperclassRemoval < Evilution::Mutator::Base
11
11
  @mutations = []
12
12
  @filter = filter
13
13
 
14
- tree = self.class.parsed_tree_for(subject.file_path, @file_source)
15
- enclosing = find_enclosing_class(tree, subject.line_number)
14
+ enclosing = find_target_class(subject)
16
15
  return @mutations unless enclosing
17
- return @mutations unless enclosing.superclass
18
-
19
- first_method_line = find_first_method_line(enclosing)
20
- return @mutations unless first_method_line == subject.line_number
21
16
 
22
- name_end = enclosing.constant_path.location.start_offset + enclosing.constant_path.location.length
23
- superclass_end = enclosing.superclass.location.start_offset + enclosing.superclass.location.length
24
-
25
- add_mutation(
26
- offset: name_end,
27
- length: superclass_end - name_end,
28
- replacement: "",
29
- node: enclosing
30
- )
17
+ offset, length = superclass_range(enclosing)
18
+ add_mutation(offset: offset, length: length, replacement: "", node: enclosing)
31
19
 
32
20
  @mutations
33
21
  end
34
22
 
35
23
  private
36
24
 
25
+ def find_target_class(subject)
26
+ 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
+
31
+ enclosing
32
+ end
33
+
34
+ def superclass_range(class_node)
35
+ name_loc = class_node.constant_path.location
36
+ superclass_loc = class_node.superclass.location
37
+ name_end = name_loc.start_offset + name_loc.length
38
+ superclass_end = superclass_loc.start_offset + superclass_loc.length
39
+
40
+ [name_end, superclass_end - name_end]
41
+ end
42
+
37
43
  def find_enclosing_class(tree, target_line)
38
44
  finder = ClassFinder.new(target_line)
39
45
  finder.visit(tree)
@@ -3,6 +3,26 @@
3
3
  require_relative "../mutator"
4
4
 
5
5
  class Evilution::Mutator::Registry
6
+ STRICT_EXTRA_OPERATORS = [
7
+ Evilution::Mutator::Operator::PredicateToNil
8
+ ].freeze
9
+
10
+ def self.for_profile(profile)
11
+ unless profile.is_a?(Symbol) || profile.is_a?(String)
12
+ raise ArgumentError, "unknown profile: #{profile.inspect} (expected :default or :strict)"
13
+ end
14
+
15
+ case profile.to_sym
16
+ when :default then default
17
+ when :strict
18
+ registry = default
19
+ STRICT_EXTRA_OPERATORS.each { |op| registry.register(op) }
20
+ registry
21
+ else
22
+ raise ArgumentError, "unknown profile: #{profile.inspect} (expected :default or :strict)"
23
+ end
24
+ end
25
+
6
26
  def self.default
7
27
  registry = new
8
28
  [
@@ -10,12 +10,16 @@ module Evilution::Parallel::WorkQueue::Channel::Frame
10
10
  [payload.bytesize].pack("N") + payload
11
11
  end
12
12
 
13
+ # Marshal.load is safe here: payload originates from a sibling worker the
14
+ # parent itself forked, transferred over a private pipe inside our process
15
+ # tree. No external/untrusted input ever reaches this code. See
16
+ # .rubocop.yml (Security/MarshalLoad) for the full rationale.
13
17
  def decode(header, payload)
14
18
  return nil if header.nil? || header.bytesize < 4
15
19
 
16
20
  length = header.unpack1("N")
17
21
  return nil if payload.nil? || payload.bytesize < length
18
22
 
19
- Marshal.load(payload) # rubocop:disable Security/MarshalLoad
23
+ Marshal.load(payload)
20
24
  end
21
25
  end