evilution 0.21.0 → 0.22.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 (52) hide show
  1. checksums.yaml +4 -4
  2. data/.beads/.gitignore +4 -0
  3. data/.beads/interactions.jsonl +16 -0
  4. data/.beads/issues.jsonl +9 -6
  5. data/.claude/settings.json +5 -0
  6. data/CHANGELOG.md +35 -0
  7. data/README.md +28 -13
  8. data/comparison_results/baseline_2026-04-09.md +35 -0
  9. data/comparison_results/operator_classification.md +79 -0
  10. data/comparison_results/operator_prioritization.md +68 -0
  11. data/docs/mutation_density_benchmark.md +91 -0
  12. data/lib/evilution/ast/parser.rb +2 -1
  13. data/lib/evilution/baseline.rb +14 -11
  14. data/lib/evilution/cli.rb +2 -1
  15. data/lib/evilution/config.rb +15 -3
  16. data/lib/evilution/disable_comment.rb +2 -1
  17. data/lib/evilution/integration/base.rb +124 -1
  18. data/lib/evilution/integration/minitest.rb +145 -0
  19. data/lib/evilution/integration/minitest_crash_detector.rb +55 -0
  20. data/lib/evilution/integration/rspec.rb +33 -100
  21. data/lib/evilution/isolation/fork.rb +11 -3
  22. data/lib/evilution/isolation/in_process.rb +12 -3
  23. data/lib/evilution/mcp/mutate_tool.rb +6 -6
  24. data/lib/evilution/mutator/base.rb +4 -0
  25. data/lib/evilution/mutator/operator/bitwise_complement.rb +1 -1
  26. data/lib/evilution/mutator/operator/block_pass_removal.rb +30 -0
  27. data/lib/evilution/mutator/operator/ensure_removal.rb +1 -1
  28. data/lib/evilution/mutator/operator/index_to_at.rb +30 -0
  29. data/lib/evilution/mutator/operator/index_to_dig.rb +2 -2
  30. data/lib/evilution/mutator/operator/index_to_fetch.rb +2 -2
  31. data/lib/evilution/mutator/operator/keyword_argument.rb +1 -1
  32. data/lib/evilution/mutator/operator/regex_simplification.rb +169 -0
  33. data/lib/evilution/mutator/operator/rescue_body_replacement.rb +1 -1
  34. data/lib/evilution/mutator/operator/rescue_removal.rb +1 -1
  35. data/lib/evilution/mutator/operator/symbol_literal.rb +9 -0
  36. data/lib/evilution/mutator/registry.rb +3 -0
  37. data/lib/evilution/reporter/cli.rb +19 -0
  38. data/lib/evilution/reporter/html.rb +12 -3
  39. data/lib/evilution/reporter/json.rb +14 -3
  40. data/lib/evilution/reporter/suggestion.rb +659 -2
  41. data/lib/evilution/result/mutation_result.rb +9 -2
  42. data/lib/evilution/runner.rb +56 -17
  43. data/lib/evilution/spec_resolver.rb +24 -16
  44. data/lib/evilution/version.rb +1 -1
  45. data/lib/evilution.rb +4 -0
  46. data/script/memory_check +5 -5
  47. data/scripts/benchmark_density +261 -0
  48. data/scripts/benchmark_density.yml +19 -0
  49. data/scripts/compare_mutations +404 -0
  50. data/scripts/compare_mutations.yml +24 -0
  51. data/scripts/mutant_json_adapter +224 -0
  52. metadata +17 -2
@@ -1,17 +1,29 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "fileutils"
4
3
  require "stringio"
5
- require "tmpdir"
6
4
  require_relative "base"
7
5
  require_relative "crash_detector"
8
6
  require_relative "../spec_resolver"
9
7
  require_relative "../related_spec_heuristic"
10
- require_relative "../temp_dir_tracker"
11
8
 
12
9
  require_relative "../integration"
13
10
 
14
11
  class Evilution::Integration::RSpec < Evilution::Integration::Base
