evilution 0.29.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 (58) hide show
  1. checksums.yaml +4 -4
  2. data/.beads/interactions.jsonl +54 -0
  3. data/.rubocop_todo.yml +7 -0
  4. data/CHANGELOG.md +42 -0
  5. data/README.md +194 -8
  6. data/docs/versioning.md +53 -0
  7. data/lib/evilution/ast/heredoc_span.rb +99 -0
  8. data/lib/evilution/baseline.rb +15 -2
  9. data/lib/evilution/cli/commands/compare.rb +13 -0
  10. data/lib/evilution/cli/parser/command_extractor.rb +3 -1
  11. data/lib/evilution/cli/parser/options_builder.rb +2 -2
  12. data/lib/evilution/config/file_loader.rb +40 -1
  13. data/lib/evilution/config.rb +11 -1
  14. data/lib/evilution/equivalent/heuristic/dead_code.rb +8 -1
  15. data/lib/evilution/feedback/setup_warning.rb +79 -0
  16. data/lib/evilution/gem_detector.rb +132 -0
  17. data/lib/evilution/integration/loading/body_call_neutralizer.rb +190 -0
  18. data/lib/evilution/integration/loading/mutation_applier.rb +20 -5
  19. data/lib/evilution/integration/loading/redefinition_recovery.rb +58 -1
  20. data/lib/evilution/integration/minitest.rb +37 -2
  21. data/lib/evilution/integration/rspec/result_builder.rb +20 -1
  22. data/lib/evilution/integration/rspec.rb +16 -1
  23. data/lib/evilution/isolation/fork.rb +77 -10
  24. data/lib/evilution/mcp/info_tool/response_formatter.rb +14 -1
  25. data/lib/evilution/mcp/info_tool.rb +3 -1
  26. data/lib/evilution/mcp/mutate_tool/option_parser.rb +1 -1
  27. data/lib/evilution/mcp/mutate_tool/report_trimmer.rb +58 -1
  28. data/lib/evilution/mcp/mutate_tool.rb +22 -3
  29. data/lib/evilution/mcp/session_tool.rb +7 -4
  30. data/lib/evilution/mcp.rb +6 -0
  31. data/lib/evilution/mutation.rb +13 -1
  32. data/lib/evilution/mutator/base.rb +49 -1
  33. data/lib/evilution/mutator/operator/argument_method_call_replacement.rb +59 -0
  34. data/lib/evilution/mutator/operator/block_param_removal.rb +32 -0
  35. data/lib/evilution/mutator/operator/explicit_super_mutation.rb +20 -2
  36. data/lib/evilution/mutator/operator/index_to_at.rb +13 -1
  37. data/lib/evilution/mutator/operator/last_expression_removal.rb +46 -0
  38. data/lib/evilution/mutator/operator/receiver_replacement.rb +29 -1
  39. data/lib/evilution/mutator/operator/rescue_removal.rb +59 -10
  40. data/lib/evilution/mutator/operator/splat_operator.rb +28 -1
  41. data/lib/evilution/mutator/operator/string_literal.rb +83 -6
  42. data/lib/evilution/mutator/registry.rb +2 -0
  43. data/lib/evilution/reporter/cli/line_formatters/error_rate_warning.rb +29 -0
  44. data/lib/evilution/reporter/cli/metrics_block.rb +2 -0
  45. data/lib/evilution/reporter/json.rb +2 -0
  46. data/lib/evilution/result/mutation_result.rb +12 -6
  47. data/lib/evilution/runner/baseline_runner.rb +5 -1
  48. data/lib/evilution/runner/isolation_resolver.rb +69 -8
  49. data/lib/evilution/runner/mutation_planner.rb +18 -1
  50. data/lib/evilution/session/schema.rb +44 -0
  51. data/lib/evilution/session/store.rb +5 -1
  52. data/lib/evilution/version.rb +1 -1
  53. data/lib/evilution.rb +2 -0
  54. data/schema/evilution.config.schema.json +205 -0
  55. data/script/build_runtime_snapshot +88 -0
  56. data/script/run_self_baseline +79 -0
  57. data/script/run_self_validation +54 -0
  58. metadata +15 -2
@@ -5,11 +5,25 @@ require_relative "../mutate_tool"
5
5
  require_relative "../../feedback"
