evilution 0.28.0 → 0.30.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 (112) hide show
  1. checksums.yaml +4 -4
  2. data/.beads/interactions.jsonl +106 -0
  3. data/.rubocop_todo.yml +7 -0
  4. data/CHANGELOG.md +49 -0
  5. data/README.md +194 -8
  6. data/docs/versioning.md +53 -0
  7. data/lib/evilution/ast/constant_names.rb +28 -11
  8. data/lib/evilution/ast/heredoc_span.rb +99 -0
  9. data/lib/evilution/ast/pattern/parser.rb +29 -17
  10. data/lib/evilution/baseline.rb +15 -2
  11. data/lib/evilution/cli/commands/compare.rb +13 -0
  12. data/lib/evilution/cli/commands/session_diff.rb +6 -4
  13. data/lib/evilution/cli/commands/subjects.rb +6 -3
  14. data/lib/evilution/cli/commands/util_mutation.rb +24 -19
  15. data/lib/evilution/cli/parser/command_extractor.rb +12 -12
  16. data/lib/evilution/cli/parser/file_args.rb +3 -1
  17. data/lib/evilution/cli/parser/options_builder.rb +31 -3
  18. data/lib/evilution/cli/parser/stdin_reader.rb +2 -2
  19. data/lib/evilution/cli/parser.rb +18 -20
  20. data/lib/evilution/cli/printers/environment.rb +19 -19
  21. data/lib/evilution/cli/printers/session_diff.rb +8 -8
  22. data/lib/evilution/compare/normalizer.rb +10 -5
  23. data/lib/evilution/config/file_loader.rb +40 -1
  24. data/lib/evilution/config.rb +21 -11
  25. data/lib/evilution/disable_comment.rb +21 -12
  26. data/lib/evilution/equivalent/heuristic/dead_code.rb +8 -1
  27. data/lib/evilution/feedback/setup_warning.rb +79 -0
  28. data/lib/evilution/gem_detector.rb +132 -0
  29. data/lib/evilution/integration/loading/body_call_neutralizer.rb +190 -0
  30. data/lib/evilution/integration/loading/mutation_applier.rb +35 -15
  31. data/lib/evilution/integration/loading/redefinition_recovery.rb +58 -1
  32. data/lib/evilution/integration/minitest.rb +60 -16
  33. data/lib/evilution/integration/rspec/result_builder.rb +20 -1
  34. data/lib/evilution/integration/rspec.rb +20 -1
  35. data/lib/evilution/isolation/fork.rb +104 -27
  36. data/lib/evilution/mcp/info_tool/actions/subjects.rb +32 -23
  37. data/lib/evilution/mcp/info_tool/actions/tests.rb +22 -12
  38. data/lib/evilution/mcp/info_tool/request_parser.rb +3 -1
  39. data/lib/evilution/mcp/info_tool/response_formatter.rb +14 -1
  40. data/lib/evilution/mcp/info_tool.rb +10 -2
  41. data/lib/evilution/mcp/mutate_tool/option_parser.rb +4 -2
  42. data/lib/evilution/mcp/mutate_tool/report_trimmer.rb +58 -1
  43. data/lib/evilution/mcp/mutate_tool/survived_enricher.rb +19 -9
  44. data/lib/evilution/mcp/mutate_tool.rb +49 -17
  45. data/lib/evilution/mcp/session_tool.rb +34 -22
  46. data/lib/evilution/mcp.rb +6 -0
  47. data/lib/evilution/mutation.rb +26 -16
  48. data/lib/evilution/mutator/base.rb +66 -16
  49. data/lib/evilution/mutator/operator/argument_method_call_replacement.rb +59 -0
  50. data/lib/evilution/mutator/operator/argument_nil_substitution.rb +11 -14
  51. data/lib/evilution/mutator/operator/argument_removal.rb +11 -14
  52. data/lib/evilution/mutator/operator/begin_unwrap.rb +17 -5
  53. data/lib/evilution/mutator/operator/bitwise_complement.rb +26 -19
  54. data/lib/evilution/mutator/operator/block_param_removal.rb +50 -8
  55. data/lib/evilution/mutator/operator/block_pass_removal.rb +19 -15
  56. data/lib/evilution/mutator/operator/case_when.rb +7 -5
  57. data/lib/evilution/mutator/operator/conditional_branch.rb +22 -22
  58. data/lib/evilution/mutator/operator/equality_to_identity.rb +8 -3
  59. data/lib/evilution/mutator/operator/explicit_super_mutation.rb +36 -14
  60. data/lib/evilution/mutator/operator/index_to_at.rb +18 -5
  61. data/lib/evilution/mutator/operator/index_to_dig.rb +12 -6
  62. data/lib/evilution/mutator/operator/index_to_fetch.rb +5 -4
  63. data/lib/evilution/mutator/operator/keyword_argument.rb +30 -25
  64. data/lib/evilution/mutator/operator/last_expression_removal.rb +46 -0
  65. data/lib/evilution/mutator/operator/mixin_removal.rb +20 -14
  66. data/lib/evilution/mutator/operator/multiple_assignment.rb +12 -13
  67. data/lib/evilution/mutator/operator/receiver_replacement.rb +38 -7
  68. data/lib/evilution/mutator/operator/regex_simplification.rb +62 -67
  69. data/lib/evilution/mutator/operator/rescue_body_replacement.rb +9 -8
  70. data/lib/evilution/mutator/operator/rescue_removal.rb +58 -12
  71. data/lib/evilution/mutator/operator/splat_operator.rb +28 -1
  72. data/lib/evilution/mutator/operator/string_literal.rb +83 -6
  73. data/lib/evilution/mutator/operator/superclass_removal.rb +21 -15
  74. data/lib/evilution/mutator/registry.rb +2 -0
  75. data/lib/evilution/parallel/work_queue/dispatcher.rb +15 -8
  76. data/lib/evilution/parallel/work_queue/worker.rb +10 -7
  77. data/lib/evilution/parallel/work_queue.rb +35 -18
  78. data/lib/evilution/reporter/cli/item_formatters/coverage_gap.rb +13 -8
  79. data/lib/evilution/reporter/cli/line_formatters/error_rate_warning.rb +29 -0
  80. data/lib/evilution/reporter/cli/line_formatters/mutations.rb +17 -8
  81. data/lib/evilution/reporter/cli/metrics_block.rb +2 -0
  82. data/lib/evilution/reporter/json.rb +54 -18
  83. data/lib/evilution/reporter/suggestion/diff_helpers.rb +0 -13
  84. data/lib/evilution/reporter/suggestion/diff_lines.rb +28 -0
  85. data/lib/evilution/reporter/suggestion/templates/minitest.rb +20 -14
  86. data/lib/evilution/reporter/suggestion/templates/rspec.rb +19 -13
  87. data/lib/evilution/result/mutation_result.rb +12 -6
  88. data/lib/evilution/runner/baseline_runner.rb +20 -9
  89. data/lib/evilution/runner/diagnostics.rb +13 -9
  90. data/lib/evilution/runner/isolation_resolver.rb +75 -12
  91. data/lib/evilution/runner/mutation_executor/result_cache.rb +3 -1
  92. data/lib/evilution/runner/mutation_executor/strategy/parallel.rb +32 -10
  93. data/lib/evilution/runner/mutation_executor/strategy/sequential.rb +1 -1
  94. data/lib/evilution/runner/mutation_executor.rb +2 -0
  95. data/lib/evilution/runner/mutation_planner.rb +53 -16
  96. data/lib/evilution/runner/subject_pipeline.rb +21 -11
  97. data/lib/evilution/runner.rb +3 -3
  98. data/lib/evilution/session/diff.rb +15 -6
  99. data/lib/evilution/session/schema.rb +44 -0
  100. data/lib/evilution/session/store.rb +5 -1
  101. data/lib/evilution/spec_ast_cache.rb +26 -12
  102. data/lib/evilution/version.rb +1 -1
  103. data/lib/evilution.rb +2 -0
  104. data/schema/evilution.config.schema.json +205 -0
  105. data/script/build_runtime_snapshot +88 -0
  106. data/script/memory_check +11 -5
  107. data/script/run_self_baseline +79 -0
  108. data/script/run_self_validation +54 -0
  109. data/scripts/benchmark_density +10 -9
  110. data/scripts/compare_mutations +38 -21
  111. data/scripts/mutant_json_adapter +7 -4
  112. metadata +16 -2
