evilution 1.3.0 → 1.4.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 +62 -0
- data/.rubocop_todo.yml +5 -0
- data/CHANGELOG.md +77 -0
- data/README.md +80 -24
- data/docs/architecture.md +89 -9
- data/docs/ast_pattern_syntax.md +50 -5
- data/docs/isolation.md +39 -2
- data/docs/migration-from-mutant.md +1 -1
- data/lib/evilution/ast/aasm_declaration.rb +133 -0
- data/lib/evilution/ast/callback_declaration.rb +91 -0
- data/lib/evilution/ast/included_block.rb +27 -0
- data/lib/evilution/ast/literal_callable.rb +21 -0
- data/lib/evilution/ast/parser.rb +119 -16
- data/lib/evilution/ast/pattern/method_name.rb +63 -0
- data/lib/evilution/ast/pattern/parser.rb +14 -15
- data/lib/evilution/ast/regexp_pattern.rb +104 -0
- data/lib/evilution/ast/scope_declaration.rb +45 -0
- data/lib/evilution/ast/uncovered_code.rb +88 -0
- data/lib/evilution/ast/value_object_definition.rb +25 -0
- data/lib/evilution/baseline/failure_formatter.rb +27 -0
- data/lib/evilution/baseline/report.rb +66 -0
- data/lib/evilution/baseline/spec_failure.rb +38 -0
- data/lib/evilution/baseline.rb +67 -30
- data/lib/evilution/cli/parser/file_args.rb +2 -1
- data/lib/evilution/cli/parser/options_builder.rb +1 -1
- data/lib/evilution/cli.rb +3 -2
- data/lib/evilution/config/validators/spec_mappings.rb +2 -1
- data/lib/evilution/config.rb +3 -2
- data/lib/evilution/equivalent/detector.rb +3 -1
- data/lib/evilution/equivalent/heuristic/guarded_index_fetch/condition.rb +66 -0
- data/lib/evilution/equivalent/heuristic/guarded_index_fetch/disturbance.rb +54 -0
- data/lib/evilution/equivalent/heuristic/guarded_index_fetch/early_exit.rb +73 -0
- data/lib/evilution/equivalent/heuristic/guarded_index_fetch/guard.rb +36 -0
- data/lib/evilution/equivalent/heuristic/guarded_index_fetch/index_read.rb +82 -0
- data/lib/evilution/equivalent/heuristic/guarded_index_fetch/node_path.rb +43 -0
- data/lib/evilution/equivalent/heuristic/guarded_index_fetch.rb +65 -0
- data/lib/evilution/example_filter.rb +45 -0
- data/lib/evilution/hooks/registry.rb +2 -1
- data/lib/evilution/integration/base.rb +2 -0
- data/lib/evilution/integration/known_failures.rb +26 -0
- data/lib/evilution/integration/loading/body_call_neutralizer.rb +132 -13
- data/lib/evilution/integration/loading/callback_redeclaration.rb +186 -0
- data/lib/evilution/integration/loading/concern_redeclaration.rb +61 -0
- data/lib/evilution/integration/loading/concern_state_cleaner.rb +19 -12
- data/lib/evilution/integration/loading/mutation_applier.rb +16 -0
- data/lib/evilution/integration/loading/test_class_cache.rb +46 -0
- data/lib/evilution/integration/minitest/test_ids.rb +21 -0
- data/lib/evilution/integration/minitest.rb +45 -6
- data/lib/evilution/integration/rspec/baseline_runner.rb +47 -3
- data/lib/evilution/integration/rspec/example_ids.rb +35 -0
- data/lib/evilution/integration/rspec/result_builder.rb +6 -1
- data/lib/evilution/integration/rspec/state_guard/anonymous_example_group_examples.rb +36 -0
- data/lib/evilution/integration/rspec/state_guard.rb +4 -1
- data/lib/evilution/integration/rspec.rb +22 -4
- data/lib/evilution/integration/test_unit/result_builder.rb +6 -0
- data/lib/evilution/integration/test_unit/test_ids.rb +18 -0
- data/lib/evilution/integration/test_unit.rb +37 -9
- data/lib/evilution/isolation/fork.rb +2 -1
- data/lib/evilution/isolation/in_process.rb +21 -4
- data/lib/evilution/mcp/info_tool/status_glossary.rb +2 -2
- data/lib/evilution/mcp/mutate_tool/progress_streamer.rb +2 -1
- data/lib/evilution/memory/leak_check.rb +27 -3
- data/lib/evilution/mutation.rb +7 -2
- data/lib/evilution/mutator/base.rb +49 -5
- data/lib/evilution/mutator/operator/alias_removal.rb +126 -0
- data/lib/evilution/mutator/operator/argument_order_permutation.rb +101 -0
- data/lib/evilution/mutator/operator/argument_removal.rb +9 -1
- data/lib/evilution/mutator/operator/comparison_operand_swap.rb +54 -0
- data/lib/evilution/mutator/operator/data_struct_member.rb +82 -0
- data/lib/evilution/mutator/operator/exception_swallow.rb +84 -0
- data/lib/evilution/mutator/operator/format_specifier_swap.rb +100 -0
- data/lib/evilution/mutator/operator/forwarded_argument_drop.rb +142 -0
- data/lib/evilution/mutator/operator/integer_division_to_fdiv.rb +62 -0
- data/lib/evilution/mutator/operator/keyword_argument.rb +25 -2
- data/lib/evilution/mutator/operator/keyword_value_swap.rb +75 -0
- data/lib/evilution/mutator/operator/no_matching_pattern_else.rb +47 -0
- data/lib/evilution/mutator/operator/numbered_parameter_swap.rb +78 -0
- data/lib/evilution/mutator/operator/off_by_one_boundary.rb +68 -0
- data/lib/evilution/mutator/operator/optional_parameter_to_required.rb +14 -3
- data/lib/evilution/mutator/operator/pattern_matching_array.rb +12 -1
- data/lib/evilution/mutator/operator/pattern_wildcard_widening.rb +117 -0
- data/lib/evilution/mutator/operator/pin_operator_removal.rb +48 -0
- data/lib/evilution/mutator/operator/regex_simplification.rb +53 -132
- data/lib/evilution/mutator/operator/regexp_alternation_branch_deletion.rb +54 -0
- data/lib/evilution/mutator/operator/regexp_anchor_promotion.rb +43 -0
- data/lib/evilution/mutator/operator/regexp_capture_to_passive.rb +61 -0
- data/lib/evilution/mutator/operator/regexp_character_type_complement.rb +54 -0
- data/lib/evilution/mutator/operator/regexp_named_group_rename.rb +106 -0
- data/lib/evilution/mutator/operator/regexp_option_removal.rb +51 -0
- data/lib/evilution/mutator/operator/regexp_quantifier_minimum_swap.rb +45 -0
- data/lib/evilution/mutator/operator/rescue_else_concatenation.rb +62 -0
- data/lib/evilution/mutator/operator/rescue_handler_concatenation.rb +69 -0
- data/lib/evilution/mutator/operator/rescue_handler_promotion.rb +65 -0
- data/lib/evilution/mutator/operator/return_keyword_removal.rb +79 -0
- data/lib/evilution/mutator/operator/rightward_assignment.rb +46 -0
- data/lib/evilution/mutator/operator/send_mutation.rb +2 -0
- data/lib/evilution/mutator/operator/splat_operator.rb +38 -13
- data/lib/evilution/mutator/operator/statement_reorder.rb +153 -0
- data/lib/evilution/mutator/registry.rb +31 -2
- data/lib/evilution/mutator/rescue_handlers.rb +81 -0
- data/lib/evilution/process_supervisor.rb +3 -2
- data/lib/evilution/reporter/cli/line_formatters/baseline_neutralized_notice.rb +55 -0
- data/lib/evilution/reporter/cli/metrics_block.rb +2 -0
- data/lib/evilution/reporter/json/baseline.rb +15 -0
- data/lib/evilution/reporter/json.rb +7 -2
- data/lib/evilution/result/baseline_neutralization.rb +10 -0
- data/lib/evilution/result/mutation_result.rb +9 -1
- data/lib/evilution/result/summary.rb +42 -2
- data/lib/evilution/runner/baseline_runner.rb +9 -4
- data/lib/evilution/runner/canary.rb +2 -57
- data/lib/evilution/runner/canary_failure_message.rb +101 -0
- data/lib/evilution/runner/mutation_executor/neutralizer/baseline_failed.rb +22 -8
- data/lib/evilution/runner/mutation_executor/result_cache.rb +3 -0
- data/lib/evilution/runner/mutation_executor/result_packer.rb +4 -2
- data/lib/evilution/runner/mutation_executor.rb +1 -1
- data/lib/evilution/runner/mutation_planner.rb +11 -2
- data/lib/evilution/runner/report_publisher.rb +3 -2
- data/lib/evilution/runner/subject_pipeline.rb +46 -1
- data/lib/evilution/runner.rb +4 -1
- data/lib/evilution/subject.rb +8 -2
- data/lib/evilution/version.rb +1 -1
- data/lib/evilution.rb +28 -0
- data/script/memory_check +62 -16
- metadata +83 -4
|
@@ -9,6 +9,22 @@ require_relative "../result/mutation_result"
|
|
|
9
9
|
require_relative "../isolation"
|
|
10
10
|
|
|
11
11
|
class Evilution::Isolation::InProcess
|
|
12
|
+
# Raised into the run when its time is up. A SignalException, because the
|
|
13
|
+
# timeout has to end the whole test run and a test framework rescues nearly
|
|
14
|
+
# everything a test raises, Timeout::Error included: it would record one
|
|
15
|
+
# failed test and go on to the next, which may reach the same endless loop
|
|
16
|
+
# with no timer left to stop it. Minitest, Test::Unit and RSpec all let a
|
|
17
|
+
# SignalException through.
|
|
18
|
+
#
|
|
19
|
+
# Not an Interrupt, though it is one of those: tooling that handles the
|
|
20
|
+
# user's Ctrl-C (maxitest) rescues Interrupt and skips every test from then
|
|
21
|
+
# on, for the rest of the process.
|
|
22
|
+
class Expired < SignalException
|
|
23
|
+
def initialize(_message = nil)
|
|
24
|
+
super("SIGALRM")
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
12
28
|
@null_out = File.open(File::NULL, "w")
|
|
13
29
|
@null_err = File.open(File::NULL, "w")
|
|
14
30
|
|
|
@@ -33,7 +49,7 @@ class Evilution::Isolation::InProcess
|
|
|
33
49
|
private
|
|
34
50
|
|
|
35
51
|
# The Dir.chdir block is inside the Timeout.timeout block so that a
|
|
36
|
-
#
|
|
52
|
+
# timeout raised mid-call still unwinds through Dir.chdir's ensure
|
|
37
53
|
# and restores the parent CWD before the rescue clause runs. The sandbox
|
|
38
54
|
# contains any relative-path writes from path-relativizing mutations
|
|
39
55
|
# (EV-wqxu / GH #1278). Evilution.with_isolated_worker signals the rest of
|
|
@@ -41,7 +57,7 @@ class Evilution::Isolation::InProcess
|
|
|
41
57
|
# SourceEvaluator/Integration) to anchor project-relative paths to
|
|
42
58
|
# PROJECT_ROOT for the duration of the call.
|
|
43
59
|
def execute_with_timeout(mutation, test_command, timeout, sandbox_dir)
|
|
44
|
-
result = Timeout.timeout(timeout) do
|
|
60
|
+
result = Timeout.timeout(timeout, Expired) do
|
|
45
61
|
Evilution.with_isolated_worker do
|
|
46
62
|
Dir.chdir(sandbox_dir) do
|
|
47
63
|
suppress_output { test_command.call(mutation) }
|
|
@@ -49,7 +65,7 @@ class Evilution::Isolation::InProcess
|
|
|
49
65
|
end
|
|
50
66
|
end
|
|
51
67
|
{ timeout: false }.merge(result)
|
|
52
|
-
rescue
|
|
68
|
+
rescue Expired
|
|
53
69
|
{ timeout: true }
|
|
54
70
|
rescue ScriptError, StandardError => e
|
|
55
71
|
{
|
|
@@ -106,7 +122,8 @@ class Evilution::Isolation::InProcess
|
|
|
106
122
|
message: result[:error],
|
|
107
123
|
klass: result[:error_class],
|
|
108
124
|
backtrace: result[:error_backtrace]
|
|
109
|
-
)
|
|
125
|
+
),
|
|
126
|
+
known_failures_only: result[:known_failures_only]
|
|
110
127
|
)
|
|
111
128
|
end
|
|
112
129
|
end
|
|
@@ -32,8 +32,8 @@ module Evilution::MCP::InfoTool::StatusGlossary
|
|
|
32
32
|
},
|
|
33
33
|
{
|
|
34
34
|
"status" => "neutral",
|
|
35
|
-
"meaning" => "
|
|
36
|
-
"
|
|
35
|
+
"meaning" => "No verdict: the tests failed only on examples that were already failing before " \
|
|
36
|
+
"the mutation was applied, or the test process crashed on infrastructure. " \
|
|
37
37
|
"Not a meaningful mutation signal.",
|
|
38
38
|
"counted_in_score" => false
|
|
39
39
|
},
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require "json"
|
|
4
4
|
require_relative "../mutate_tool"
|
|
5
5
|
require_relative "../../reporter/suggestion"
|
|
6
|
+
require_relative "../../diagnostic"
|
|
6
7
|
|
|
7
8
|
module Evilution::MCP::MutateTool::ProgressStreamer
|
|
8
9
|
def self.build(server_context:, suggest_tests:, integration:)
|
|
@@ -21,7 +22,7 @@ module Evilution::MCP::MutateTool::ProgressStreamer
|
|
|
21
22
|
detail = build_suggestion_detail(result.mutation, suggestion)
|
|
22
23
|
server_context.report_progress(survivor_index, message: ::JSON.generate(detail))
|
|
23
24
|
rescue StandardError => e
|
|
24
|
-
warn
|
|
25
|
+
Evilution::Diagnostic.warn("[evilution] progress stream disabled after error: #{e.class}: #{e.message}")
|
|
25
26
|
disabled = true
|
|
26
27
|
end
|
|
27
28
|
end
|
|
@@ -9,9 +9,11 @@ class Evilution::Memory::LeakCheck
|
|
|
9
9
|
|
|
10
10
|
attr_reader :samples
|
|
11
11
|
|
|
12
|
-
def initialize(iterations: DEFAULT_ITERATIONS, max_growth_kb: DEFAULT_MAX_GROWTH_KB
|
|
12
|
+
def initialize(iterations: DEFAULT_ITERATIONS, max_growth_kb: DEFAULT_MAX_GROWTH_KB,
|
|
13
|
+
warmup_iterations: WARMUP_ITERATIONS)
|
|
13
14
|
@iterations = iterations
|
|
14
15
|
@max_growth_kb = max_growth_kb
|
|
16
|
+
@warmup_iterations = warmup_iterations
|
|
15
17
|
@samples = []
|
|
16
18
|
end
|
|
17
19
|
|
|
@@ -32,8 +34,23 @@ class Evilution::Memory::LeakCheck
|
|
|
32
34
|
samples.last - samples.first
|
|
33
35
|
end
|
|
34
36
|
|
|
35
|
-
|
|
37
|
+
# Growth from the first sample to the last, less the largest rise between two
|
|
38
|
+
# consecutive samples.
|
|
39
|
+
#
|
|
40
|
+
# RSS does not grow by the byte. The allocator takes memory in chunks, so a
|
|
41
|
+
# workload that has stopped growing can still step up once, by several MB,
|
|
42
|
+
# wherever the heap happens to cross a boundary, and that one step decided
|
|
43
|
+
# the endpoint reading. A leak is not one step: it grows across the run, and
|
|
44
|
+
# taking out its largest step leaves the rest of it.
|
|
45
|
+
def sustained_growth_kb
|
|
36
46
|
kb = growth_kb
|
|
47
|
+
return kb if kb.nil? || samples.size < 2
|
|
48
|
+
|
|
49
|
+
kb - [largest_step_kb, 0].max
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def passed?
|
|
53
|
+
kb = sustained_growth_kb
|
|
37
54
|
return false if kb.nil?
|
|
38
55
|
|
|
39
56
|
kb <= @max_growth_kb
|
|
@@ -41,8 +58,12 @@ class Evilution::Memory::LeakCheck
|
|
|
41
58
|
|
|
42
59
|
private
|
|
43
60
|
|
|
61
|
+
def largest_step_kb
|
|
62
|
+
samples.each_cons(2).map { |before, after| after - before }.max
|
|
63
|
+
end
|
|
64
|
+
|
|
44
65
|
def warmup(&block)
|
|
45
|
-
|
|
66
|
+
@warmup_iterations.times { block.call }
|
|
46
67
|
GC.start
|
|
47
68
|
GC.compact if GC.respond_to?(:compact)
|
|
48
69
|
end
|
|
@@ -74,10 +95,13 @@ class Evilution::Memory::LeakCheck
|
|
|
74
95
|
end
|
|
75
96
|
|
|
76
97
|
def result
|
|
98
|
+
sustained_kb = sustained_growth_kb
|
|
77
99
|
{
|
|
78
100
|
passed: passed?,
|
|
79
101
|
growth_kb: growth_kb,
|
|
80
102
|
growth_mb: growth_kb ? growth_kb / 1024.0 : nil,
|
|
103
|
+
sustained_growth_kb: sustained_kb,
|
|
104
|
+
sustained_growth_mb: sustained_kb ? sustained_kb / 1024.0 : nil,
|
|
81
105
|
samples: samples,
|
|
82
106
|
iterations: @iterations,
|
|
83
107
|
max_growth_kb: @max_growth_kb,
|
data/lib/evilution/mutation.rb
CHANGED
|
@@ -8,10 +8,14 @@ class Evilution::Mutation
|
|
|
8
8
|
Slice = Data.define(:original, :mutated)
|
|
9
9
|
Location = Data.define(:file_path, :line, :column)
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
# restore_source: the source that puts back what applying this mutation
|
|
12
|
+
# changed and re-evaluating another mutation of the file would not -- a
|
|
13
|
+
# scope declaration (see Mutator::Base#build_restore_source). nil when
|
|
14
|
+
# nothing needs restoring.
|
|
15
|
+
attr_reader :subject, :operator_name, :parse_status, :location, :restore_source
|
|
12
16
|
|
|
13
17
|
def initialize(subject:, operator_name:, sources:, location:,
|
|
14
|
-
slice: nil, parse_status: :ok, eval_source: nil)
|
|
18
|
+
slice: nil, parse_status: :ok, eval_source: nil, restore_source: nil)
|
|
15
19
|
@subject = subject
|
|
16
20
|
@operator_name = operator_name
|
|
17
21
|
@sources = sources
|
|
@@ -19,6 +23,7 @@ class Evilution::Mutation
|
|
|
19
23
|
@slice = slice
|
|
20
24
|
@parse_status = parse_status
|
|
21
25
|
@eval_source = eval_source
|
|
26
|
+
@restore_source = restore_source
|
|
22
27
|
@diff = nil
|
|
23
28
|
end
|
|
24
29
|
|
|
@@ -84,12 +84,12 @@ class Evilution::Mutator::Base < Prism::Visitor
|
|
|
84
84
|
replacement_bytesize: replacement.bytesize
|
|
85
85
|
)
|
|
86
86
|
|
|
87
|
-
mutation = build_mutation_record(node, surgery, slices)
|
|
87
|
+
mutation = build_mutation_record(node, surgery, slices, offset)
|
|
88
88
|
@mutations << mutation
|
|
89
89
|
mutation
|
|
90
90
|
end
|
|
91
91
|
|
|
92
|
-
def build_mutation_record(node, surgery, slices)
|
|
92
|
+
def build_mutation_record(node, surgery, slices, offset)
|
|
93
93
|
Evilution::Mutation.new(
|
|
94
94
|
subject: @subject,
|
|
95
95
|
operator_name: self.class.operator_name,
|
|
@@ -101,7 +101,8 @@ class Evilution::Mutator::Base < Prism::Visitor
|
|
|
101
101
|
column: node.location.start_column
|
|
102
102
|
),
|
|
103
103
|
parse_status: surgery.status,
|
|
104
|
-
eval_source: build_eval_source(surgery)
|
|
104
|
+
eval_source: build_eval_source(surgery, offset),
|
|
105
|
+
restore_source: build_restore_source(offset)
|
|
105
106
|
)
|
|
106
107
|
end
|
|
107
108
|
|
|
@@ -112,10 +113,44 @@ class Evilution::Mutator::Base < Prism::Visitor
|
|
|
112
113
|
# them at apply time. Passing the subject's file path lets the neutralizer
|
|
113
114
|
# skip files the parent never preloaded — those are lazy plugin files whose
|
|
114
115
|
# DSL calls are still needed for the child fork's first-time load.
|
|
115
|
-
|
|
116
|
+
# A scope or aasm subject's mutation sits inside a class-body declaration,
|
|
117
|
+
# which must run again for the mutated body to replace the loaded one, so
|
|
118
|
+
# that one declaration is kept.
|
|
119
|
+
def build_eval_source(surgery, offset)
|
|
116
120
|
return surgery.source unless surgery.ok?
|
|
117
121
|
|
|
118
|
-
@body_call_neutralizer.call(surgery.source, file_path: @subject.file_path)
|
|
122
|
+
@body_call_neutralizer.call(surgery.source, file_path: @subject.file_path, **redeclaration(offset))
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Re-evaluating any mutation of a file redefines all its methods, which is
|
|
126
|
+
# how an in-process run puts a mutated method back. Class-body calls are
|
|
127
|
+
# neutralized in that source, so a mutated scope or aasm callable would stay
|
|
128
|
+
# installed; the original file with just that declaration kept replaces it
|
|
129
|
+
# again.
|
|
130
|
+
def build_restore_source(offset)
|
|
131
|
+
return nil unless redeclared_subject?
|
|
132
|
+
|
|
133
|
+
@body_call_neutralizer.call(@file_source, file_path: @subject.file_path, **redeclaration(offset))
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Subjects whose body is written inside a class-body declaration and only
|
|
137
|
+
# takes effect when that declaration runs again.
|
|
138
|
+
REDECLARED_KINDS = %i[scope aasm callback].freeze
|
|
139
|
+
private_constant :REDECLARED_KINDS
|
|
140
|
+
|
|
141
|
+
def redeclared_subject?
|
|
142
|
+
REDECLARED_KINDS.include?(@subject.kind)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# What the neutralizer needs to keep a redeclared subject's declaration:
|
|
146
|
+
# where the mutation is, and the lines the declaration takes up in the
|
|
147
|
+
# file as it was loaded -- which is where the callbacks it registered then
|
|
148
|
+
# say they come from, whatever the mutation does to its length.
|
|
149
|
+
def redeclaration(offset)
|
|
150
|
+
return {} unless redeclared_subject?
|
|
151
|
+
|
|
152
|
+
first = @subject.line_number
|
|
153
|
+
{ keep_offset: offset, keep_lines: first..(first + @subject.source.count("\n")) }
|
|
119
154
|
end
|
|
120
155
|
|
|
121
156
|
NEWLINE_BYTE = 10
|
|
@@ -157,6 +192,15 @@ class Evilution::Mutator::Base < Prism::Visitor
|
|
|
157
192
|
.downcase
|
|
158
193
|
end
|
|
159
194
|
|
|
195
|
+
# The kinds of Subject the operator mutates. Operators work on method
|
|
196
|
+
# bodies, and on the bodies of callables handed to a scope, aasm or
|
|
197
|
+
# callback declaration, which run as part of the class's behaviour; one that also has
|
|
198
|
+
# something to say about a constant subject (a definition outside any
|
|
199
|
+
# method) adds :constant.
|
|
200
|
+
def self.subject_kinds
|
|
201
|
+
%i[method scope aasm callback]
|
|
202
|
+
end
|
|
203
|
+
|
|
160
204
|
@parse_cache = {}
|
|
161
205
|
|
|
162
206
|
def self.parsed_tree_for(file_path, file_source)
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
require_relative "../operator"
|
|
6
|
+
|
|
7
|
+
# Drop an alias declaration from a class or module body: `alias length size`
|
|
8
|
+
# and `alias_method :count, :size` are removed.
|
|
9
|
+
#
|
|
10
|
+
# A survivor means no example calls the method by its alias, so the alias is
|
|
11
|
+
# a surface the suite never exercises — dead, or reached only where nothing
|
|
12
|
+
# asserts.
|
|
13
|
+
#
|
|
14
|
+
# Declarations sit in the class body, outside every method, where no subject
|
|
15
|
+
# reaches them. They are attributed to the first method of the innermost
|
|
16
|
+
# enclosing class, module or `class << self`, the way DataStructMember and
|
|
17
|
+
# MixinRemoval attribute theirs. An `alias_method` call inside a method is
|
|
18
|
+
# left to the generic call operators, and global-variable aliases
|
|
19
|
+
# (`alias $new $old`) are not method surface.
|
|
20
|
+
class Evilution::Mutator::Operator::AliasRemoval < Evilution::Mutator::Base
|
|
21
|
+
def call(subject, filter: nil)
|
|
22
|
+
@subject = subject
|
|
23
|
+
@file_source = File.read(subject.file_path)
|
|
24
|
+
@mutations = []
|
|
25
|
+
@filter = filter
|
|
26
|
+
|
|
27
|
+
declarations_for(subject).each { |declaration| remove(declaration) }
|
|
28
|
+
@mutations
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def remove(declaration)
|
|
34
|
+
add_mutation(
|
|
35
|
+
offset: declaration.location.start_offset,
|
|
36
|
+
length: declaration.location.length,
|
|
37
|
+
replacement: "",
|
|
38
|
+
node: declaration
|
|
39
|
+
)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def declarations_for(subject)
|
|
43
|
+
tree = self.class.parsed_tree_for(subject.file_path, @file_source)
|
|
44
|
+
collector = DeclarationCollector.new
|
|
45
|
+
collector.visit(tree)
|
|
46
|
+
|
|
47
|
+
collector.declarations.filter_map do |declaration, scope|
|
|
48
|
+
declaration if first_method_line(scope) == subject.line_number
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def first_method_line(scope)
|
|
53
|
+
finder = FirstMethodFinder.new
|
|
54
|
+
scope.compact_child_nodes.each { |child| finder.visit(child) }
|
|
55
|
+
finder.line
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Collects alias declarations written in a class, module or singleton class
|
|
59
|
+
# body, each paired with the innermost such scope. Method bodies are not
|
|
60
|
+
# searched, and a top-level alias has no scope to attribute it to.
|
|
61
|
+
class DeclarationCollector < Prism::Visitor
|
|
62
|
+
attr_reader :declarations
|
|
63
|
+
|
|
64
|
+
def initialize
|
|
65
|
+
super
|
|
66
|
+
@declarations = []
|
|
67
|
+
@scopes = []
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def visit_class_node(node)
|
|
71
|
+
within(node) { super }
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def visit_module_node(node)
|
|
75
|
+
within(node) { super }
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def visit_singleton_class_node(node)
|
|
79
|
+
within(node) { super }
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def visit_def_node(_node); end
|
|
83
|
+
|
|
84
|
+
def visit_alias_method_node(node)
|
|
85
|
+
record(node)
|
|
86
|
+
super
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Blocks in the body (`included do ... end`) are searched too: an alias
|
|
90
|
+
# there still belongs to the enclosing scope.
|
|
91
|
+
def visit_call_node(node)
|
|
92
|
+
record(node) if node.name == :alias_method && node.receiver.nil?
|
|
93
|
+
super
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
private
|
|
97
|
+
|
|
98
|
+
def record(node)
|
|
99
|
+
@declarations << [node, @scopes.last] unless @scopes.empty?
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def within(scope)
|
|
103
|
+
@scopes.push(scope)
|
|
104
|
+
yield
|
|
105
|
+
ensure
|
|
106
|
+
@scopes.pop
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Finds the line of the first method written in a scope. Nested classes,
|
|
111
|
+
# modules and singleton classes are not searched: their methods are subjects
|
|
112
|
+
# of that inner scope.
|
|
113
|
+
class FirstMethodFinder < Prism::Visitor
|
|
114
|
+
attr_reader :line
|
|
115
|
+
|
|
116
|
+
def visit_class_node(_node); end
|
|
117
|
+
|
|
118
|
+
def visit_module_node(_node); end
|
|
119
|
+
|
|
120
|
+
def visit_singleton_class_node(_node); end
|
|
121
|
+
|
|
122
|
+
def visit_def_node(node)
|
|
123
|
+
@line = node.location.start_line if @line.nil?
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../operator"
|
|
4
|
+
|
|
5
|
+
# Swap neighbouring positional arguments: `compute(a, b)` becomes
|
|
6
|
+
# `compute(b, a)`.
|
|
7
|
+
#
|
|
8
|
+
# A survivor means no example tells the two values apart — typically a test
|
|
9
|
+
# whose fixtures are symmetric, so the method could take them in either order
|
|
10
|
+
# unnoticed.
|
|
11
|
+
#
|
|
12
|
+
# Keyword arguments and a block argument stay where they are, and a splat
|
|
13
|
+
# keeps its neighbours in place: it stands for an unknown number of
|
|
14
|
+
# arguments, so a value swapped with it would not land where the original
|
|
15
|
+
# stood. Identical neighbours are not swapped, since that would reproduce the
|
|
16
|
+
# original call.
|
|
17
|
+
class Evilution::Mutator::Operator::ArgumentOrderPermutation < Evilution::Mutator::Base
|
|
18
|
+
# `raise ArgumentError, "message"` reversed raises TypeError wherever it is
|
|
19
|
+
# reached, which only shows that the line ran.
|
|
20
|
+
RAISING_METHODS = %i[raise fail].freeze
|
|
21
|
+
|
|
22
|
+
# The format string swapped with a value raises TypeError the same way;
|
|
23
|
+
# what the string renders is FormatSpecifierSwap's job.
|
|
24
|
+
FORMAT_METHODS = %i[format sprintf printf].freeze
|
|
25
|
+
|
|
26
|
+
# Core methods that treat their arguments as an unordered set of
|
|
27
|
+
# alternatives, so any order gives the same answer.
|
|
28
|
+
ORDER_FREE_METHODS = %i[start_with? end_with?].freeze
|
|
29
|
+
|
|
30
|
+
# Arguments whose position does not map to a single value.
|
|
31
|
+
POSITIONLESS_TYPES = [Prism::SplatNode, Prism::ForwardingArgumentsNode].freeze
|
|
32
|
+
|
|
33
|
+
def visit_call_node(node)
|
|
34
|
+
swap_arguments(node) if swappable_call?(node)
|
|
35
|
+
super
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def visit_super_node(node)
|
|
39
|
+
swap_arguments(node)
|
|
40
|
+
super
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def visit_yield_node(node)
|
|
44
|
+
swap_arguments(node)
|
|
45
|
+
super
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
# The last argument of an index write is the value being stored, not a peer
|
|
51
|
+
# of the index.
|
|
52
|
+
def swappable_call?(node)
|
|
53
|
+
return false if node.name == :[]=
|
|
54
|
+
return false if ORDER_FREE_METHODS.include?(node.name)
|
|
55
|
+
return false if set_literal?(node)
|
|
56
|
+
|
|
57
|
+
!(node.receiver.nil? && (RAISING_METHODS.include?(node.name) || FORMAT_METHODS.include?(node.name)))
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# `Set[a, b]` builds a set, where the order of elements does not count.
|
|
61
|
+
def set_literal?(node)
|
|
62
|
+
return false unless node.name == :[]
|
|
63
|
+
|
|
64
|
+
receiver = node.receiver
|
|
65
|
+
case receiver
|
|
66
|
+
when Prism::ConstantReadNode then receiver.name == :Set
|
|
67
|
+
when Prism::ConstantPathNode then receiver.parent.nil? && receiver.name == :Set
|
|
68
|
+
else false
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def swap_arguments(node)
|
|
73
|
+
return if node.arguments.nil?
|
|
74
|
+
|
|
75
|
+
positionals = node.arguments.arguments.grep_v(Prism::KeywordHashNode)
|
|
76
|
+
positionals.each_cons(2) do |left, right|
|
|
77
|
+
emit_swap(node, left, right) if swappable_pair?(left, right)
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def swappable_pair?(left, right)
|
|
82
|
+
return false if [left, right].any? { |argument| POSITIONLESS_TYPES.include?(argument.class) }
|
|
83
|
+
|
|
84
|
+
left.slice != right.slice
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Whatever separates the two arguments — the comma, line breaks, comments —
|
|
88
|
+
# stays between them, so the call keeps its layout.
|
|
89
|
+
def emit_swap(node, left, right)
|
|
90
|
+
start_offset = left.location.start_offset
|
|
91
|
+
end_offset = right.location.end_offset
|
|
92
|
+
separator = byteslice_source(left.location.end_offset, right.location.start_offset - left.location.end_offset)
|
|
93
|
+
|
|
94
|
+
add_mutation(
|
|
95
|
+
offset: start_offset,
|
|
96
|
+
length: end_offset - start_offset,
|
|
97
|
+
replacement: "#{right.slice}#{separator}#{left.slice}",
|
|
98
|
+
node: node
|
|
99
|
+
)
|
|
100
|
+
end
|
|
101
|
+
end
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "../operator"
|
|
4
|
+
require_relative "../../ast/value_object_definition"
|
|
4
5
|
|
|
5
6
|
class Evilution::Mutator::Operator::ArgumentRemoval < Evilution::Mutator::Base
|
|
6
7
|
SKIP_TYPES = [
|
|
@@ -30,7 +31,14 @@ class Evilution::Mutator::Operator::ArgumentRemoval < Evilution::Mutator::Base
|
|
|
30
31
|
end
|
|
31
32
|
|
|
32
33
|
def mutable?(node, args)
|
|
33
|
-
args && args.length >= 2 && positional_only?(args) && node.name != :[]=
|
|
34
|
+
args && args.length >= 2 && positional_only?(args) && node.name != :[]= && !member_list?(node)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# DataStructMember owns the member list of `Data.define` / `Struct.new` and
|
|
38
|
+
# drops each member itself; removing an argument here would emit the same
|
|
39
|
+
# mutant a second time.
|
|
40
|
+
def member_list?(node)
|
|
41
|
+
Evilution::AST::ValueObjectDefinition.match?(node)
|
|
34
42
|
end
|
|
35
43
|
|
|
36
44
|
def positional_only?(args)
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../operator"
|
|
4
|
+
|
|
5
|
+
# Swap the operands of a spaceship comparison: `a <=> b` becomes `b <=> a`.
|
|
6
|
+
#
|
|
7
|
+
# The result changes sign, so a sort block sorts the other way and a custom
|
|
8
|
+
# `<=>` orders the other way round. A survivor means no example asserts the
|
|
9
|
+
# direction of the ordering.
|
|
10
|
+
#
|
|
11
|
+
# Two-argument ordering methods (`between?`, `clamp`) are reversed by
|
|
12
|
+
# ArgumentOrderPermutation. Identical operands are not swapped, since that
|
|
13
|
+
# would reproduce the original comparison, and neither is a safe-navigation
|
|
14
|
+
# call, where moving the other operand into the receiver's place changes what
|
|
15
|
+
# may be nil.
|
|
16
|
+
class Evilution::Mutator::Operator::ComparisonOperandSwap < Evilution::Mutator::Base
|
|
17
|
+
def visit_call_node(node)
|
|
18
|
+
swap_operands(node) if spaceship?(node)
|
|
19
|
+
super
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
# Ruby has no receiverless spaceship, but the explicit form can be written
|
|
25
|
+
# with no argument or several (`a.<=>`, `a.<=>(b, c)`); those have no pair
|
|
26
|
+
# of operands to swap.
|
|
27
|
+
def spaceship?(node)
|
|
28
|
+
return false unless node.name == :<=> && !node.safe_navigation?
|
|
29
|
+
return false if node.arguments.nil?
|
|
30
|
+
|
|
31
|
+
node.arguments.arguments.length == 1
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Whatever sits between the operands — the operator and its spacing, or
|
|
35
|
+
# `.<=>(` in the explicit form — stays in place.
|
|
36
|
+
def swap_operands(node)
|
|
37
|
+
left = node.receiver
|
|
38
|
+
right = node.arguments.arguments.first
|
|
39
|
+
return if left.slice == right.slice
|
|
40
|
+
|
|
41
|
+
start_offset = left.location.start_offset
|
|
42
|
+
|
|
43
|
+
add_mutation(
|
|
44
|
+
offset: start_offset,
|
|
45
|
+
length: right.location.end_offset - start_offset,
|
|
46
|
+
replacement: "#{right.slice}#{source_between(left, right)}#{left.slice}",
|
|
47
|
+
node: node
|
|
48
|
+
)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def source_between(left, right)
|
|
52
|
+
byteslice_source(left.location.end_offset, right.location.start_offset - left.location.end_offset)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
require_relative "../operator"
|
|
6
|
+
require_relative "../../ast/value_object_definition"
|
|
7
|
+
|
|
8
|
+
# Mutate the member list of a value-object definition: `Data.define(:a, :b)`
|
|
9
|
+
# and `Struct.new(:a, :b)` lose one member at a time, and have each adjacent
|
|
10
|
+
# pair of members swapped.
|
|
11
|
+
#
|
|
12
|
+
# A surviving drop means no example reads or sets that member. A surviving swap
|
|
13
|
+
# means the type is built positionally somewhere the order is never asserted,
|
|
14
|
+
# so two members could trade values unnoticed.
|
|
15
|
+
#
|
|
16
|
+
# Definitions outside any method — assigned to a constant, or the superclass
|
|
17
|
+
# of a class — are constant subjects of their own, and this operator mutates
|
|
18
|
+
# them there. A constant subject is the definition alone: definitions nested
|
|
19
|
+
# in its block are subjects of their own too.
|
|
20
|
+
class Evilution::Mutator::Operator::DataStructMember < Evilution::Mutator::Base
|
|
21
|
+
# Arguments whose position and meaning are known: members are symbols, a
|
|
22
|
+
# string is the class name `Struct.new("Name", ...)` takes first, and the
|
|
23
|
+
# keyword hash carries `keyword_init:`. Anything else (a splat, a variable)
|
|
24
|
+
# hides the member list, so the definition is left alone.
|
|
25
|
+
KNOWN_ARGUMENT_TYPES = [Prism::SymbolNode, Prism::StringNode, Prism::KeywordHashNode].freeze
|
|
26
|
+
|
|
27
|
+
def self.subject_kinds
|
|
28
|
+
%i[method constant]
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# A constant subject's node is the definition itself, so the visit stops
|
|
32
|
+
# there rather than reaching definitions nested in its block.
|
|
33
|
+
def visit_call_node(node)
|
|
34
|
+
mutate_members(node) if Evilution::AST::ValueObjectDefinition.match?(node)
|
|
35
|
+
super unless @subject.kind == :constant
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def mutate_members(node)
|
|
41
|
+
arguments = node.arguments ? node.arguments.arguments : []
|
|
42
|
+
members = member_indexes(arguments)
|
|
43
|
+
# A lone member has no neighbour to swap with, and dropping it would leave
|
|
44
|
+
# `Struct.new()`, which raises on Rubies that require at least one member.
|
|
45
|
+
return if members.length < 2
|
|
46
|
+
|
|
47
|
+
slices = arguments.map(&:slice)
|
|
48
|
+
members.each { |index| emit_member_drop(node, slices, index) }
|
|
49
|
+
members.each_cons(2) { |left, right| emit_member_swap(node, slices, left, right) }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def member_indexes(arguments)
|
|
53
|
+
return [] unless arguments.all? { |argument| KNOWN_ARGUMENT_TYPES.include?(argument.class) }
|
|
54
|
+
|
|
55
|
+
arguments.each_index.select { |index| arguments[index].is_a?(Prism::SymbolNode) }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def emit_member_drop(node, slices, index)
|
|
59
|
+
remaining = slices.dup
|
|
60
|
+
remaining.delete_at(index)
|
|
61
|
+
replace_arguments(node, remaining)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def emit_member_swap(node, slices, left, right)
|
|
65
|
+
return if slices[left] == slices[right]
|
|
66
|
+
|
|
67
|
+
swapped = slices.dup
|
|
68
|
+
swapped[left], swapped[right] = swapped[right], swapped[left]
|
|
69
|
+
replace_arguments(node, swapped)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def replace_arguments(node, slices)
|
|
73
|
+
location = node.arguments.location
|
|
74
|
+
|
|
75
|
+
add_mutation(
|
|
76
|
+
offset: location.start_offset,
|
|
77
|
+
length: location.length,
|
|
78
|
+
replacement: slices.join(", "),
|
|
79
|
+
node: node
|
|
80
|
+
)
|
|
81
|
+
end
|
|
82
|
+
end
|