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.
- checksums.yaml +4 -4
- data/.beads/interactions.jsonl +106 -0
- data/.rubocop_todo.yml +7 -0
- data/CHANGELOG.md +49 -0
- data/README.md +194 -8
- data/docs/versioning.md +53 -0
- data/lib/evilution/ast/constant_names.rb +28 -11
- data/lib/evilution/ast/heredoc_span.rb +99 -0
- data/lib/evilution/ast/pattern/parser.rb +29 -17
- data/lib/evilution/baseline.rb +15 -2
- data/lib/evilution/cli/commands/compare.rb +13 -0
- data/lib/evilution/cli/commands/session_diff.rb +6 -4
- data/lib/evilution/cli/commands/subjects.rb +6 -3
- data/lib/evilution/cli/commands/util_mutation.rb +24 -19
- data/lib/evilution/cli/parser/command_extractor.rb +12 -12
- data/lib/evilution/cli/parser/file_args.rb +3 -1
- data/lib/evilution/cli/parser/options_builder.rb +31 -3
- data/lib/evilution/cli/parser/stdin_reader.rb +2 -2
- data/lib/evilution/cli/parser.rb +18 -20
- data/lib/evilution/cli/printers/environment.rb +19 -19
- data/lib/evilution/cli/printers/session_diff.rb +8 -8
- data/lib/evilution/compare/normalizer.rb +10 -5
- data/lib/evilution/config/file_loader.rb +40 -1
- data/lib/evilution/config.rb +21 -11
- data/lib/evilution/disable_comment.rb +21 -12
- 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 +35 -15
- data/lib/evilution/integration/loading/redefinition_recovery.rb +58 -1
- data/lib/evilution/integration/minitest.rb +60 -16
- data/lib/evilution/integration/rspec/result_builder.rb +20 -1
- data/lib/evilution/integration/rspec.rb +20 -1
- data/lib/evilution/isolation/fork.rb +104 -27
- data/lib/evilution/mcp/info_tool/actions/subjects.rb +32 -23
- data/lib/evilution/mcp/info_tool/actions/tests.rb +22 -12
- data/lib/evilution/mcp/info_tool/request_parser.rb +3 -1
- data/lib/evilution/mcp/info_tool/response_formatter.rb +14 -1
- data/lib/evilution/mcp/info_tool.rb +10 -2
- data/lib/evilution/mcp/mutate_tool/option_parser.rb +4 -2
- data/lib/evilution/mcp/mutate_tool/report_trimmer.rb +58 -1
- data/lib/evilution/mcp/mutate_tool/survived_enricher.rb +19 -9
- data/lib/evilution/mcp/mutate_tool.rb +49 -17
- data/lib/evilution/mcp/session_tool.rb +34 -22
- data/lib/evilution/mcp.rb +6 -0
- data/lib/evilution/mutation.rb +26 -16
- data/lib/evilution/mutator/base.rb +66 -16
- data/lib/evilution/mutator/operator/argument_method_call_replacement.rb +59 -0
- data/lib/evilution/mutator/operator/argument_nil_substitution.rb +11 -14
- data/lib/evilution/mutator/operator/argument_removal.rb +11 -14
- data/lib/evilution/mutator/operator/begin_unwrap.rb +17 -5
- data/lib/evilution/mutator/operator/bitwise_complement.rb +26 -19
- data/lib/evilution/mutator/operator/block_param_removal.rb +50 -8
- data/lib/evilution/mutator/operator/block_pass_removal.rb +19 -15
- data/lib/evilution/mutator/operator/case_when.rb +7 -5
- data/lib/evilution/mutator/operator/conditional_branch.rb +22 -22
- data/lib/evilution/mutator/operator/equality_to_identity.rb +8 -3
- data/lib/evilution/mutator/operator/explicit_super_mutation.rb +36 -14
- data/lib/evilution/mutator/operator/index_to_at.rb +18 -5
- data/lib/evilution/mutator/operator/index_to_dig.rb +12 -6
- data/lib/evilution/mutator/operator/index_to_fetch.rb +5 -4
- data/lib/evilution/mutator/operator/keyword_argument.rb +30 -25
- data/lib/evilution/mutator/operator/last_expression_removal.rb +46 -0
- data/lib/evilution/mutator/operator/mixin_removal.rb +20 -14
- data/lib/evilution/mutator/operator/multiple_assignment.rb +12 -13
- data/lib/evilution/mutator/operator/receiver_replacement.rb +38 -7
- data/lib/evilution/mutator/operator/regex_simplification.rb +62 -67
- data/lib/evilution/mutator/operator/rescue_body_replacement.rb +9 -8
- data/lib/evilution/mutator/operator/rescue_removal.rb +58 -12
- data/lib/evilution/mutator/operator/splat_operator.rb +28 -1
- data/lib/evilution/mutator/operator/string_literal.rb +83 -6
- data/lib/evilution/mutator/operator/superclass_removal.rb +21 -15
- data/lib/evilution/mutator/registry.rb +2 -0
- data/lib/evilution/parallel/work_queue/dispatcher.rb +15 -8
- data/lib/evilution/parallel/work_queue/worker.rb +10 -7
- data/lib/evilution/parallel/work_queue.rb +35 -18
- data/lib/evilution/reporter/cli/item_formatters/coverage_gap.rb +13 -8
- data/lib/evilution/reporter/cli/line_formatters/error_rate_warning.rb +29 -0
- data/lib/evilution/reporter/cli/line_formatters/mutations.rb +17 -8
- data/lib/evilution/reporter/cli/metrics_block.rb +2 -0
- data/lib/evilution/reporter/json.rb +54 -18
- data/lib/evilution/reporter/suggestion/diff_helpers.rb +0 -13
- data/lib/evilution/reporter/suggestion/diff_lines.rb +28 -0
- data/lib/evilution/reporter/suggestion/templates/minitest.rb +20 -14
- data/lib/evilution/reporter/suggestion/templates/rspec.rb +19 -13
- data/lib/evilution/result/mutation_result.rb +12 -6
- data/lib/evilution/runner/baseline_runner.rb +20 -9
- data/lib/evilution/runner/diagnostics.rb +13 -9
- data/lib/evilution/runner/isolation_resolver.rb +75 -12
- data/lib/evilution/runner/mutation_executor/result_cache.rb +3 -1
- data/lib/evilution/runner/mutation_executor/strategy/parallel.rb +32 -10
- data/lib/evilution/runner/mutation_executor/strategy/sequential.rb +1 -1
- data/lib/evilution/runner/mutation_executor.rb +2 -0
- data/lib/evilution/runner/mutation_planner.rb +53 -16
- data/lib/evilution/runner/subject_pipeline.rb +21 -11
- data/lib/evilution/runner.rb +3 -3
- data/lib/evilution/session/diff.rb +15 -6
- data/lib/evilution/session/schema.rb +44 -0
- data/lib/evilution/session/store.rb +5 -1
- data/lib/evilution/spec_ast_cache.rb +26 -12
- 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/memory_check +11 -5
- data/script/run_self_baseline +79 -0
- data/script/run_self_validation +54 -0
- data/scripts/benchmark_density +10 -9
- data/scripts/compare_mutations +38 -21
- data/scripts/mutant_json_adapter +7 -4
- 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
|
-
|
|
69
|
-
return error_response("config_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(
|
|
80
|
+
entries = entries.first(result.limit) unless result.limit.nil?
|
|
76
81
|
|
|
77
|
-
|
|
78
|
-
success_response(
|
|
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
|
|
87
|
+
return LimitResult.new(limit: nil, error: nil) if limit.nil?
|
|
83
88
|
|
|
84
89
|
coerced = Integer(limit)
|
|
85
|
-
return
|
|
90
|
+
return LimitResult.new(limit: nil, error: "limit must be a non-negative integer") if coerced.negative?
|
|
86
91
|
|
|
87
|
-
|
|
92
|
+
LimitResult.new(limit: coerced, error: nil)
|
|
88
93
|
rescue ArgumentError, TypeError
|
|
89
|
-
|
|
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
|
-
|
|
115
|
-
return
|
|
116
|
+
validation = validate_diff_args(base, head, dir)
|
|
117
|
+
return validation if validation
|
|
116
118
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
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
|
|
@@ -74,22 +86,17 @@ class Evilution::Mutation
|
|
|
74
86
|
private
|
|
75
87
|
|
|
76
88
|
def compute_diff
|
|
77
|
-
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
39
|
-
|
|
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 <<
|
|
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:
|
|
49
|
-
slice: Evilution::Mutation::Slice.new(original:
|
|
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
|
|
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
|
-
|
|
9
|
-
|
|
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
|
-
|
|
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
|