6
6
  require_relative "../../feedback/detector"
7
7
  require_relative "../../feedback/messages"
8
+ require_relative "../../feedback/setup_warning"
8
9
 
9
10
  module Evilution::MCP::MutateTool::ReportTrimmer
10
11
  MINIMAL_KEYS = %w[summary survived].freeze
11
12
  FULL_DIFF_STRIP_KEYS = %w[killed neutral equivalent unresolved unparseable].freeze
12
13
  SUMMARY_DROP_KEYS = %w[killed neutral equivalent unparseable].freeze
14
+ # EV-187j / GH #1169: at summary verbosity, non-survived entries that remain
15
+ # (timed_out, errors, unresolved) shed `diff` and `error_backtrace` payload —
16
+ # those two fields dominate response size at scale and aren't actionable for
17
+ # a scanning agent. Survived stays full-detail.
18
+ SUMMARY_HEAVY_FIELDS = %w[diff error_backtrace].freeze
19
+ SUMMARY_TRIM_KEYS = %w[timed_out errors unresolved].freeze
20
+
21
+ # EV-t7kh / GH #1170: at minimal verbosity we surface a small sample of any
22
+ # errored mutations so agents are not stuck in a diagnose-vs-token-cap
23
+ # deadlock when a run is partly-broken. Bounded to keep the payload tiny.
24
+ ERROR_SAMPLE_LIMIT = 3
25
+ ERROR_BACKTRACE_HEAD_LINES = 5
26
+ ERROR_SAMPLE_KEYS = %w[operator file line error_message error_class].freeze
13
27
 
14
28
  def self.call(json_string, verbosity:, survived_results:, config:, enricher:, summary: nil)
15
29
  data = ::JSON.parse(json_string)
@@ -18,14 +32,38 @@ module Evilution::MCP::MutateTool::ReportTrimmer
18
32
  FULL_DIFF_STRIP_KEYS.each { |key| strip_diffs(data, key) }
19
33
  when "summary"
20
34
  SUMMARY_DROP_KEYS.each { |key| data.delete(key) }
35
+ SUMMARY_TRIM_KEYS.each { |key| strip_heavy_fields(data, key) }
21
36
  when "minimal"
22
- data.keep_if { |key, _| MINIMAL_KEYS.include?(key) }
37
+ apply_minimal(data)
23
38
  end
24
39
  enricher.call(data, survived_results, config)
25
40
  embed_feedback(data, summary) unless verbosity == "minimal"
41
+ embed_setup_warning(data, summary)
26
42
  ::JSON.generate(data)
27
43
  end
28
44
 
45
+ def self.apply_minimal(data)
46
+ sample = error_sample(data["errors"])
47
+ data.keep_if { |key, _| MINIMAL_KEYS.include?(key) }
48
+ data["errors"] = sample if sample
49
+ end
50
+ private_class_method :apply_minimal
51
+
52
+ def self.error_sample(entries)
53
+ return nil unless entries.is_a?(Array) && !entries.empty?
54
+
55
+ entries.first(ERROR_SAMPLE_LIMIT).map { |entry| trim_error_entry(entry) }
56
+ end
57
+ private_class_method :error_sample
58
+
59
+ def self.trim_error_entry(entry)
60
+ trimmed = entry.slice(*ERROR_SAMPLE_KEYS)
61
+ backtrace = entry["error_backtrace"]
62
+ trimmed["error_backtrace"] = backtrace.first(ERROR_BACKTRACE_HEAD_LINES) if backtrace.is_a?(Array)
63
+ trimmed
64
+ end
65
+ private_class_method :trim_error_entry
66
+
29
67
  def self.strip_diffs(data, key)
30
68
  return unless data[key]
31
69
 
@@ -33,6 +71,15 @@ module Evilution::MCP::MutateTool::ReportTrimmer
33
71
  end
34
72
  private_class_method :strip_diffs
35
73
 
74
+ def self.strip_heavy_fields(data, key)
75
+ return unless data[key].is_a?(Array)
76
+
77
+ data[key].each do |entry|
78
+ SUMMARY_HEAVY_FIELDS.each { |field| entry.delete(field) }
79
+ end
80
+ end
81
+ private_class_method :strip_heavy_fields
82
+
36
83
  def self.embed_feedback(data, summary)