12
+ def self.baseline_runner
13
+ lambda { |spec_file|
14
+ require "rspec/core"
15
+ ::RSpec.reset
16
+ status = ::RSpec::Core::Runner.run(
17
+ ["--format", "progress", "--no-color", "--order", "defined", spec_file]
18
+ )
19
+ status.zero?
20
+ }
21
+ end
22
+
23
+ def self.baseline_options
24
+ { runner: baseline_runner }
25
+ end
26
+
15
27
  def initialize(test_files: nil, hooks: nil)
16
28
  @test_files = test_files
17
29
  @rspec_loaded = false
@@ -22,105 +34,24 @@ class Evilution::Integration::RSpec < Evilution::Integration::Base
22
34
  super(hooks: hooks)
23
35
  end
24
36
 
25
- def call(mutation)
26
- @temp_dir = nil
27
- ensure_rspec_loaded
28
- @hooks.fire(:mutation_insert_pre, mutation: mutation, file_path: mutation.file_path) if @hooks
29
- apply_mutation(mutation)
30
- @hooks.fire(:mutation_insert_post, mutation: mutation, file_path: mutation.file_path) if @hooks
31
- run_rspec(mutation)
32
- ensure
33
- restore_original(mutation)
34
- end
35
-
36
37
  private
37
38
 
38
39
  attr_reader :test_files
39
40
 
40
- def ensure_rspec_loaded
41
+ def ensure_framework_loaded
41
42
  return if @rspec_loaded
42
43
 
43
- @hooks.fire(:setup_integration_pre, integration: :rspec) if @hooks
44
+ fire_hook(:setup_integration_pre, integration: :rspec)
44
45
  require "rspec/core"
45
46
  Evilution::Integration::CrashDetector.register_with_rspec
46
47
  @rspec_loaded = true
47
- @hooks.fire(:setup_integration_post, integration: :rspec) if @hooks
48
+ fire_hook(:setup_integration_post, integration: :rspec)
48
49
  rescue LoadError => e
49
50
  raise Evilution::Error, "rspec-core is required but not available: #{e.message}"
50
51
  end
51
52
 
52
- def apply_mutation(mutation)
53
- @temp_dir = Dir.mktmpdir("evilution")
54
- Evilution::TempDirTracker.register(@temp_dir)
55
- @displaced_feature = nil
56
- subpath = resolve_require_subpath(mutation.file_path)
57
-
58
- if subpath
59
- dest = File.join(@temp_dir, subpath)
60
- FileUtils.mkdir_p(File.dirname(dest))
61
- File.write(dest, mutation.mutated_source)
62
- $LOAD_PATH.unshift(@temp_dir)
63
- displace_loaded_feature(mutation.file_path)
64
- else
65
- absolute = File.expand_path(mutation.file_path)
66
- dest = File.join(@temp_dir, absolute)
67
- FileUtils.mkdir_p(File.dirname(dest))
68
- File.write(dest, mutation.mutated_source)
69
- load(dest)
70
- end
71
- end
72
-
73
- def restore_original(mutation) # rubocop:disable Lint/UnusedMethodArgument
74
- return unless @temp_dir
75
-
76
- $LOAD_PATH.delete(@temp_dir)
77
- $LOADED_FEATURES.reject! { |f| f.start_with?(@temp_dir) }
78
- $LOADED_FEATURES << @displaced_feature if @displaced_feature && !$LOADED_FEATURES.include?(@displaced_feature)
79
- @displaced_feature = nil
80
- FileUtils.rm_rf(@temp_dir)
81
- Evilution::TempDirTracker.unregister(@temp_dir)
82
- @temp_dir = nil
83
- end
84
-
85
- def resolve_require_subpath(file_path)
86
- absolute = File.expand_path(file_path)
87
- best_subpath = nil
88
-
89
- $LOAD_PATH.each do |entry|
90
- dir = File.expand_path(entry)
91
- prefix = dir.end_with?("/") ? dir : "#{dir}/"
92
- next unless absolute.start_with?(prefix)
93
-
94
- candidate = absolute.delete_prefix(prefix)
95
- best_subpath = candidate if best_subpath.nil? || candidate.length < best_subpath.length
96
- end
97
-
98
- best_subpath
99
- end
100
-
101
- def displace_loaded_feature(file_path)
102
- absolute = File.expand_path(file_path)
103
- return unless $LOADED_FEATURES.include?(absolute)
104
-
105
- @displaced_feature = absolute
106
- $LOADED_FEATURES.delete(absolute)
107
- end
108
-
109
- def run_rspec(mutation)
110
- # When used via the Runner with Isolation::Fork, each mutation is executed
111
- # in its own forked child process, so RSpec state (loaded example groups,
112
- # world, configuration) cannot accumulate across mutation runs — the child
113
- # process exits after each run.
114
- #
115
- # This integration can also be invoked directly (e.g. in specs or alternative
116
- # runners) without fork isolation. clear_examples reuses the existing World
117
- # and Configuration (avoiding per-run instance growth) while clearing loaded
118
- # example groups, constants, and configuration state.
119
- if ::RSpec.respond_to?(:clear_examples)
120
- ::RSpec.clear_examples
121
- else
122
- ::RSpec.reset
123
- end
53
+ def run_tests(mutation)
54
+ reset_state
124
55
 