@@ -15,7 +15,9 @@ class Evilution::MCP::SessionTool < MCP::Tool
15
15
  "'show' returns the full report for a session (summary, survived mutations with diffs, git context), " \
16
16
  "'diff' compares two sessions and surfaces new regressions, fixed mutations, persistent survivors, and score delta. " \
17
17
  "Prefer this over the CLI when auditing mutation score trends, triaging survivors, " \
18
- "or verifying that a fix killed the right mutant."
18
+ "or verifying that a fix killed the right mutant. " \
19
+ "Contract: input schema, action enum, and output payloads are stable for the 1.x line; " \
20
+ "see README \"MCP Server\" section for the full deprecation policy."
19
21
  input_schema(
20
22
  properties: {
21
23
  action: {
@@ -50,6 +52,9 @@ class Evilution::MCP::SessionTool < MCP::Tool
50
52
 
51
53
  VALID_ACTIONS = %w[list show diff].freeze
52
54
 
55
+ LimitResult = Data.define(:limit, :error)
56
+ private_constant :LimitResult
57
+
53
58
  class << self
54
59
  def call(server_context:, action: nil, results_dir: nil, limit: nil, path: nil, base: nil, head: nil)
55
60
  return error_response("config_error", "action is required") unless action
@@ -65,28 +70,28 @@ class Evilution::MCP::SessionTool < MCP::Tool
65
70
  private
66
71
 
67
72
  def list_action(results_dir:, limit:)
68
- normalized_limit, limit_error = normalize_limit(limit)
69
- return error_response("config_error", limit_error) if limit_error
73
+ result = normalize_limit(limit)
74
+ return error_response("config_error", result.error) if result.error
70
75
 
71
76
  store_opts = {}
72
77
  store_opts[:results_dir] = results_dir if results_dir
73
78
  store = Evilution::Session::Store.new(**store_opts)
74
79
  entries = store.list
75
- entries = entries.first(normalized_limit) unless normalized_limit.nil?
80
+ entries = entries.first(result.limit) unless result.limit.nil?
76
81
 
77
- payload = entries.map { |e| e.transform_keys(&:to_s) }
78
- success_response(payload)
82
+ sessions = entries.map { |e| e.transform_keys(&:to_s) }
83
+ success_response("schema_version" => Evilution::MCP::CONTRACT_VERSION, "sessions" => sessions)
79
84
  end
80
85
 
81
86
  def normalize_limit(limit)
82
- return [nil, nil] if limit.nil?
87
+ return LimitResult.new(limit: nil, error: nil) if limit.nil?
83
88
 
84
89
  coerced = Integer(limit)
85
- return [nil, "limit must be a non-negative integer"] if coerced.negative?
90
+ return LimitResult.new(limit: nil, error: "limit must be a non-negative integer") if coerced.negative?
86
91
 
87
- [coerced, nil]
92
+ LimitResult.new(limit: coerced, error: nil)
88
93
  rescue ArgumentError, TypeError
89
- [nil, "limit must be a non-negative integer"]
94
+ LimitResult.new(limit: nil, error: "limit must be a non-negative integer")
90
95
  end
91
96
 
92
97
  def show_action(path:, results_dir:)
@@ -107,20 +112,13 @@ class Evilution::MCP::SessionTool < MCP::Tool
107
112
  end
108
113
 
109
114
  def diff_action(base:, head:, results_dir:)
110
- return error_response("config_error", "base is required") unless base
111
- return error_response("config_error", "head is required") unless head
112
-
113
115
  dir = results_dir || Evilution::Session::Store::DEFAULT_DIR
114
- return error_response("config_error", "base must be under results directory") unless within?(base, dir)
115
- return error_response("config_error", "head must be under results directory") unless within?(head, dir)
116
+ validation = validate_diff_args(base, head, dir)
117
+ return validation if validation
116
118
 
117
- store = Evilution::Session::Store.new(results_dir: dir)
118
- base_data = store.load(base)
119
- head_data = store.load(head)
120
-
121
- diff = Evilution::Session::Diff.new
122
- result = diff.call(base_data, head_data)
123
- success_response(result.to_h)
119
+ result = load_and_diff(base, head, dir)
120
+ payload = { "schema_version" => Evilution::MCP::CONTRACT_VERSION }.merge(result.to_h)
121
+ success_response(payload)
124
122
  rescue Evilution::Error => e
125
123
  error_response("not_found", e.message)
126
124
  rescue ::JSON::ParserError => e
@@ -129,6 +127,20 @@ class Evilution::MCP::SessionTool < MCP::Tool
129
127
  error_response("runtime_error", e.message)
130
128
  end
131
129
 
130
+ def validate_diff_args(base, head, dir)
131
+ return error_response("config_error", "base is required") unless base
132
+ return error_response("config_error", "head is required") unless head
133
+ return error_response("config_error", "base must be under results directory") unless within?(base, dir)
134
+ return error_response("config_error", "head must be under results directory") unless within?(head, dir)
135
+
136
+ nil
137
+ end
138
+
139
+ def load_and_diff(base, head, dir)
140
+ store = Evilution::Session::Store.new(results_dir: dir)
141
+ Evilution::Session::Diff.new.call(store.load(base), store.load(head))
142
+ end
143
+
132
144
  def within?(path, results_dir)
133
145
  resolved_root = canonical_path(results_dir)
134
146
  resolved_path = canonical_path(path)
data/lib/evilution/mcp.rb CHANGED
@@ -1,4 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Evilution::MCP
4
+ # Public contract version for the evilution MCP tool surface (input schemas,
5
+ # output payload shapes, error envelope, action enumerations). Bumped only
6
+ # at MAJOR releases per docs/versioning.md. Independent of session JSON
7
+ # schema versioning (Evilution::Session::Schema) so the two surfaces can
8
+ # rev separately.
9
+ CONTRACT_VERSION = 1
4
10
  end
@@ -10,13 +10,15 @@ class Evilution::Mutation
10
10
 
11
11
  attr_reader :subject, :operator_name, :parse_status, :location
12
12
 
13
- def initialize(subject:, operator_name:, sources:, location:, slice: nil, parse_status: :ok)
13
+ def initialize(subject:, operator_name:, sources:, location:,
14
+ slice: nil, parse_status: :ok, eval_source: nil)
14
15
  @subject = subject
15
16
  @operator_name = operator_name
16
17
  @sources = sources
17
18
  @location = location
18
19
  @slice = slice
19
20
  @parse_status = parse_status
21
+ @eval_source = eval_source
20
22
  @diff = nil
21
23
  end
22
24
 
@@ -28,6 +30,16 @@ class Evilution::Mutation
28
30
  @sources&.mutated
29
31
  end
30
32
 
33
+ # Source to feed to the load-time evaluator. Defaults to mutated_source
34
+ # when no pre-eval transform was applied at generation time. Mutator::Base
35
+ # populates this with the neutralized version (top-level idempotency-
36
+ # violating calls replaced with `nil`) so the worker eval doesn't re-run
37
+ # them. The neutralization Prism parse happens once at generation time,
38
+ # not per worker iteration.
39
+ def eval_source
40
+ @eval_source || mutated_source
41
+ end
42
+
31
43
  def original_slice
32
44
  @slice&.original
33
45
  end
@@ -74,22 +86,17 @@ class Evilution::Mutation
74
86
  private
75
87
 
76
88
  def compute_diff
77
- original_lines = original_source.lines
78
- mutated_lines = mutated_source.lines
79
- diffs = ::Diff::LCS.diff(original_lines, mutated_lines)
80
-
89
+ diffs = ::Diff::LCS.diff(original_source.lines, mutated_source.lines)
81
90
  return "" if diffs.empty?
82
91
 
83
- result = []
84
- diffs.flatten(1).each do |change|
85
- case change.action
86
- when "-"
87
- result << "- #{change.element.chomp}"
88
- when "+"
89
- result << "+ #{change.element.chomp}"
90
- end
92
+ diffs.flatten(1).filter_map { |change| format_diff_change(change) }.join("\n")
93
+ end
94
+
95
+ def format_diff_change(change)
96
+ case change.action
97
+ when "-" then "- #{change.element.chomp}"
98
+ when "+" then "+ #{change.element.chomp}"
91
99
  end
92
- result.join("\n")
93
100
  end
94
101
 
95
102
  def compute_unified_diff
@@ -97,15 +104,18 @@ class Evilution::Mutation
97
104
 
98
105
  original_lines = @slice.original.lines
99
106
  mutated_lines = @slice.mutated.lines
100
- body = ::Diff::LCS.sdiff(original_lines, mutated_lines).map { |c| format_sdiff_change(c) }.join("\n")
101
107
  [
102
108
  "--- a/#{file_path}",
103
109
  "+++ b/#{file_path}",
104
110
  "@@ -#{line},#{original_lines.length} +#{line},#{mutated_lines.length} @@",
105
- body
111
+ unified_diff_body(original_lines, mutated_lines)
106
112
  ].reject(&:empty?).join("\n")
107
113
  end
108
114
 
115
+ def unified_diff_body(original_lines, mutated_lines)
116
+ ::Diff::LCS.sdiff(original_lines, mutated_lines).map { |c| format_sdiff_change(c) }.join("\n")
117
+ end
118
+
109
119
  def format_sdiff_change(change)
110
120
  case change.action
111
121
  when "=" then " #{change.old_element.chomp}"
@@ -3,14 +3,31 @@
3
3
  require "prism"
4
4
 
5
5
  require_relative "../mutator"
6
+ require_relative "../integration/loading/body_call_neutralizer"
7
+ require_relative "../ast/heredoc_span"
6
8
 
7
9
  class Evilution::Mutator::Base < Prism::Visitor
10
+ AffectedSlices = Data.define(:original, :mutated)
11
+ private_constant :AffectedSlices
12
+
13
+ # Match a heredoc anchor `<<MARKER` / `<<-MARKER` / `<<~MARKER` (optionally
14
+ # quoted) when the identifier sits directly against the `<<` (or its
15
+ # `-`/`~`/quote prefix). The space-separated forms `arr << x`, `1 << 2`,
16
+ # `class << self` deliberately do NOT match — `<<` alone is also Ruby's
17
+ # shift / append / singleton-class operator, and skipping mutations that
18
+ # only contain those would lose useful coverage. False positive on the
19
+ # unusual no-space shift `obj<<value` is accepted (over-skip is safer than
20
+ # emitting an unparseable mutation).
21
+ HEREDOC_ANCHOR_PATTERN = /<<[-~]?["'`]?[A-Za-z_]/
22
+ private_constant :HEREDOC_ANCHOR_PATTERN
23
+
8
24
  attr_reader :mutations
9
25
 
10
26
  def initialize(**_options)
11
27
  @mutations = []
12
28
  @subject = nil
13
29
  @file_source = nil
30
+ @body_call_neutralizer = Evilution::Integration::Loading::BodyCallNeutralizer.new
14
31
  end
15
32
 
16
33
  def call(subject, filter: nil)
@@ -27,35 +44,68 @@ class Evilution::Mutator::Base < Prism::Visitor
27
44
  def add_mutation(offset:, length:, replacement:, node:)
28
45
  return if @filter && @filter.skip?(node)
29
46
 
30
- surgery = Evilution::AST::SourceSurgeon.apply(
31
- @file_source,
32
- offset: offset,
33
- length: length,
34
- replacement: replacement
47
+ # When the byte range opens a heredoc but stops at its inline anchor,
48
+ # extend it to cover the body+terminator. Without this every operator
49
+ # whose edit straddles a `<<~MARKER` anchor (argument_removal,
50
+ # argument_nil_substitution, method_call_removal, statement_deletion,
51
+ # method_body_replacement, block_removal, conditional_branch,
52
+ # string_interpolation, ...) emits an orphan-heredoc unparseable.
53
+ extended_length = Evilution::AST::HeredocSpan.extend_length(
54
+ node: node, offset: offset, length: length
35
55
  )
36
- mutated_source = surgery.source
37
56
 
38
- original_slice, mutated_slice = slice_affected_lines(
39
- mutated_source: mutated_source,
57
+ # If the replacement re-references a heredoc anchor (e.g. argument_removal
58
+ # rebuilding the args list with a kept `<<~MSG` arg), we cannot safely
59
+ # extend the range — doing so would strip the kept heredoc's body without
60
+ # putting it back. Skip the mutation rather than emit unparseable bytes.
61
+ # When the replacement is heredoc-free (nil, "", a literal, or a non-
62
+ # heredoc kept arg), extension cleanly sweeps the orphaned body+terminator.
63
+ return if extended_length > length && replacement.match?(HEREDOC_ANCHOR_PATTERN)
64
+
65
+ length = extended_length
66
+
67
+ surgery = Evilution::AST::SourceSurgeon.apply(
68
+ @file_source, offset: offset, length: length, replacement: replacement
69
+ )
70
+ slices = slice_affected_lines(
71
+ mutated_source: surgery.source,
40
72
  offset: offset,
41
73
  length: length,
42
74
  replacement_bytesize: replacement.bytesize
43
75
  )
44
76
 
45
- @mutations << Evilution::Mutation.new(
77
+ @mutations << build_mutation_record(node, surgery, slices)
78
+ end
79
+
80
+ def build_mutation_record(node, surgery, slices)
81
+ Evilution::Mutation.new(
46
82
  subject: @subject,
47
83
  operator_name: self.class.operator_name,
48
- sources: Evilution::Mutation::Sources.new(original: @file_source, mutated: mutated_source),
49
- slice: Evilution::Mutation::Slice.new(original: original_slice, mutated: mutated_slice),
84
+ sources: Evilution::Mutation::Sources.new(original: @file_source, mutated: surgery.source),
85
+ slice: Evilution::Mutation::Slice.new(original: slices.original, mutated: slices.mutated),
50
86
  location: Evilution::Mutation::Location.new(
51
87
  file_path: @subject.file_path,
52
88
  line: node.location.start_line,
53
89
  column: node.location.start_column
54
90
  ),
55
- parse_status: surgery.status
91
+ parse_status: surgery.status,
92
+ eval_source: build_eval_source(surgery)
56
93
  )
57
94
  end
58
95
 
96
+ # Pre-compute the worker's eval target so per-iter Prism re-parse cost
97
+ # stays out of the hot path. For parseable mutations we strip class/module
98
+ # body calls that are known to be non-idempotent (registries etc.); for
99
+ # unparseable bytes we fall through so the syntax validator can reject
100
+ # them at apply time. Passing the subject's file path lets the neutralizer
101
+ # skip files the parent never preloaded — those are lazy plugin files whose
102
+ # DSL calls are still needed for the child fork's first-time load.
103
+ def build_eval_source(surgery)
104
+ return surgery.source unless surgery.ok?
105
+
106
+ @body_call_neutralizer.call(surgery.source, file_path: @subject.file_path)
107
+ end
108
+
59
109
  NEWLINE_BYTE = 10
60
110
  private_constant :NEWLINE_BYTE
61
111
 
@@ -64,10 +114,10 @@ class Evilution::Mutator::Base < Prism::Visitor
64
114
  orig_line_end = line_end_byte(@file_source, [offset + length - 1, line_start].max)
65
115
  mut_line_end = line_end_byte(mutated_source, [offset + replacement_bytesize - 1, line_start].max)
66
116
 
67
- [
68
- @file_source.byteslice(line_start, orig_line_end - line_start),
69
- mutated_source.byteslice(line_start, mut_line_end - line_start)
70
- ]
117
+ AffectedSlices.new(
118
+ original: @file_source.byteslice(line_start, orig_line_end - line_start),
119
+ mutated: mutated_source.byteslice(line_start, mut_line_end - line_start)
120
+ )
71
121
  end
72
122
 
73
123
  def line_start_byte(source, offset)
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../operator"
4
+
5
+ # Replaces a method-call argument with its receiver: `fn(x.attr)` -> `fn(x)`.
6
+ # High-signal for log payloads, structured-data construction, and API
7
+ # request bodies where a method call on a local variable / param appears in
8
+ # argument position. Covered byte-wise by `MethodCallRemoval` already; this
9
+ # operator surfaces the same byte change under a more specific name so the
10
+ # argument-substitution pattern is legible in mutation output.
11
+ #
12
+ # Fires for:
13
+ # - positional / keyword arguments of any CallNode
14
+ # - hash values inside HashNode / KeywordHashNode (incl. inside call args)
15
+ # - array elements inside ArrayNode
16
+ class Evilution::Mutator::Operator::ArgumentMethodCallReplacement < Evilution::Mutator::Base
17
+ def visit_call_node(node)
18
+ node.arguments.arguments.each { |arg| try_replace(arg) } if node.arguments
19
+
20
+ super
21
+ end
22
+
23
+ def visit_array_node(node)
24
+ node.elements.each { |element| try_replace(element) }
25
+ super
26
+ end
27
+
28
+ def visit_hash_node(node)
29
+ process_assocs(node.elements)
30
+ super
31
+ end
32
+
33
+ def visit_keyword_hash_node(node)
34
+ process_assocs(node.elements)
35
+ super
36
+ end
37
+
38
+ private
39
+
40
+ def process_assocs(elements)
41
+ elements.each do |assoc|
42
+ next unless assoc.is_a?(Prism::AssocNode)
43
+
44
+ try_replace(assoc.value)
45
+ end
46
+ end
47
+
48
+ def try_replace(value)
49
+ return unless value.is_a?(Prism::CallNode)
50
+ return unless value.receiver
51
+
52
+ add_mutation(
53
+ offset: value.location.start_offset,
54
+ length: value.location.length,
55
+ replacement: value.receiver.slice,
56
+ node: value
57
+ )
58
+ end
59
+ end
@@ -12,26 +12,23 @@ class Evilution::Mutator::Operator::ArgumentNilSubstitution < Evilution::Mutator
12
12
 
13
13
  def visit_call_node(node)
14
14
  args = node.arguments&.arguments
15
-
16
- if mutable?(node, args)
17
- args.each_index do |i|
18
- parts = args.each_with_index.map { |a, j| j == i ? "nil" : a.slice }
19
- replacement = parts.join(", ")
20
-
21
- add_mutation(
22
- offset: node.arguments.location.start_offset,
23
- length: node.arguments.location.length,
24
- replacement: replacement,
25
- node: node
26
- )
27
- end
28
- end
15
+ args.each_index { |i| emit_nil_substitution(node, args, i) } if mutable?(node, args)
29
16
 
30
17
  super
31
18
  end
32
19
 
33
20
  private
34
21
 
22
+ def emit_nil_substitution(node, args, i)
23
+ parts = args.each_with_index.map { |a, j| j == i ? "nil" : a.slice }
24
+ add_mutation(
25
+ offset: node.arguments.location.start_offset,
26
+ length: node.arguments.location.length,
27
+ replacement: parts.join(", "),
28
+ node: node
29
+ )
30
+ end
31
+
35
32
  def mutable?(node, args)
36
33
  args && args.length >= 1 && positional_only?(args) && node.name != :[]=
37
34
  end
@@ -12,26 +12,23 @@ class Evilution::Mutator::Operator::ArgumentRemoval < Evilution::Mutator::Base
12
12
 
13
13
  def visit_call_node(node)
14
14
  args = node.arguments&.arguments
15
-
16
- if mutable?(node, args)
17
- args.each_index do |i|
18
- remaining = args.each_with_index.filter_map { |a, j| a.slice if j != i }
19
- replacement = remaining.join(", ")
20
-
21
- add_mutation(
22
- offset: node.arguments.location.start_offset,
23
- length: node.arguments.location.length,
24
- replacement:,
25
- node:
26
- )
27
- end
28
- end
15
+ args.each_index { |i| emit_argument_removal(node, args, i) } if mutable?(node, args)
29
16
 
30
17
  super
31
18
  end
32
19
 
33
20
  private
34
21
 
22
+ def emit_argument_removal(node, args, i)
23
+ remaining = args.each_with_index.filter_map { |a, j| a.slice if j != i }
24
+ add_mutation(
25
+ offset: node.arguments.location.start_offset,
26
+ length: node.arguments.location.length,
27
+ replacement: remaining.join(", "),
28
+ node: node
29
+ )
30
+ end
31
+
35
32
  def mutable?(node, args)
36
33
  args && args.length >= 2 && positional_only?(args) && node.name != :[]=
37
34
  end
@@ -4,18 +4,30 @@ require_relative "../operator"
4
4
 
5
5
  class Evilution::Mutator::Operator::BeginUnwrap < Evilution::Mutator::Base
6
6
  def visit_begin_node(node)
7
- return super if node.rescue_clause || node.else_clause || node.ensure_clause
8
- return super if node.statements.nil?
9
- return super if node.begin_keyword_loc.nil?
7
+ return super unless unwrappable?(node)
10
8
 
11
- body_text = @file_source.byteslice(node.statements.location.start_offset, node.statements.location.length)
12
9
  add_mutation(
13
10
  offset: node.location.start_offset,
14
11
  length: node.location.length,
15
- replacement: body_text,
12
+ replacement: body_text(node),
16
13
  node: node
17
14
  )
18
15
 
19
16
  super
20
17
  end
18
+
19
+ private
20
+
21
+ def unwrappable?(node)
22
+ return false if node.rescue_clause || node.else_clause || node.ensure_clause
23
+ return false if node.statements.nil?
24
+ return false if node.begin_keyword_loc.nil?
25
+
26
+ true
27
+ end
28
+
29
+ def body_text(node)
30
+ loc = node.statements.location
31
+ @file_source.byteslice(loc.start_offset, loc.length)
32
+ end
21
33
  end
@@ -5,27 +5,34 @@ require_relative "../operator"
5
5
  class Evilution::Mutator::Operator::BitwiseComplement < Evilution::Mutator::Base
6
6
  def visit_call_node(node)
7
7
  if node.name == :~ && node.receiver && node.arguments.nil?
8
- loc = node.message_loc
9
- receiver_loc = node.receiver.location
10
-
11
- # Remove ~: replace entire ~expr with just the receiver expression
12
- receiver_source = byteslice_source(receiver_loc.start_offset, receiver_loc.length)
13
- add_mutation(
14
- offset: node.location.start_offset,
15
- length: node.location.length,
16
- replacement: receiver_source,
17
- node: node
18
- )
19
-
20
- # Swap ~ with unary minus
21
- add_mutation(
22
- offset: loc.start_offset,
23
- length: loc.length,
24
- replacement: "-",
25
- node: node
26
- )
8
+ emit_remove_complement(node)
9
+ emit_swap_to_minus(node)
27
10
  end
28
11
 
29
12
  super
30
13
  end
14
+
15
+ private
16
+
17
+ # Replace `~expr` with just `expr`.
18
+ def emit_remove_complement(node)
19
+ receiver_loc = node.receiver.location
20
+ add_mutation(
21
+ offset: node.location.start_offset,
22
+ length: node.location.length,
23
+ replacement: byteslice_source(receiver_loc.start_offset, receiver_loc.length),
24
+ node: node
25
+ )
26
+ end
27
+
28
+ # Swap `~` with unary minus.
29
+ def emit_swap_to_minus(node)
30
+ loc = node.message_loc
31
+ add_mutation(
32
+ offset: loc.start_offset,
33
+ length: loc.length,
34
+ replacement: "-",
35
+ node: node
36
+ )
37
+ end
31
38
  end
@@ -6,6 +6,7 @@ class Evilution::Mutator::Operator::BlockParamRemoval < Evilution::Mutator::Base
6
6
  def visit_def_node(node)
7
7
  return super unless node.parameters
8
8
  return super unless node.parameters.block
9
+ return super if anonymous_block_forwarded?(node)
9
10
 
10
11
  if only_block_param?(node.parameters)
11
12
  remove_entire_params(node)
@@ -26,6 +27,37 @@ class Evilution::Mutator::Operator::BlockParamRemoval < Evilution::Mutator::Base
26
27
  params.keyword_rest.nil?
27
28
  end
28
29
 
30
+ # Skip the mutation when the def declares an anonymous `&` block parameter
31
+ # AND the body uses `&` to forward it. Ruby parses `def f(&) = g(&)` only
32
+ # because the signature declares the anonymous block; stripping `&` from the
33
+ # signature leaves the body's `&` orphaned and produces a parse error.
34
+ # Named block params (`&block`) are not affected here — removing those leaves
35
+ # body references like `block.call` as NameError at runtime, still parseable
36
+ # and still a useful (kill-able) mutation.
37
+ def anonymous_block_forwarded?(node)
38
+ return false unless node.parameters.block.name.nil?
39
+ return false if node.body.nil?
40
+
41
+ body_contains_anonymous_forward?(node.body)
42
+ end
43
+
44
+ def body_contains_anonymous_forward?(body)
45
+ queue = [body]
46
+ until queue.empty?
47
+ current = queue.shift
48
+ return true if current.is_a?(Prism::BlockArgumentNode) && current.expression.nil?
49
+
50
+ # A nested def introduces a fresh method scope, so any `&` inside it
51
+ # forwards the inner method's own block parameter, not ours. Stop
52
+ # descent. Blocks and lambdas inherit the enclosing method scope, so
53
+ # we keep walking through them.
54
+ next if current.is_a?(Prism::DefNode)
55
+
56
+ queue.concat(current.compact_child_nodes)
57
+ end
58
+ false
59
+ end
60
+
29
61
  def remove_entire_params(node)
30
62
  start_offset = node.lparen_loc.start_offset
31
63
  end_offset = node.rparen_loc.start_offset + node.rparen_loc.length
@@ -38,14 +70,7 @@ class Evilution::Mutator::Operator::BlockParamRemoval < Evilution::Mutator::Base
38
70
  end
39
71
 
40
72
  def remove_block_param(node)
41
- block_loc = node.parameters.block.location
42
- params_text = @file_source.byteslice(node.parameters.location.start_offset, node.parameters.location.length)
43
- block_rel = block_loc.start_offset - node.parameters.location.start_offset
44
-
45
- # Find the comma before the block param and remove ", &block"
46
- comma_pos = params_text.rindex(",", block_rel - 1)
47
- remove_start = node.parameters.location.start_offset + comma_pos
48
- remove_end = block_loc.start_offset + block_loc.length
73
+ remove_start, remove_end = block_param_removal_range(node)
49
74
 
50
75
  add_mutation(
51
76
  offset: remove_start,
@@ -54,4 +79,21 @@ class Evilution::Mutator::Operator::BlockParamRemoval < Evilution::Mutator::Base
54
79
  node: node
55
80
  )
56
81
  end
82
+
83
+ # Range covering ", &block" — from the comma before the block param to the end of the block param.
84
+ def block_param_removal_range(node)
85
+ params_loc = node.parameters.location
86
+ block_loc = node.parameters.block.location
87
+ comma_pos = params_text(params_loc).rindex(",", block_loc.start_offset - params_loc.start_offset - 1)
88
+
89
+ [params_loc.start_offset + comma_pos, end_offset(block_loc)]
90
+ end
91
+
92
+ def params_text(params_loc)
93
+ @file_source.byteslice(params_loc.start_offset, params_loc.length)
94
+ end
95
+
96
+ def end_offset(loc)
97
+ loc.start_offset + loc.length
98
+ end
57
99
  end