37
84
  return unless Evilution::Feedback::Detector.friction?(summary)
38
85
 
@@ -40,4 +87,14 @@ module Evilution::MCP::MutateTool::ReportTrimmer
40
87
  data["feedback_hint"] = Evilution::Feedback::Messages.mcp_hint
41
88
  end
42
89
  private_class_method :embed_feedback
90
+
91
+ # Surface a clear pointer at the most common silent-failure: every mutation
92
+ # errored because the worker couldn't load the project (typically Rails
93
+ # autoload + MCP's preload-disabled-by-default). When detected, attach a
94
+ # `setup_warning` field so the agent doesn't trust a 0.0 score blindly.
95
+ def self.embed_setup_warning(data, summary)
96
+ warning = Evilution::Feedback::SetupWarning.call(summary)
97
+ data["setup_warning"] = warning if warning
98
+ end
99
+ private_class_method :embed_setup_warning
43
100
  end
@@ -27,8 +27,11 @@ class Evilution::MCP::MutateTool < MCP::Tool
27
27
  "so the agent can jump straight to writing the missing test. " \
28
28
  "Prefer this over shelling out to 'evilution' — the response is machine-readable " \
29
29
  "and already trimmed for survived-mutant triage. " \
30
+ "CLI equivalent: `evilution run [files...]` (or `evilution mutate [files...]` as an alias). " \
30
31
  "Hitting errors, friction, or missing capabilities? See evilution-info action=feedback for the " \
31
- "public feedback channel — ask the user before posting anything."
32
+ "public feedback channel — ask the user before posting anything. " \
33
+ "Contract: input schema and output payload are stable for the 1.x line; " \
34
+ "see README \"MCP Server\" section for the full deprecation policy."
32
35
  input_schema(
33
36
  properties: {
34
37
  files: {
@@ -86,6 +89,20 @@ class Evilution::MCP::MutateTool < MCP::Tool
86
89
  type: "boolean",
87
90
  description: "Save session results to .evilution/results/ for later inspection via evilution-session"
88
91
  },
92
+ preload: {
93
+ oneOf: [
94
+ { type: "string" },
95
+ { type: "boolean", enum: [false] }
96
+ ],
97
+ description: "Preload a file (e.g. 'spec/rails_helper.rb') into the MCP server process before " \
98
+ "forking workers. Default: false. Pass an explicit path (string) for Rails / Zeitwerk " \
99
+ "projects where workers need autoloaded constants resolved before the mutation runs — " \
100
+ "without this, autoload-dependent code fails with NameError on every mutation, producing " \
101
+ "all-errored / score 0.0 results. Pass `false` to explicitly disable preload (e.g. to " \
102
+ "override a `.evilution.yml` setting). `true` is not accepted — pass an explicit path. " \
103
+ "Trade-off: opt-in pollutes the long-lived MCP server process for the lifetime of the " \
104
+ "session; restart the server when switching projects."
105
+ },
89
106
  skip_config: {
90
107
  type: "boolean",
91
108
  description: "When true, ignore .evilution.yml / config/evilution.yml. " \
@@ -97,8 +114,10 @@ class Evilution::MCP::MutateTool < MCP::Tool
97
114
  type: "string",
98
115
  enum: %w[full summary minimal],
99
116
  description: "Response verbosity: full (all entries, diffs stripped from killed/neutral/equivalent), " \
100
- "summary (omits killed/neutral/equivalent arrays; default), " \
101
- "minimal (only summary + survived)"
117
+ "summary (omits killed/neutral/equivalent arrays; remaining timed_out/errors/unresolved " \
118
+ "entries shed `diff` and `error_backtrace` to bound payload size; default), " \
119
+ "minimal (summary + survived; also includes a trimmed sample of up to 3 errored " \
120
+ "entries with error_message and a 5-line backtrace head when errors > 0)"
102
121
  }
103
122
  }
104
123
  )