125
56
  out = StringIO.new
126
57
  err = StringIO.new
@@ -139,6 +70,19 @@ class Evilution::Integration::RSpec < Evilution::Integration::Base
139
70
  release_rspec_state(eg_before)
140
71
  end
141
72
 
73
+ def build_args(mutation)
74
+ files = resolve_test_files(mutation)
75
+ ["--format", "progress", "--no-color", "--order", "defined", *files]
76
+ end
77
+
78
+ def reset_state
79
+ if ::RSpec.respond_to?(:clear_examples)
80
+ ::RSpec.clear_examples
81
+ else
82
+ ::RSpec.reset
83
+ end
84
+ end
85
+
142
86
  def snapshot_example_groups
143
87
  groups = Set.new
144
88
  ObjectSpace.each_object(Class) do |klass|
@@ -150,11 +94,6 @@ class Evilution::Integration::RSpec < Evilution::Integration::Base
150
94
 
151
95
  def release_rspec_state(eg_before)
152
96
  release_example_groups(eg_before)
153
- # Remove ExampleGroups constants so the named reference is dropped.
154
- # We avoid a full RSpec.reset here because it creates new World and
155
- # Configuration instances each call; the pre-run reset already handles
156
- # that. Instead, clear the world's example_groups array (which holds
157
- # direct class references) and the source cache.
158
97
  ::RSpec::ExampleGroups.remove_all_constants if defined?(::RSpec::ExampleGroups)
159
98
  release_world_example_groups
160
99
  end
@@ -166,7 +105,6 @@ class Evilution::Integration::RSpec < Evilution::Integration::Base
166
105
  next unless klass < ::RSpec::Core::ExampleGroup
167
106
  next if eg_before.include?(klass.object_id)
168
107
 
169
- # Remove nested module constants (LetDefinitions, NamedSubjectPreventSuper)
170
108
  klass.constants(false).each do |const|
171
109
  klass.send(:remove_const, const)
172
110
  rescue NameError # rubocop:disable Lint/SuppressedException
@@ -205,11 +143,6 @@ class Evilution::Integration::RSpec < Evilution::Integration::Base
205
143
  end
206
144
  end
207
145
 
208
- def build_args(mutation)
209
- files = resolve_test_files(mutation)
210
- ["--format", "progress", "--no-color", "--order", "defined", *files]
211
- end
212
-
213
146
  def resolve_test_files(mutation)
214
147
  return test_files if test_files
215
148
 
@@ -57,8 +57,13 @@ class Evilution::Isolation::Fork
57
57
  def execute_in_child(mutation, test_command)
58
58
  result = test_command.call(mutation)
59
59
  { child_rss_kb: Evilution::Memory.rss_kb }.merge(result)
60
- rescue StandardError => e
61
- { passed: false, error: e.message }
60
+ rescue ScriptError, StandardError => e
61
+ {
62
+ passed: false,
63
+ error: e.message,
64
+ error_class: e.class.name,
65
+ error_backtrace: Array(e.backtrace).first(5)
66
+ }
62
67
  end
63
68
 
64
69
  def wait_for_result(pid, read_io, timeout)
