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.
- checksums.yaml +4 -4
- data/.beads/interactions.jsonl +54 -0
- data/.rubocop_todo.yml +7 -0
- data/CHANGELOG.md +42 -0
- data/README.md +194 -8
- data/docs/versioning.md +53 -0
- data/lib/evilution/ast/heredoc_span.rb +99 -0
- data/lib/evilution/baseline.rb +15 -2
- data/lib/evilution/cli/commands/compare.rb +13 -0
- data/lib/evilution/cli/parser/command_extractor.rb +3 -1
- data/lib/evilution/cli/parser/options_builder.rb +2 -2
- data/lib/evilution/config/file_loader.rb +40 -1
- data/lib/evilution/config.rb +11 -1
- data/lib/evilution/equivalent/heuristic/dead_code.rb +8 -1
- data/lib/evilution/feedback/setup_warning.rb +79 -0
- data/lib/evilution/gem_detector.rb +132 -0
- data/lib/evilution/integration/loading/body_call_neutralizer.rb +190 -0
- data/lib/evilution/integration/loading/mutation_applier.rb +20 -5
- data/lib/evilution/integration/loading/redefinition_recovery.rb +58 -1
- data/lib/evilution/integration/minitest.rb +37 -2
- data/lib/evilution/integration/rspec/result_builder.rb +20 -1
- data/lib/evilution/integration/rspec.rb +16 -1
- data/lib/evilution/isolation/fork.rb +77 -10
- data/lib/evilution/mcp/info_tool/response_formatter.rb +14 -1
- data/lib/evilution/mcp/info_tool.rb +3 -1
- data/lib/evilution/mcp/mutate_tool/option_parser.rb +1 -1
- data/lib/evilution/mcp/mutate_tool/report_trimmer.rb +58 -1
- data/lib/evilution/mcp/mutate_tool.rb +22 -3
- data/lib/evilution/mcp/session_tool.rb +7 -4
- data/lib/evilution/mcp.rb +6 -0
- data/lib/evilution/mutation.rb +13 -1
- data/lib/evilution/mutator/base.rb +49 -1
- data/lib/evilution/mutator/operator/argument_method_call_replacement.rb +59 -0
- data/lib/evilution/mutator/operator/block_param_removal.rb +32 -0
- data/lib/evilution/mutator/operator/explicit_super_mutation.rb +20 -2
- data/lib/evilution/mutator/operator/index_to_at.rb +13 -1
- data/lib/evilution/mutator/operator/last_expression_removal.rb +46 -0
- data/lib/evilution/mutator/operator/receiver_replacement.rb +29 -1
- data/lib/evilution/mutator/operator/rescue_removal.rb +59 -10
- data/lib/evilution/mutator/operator/splat_operator.rb +28 -1
- data/lib/evilution/mutator/operator/string_literal.rb +83 -6
- data/lib/evilution/mutator/registry.rb +2 -0
- data/lib/evilution/reporter/cli/line_formatters/error_rate_warning.rb +29 -0
- data/lib/evilution/reporter/cli/metrics_block.rb +2 -0
- data/lib/evilution/reporter/json.rb +2 -0
- data/lib/evilution/result/mutation_result.rb +12 -6
- data/lib/evilution/runner/baseline_runner.rb +5 -1
- data/lib/evilution/runner/isolation_resolver.rb +69 -8
- data/lib/evilution/runner/mutation_planner.rb +18 -1
- data/lib/evilution/session/schema.rb +44 -0
- data/lib/evilution/session/store.rb +5 -1
- data/lib/evilution/version.rb +1 -1
- data/lib/evilution.rb +2 -0
- data/schema/evilution.config.schema.json +205 -0
- data/script/build_runtime_snapshot +88 -0
- data/script/run_self_baseline +79 -0
- data/script/run_self_validation +54 -0
- 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
|
|
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;
|
|
101
|
-
"
|
|
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
|
-
|
|
81
|
-
success_response(
|
|
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
|
-
|
|
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
|
data/lib/evilution/mutation.rb
CHANGED
|
@@ -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:,
|
|
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:
|
|
36
|
-
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
|
|
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
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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:
|
|
35
|
+
node: rescue_node
|
|
15
36
|
)
|
|
37
|
+
end
|
|
16
38
|
|
|
17
|
-
|
|
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
|
-
|
|
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
|
-
|
|
23
|
-
|
|
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
|
-
|
|
26
|
-
|
|
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)
|