@@ -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: {
@@ -77,8 +79,8 @@ class Evilution::MCP::SessionTool < MCP::Tool
77
79
  entries = store.list
78
80
  entries = entries.first(result.limit) unless result.limit.nil?
79
81
 
80
- payload = entries.map { |e| e.transform_keys(&:to_s) }
81
- success_response(payload)
82
+ sessions = entries.map { |e| e.transform_keys(&:to_s) }
83
+ success_response("schema_version" => Evilution::MCP::CONTRACT_VERSION, "sessions" => sessions)
82
84
  end
83
85
 
84
86
  def normalize_limit(limit)
@@ -115,7 +117,8 @@ class Evilution::MCP::SessionTool < MCP::Tool
115
117
  return validation if validation
116
118
 
117
119
  result = load_and_diff(base, head, dir)
118
- success_response(result.to_h)
120
+ payload = { "schema_version" => Evilution::MCP::CONTRACT_VERSION }.merge(result.to_h)
121
+ success_response(payload)
119
122
  rescue Evilution::Error => e
120
123
  error_response("not_found", e.message)
121
124
  rescue ::JSON::ParserError => e
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
@@ -3,17 +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
8
10
  AffectedSlices = Data.define(:original, :mutated)
9
11
  private_constant :AffectedSlices
10
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
+
11
24
  attr_reader :mutations
12
25
 
13
26
  def initialize(**_options)
14
27
  @mutations = []
15
28
  @subject = nil
16
29
  @file_source = nil
30
+ @body_call_neutralizer = Evilution::Integration::Loading::BodyCallNeutralizer.new
17
31
  end
18
32
 
19
33
  def call(subject, filter: nil)
@@ -30,6 +44,26 @@ class Evilution::Mutator::Base < Prism::Visitor
30
44
  def add_mutation(offset:, length:, replacement:, node:)
31
45
  return if @filter && @filter.skip?(node)
32
46
 
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
55
+ )
56
+
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
+
33
67
  surgery = Evilution::AST::SourceSurgeon.apply(
34
68
  @file_source, offset: offset, length: length, replacement: replacement
35
69
  )
@@ -54,10 +88,24 @@ class Evilution::Mutator::Base < Prism::Visitor
54
88
  line: node.location.start_line,
55
89
  column: node.location.start_column
56
90
  ),
57
- parse_status: surgery.status
91
+ parse_status: surgery.status,
92
+ eval_source: build_eval_source(surgery)
58
93
  )
59
94
  end
60
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
+
61
109
  NEWLINE_BYTE = 10
62
110
  private_constant :NEWLINE_BYTE
63
111
 
@@ -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
@@ -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
@@ -30,15 +30,33 @@ class Evilution::Mutator::Operator::ExplicitSuperMutation < Evilution::Mutator::
30
30
  end
31
31
 
32
32
  # super(a, b) -> super()
33
+ #
34
+ # The argument list's byte range covers only the args themselves; the
35
+ # separator (`,` + whitespace) between the last arg and a following
36
+ # `&block` (or the closing `)` after a trailing comma) is owned by the
37
+ # SuperNode itself. Replacing only `arguments.location` with `""` leaves
38
+ # `super(, &block)` or `super(,)`. Extend the removal range to the start
39
+ # of the block argument when present, otherwise to the closing paren — so
40
+ # the separator goes along with the args.
33
41
  def emit_remove_all_args(node)
42
+ start_offset = node.arguments.location.start_offset
43
+ end_offset = trailing_args_boundary(node)
44
+
34
45
  add_mutation(
35
- offset: node.arguments.location.start_offset,
36
- length: node.arguments.location.length,
46
+ offset: start_offset,
47
+ length: end_offset - start_offset,
37
48
  replacement: "",
38
49
  node: node
39
50
  )
40
51
  end
41
52
 
53
+ def trailing_args_boundary(node)
54
+ return node.block.location.start_offset if node.block
55
+ return node.rparen_loc.start_offset if node.rparen_loc
56
+
57
+ node.arguments.location.end_offset
58
+ end
59
+
42
60
  def emit_remove_arg_at(node, args, i)
43
61
  remaining = args.each_with_index.filter_map { |a, j| a.slice if j != i }