@@ -107,7 +112,10 @@ class Evilution::Isolation::Fork
107
112
  duration: duration,
108
113
  test_command: result[:test_command],
109
114
  child_rss_kb: result[:child_rss_kb],
110
- parent_rss_kb: parent_rss_kb
115
+ parent_rss_kb: parent_rss_kb,
116
+ error_message: result[:error],
117
+ error_class: result[:error_class],
118
+ error_backtrace: result[:error_backtrace]
111
119
  )
112
120
  end
113
121
  end
@@ -34,8 +34,14 @@ class Evilution::Isolation::InProcess
34
34
  { timeout: false }.merge(result)
35
35
  rescue Timeout::Error
36
36
  { timeout: true }
37
- rescue StandardError => e
38
- { timeout: false, passed: false, error: e.message }
37
+ rescue ScriptError, StandardError => e
38
+ {
39
+ timeout: false,
40
+ passed: false,
41
+ error: e.message,
42
+ error_class: e.class.name,
43
+ error_backtrace: Array(e.backtrace).first(5)
44
+ }
39
45
  end
40
46
 
41
47
  def suppress_output
@@ -74,7 +80,10 @@ class Evilution::Isolation::InProcess
74
80
  test_command: result[:test_command],
75
81
  child_rss_kb: rss_after,
76
82
  memory_delta_kb: memory_delta_kb,
77
- parent_rss_kb: rss_before
83
+ parent_rss_kb: rss_before,
84
+ error_message: result[:error],
85
+ error_class: result[:error_class],
86
+ error_backtrace: result[:error_backtrace]
78
87
  )
79
88
  end
80
89
  end
@@ -12,7 +12,7 @@ require_relative "../mcp"
12
12
  class Evilution::MCP::MutateTool < MCP::Tool
13
13
  tool_name "evilution-mutate"
14
14
  description "Run mutation testing on Ruby source files. " \
