evilution 1.3.0 → 1.5.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 +132 -0
- data/.rubocop_todo.yml +5 -0
- data/CHANGELOG.md +127 -0
- data/README.md +136 -38
- data/docs/architecture.md +135 -11
- data/docs/ast_pattern_syntax.md +50 -5
- data/docs/isolation.md +121 -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/class_body.rb +101 -0
- data/lib/evilution/ast/included_block.rb +41 -0
- data/lib/evilution/ast/literal_callable.rb +21 -0
- data/lib/evilution/ast/never_nil.rb +36 -0
- data/lib/evilution/ast/parser.rb +187 -21
- 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/round_half_mode.rb +38 -0
- data/lib/evilution/ast/scope_declaration.rb +45 -0
- data/lib/evilution/ast/source_surgeon.rb +2 -3
- data/lib/evilution/ast/syntax_check.rb +56 -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/exit_guard.rb +26 -4
- 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 +11 -1
- data/lib/evilution/integration/known_failures.rb +26 -0
- data/lib/evilution/integration/loading/body_call_neutralizer.rb +177 -13
- data/lib/evilution/integration/loading/callback_redeclaration.rb +186 -0
- data/lib/evilution/integration/loading/concern_redeclaration.rb +80 -0
- data/lib/evilution/integration/loading/concern_state_cleaner.rb +22 -15
- data/lib/evilution/integration/loading/mutation_applier.rb +44 -1
- data/lib/evilution/integration/loading/own_methods.rb +87 -0
- data/lib/evilution/integration/loading/redefinition_recovery.rb +2 -3
- data/lib/evilution/integration/loading/source_evaluator.rb +9 -2
- data/lib/evilution/integration/loading/superseded_modules.rb +103 -0
- data/lib/evilution/integration/loading/syntax_validator.rb +2 -2
- 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 +84 -11
- data/lib/evilution/isolation/in_process.rb +100 -8
- data/lib/evilution/mcp/info_tool/status_glossary.rb +15 -4
- data/lib/evilution/mcp/info_tool.rb +3 -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 +21 -9
- data/lib/evilution/mutator/base.rb +69 -5
- data/lib/evilution/mutator/constant_value_subjects.rb +13 -0
- data/lib/evilution/mutator/operator/alias_removal.rb +56 -0
- data/lib/evilution/mutator/operator/argument_nil_substitution.rb +2 -2
- data/lib/evilution/mutator/operator/argument_order_permutation.rb +101 -0
- data/lib/evilution/mutator/operator/argument_removal.rb +10 -2
- data/lib/evilution/mutator/operator/array_literal.rb +29 -0
- data/lib/evilution/mutator/operator/attr_accessor_swap.rb +68 -0
- data/lib/evilution/mutator/operator/boolean_literal_replacement.rb +2 -0
- data/lib/evilution/mutator/operator/call_to_nil.rb +12 -3
- data/lib/evilution/mutator/operator/comparison_operand_swap.rb +54 -0
- data/lib/evilution/mutator/operator/complex_literal.rb +39 -0
- data/lib/evilution/mutator/operator/compound_assignment.rb +47 -0
- data/lib/evilution/mutator/operator/constant_namespace_strip.rb +57 -0
- data/lib/evilution/mutator/operator/constant_read_to_nil.rb +58 -0
- data/lib/evilution/mutator/operator/constant_write_to_nil.rb +32 -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/explicit_super_mutation.rb +2 -2
- data/lib/evilution/mutator/operator/float_literal.rb +54 -19
- 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/freeze_removal.rb +86 -0
- data/lib/evilution/mutator/operator/hash_literal.rb +51 -0
- data/lib/evilution/mutator/operator/index_range_to_drop.rb +67 -0
- data/lib/evilution/mutator/operator/index_receiver_to_self.rb +20 -0
- data/lib/evilution/mutator/operator/index_to_key_predicate.rb +58 -0
- data/lib/evilution/mutator/operator/index_write_to_value.rb +37 -0
- data/lib/evilution/mutator/operator/integer_division_to_fdiv.rb +62 -0
- data/lib/evilution/mutator/operator/integer_literal.rb +39 -9
- 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/memoization_break.rb +18 -0
- data/lib/evilution/mutator/operator/mixin_removal.rb +18 -55
- data/lib/evilution/mutator/operator/nil_replacement.rb +2 -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/operator_write_receiver_promotion.rb +38 -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/range_replacement.rb +2 -0
- data/lib/evilution/mutator/operator/rational_literal.rb +40 -0
- data/lib/evilution/mutator/operator/reduce_to_sum.rb +1 -1
- 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_class_widening.rb +54 -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/round_half_mode_swap.rb +36 -0
- data/lib/evilution/mutator/operator/safe_navigation_insertion.rb +57 -0
- data/lib/evilution/mutator/operator/safe_navigation_removal.rb +2 -24
- data/lib/evilution/mutator/operator/send_mutation.rb +2 -0
- data/lib/evilution/mutator/operator/short_circuit_to_eager.rb +34 -0
- data/lib/evilution/mutator/operator/sleep_to_zero.rb +43 -0
- data/lib/evilution/mutator/operator/splat_operator.rb +41 -24
- data/lib/evilution/mutator/operator/statement_reorder.rb +153 -0
- data/lib/evilution/mutator/operator/string_literal.rb +29 -0
- data/lib/evilution/mutator/operator/superclass_removal.rb +14 -40
- data/lib/evilution/mutator/operator/symbol_literal.rb +47 -2
- data/lib/evilution/mutator/operator/to_i_to_integer.rb +1 -1
- data/lib/evilution/mutator/operator/variable_read_to_nil.rb +113 -0
- data/lib/evilution/mutator/operator/visibility_removal.rb +74 -0
- data/lib/evilution/mutator/primitives.rb +24 -2
- data/lib/evilution/mutator/receiver_positions.rb +79 -0
- data/lib/evilution/mutator/registry.rb +58 -2
- data/lib/evilution/mutator/rescue_handlers.rb +81 -0
- data/lib/evilution/parallel/work_queue/channel/frame.rb +6 -0
- data/lib/evilution/parallel/work_queue/channel.rb +58 -5
- data/lib/evilution/parallel/work_queue/dispatcher/reply.rb +41 -0
- data/lib/evilution/parallel/work_queue/dispatcher.rb +14 -1
- data/lib/evilution/parallel/work_queue/worker/loop.rb +5 -0
- data/lib/evilution/parallel/work_queue/worker.rb +13 -4
- data/lib/evilution/parallel/work_queue.rb +15 -9
- 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/strategy/parallel.rb +10 -0
- 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/target.rb +86 -0
- data/lib/evilution/runner/subject_pipeline.rb +57 -43
- data/lib/evilution/runner.rb +5 -2
- data/lib/evilution/subject.rb +25 -2
- data/lib/evilution/version.rb +1 -1
- data/lib/evilution.rb +90 -0
- data/script/memory_check +62 -16
- metadata +113 -4
|
@@ -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,26 +8,38 @@ class Evilution::Mutation
|
|
|
8
8
|
Slice = Data.define(:original, :mutated)
|
|
9
9
|
Location = Data.define(:file_path, :line, :column)
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
# What a mutation holds once its sources are released (see strip_sources!),
|
|
12
|
+
# and when it was built without a slice. Their fields read as nil, so the
|
|
13
|
+
# readers below need no nil check of their own.
|
|
14
|
+
STRIPPED_SOURCES = Sources.new(original: nil, mutated: nil)
|
|
15
|
+
NO_SLICE = Slice.new(original: nil, mutated: nil)
|
|
16
|
+
private_constant :STRIPPED_SOURCES, :NO_SLICE
|
|
17
|
+
|
|
18
|
+
# restore_source: the original file, evaluated after the mutation's tests
|
|
19
|
+
# to put back what applying it changed (see
|
|
20
|
+
# Mutator::Base#build_restore_source). nil when a mutation was built
|
|
21
|
+
# without one.
|
|
22
|
+
attr_reader :subject, :operator_name, :parse_status, :location, :restore_source
|
|
12
23
|
|
|
13
24
|
def initialize(subject:, operator_name:, sources:, location:,
|
|
14
|
-
slice: nil, parse_status: :ok, eval_source: nil)
|
|
25
|
+
slice: nil, parse_status: :ok, eval_source: nil, restore_source: nil)
|
|
15
26
|
@subject = subject
|
|
16
27
|
@operator_name = operator_name
|
|
17
28
|
@sources = sources
|
|
18
29
|
@location = location
|
|
19
|
-
@slice = slice
|
|
30
|
+
@slice = slice || NO_SLICE
|
|
20
31
|
@parse_status = parse_status
|
|
21
32
|
@eval_source = eval_source
|
|
33
|
+
@restore_source = restore_source
|
|
22
34
|
@diff = nil
|
|
23
35
|
end
|
|
24
36
|
|
|
25
37
|
def original_source
|
|
26
|
-
@sources
|
|
38
|
+
@sources.original
|
|
27
39
|
end
|
|
28
40
|
|
|
29
41
|
def mutated_source
|
|
30
|
-
@sources
|
|
42
|
+
@sources.mutated
|
|
31
43
|
end
|
|
32
44
|
|
|
33
45
|
# Source to feed to the load-time evaluator. Defaults to mutated_source
|
|
@@ -41,11 +53,11 @@ class Evilution::Mutation
|
|
|
41
53
|
end
|
|
42
54
|
|
|
43
55
|
def original_slice
|
|
44
|
-
@slice
|
|
56
|
+
@slice.original
|
|
45
57
|
end
|
|
46
58
|
|
|
47
59
|
def mutated_slice
|
|
48
|
-
@slice
|
|
60
|
+
@slice.mutated
|
|
49
61
|
end
|
|
50
62
|
|
|
51
63
|
def file_path
|
|
@@ -76,7 +88,7 @@ class Evilution::Mutation
|
|
|
76
88
|
|
|
77
89
|
def strip_sources!
|
|
78
90
|
diff # ensure diff is cached before clearing sources
|
|
79
|
-
@sources =
|
|
91
|
+
@sources = STRIPPED_SOURCES
|
|
80
92
|
end
|
|
81
93
|
|
|
82
94
|
def to_s
|
|
@@ -100,7 +112,7 @@ class Evilution::Mutation
|
|
|
100
112
|
end
|
|
101
113
|
|
|
102
114
|
def compute_unified_diff
|
|
103
|
-
return nil if @slice
|
|
115
|
+
return nil if @slice == NO_SLICE
|
|
104
116
|
|
|
105
117
|
original_lines = @slice.original.lines
|
|
106
118
|
mutated_lines = @slice.mutated.lines
|
|
@@ -4,6 +4,8 @@ require "prism"
|
|
|
4
4
|
|
|
5
5
|
require_relative "../mutator"
|
|
6
6
|
require_relative "primitives"
|
|
7
|
+
require_relative "constant_value_subjects"
|
|
8
|
+
require_relative "receiver_positions"
|
|
7
9
|
require_relative "../integration/loading/body_call_neutralizer"
|
|
8
10
|
require_relative "../ast/heredoc_span"
|
|
9
11
|
|
|
@@ -84,12 +86,12 @@ class Evilution::Mutator::Base < Prism::Visitor
|
|
|
84
86
|
replacement_bytesize: replacement.bytesize
|
|
85
87
|
)
|
|
86
88
|
|
|
87
|
-
mutation = build_mutation_record(node, surgery, slices)
|
|
89
|
+
mutation = build_mutation_record(node, surgery, slices, offset)
|
|
88
90
|
@mutations << mutation
|
|
89
91
|
mutation
|
|
90
92
|
end
|
|
91
93
|
|
|
92
|
-
def build_mutation_record(node, surgery, slices)
|
|
94
|
+
def build_mutation_record(node, surgery, slices, offset)
|
|
93
95
|
Evilution::Mutation.new(
|
|
94
96
|
subject: @subject,
|
|
95
97
|
operator_name: self.class.operator_name,
|
|
@@ -101,7 +103,8 @@ class Evilution::Mutator::Base < Prism::Visitor
|
|
|
101
103
|
column: node.location.start_column
|
|
102
104
|
),
|
|
103
105
|
parse_status: surgery.status,
|
|
104
|
-
eval_source: build_eval_source(surgery)
|
|
106
|
+
eval_source: build_eval_source(surgery, offset),
|
|
107
|
+
restore_source: build_restore_source(offset)
|
|
105
108
|
)
|
|
106
109
|
end
|
|
107
110
|
|
|
@@ -112,10 +115,47 @@ class Evilution::Mutator::Base < Prism::Visitor
|
|
|
112
115
|
# them at apply time. Passing the subject's file path lets the neutralizer
|
|
113
116
|
# skip files the parent never preloaded — those are lazy plugin files whose
|
|
114
117
|
# DSL calls are still needed for the child fork's first-time load.
|
|
115
|
-
|
|
118
|
+
# A scope or aasm subject's mutation sits inside a class-body declaration,
|
|
119
|
+
# which must run again for the mutated body to replace the loaded one, so
|
|
120
|
+
# that one declaration is kept.
|
|
121
|
+
def build_eval_source(surgery, offset)
|
|
116
122
|
return surgery.source unless surgery.ok?
|
|
117
123
|
|
|
118
|
-
@body_call_neutralizer.call(surgery.source, file_path: @subject.file_path)
|
|
124
|
+
@body_call_neutralizer.call(surgery.source, file_path: @subject.file_path, **redeclaration(offset))
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# The original file, evaluated after a mutation's tests to put back what
|
|
128
|
+
# the mutation changed. Without it an in-process run would leave the last
|
|
129
|
+
# mutation of a file applied while the files after it are mutated, failing
|
|
130
|
+
# their tests for them. Its class-body calls are neutralized like the eval
|
|
131
|
+
# source's; a scope, aasm or callback subject keeps its own declaration, so
|
|
132
|
+
# the mutated callable is declared over again as written.
|
|
133
|
+
def build_restore_source(offset)
|
|
134
|
+
return self.class.original_restore_source(@subject.file_path, @file_source, @body_call_neutralizer) unless redeclared_subject?
|
|
135
|
+
|
|
136
|
+
@body_call_neutralizer.call(@file_source, file_path: @subject.file_path, **redeclaration(offset))
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Subjects whose body is written inside a class-body declaration and only
|
|
140
|
+
# takes effect when that declaration runs again.
|
|
141
|
+
REDECLARED_KINDS = %i[scope aasm callback].freeze
|
|
142
|
+
private_constant :REDECLARED_KINDS
|
|
143
|
+
|
|
144
|
+
# So is anything written in a concern's hook block, a method included: the
|
|
145
|
+
# block has to run again on the classes that took the concern already.
|
|
146
|
+
def redeclared_subject?
|
|
147
|
+
REDECLARED_KINDS.include?(@subject.kind) || @subject.concern_hook?
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# What the neutralizer needs to keep a redeclared subject's declaration:
|
|
151
|
+
# where the mutation is, and the lines the declaration takes up in the
|
|
152
|
+
# file as it was loaded -- which is where the callbacks it registered then
|
|
153
|
+
# say they come from, whatever the mutation does to its length.
|
|
154
|
+
def redeclaration(offset)
|
|
155
|
+
return {} unless redeclared_subject?
|
|
156
|
+
|
|
157
|
+
first = @subject.line_number
|
|
158
|
+
{ keep_offset: offset, keep_lines: first..(first + @subject.source.count("\n")) }
|
|
119
159
|
end
|
|
120
160
|
|
|
121
161
|
NEWLINE_BYTE = 10
|
|
@@ -157,6 +197,15 @@ class Evilution::Mutator::Base < Prism::Visitor
|
|
|
157
197
|
.downcase
|
|
158
198
|
end
|
|
159
199
|
|
|
200
|
+
# The kinds of Subject the operator mutates. Operators work on method
|
|
201
|
+
# bodies, and on the bodies of callables handed to a scope, aasm or
|
|
202
|
+
# callback declaration, which run as part of the class's behaviour; one that also has
|
|
203
|
+
# something to say about a constant subject (a definition outside any
|
|
204
|
+
# method) adds :constant.
|
|
205
|
+
def self.subject_kinds
|
|
206
|
+
%i[method scope aasm callback]
|
|
207
|
+
end
|
|
208
|
+
|
|
160
209
|
@parse_cache = {}
|
|
161
210
|
|
|
162
211
|
def self.parsed_tree_for(file_path, file_source)
|
|
@@ -169,7 +218,22 @@ class Evilution::Mutator::Base < Prism::Visitor
|
|
|
169
218
|
tree
|
|
170
219
|
end
|
|
171
220
|
|
|
221
|
+
@restore_cache = {}
|
|
222
|
+
|
|
223
|
+
# The restore source of a subject with no declaration of its own to keep is
|
|
224
|
+
# the same for every mutation of the file: neutralized once and shared.
|
|
225
|
+
def self.original_restore_source(file_path, file_source, neutralizer)
|
|
226
|
+
cache = Evilution::Mutator::Base.instance_variable_get(:@restore_cache)
|
|
227
|
+
entry = cache[file_path]
|
|
228
|
+
return entry[:source] if entry && entry[:source_hash] == file_source.hash
|
|
229
|
+
|
|
230
|
+
source = neutralizer.call(file_source, file_path: file_path).freeze
|
|
231
|
+
cache[file_path] = { source_hash: file_source.hash, source: source }
|
|
232
|
+
source
|
|
233
|
+
end
|
|
234
|
+
|
|
172
235
|
def self.clear_parse_cache!
|
|
173
236
|
Evilution::Mutator::Base.instance_variable_set(:@parse_cache, {})
|
|
237
|
+
Evilution::Mutator::Base.instance_variable_set(:@restore_cache, {})
|
|
174
238
|
end
|
|
175
239
|
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../mutator"
|
|
4
|
+
|
|
5
|
+
# Extended by the operators that mutate a value wherever it is written, so
|
|
6
|
+
# that they also run on the value of a constant assignment: a literal in
|
|
7
|
+
# `LIMIT = 10` or in `DEFAULTS = { size: 1 }` is as much a literal as one in
|
|
8
|
+
# a method body.
|
|
9
|
+
module Evilution::Mutator::ConstantValueSubjects
|
|
10
|
+
def subject_kinds
|
|
11
|
+
super + %i[constant_write]
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
require_relative "../operator"
|
|
6
|
+
require_relative "../../ast/class_body"
|
|
7
|
+
|
|
8
|
+
# Drop an alias declaration from a class or module body: `alias length size`
|
|
9
|
+
# and `alias_method :count, :size` are removed.
|
|
10
|
+
#
|
|
11
|
+
# A survivor means no example calls the method by its alias, so the alias is
|
|
12
|
+
# a surface the suite never exercises — dead, or reached only where nothing
|
|
13
|
+
# asserts.
|
|
14
|
+
#
|
|
15
|
+
# Declarations sit in the class body, outside every method, where no subject
|
|
16
|
+
# reaches them. They are attributed to the method anchoring the enclosing
|
|
17
|
+
# body (see Evilution::AST::ClassBody); a top-level alias has no body to be
|
|
18
|
+
# attributed to. An `alias_method` call inside a method is left to the
|
|
19
|
+
# generic call operators, and global-variable aliases (`alias $new $old`)
|
|
20
|
+
# are not method surface.
|
|
21
|
+
class Evilution::Mutator::Operator::AliasRemoval < Evilution::Mutator::Base
|
|
22
|
+
def call(subject, filter: nil)
|
|
23
|
+
@subject = subject
|
|
24
|
+
@file_source = File.read(subject.file_path)
|
|
25
|
+
@mutations = []
|
|
26
|
+
@filter = filter
|
|
27
|
+
|
|
28
|
+
declarations_for(subject).each { |declaration| remove(declaration) }
|
|
29
|
+
@mutations
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def remove(declaration)
|
|
35
|
+
add_mutation(
|
|
36
|
+
offset: declaration.location.start_offset,
|
|
37
|
+
length: declaration.location.length,
|
|
38
|
+
replacement: "",
|
|
39
|
+
node: declaration
|
|
40
|
+
)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def declarations_for(subject)
|
|
44
|
+
tree = self.class.parsed_tree_for(subject.file_path, @file_source)
|
|
45
|
+
|
|
46
|
+
Evilution::AST::ClassBody.anchored_at(tree, subject.line_number).flat_map do |body|
|
|
47
|
+
body.declarations { |node| declaration?(node) }
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def declaration?(node)
|
|
52
|
+
return true if node.is_a?(Prism::AliasMethodNode)
|
|
53
|
+
|
|
54
|
+
node.is_a?(Prism::CallNode) && node.name == :alias_method && node.receiver.nil?
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -11,7 +11,7 @@ class Evilution::Mutator::Operator::ArgumentNilSubstitution < Evilution::Mutator
|
|
|
11
11
|
].freeze
|
|
12
12
|
|
|
13
13
|
def visit_call_node(node)
|
|
14
|
-
args = node
|
|
14
|
+
args = argument_nodes(node)
|
|
15
15
|
args.each_index { |i| emit_nil_substitution(node, args, i) } if mutable?(node, args)
|
|
16
16
|
|
|
17
17
|
super
|
|
@@ -30,7 +30,7 @@ class Evilution::Mutator::Operator::ArgumentNilSubstitution < Evilution::Mutator
|
|
|
30
30
|
end
|
|
31
31
|
|
|
32
32
|
def mutable?(node, args)
|
|
33
|
-
|
|
33
|
+
positional_only?(args) && node.name != :[]=
|
|
34
34
|
end
|
|
35
35
|
|
|
36
36
|
def positional_only?(args)
|
|
@@ -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 = [
|
|
@@ -11,7 +12,7 @@ class Evilution::Mutator::Operator::ArgumentRemoval < Evilution::Mutator::Base
|
|
|
11
12
|
].freeze
|
|
12
13
|
|
|
13
14
|
def visit_call_node(node)
|
|
14
|
-
args = node
|
|
15
|
+
args = argument_nodes(node)
|
|
15
16
|
args.each_index { |i| emit_argument_removal(node, args, i) } if mutable?(node, args)
|
|
16
17
|
|
|
17
18
|
super
|
|
@@ -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
|
|
34
|
+
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)
|
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
require_relative "../operator"
|
|
4
4
|
|
|
5
5
|
class Evilution::Mutator::Operator::ArrayLiteral < Evilution::Mutator::Base
|
|
6
|
+
extend Evilution::Mutator::ConstantValueSubjects
|
|
7
|
+
|
|
6
8
|
def visit_array_node(node)
|
|
7
9
|
if node.opening_loc && node.elements.any?
|
|
8
10
|
add_mutation(
|
|
@@ -18,8 +20,35 @@ class Evilution::Mutator::Operator::ArrayLiteral < Evilution::Mutator::Base
|
|
|
18
20
|
replacement: "nil",
|
|
19
21
|
node: node
|
|
20
22
|
)
|
|
23
|
+
|
|
24
|
+
mutate_delete_elements(node.elements)
|
|
25
|
+
mutate_promote_only_element(node)
|
|
21
26
|
end
|
|
22
27
|
|
|
23
28
|
super
|
|
24
29
|
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
# Delete each element in turn: a survivor means no example checks that
|
|
34
|
+
# element. A single element is left to the `[]` replacement. A heredoc
|
|
35
|
+
# element stays: its body sits after the closing bracket, so one cut cannot
|
|
36
|
+
# take both, and the cut that does not parse is dropped.
|
|
37
|
+
def mutate_delete_elements(elements)
|
|
38
|
+
return if elements.one?
|
|
39
|
+
|
|
40
|
+
elements.each_index { |index| delete_element(elements, index) }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# `[x]` becomes `x`: a survivor means the wrapping does not matter, as when
|
|
44
|
+
# the caller flattens or splats the value anyway. The words of `%w[a]` are
|
|
45
|
+
# not written as expressions and stay. An element that is not an expression
|
|
46
|
+
# on its own (`*x`, the bare keywords of `[a: 1]`) does not parse once
|
|
47
|
+
# promoted and is dropped, and so is a heredoc, whose body cannot follow
|
|
48
|
+
# the cut.
|
|
49
|
+
def mutate_promote_only_element(node)
|
|
50
|
+
return unless node.elements.one? && node.opening == "["
|
|
51
|
+
|
|
52
|
+
replace_span(node:, target: node, replacement: receiver_source(node.elements.first))
|
|
53
|
+
end
|
|
25
54
|
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
require_relative "../operator"
|
|
6
|
+
require_relative "../../ast/class_body"
|
|
7
|
+
|
|
8
|
+
# Mutate an attribute declaration in a class or module body two ways.
|
|
9
|
+
#
|
|
10
|
+
# Widening: `attr_reader :name` and `attr_writer :name` become
|
|
11
|
+
# `attr_accessor :name`. A survivor means no example checks that the missing
|
|
12
|
+
# half is missing: that the attribute cannot be written, or cannot be read.
|
|
13
|
+
#
|
|
14
|
+
# Removal: an `attr_reader`, `attr_writer` or `attr_accessor` declaration is
|
|
15
|
+
# dropped. A survivor means no example uses any of the methods it defines.
|
|
16
|
+
#
|
|
17
|
+
# Declarations sit in the class body, outside every method, where no subject
|
|
18
|
+
# reaches them. They are attributed to the method anchoring the enclosing
|
|
19
|
+
# body (see Evilution::AST::ClassBody); a top-level declaration has no body
|
|
20
|
+
# to be attributed to. A declaration wrapped in a visibility call
|
|
21
|
+
# (`private attr_reader :name`) is left alone: removing it would leave a bare
|
|
22
|
+
# `private` behind.
|
|
23
|
+
class Evilution::Mutator::Operator::AttrAccessorSwap < Evilution::Mutator::Base
|
|
24
|
+
WIDENED = "attr_accessor"
|
|
25
|
+
NARROW = %i[attr_reader attr_writer].freeze
|
|
26
|
+
DECLARATIONS = [*NARROW, :attr_accessor].freeze
|
|
27
|
+
|
|
28
|
+
def call(subject, filter: nil)
|
|
29
|
+
@subject = subject
|
|
30
|
+
@file_source = File.read(subject.file_path)
|
|
31
|
+
@mutations = []
|
|
32
|
+
@filter = filter
|
|
33
|
+
|
|
34
|
+
declarations_for(subject).each do |declaration|
|
|
35
|
+
widen(declaration) if NARROW.include?(declaration.name)
|
|
36
|
+
remove(declaration)
|
|
37
|
+
end
|
|
38
|
+
@mutations
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def widen(declaration)
|
|
44
|
+
name = declaration.message_loc
|
|
45
|
+
add_mutation(offset: name.start_offset, length: name.length, replacement: WIDENED, node: declaration)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def remove(declaration)
|
|
49
|
+
add_mutation(
|
|
50
|
+
offset: declaration.location.start_offset,
|
|
51
|
+
length: declaration.location.length,
|
|
52
|
+
replacement: "",
|
|
53
|
+
node: declaration
|
|
54
|
+
)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def declarations_for(subject)
|
|
58
|
+
tree = self.class.parsed_tree_for(subject.file_path, @file_source)
|
|
59
|
+
|
|
60
|
+
Evilution::AST::ClassBody.anchored_at(tree, subject.line_number).flat_map do |body|
|
|
61
|
+
body.declarations { |node| declaration?(node) }
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def declaration?(node)
|
|
66
|
+
node.is_a?(Prism::CallNode) && node.receiver.nil? && DECLARATIONS.include?(node.name) && !node.arguments.nil?
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -13,9 +13,13 @@ require_relative "../operator"
|
|
|
13
13
|
# (every statement of a body except the last) has no value to replace;
|
|
14
14
|
# deleting it is statement_deletion's job. A call used as the receiver of
|
|
15
15
|
# another call turns into `nil.foo`, a NoMethodError any test that reaches it
|
|
16
|
-
# kills; the outer call is nil-ified instead.
|
|
17
|
-
# (`
|
|
16
|
+
# kills; the outer call is nil-ified instead. The receiver of a write
|
|
17
|
+
# (`user.account.total += 1`, `cache[key] ||= value`) is skipped likewise. An
|
|
18
|
+
# attribute or index write (`a.b = c`, `a[i] = c`) is a side effect, not a
|
|
19
|
+
# value.
|
|
18
20
|
class Evilution::Mutator::Operator::CallToNil < Evilution::Mutator::Base
|
|
21
|
+
include Evilution::Mutator::ReceiverPositions
|
|
22
|
+
|
|
19
23
|
def call(subject, **)
|
|
20
24
|
@skipped_calls = Set.new
|
|
21
25
|
super
|
|
@@ -27,8 +31,13 @@ class Evilution::Mutator::Operator::CallToNil < Evilution::Mutator::Base
|
|
|
27
31
|
end
|
|
28
32
|
|
|
29
33
|
def visit_call_node(node)
|
|
30
|
-
@skipped_calls.add(node.receiver)
|
|
31
34
|
mutate_to_nil(node) unless @skipped_calls.include?(node) || node.attribute_write?
|
|
32
35
|
super
|
|
33
36
|
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def receiver_found(node)
|
|
41
|
+
@skipped_calls.add(node)
|
|
42
|
+
end
|
|
34
43
|
end
|