44
62
  add_mutation(
@@ -22,10 +22,22 @@ class Evilution::Mutator::Operator::IndexToAt < Evilution::Mutator::Base
22
22
  @file_source.byteslice(loc.start_offset, loc.length)
23
23
  end
24
24
 
25
+ # EV-pn5y / GH #1173: Hash has no #at method, so the symbol/string keys that
26
+ # almost always indicate a Hash receiver are skipped here — otherwise the
27
+ # mutated source crashes with NoMethodError instead of yielding a measurable
28
+ # mutation. Integer literals and variable/expression keys are still mutated;
29
+ # if the receiver in those cases turns out to be a Hash the mutation will
30
+ # still raise NoMethodError at runtime, but those shapes are far rarer in
31
+ # practice and the AST gives no reliable receiver-type signal to filter on.
25
32
  def indexable?(node)
26
33
  node.name == :[] &&
27
34
  node.receiver &&
28
35
  node.arguments &&
29
- node.arguments.arguments.length == 1
36
+ node.arguments.arguments.length == 1 &&
37
+ !hash_key_shape?(node.arguments.arguments.first)
38
+ end
39
+
40
+ def hash_key_shape?(arg)
41
+ arg.is_a?(Prism::SymbolNode) || arg.is_a?(Prism::StringNode)
30
42
  end
31
43
  end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../operator"
4
+
5
+ # Removes a trailing literal expression (true/false/nil/integer/symbol) from
6
+ # a method body. Targets the idiomatic Ruby pattern `def foo?; side_effect;
7
+ # true; end` where the explicit literal return value is the high-signal
8
+ # behavior under test — dropping it makes the method return whatever the
9
+ # preceding statement evaluates to. Strong against predicates and
10
+ # command-query split methods.
11
+ class Evilution::Mutator::Operator::LastExpressionRemoval < Evilution::Mutator::Base
12
+ LITERAL_NODE_TYPES = [
13
+ Prism::TrueNode,
14
+ Prism::FalseNode,
15
+ Prism::NilNode,
16
+ Prism::IntegerNode,
17
+ Prism::SymbolNode
18
+ ].freeze
19
+
20
+ def visit_def_node(node)
21
+ last_literal = trailing_literal(node)
22
+ if last_literal
23
+ add_mutation(
24
+ offset: last_literal.location.start_offset,
25
+ length: last_literal.location.length,
26
+ replacement: "",
27
+ node: last_literal
28
+ )
29
+ end
30
+
31
+ super
32
+ end
33
+
34
+ private
35
+
36
+ def trailing_literal(node)
37
+ body = node.body
38
+ return nil unless body.is_a?(Prism::StatementsNode)
39
+ return nil if body.body.empty?
40
+
41
+ last = body.body.last
42
+ return nil unless LITERAL_NODE_TYPES.any? { |t| last.is_a?(t) }
43
+
44
+ last
45
+ end
46
+ end
@@ -3,8 +3,20 @@
3
3
  require_relative "../operator"
4
4
 
5
5
  class Evilution::Mutator::Operator::ReceiverReplacement < Evilution::Mutator::Base
6
+ # Ruby reserved words. A call like `self.class` — when stripped of its
7
+ # `self.` receiver — becomes the bare token `class`, which the parser reads
8
+ # as the class-definition keyword rather than a method call. Producing this
9
+ # mutation guarantees an unparseable result. Skip when the call's method
10
+ # name collides with any reserved keyword.
11
+ RUBY_RESERVED_KEYWORDS = %i[
12
+ BEGIN END __ENCODING__ __FILE__ __LINE__
13
+ alias and begin break case class def defined? do else elsif end
14
+ ensure false for if in module next nil not or redo rescue retry
15
+ return self super then true undef unless until when while yield
16
+ ].to_set.freeze
17
+
6
18
  def visit_call_node(node)
7
- if node.receiver.is_a?(Prism::SelfNode)
19
+ if eligible_self_call?(node)
8
20
  add_mutation(
9
21
  offset: node.location.start_offset,
10
22
  length: node.location.length,
@@ -18,6 +30,22 @@ class Evilution::Mutator::Operator::ReceiverReplacement < Evilution::Mutator::Ba
18
30
 
19
31
  private
20
32
 
33
+ def eligible_self_call?(node)
34
+ return false unless node.receiver.is_a?(Prism::SelfNode)
35
+ return false if reserved_keyword_method?(node.name)
36
+
37
+ true
38
+ end
39
+
40
+ # `self.class = value` is a writer call whose Prism `name` is `:class=` —
41
+ # not `:class` — so a literal lookup in RUBY_RESERVED_KEYWORDS misses it,
42
+ # and stripping the receiver leaves `class = value` (parse error). Normalize
43
+ # the trailing `=` from writer forms before comparing.
44
+ def reserved_keyword_method?(name)
45
+ base = name.to_s.chomp("=").to_sym
46
+ RUBY_RESERVED_KEYWORDS.include?(base)
47
+ end
48
+
21
49
  def call_without_self_text(node)
22
50
  message_start = node.message_loc.start_offset
23
51
  call_end = node.location.start_offset + node.location.length
@@ -3,27 +3,76 @@
3
3
  require_relative "../operator"
4
4
 
5
5
  class Evilution::Mutator::Operator::RescueRemoval < Evilution::Mutator::Base
6
- def visit_rescue_node(node)
7
- remove_start = line_start_before(node.keyword_loc.start_offset)
8
- remove_end = rescue_end_offset(node)
6
+ # Visit BeginNode (not RescueNode directly) so we have access to sibling
7
+ # clauses — `else_clause`, `ensure_clause`, `end_keyword_loc` — which we
8
+ # need to compute the rescue clause's boundary and to drop a soon-to-be-
9
+ # orphaned `else` along with the rescue.
10
+ def visit_begin_node(node)
11
+ walk_rescue_chain(node) { |rescue_node| emit_removal(node, rescue_node) }
12
+
13
+ super
14
+ end
15
+
16
+ private
17
+
18
+ def walk_rescue_chain(begin_node)
19
+ current = begin_node.rescue_clause
20
+ while current
21
+ yield current
22
+ current = current.subsequent
23
+ end
24
+ end
25
+
26
+ def emit_removal(begin_node, rescue_node)
27
+ remove_start = line_start_before(rescue_node.keyword_loc.start_offset)
28
+ remove_end = rescue_clause_end(begin_node, rescue_node)
29
+ remove_end = else_end(begin_node) if removing_sole_rescue_orphans_else?(begin_node, rescue_node)
9
30
 
10
31
  add_mutation(
11
32
  offset: remove_start,
12
33
  length: remove_end - remove_start,
13
34
  replacement: "",
14
- node: node
35
+ node: rescue_node
15
36
  )
37
+ end
16
38
 
17
- super
39
+ # The rescue clause's bytes run from its `rescue` keyword up to the start
40
+ # of the next sibling clause: another `rescue`, then `else`, `ensure`, or
41
+ # the begin's `end`. Using these structural boundaries instead of the
42
+ # statements body fixes the underflow when the body is comment-only or
43
+ # otherwise empty — the old code stopped at the `rescue` keyword and left
44
+ # the exception class name orphaned in the source.
45
+ def rescue_clause_end(begin_node, rescue_node)
46
+ boundary = next_clause_start(begin_node, rescue_node)
47
+ line_start_before(boundary)
18
48
  end
19
49
 
20
- private
50
+ def next_clause_start(begin_node, rescue_node)
51
+ return rescue_node.subsequent.keyword_loc.start_offset if rescue_node.subsequent
52
+ return begin_node.else_clause.else_keyword_loc.start_offset if begin_node.else_clause
53
+ return begin_node.ensure_clause.ensure_keyword_loc.start_offset if begin_node.ensure_clause
21
54
 
22
- def rescue_end_offset(node)
23
- return line_start_before(node.subsequent.keyword_loc.start_offset) if node.subsequent
55
+ begin_node.end_keyword_loc.start_offset
56
+ end
57
+
58
+ # An `else` clause is grammatically tied to a `rescue` chain. Stripping
59
+ # the last remaining rescue without also dropping `else` leaves
60
+ # `begin ... else ... end` which Ruby rejects. Detect: the chain has one
61
+ # rescue, and an else exists.
62
+ def removing_sole_rescue_orphans_else?(begin_node, rescue_node)
63
+ return false unless begin_node.else_clause
64
+ return false unless rescue_node == begin_node.rescue_clause
65
+ return false if rescue_node.subsequent
66
+
67
+ true
68
+ end
24
69
 
25
- loc = node.statements ? node.statements.location : node.keyword_loc
26
- loc.start_offset + loc.length
70
+ def else_end(begin_node)
71
+ if begin_node.ensure_clause
72
+ line_start_before(begin_node.ensure_clause.ensure_keyword_loc.start_offset)
73
+ else
74
+ line_start_before(begin_node.end_keyword_loc.start_offset)
75
+ end
27
76
  end
28
77
 
29
78
  def line_start_before(offset)