15
- "Use suggest_tests: true to get concrete RSpec test code for surviving mutants."
15
+ "Use suggest_tests: true to get concrete test code (RSpec or Minitest) for surviving mutants."
16
16
  input_schema(
17
17
  properties: {
18
18
  files: {
@@ -43,7 +43,7 @@ class Evilution::MCP::MutateTool < MCP::Tool
43
43
  },
44
44
  suggest_tests: {
45
45
  type: "boolean",
46
- description: "When true, suggestions for survived mutants include concrete RSpec test code " \
46
+ description: "When true, suggestions for survived mutants include concrete test code " \
47
47
  "instead of static description text (default: false)"
48
48
  },
49
49
  verbosity: {
@@ -64,10 +64,10 @@ class Evilution::MCP::MutateTool < MCP::Tool
64
64
  config_opts = build_config_opts(parsed_files, line_ranges, target, timeout, jobs, fail_fast, spec,
65
65
  suggest_tests)
66
66
  config = Evilution::Config.new(**config_opts)
67
- on_result = build_streaming_callback(server_context, suggest_tests)
67
+ on_result = build_streaming_callback(server_context, suggest_tests, config.integration)
68
68
  runner = Evilution::Runner.new(config: config, on_result: on_result)
69
69
  summary = runner.call
70
- report = Evilution::Reporter::JSON.new(suggest_tests: suggest_tests == true).call(summary)
70
+ report = Evilution::Reporter::JSON.new(suggest_tests: suggest_tests == true, integration: config.integration).call(summary)
71
71
  compact = trim_report(report, normalize_verbosity(verbosity))
72
72
 
73
73
  ::MCP::Tool::Response.new([{ type: "text", text: compact }])
@@ -156,10 +156,10 @@ class Evilution::MCP::MutateTool < MCP::Tool
156
156
  data[key].each { |entry| entry.delete("diff") }
157
157
  end
158
158
 
159
- def build_streaming_callback(server_context, suggest_tests)
159
+ def build_streaming_callback(server_context, suggest_tests, integration)
160
160
  return nil unless suggest_tests && server_context.respond_to?(:report_progress)
161
161
 
162
- suggestion = Evilution::Reporter::Suggestion.new(suggest_tests: true)
162
+ suggestion = Evilution::Reporter::Suggestion.new(suggest_tests: true, integration: integration)
163
163
  survivor_index = 0
164
164
 
165
165
  proc do |result|
@@ -45,6 +45,10 @@ class Evilution::Mutator::Base < Prism::Visitor
45
45
  )
46
46
  end
47
47
 
48
+ def byteslice_source(offset, length)
49
+ @file_source.byteslice(offset, length).force_encoding(@file_source.encoding)
50
+ end
51
+
48
52
  def self.operator_name
49
53
  class_name = name || "anonymous"
50
54
  class_name.split("::").last
@@ -9,7 +9,7 @@ class Evilution::Mutator::Operator::BitwiseComplement < Evilution::Mutator::Base
9
9
  receiver_loc = node.receiver.location
10
10
 
11
11
  # Remove ~: replace entire ~expr with just the receiver expression
12
- receiver_source = @file_source[receiver_loc.start_offset, receiver_loc.length]
12
+ receiver_source = byteslice_source(receiver_loc.start_offset, receiver_loc.length)
13
13
  add_mutation(
14
14
  offset: node.location.start_offset,
15
15
  length: node.location.length,
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../operator"
4
+
5
+ class Evilution::Mutator::Operator::BlockPassRemoval < Evilution::Mutator::Base
6
+ def visit_call_node(node)
7
+ if node.block.is_a?(Prism::BlockArgumentNode)
8
+ block_node = node.block
9
+ call_start = node.location.start_offset
10
+ node_end = call_start + node.location.length
11
+ block_end = block_node.location.start_offset + block_node.location.length
12
+
13
+ prefix = @file_source.byteslice(call_start...block_node.location.start_offset).rstrip
14
+ suffix = @file_source.byteslice(block_end...node_end)
15
+
16
+ # Clean up: remove trailing comma from prefix, remove empty parens
17
+ prefix = prefix.sub(/,\s*\z/, "")
18
+ replacement = "#{prefix}#{suffix}".sub(/\(\s*\)/, "")
19
+
20
+ add_mutation(
21
+ offset: call_start,
22
+ length: node.location.length,
23
+ replacement: replacement,
24
+ node: node
25
+ )
26
+ end
27
+
28
+ super
29
+ end
30
+ end
@@ -21,7 +21,7 @@ class Evilution::Mutator::Operator::EnsureRemoval < Evilution::Mutator::Base
21
21
 
22
22
  def line_start_after_newline(offset)
23
23
  pos = offset
24
- pos -= 1 while pos.positive? && @file_source[pos - 1] != "\n"
24
+ pos -= 1 while pos.positive? && @file_source.getbyte(pos - 1) != 0x0A
25
25
  pos
26
26
  end
27
27
  end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../operator"
4
+
5
+ class Evilution::Mutator::Operator::IndexToAt < Evilution::Mutator::Base
6
+ def visit_call_node(node)
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
+ add_mutation(
12
+ offset: node.location.start_offset,
13
+ length: node.location.length,
14
+ replacement: "#{receiver_source}.at(#{arg_source})",
15
+ node: node
16
+ )
17
+ end
18
+
19
+ super
20
+ end
21
+
22
+ private
23
+
24
+ def indexable?(node)
25
+ node.name == :[] &&
26
+ node.receiver &&
27
+ node.arguments &&
28
+ node.arguments.arguments.length == 1
29
+ end
30
+ end
@@ -11,8 +11,8 @@ class Evilution::Mutator::Operator::IndexToDig < Evilution::Mutator::Base
11
11
  def visit_call_node(node)
12
12
  if chain_head?(node)
13
13
  root, args = collect_chain(node)
14
- root_source = @file_source[root.location.start_offset, root.location.length]
15
- arg_sources = args.map { |a| @file_source[a.location.start_offset, a.location.length] }
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
 
17
17
  add_mutation(
18
18
  offset: node.location.start_offset,
@@ -5,8 +5,8 @@ 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 = @file_source[node.receiver.location.start_offset, node.receiver.location.length]
9
- arg_source = @file_source[node.arguments.location.start_offset, node.arguments.location.length]
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
10
 
11
11
  add_mutation(
12
12
  offset: node.location.start_offset,
@@ -26,7 +26,7 @@ class Evilution::Mutator::Operator::KeywordArgument < Evilution::Mutator::Base
26
26
  add_mutation(
27
27
  offset: kw_loc.start_offset,
28
28
  length: kw_loc.length,
29
- replacement: @file_source[name_loc.start_offset...name_loc.end_offset],
29
+ replacement: byteslice_source(name_loc.start_offset, name_loc.end_offset - name_loc.start_offset),
30
30
  node: kw
31
31
  )
32
32
  end
@@ -0,0 +1,169 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../operator"
4
+
5
+ class Evilution::Mutator::Operator::RegexSimplification < Evilution::Mutator::Base
6
+ def visit_regular_expression_node(node)
7
+ content = node.content
8
+ return super if content.empty?
9
+
10
+ content_offset = node.content_loc.start_offset
11
+
12
+ remove_quantifiers(node, content, content_offset)
13
+ remove_anchors(node, content, content_offset)
14
+ remove_character_class_ranges(node, content, content_offset)
15
+
16
+ super
17
+ end
18
+
19
+ private
20
+
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
27
+ end
28
+
29
+ if content[i] == "["
30
+ i = skip_character_class(content, i)
31
+ next
32
+ end
33
+
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
47
+ end
48
+
49
+ def match_quantifier(content, pos)
50
+ case content[pos]
51
+ when "+", "*", "?"
52
+ content[pos]
53
+ when "{"
54
+ if (m = content[pos..].match(/\A\{\d+(?:,\d*)?\}/))
55
+ m[0]
56
+ end
57
+ end
58
+ end
59
+
60
+ 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
77
+ end
78
+
79
+ if content[i] == "["
80
+ i = skip_character_class(content, i)
81
+ next
82
+ end
83
+
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
92
+
93
+ i += 1
94
+ end
95
+ end
96
+
97
+ def match_backslash_anchor(content, pos)
98
+ return nil unless content[pos] == "\\"
99
+
100
+ two_char = content[pos, 2]
101
+ return two_char if %w[\\A \\z \\Z].include?(two_char)
102
+
103
+ nil
104
+ end
105
+
106
+ 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] == "["
115
+ scan_ranges_in_class(node, content, content_offset, i)
116
+ i = skip_character_class(content, i)
117
+ else
118
+ i += 1
119
+ end
120
+ end
121
+ end
122
+
123
+ def scan_ranges_in_class(node, content, content_offset, class_start)
124
+ first_item = skip_class_prefix(content, class_start)
125
+ i = first_item
126
+
127
+ while i < content.length && content[i] != "]"
128
+ if content[i] == "\\"
129
+ i += 2
130
+ next
131
+ end
132
+
133
+ emit_range_removal(node, content, content_offset, first_item, i) if content[i] == "-"
134
+ i += 1
135
+ end
136
+ end
137
+
138
+ def skip_class_prefix(content, class_start)
139
+ i = class_start + 1
140
+ i += 1 if i < content.length && content[i] == "^"
141
+ i += 1 if i < content.length && content[i] == "]"
142
+ i
143
+ end
144
+
145
+ def emit_range_removal(node, content, content_offset, first_item, pos)
146
+ return unless pos > first_item && pos + 1 < content.length && content[pos + 1] != "]"
147
+
148
+ add_mutation(
149
+ offset: content_offset + pos,
150
+ length: 1,
151
+ replacement: "",
152
+ node: node
153
+ )
154
+ end
155
+
156
+ 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] == "]"
160
+
161
+ while i < content.length
162
+ return i + 1 if content[i] == "]"
163
+
164
+ i += content[i] == "\\" ? 2 : 1
165
+ end
166
+
167
+ i
168
+ end
169
+ end
@@ -85,7 +85,7 @@ class Evilution::Mutator::Operator::RescueBodyReplacement < Evilution::Mutator::
85
85
  def indentation_of(offset)
86
86
  pos = offset - 1
87
87
  col = 0
88
- while pos >= 0 && @file_source[pos] != "\n"
88
+ while pos >= 0 && @file_source.getbyte(pos) != 0x0A
89
89
  col += 1
90
90
  pos -= 1
91
91
  end
@@ -31,7 +31,7 @@ class Evilution::Mutator::Operator::RescueRemoval < Evilution::Mutator::Base
31
31
 
32
32
  def line_start_before(offset)
33
33
  pos = offset - 1
34
- pos -= 1 while pos.positive? && @file_source[pos] != "\n"
34
+ pos -= 1 while pos.positive? && @file_source.getbyte(pos) != 0x0A
35
35
  pos
36
36
  end
37
37
  end
@@ -4,6 +4,8 @@ require_relative "../operator"
4
4
 
5
5
  class Evilution::Mutator::Operator::SymbolLiteral < Evilution::Mutator::Base
6
6
  def visit_symbol_node(node)
7
+ return super if label_form?(node)
8
+
7
9
  add_mutation(
8
10
  offset: node.location.start_offset,
9
11
  length: node.location.length,
@@ -20,4 +22,11 @@ class Evilution::Mutator::Operator::SymbolLiteral < Evilution::Mutator::Base
20
22
 
21
23
  super
22
24
  end
25
+
26
+ private
27
+
28
+ def label_form?(node)
29
+ closing = node.closing_loc
30
+ !closing.nil? && closing.slice == ":"
31
+ end
23
32
  end
@@ -27,9 +27,11 @@ class Evilution::Mutator::Registry
27
27
  Evilution::Mutator::Operator::MethodCallRemoval,
28
28
  Evilution::Mutator::Operator::ArgumentRemoval,
29
29
  Evilution::Mutator::Operator::BlockRemoval,
30
+ Evilution::Mutator::Operator::BlockPassRemoval,
30
31
  Evilution::Mutator::Operator::ConditionalFlip,
31
32
  Evilution::Mutator::Operator::RangeReplacement,
32
33
  Evilution::Mutator::Operator::RegexpMutation,
34
+ Evilution::Mutator::Operator::RegexSimplification,
33
35
  Evilution::Mutator::Operator::ReceiverReplacement,
34
36
  Evilution::Mutator::Operator::SendMutation,
35
37
  Evilution::Mutator::Operator::ArgumentNilSubstitution,
@@ -52,6 +54,7 @@ class Evilution::Mutator::Registry
52
54
  Evilution::Mutator::Operator::BitwiseComplement,
53
55
  Evilution::Mutator::Operator::ZsuperRemoval,
54
56
  Evilution::Mutator::Operator::ExplicitSuperMutation,
57
+ Evilution::Mutator::Operator::IndexToAt,
55
58
  Evilution::Mutator::Operator::IndexToFetch,
56
59
  Evilution::Mutator::Operator::IndexToDig,
57
60
  Evilution::Mutator::Operator::IndexAssignmentRemoval,
@@ -19,6 +19,7 @@ class Evilution::Reporter::CLI
19
19
  append_survived(lines, summary)
20
20
  append_neutral(lines, summary)
21
21
  append_equivalent(lines, summary)
22
+ append_errors(lines, summary)
22
23
  append_disabled(lines, summary)
23
24
  lines << ""
24
25
  lines << "[TRUNCATED] Stopped early due to --fail-fast" if summary.truncated?
@@ -54,6 +55,24 @@ class Evilution::Reporter::CLI
54
55
  summary.equivalent_results.each { |result| lines << format_neutral(result) }
55
56
  end
56
57
 
58
+ def append_errors(lines, summary)
59
+ errored = summary.results.select(&:error?)
60
+ return if errored.empty?
61
+
62
+ lines << ""
63
+ lines << "Errored mutations:"
64
+ errored.each { |result| lines << format_error(result) }
65
+ end
66
+
67
+ def format_error(result)
68
+ mutation = result.mutation
69
+ header = " #{mutation.operator_name}: #{mutation.file_path}:#{mutation.line}"
70
+ return header unless result.error_message
71
+
72
+ indented = result.error_message.lines.map { |line| " #{line.chomp}" }.join("\n")
73
+ "#{header}\n#{indented}"
74
+ end
75
+
57
76
  def append_disabled(lines, summary)
58
77
  return unless summary.disabled_mutations.any?
59
78