evilution 1.2.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 +100 -0
- data/.rubocop_todo.yml +14 -0
- data/CHANGELOG.md +124 -0
- data/README.md +142 -28
- data/docs/architecture.md +123 -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 +120 -16
- data/lib/evilution/ast/pattern/filter.rb +1 -0
- data/lib/evilution/ast/pattern/method_name.rb +63 -0
- data/lib/evilution/ast/pattern/parser.rb +15 -15
- data/lib/evilution/ast/pattern.rb +2 -0
- data/lib/evilution/ast/regexp_pattern.rb +104 -0
- data/lib/evilution/ast/scope_declaration.rb +45 -0
- data/lib/evilution/ast/sorbet_sig_detector.rb +1 -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 +101 -42
- data/lib/evilution/child_output.rb +1 -1
- data/lib/evilution/cli/command.rb +1 -0
- data/lib/evilution/cli/commands/tests_list.rb +4 -3
- data/lib/evilution/cli/commands.rb +2 -0
- data/lib/evilution/cli/dispatcher.rb +2 -0
- data/lib/evilution/cli/exit_guard.rb +2 -0
- data/lib/evilution/cli/parsed_args.rb +2 -0
- data/lib/evilution/cli/parser/command_extractor.rb +2 -0
- data/lib/evilution/cli/parser/file_args.rb +4 -1
- data/lib/evilution/cli/parser/options_builder.rb +4 -1
- data/lib/evilution/cli/parser/stdin_reader.rb +1 -0
- data/lib/evilution/cli/parser.rb +1 -0
- data/lib/evilution/cli/printers/tests_list.rb +5 -4
- data/lib/evilution/cli/printers.rb +2 -0
- data/lib/evilution/cli/result.rb +2 -0
- data/lib/evilution/cli.rb +8 -2
- data/lib/evilution/config/builders.rb +2 -0
- data/lib/evilution/config/env_loader.rb +2 -0
- data/lib/evilution/config/file_loader.rb +1 -0
- data/lib/evilution/config/sources.rb +1 -0
- data/lib/evilution/config/validators/example_targeting_cache.rb +1 -0
- data/lib/evilution/config/validators/example_targeting_fallback.rb +1 -0
- data/lib/evilution/config/validators/example_targeting_strategy.rb +1 -0
- data/lib/evilution/config/validators/fail_fast.rb +1 -0
- data/lib/evilution/config/validators/hooks.rb +1 -0
- data/lib/evilution/config/validators/ignore_patterns.rb +1 -0
- data/lib/evilution/config/validators/integration.rb +1 -0
- data/lib/evilution/config/validators/isolation.rb +1 -0
- data/lib/evilution/config/validators/jobs.rb +1 -0
- data/lib/evilution/config/validators/preload.rb +1 -0
- data/lib/evilution/config/validators/profile.rb +1 -0
- data/lib/evilution/config/validators/spec_mappings.rb +3 -1
- data/lib/evilution/config/validators/spec_pattern.rb +1 -0
- data/lib/evilution/config/validators/warmup.rb +17 -0
- data/lib/evilution/config/validators.rb +2 -0
- data/lib/evilution/config.rb +7 -5
- data/lib/evilution/coverage.rb +1 -1
- data/lib/evilution/coverage_example_filter.rb +1 -1
- data/lib/evilution/diagnostic.rb +1 -1
- 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 +46 -1
- 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 +139 -12
- 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/loading/test_load_path.rb +21 -13
- 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/configuration_state.rb +1 -0
- data/lib/evilution/integration/rspec/state_guard/configuration_streams.rb +1 -0
- data/lib/evilution/integration/rspec/state_guard/example_groups_constants.rb +1 -2
- data/lib/evilution/integration/rspec/state_guard/internals.rb +1 -2
- data/lib/evilution/integration/rspec/state_guard/object_space_example_groups.rb +1 -2
- data/lib/evilution/integration/rspec/state_guard/reporter_arrays.rb +1 -0
- data/lib/evilution/integration/rspec/state_guard/world_example_groups.rb +1 -0
- data/lib/evilution/integration/rspec/state_guard/world_filtered_examples.rb +1 -0
- data/lib/evilution/integration/rspec/state_guard/world_sources_by_path.rb +1 -0
- data/lib/evilution/integration/rspec/state_guard.rb +10 -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/actions/environment.rb +1 -0
- data/lib/evilution/mcp/info_tool/actions/feedback.rb +1 -0
- data/lib/evilution/mcp/info_tool/actions/statuses.rb +1 -0
- data/lib/evilution/mcp/info_tool/actions/subjects.rb +1 -0
- data/lib/evilution/mcp/info_tool/actions/tests.rb +1 -0
- 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 +8 -3
- data/lib/evilution/mutator/base.rb +49 -5
- data/lib/evilution/mutator/operator/alias_removal.rb +126 -0
- data/lib/evilution/mutator/operator/argument_list_removal.rb +52 -0
- data/lib/evilution/mutator/operator/argument_order_permutation.rb +101 -0
- data/lib/evilution/mutator/operator/argument_propagation.rb +54 -0
- data/lib/evilution/mutator/operator/argument_removal.rb +9 -1
- data/lib/evilution/mutator/operator/array_coercion_to_literal.rb +34 -0
- data/lib/evilution/mutator/operator/attribute_write_to_read.rb +34 -0
- data/lib/evilution/mutator/operator/bang_method.rb +11 -3
- data/lib/evilution/mutator/operator/binary_operand_promotion.rb +63 -0
- data/lib/evilution/mutator/operator/block_body_promotion.rb +45 -0
- data/lib/evilution/mutator/operator/block_body_to_nil.rb +52 -0
- data/lib/evilution/mutator/operator/block_body_to_raise.rb +34 -0
- data/lib/evilution/mutator/operator/call_to_nil.rb +34 -0
- data/lib/evilution/mutator/operator/coercion_emptying.rb +60 -0
- data/lib/evilution/mutator/operator/collection_replacement.rb +19 -9
- data/lib/evilution/mutator/operator/comparison_operand_swap.rb +54 -0
- data/lib/evilution/mutator/operator/comparison_replacement.rb +37 -12
- data/lib/evilution/mutator/operator/const_get_to_constant_path.rb +50 -0
- data/lib/evilution/mutator/operator/data_struct_member.rb +82 -0
- data/lib/evilution/mutator/operator/dig_to_fetch_chain.rb +45 -0
- data/lib/evilution/mutator/operator/double_negation_removal.rb +22 -0
- data/lib/evilution/mutator/operator/dynamic_dispatch_resolution.rb +79 -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/inequality_to_negated_identity.rb +43 -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_argument_removal.rb +47 -0
- 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/proc_to_lambda.rb +43 -0
- data/lib/evilution/mutator/operator/receiver_constructor_swap.rb +51 -0
- data/lib/evilution/mutator/operator/reduce_to_sum.rb +58 -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_anchor_to_predicate.rb +136 -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/safe_navigation_removal.rb +67 -0
- data/lib/evilution/mutator/operator/send_mutation.rb +44 -6
- data/lib/evilution/mutator/operator/splat_operator.rb +38 -13
- data/lib/evilution/mutator/operator/statement_reorder.rb +153 -0
- data/lib/evilution/mutator/operator/symbol_to_proc_replacement.rb +59 -0
- data/lib/evilution/mutator/operator/to_i_to_integer.rb +38 -0
- data/lib/evilution/mutator/primitives.rb +25 -0
- data/lib/evilution/mutator/registry.rb +54 -2
- data/lib/evilution/mutator/rescue_handlers.rb +81 -0
- data/lib/evilution/parallel/pool.rb +1 -0
- data/lib/evilution/parallel_db_warning.rb +1 -1
- data/lib/evilution/process_supervisor.rb +3 -2
- data/lib/evilution/rails_warmup.rb +61 -0
- 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/html/report.rb +1 -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/coverage_gap_grouper.rb +1 -0
- data/lib/evilution/result/mutation_result.rb +9 -1
- data/lib/evilution/result/subject_scorer.rb +1 -0
- data/lib/evilution/result/summary.rb +42 -2
- data/lib/evilution/runner/baseline_runner.rb +26 -11
- data/lib/evilution/runner/canary.rb +2 -57
- data/lib/evilution/runner/canary_failure_message.rb +101 -0
- data/lib/evilution/runner/isolation_resolver.rb +23 -2
- data/lib/evilution/runner/mutation_executor/neutralizer/baseline_failed.rb +35 -14
- 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/source_ast_cache.rb +1 -1
- data/lib/evilution/spec_ast_cache.rb +1 -1
- data/lib/evilution/subject.rb +8 -2
- data/lib/evilution/version.rb +1 -1
- data/lib/evilution.rb +51 -0
- data/script/memory_check +62 -16
- metadata +108 -4
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../operator"
|
|
4
|
+
|
|
5
|
+
# Replace an `Array()` coercion with an array literal: `Array(value)` becomes
|
|
6
|
+
# `[value]`, and the same for `Kernel.Array(value)` / `Kernel::Array(value)`.
|
|
7
|
+
#
|
|
8
|
+
# The two agree only on a plain scalar. `Array(nil)` is `[]` where `[nil]`
|
|
9
|
+
# is not, an array or hash is converted rather than wrapped, and anything
|
|
10
|
+
# responding to `to_ary` / `to_a` is unpacked. A survivor means no test
|
|
11
|
+
# passes nil or a collection through the coercion.
|
|
12
|
+
class Evilution::Mutator::Operator::ArrayCoercionToLiteral < Evilution::Mutator::Base
|
|
13
|
+
def visit_call_node(node)
|
|
14
|
+
rewrite(node) if node.name == :Array && kernel_receiver?(node.receiver) && node.block.nil?
|
|
15
|
+
super
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
def rewrite(node)
|
|
21
|
+
return unless node.arguments in Prism::ArgumentsNode[arguments: [argument]]
|
|
22
|
+
return if argument.is_a?(Prism::SplatNode)
|
|
23
|
+
|
|
24
|
+
replace_span(node: node, target: node, replacement: "[#{source_of(argument)}]")
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def kernel_receiver?(receiver)
|
|
28
|
+
case receiver
|
|
29
|
+
when nil then true
|
|
30
|
+
when Prism::ConstantReadNode then receiver.name == :Kernel
|
|
31
|
+
when Prism::ConstantPathNode then receiver.parent.nil? && receiver.name == :Kernel
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../operator"
|
|
4
|
+
|
|
5
|
+
# Replace an attribute or index write with the matching read: `a.foo = b`
|
|
6
|
+
# becomes `a.foo`, `a[i] = b` becomes `a[i]`.
|
|
7
|
+
#
|
|
8
|
+
# A survivor means nothing observes the write. Only positions
|
|
9
|
+
# statement_deletion does not reach are mutated: a write that is one of
|
|
10
|
+
# several statements in a body is already deleted there, and turning it into
|
|
11
|
+
# a read differs from that only by the reader call.
|
|
12
|
+
class Evilution::Mutator::Operator::AttributeWriteToRead < Evilution::Mutator::Base
|
|
13
|
+
def call(subject, **)
|
|
14
|
+
@deletable_statements = Set.new
|
|
15
|
+
super
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def visit_statements_node(node)
|
|
19
|
+
@deletable_statements.merge(node.body) if node.body.length > 1
|
|
20
|
+
super
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def visit_call_node(node)
|
|
24
|
+
mutate_to_read(node) if node.attribute_write? && !@deletable_statements.include?(node)
|
|
25
|
+
super
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def mutate_to_read(node)
|
|
31
|
+
read_length = node.message_loc.end_offset - node.start_offset
|
|
32
|
+
replace_span(node: node, target: node, replacement: byteslice_source(node.start_offset, read_length))
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -3,10 +3,18 @@
|
|
|
3
3
|
require_relative "../operator"
|
|
4
4
|
|
|
5
5
|
class Evilution::Mutator::Operator::BangMethod < Evilution::Mutator::Base
|
|
6
|
+
# Methods that have an in-place bang twin on at least one of String, Array,
|
|
7
|
+
# Hash or Set (the same set on every supported Ruby, 3.3+), plus `update` /
|
|
8
|
+
# `save` for ActiveRecord. String's `succ` / `next` are left out: those
|
|
9
|
+
# names are far more often called on Integer, which has no bang twin.
|
|
6
10
|
KNOWN_BANG_PAIRS = %i[
|
|
7
|
-
sort map collect select reject uniq compact flatten
|
|
8
|
-
shuffle reverse slice
|
|
9
|
-
|
|
11
|
+
sort sort_by map collect select filter reject uniq compact flatten
|
|
12
|
+
shuffle reverse rotate slice
|
|
13
|
+
gsub sub tr tr_s strip lstrip rstrip chomp chop squeeze delete
|
|
14
|
+
delete_prefix delete_suffix capitalize downcase upcase swapcase
|
|
15
|
+
scrub encode unicode_normalize
|
|
16
|
+
merge transform_keys transform_values
|
|
17
|
+
update save
|
|
10
18
|
].to_set.freeze
|
|
11
19
|
|
|
12
20
|
def visit_call_node(node)
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../operator"
|
|
4
|
+
|
|
5
|
+
# Replace an arithmetic or bitwise expression with one of its operands:
|
|
6
|
+
# `a + b` becomes `a` and `b`.
|
|
7
|
+
#
|
|
8
|
+
# A survivor means one operand never affects the tested result. Comparisons
|
|
9
|
+
# are left to the comparison and conditional operators.
|
|
10
|
+
#
|
|
11
|
+
# An operand is not promoted when the other side is the operator's integer
|
|
12
|
+
# identity — `a + 0` or `a * 1` already equals `a`. A float literal does not
|
|
13
|
+
# count (`a + 0.0` turns an Integer into a Float), and neither do shifts,
|
|
14
|
+
# since `<<` also appends. A binary send in void statement position is left
|
|
15
|
+
# to statement_deletion.
|
|
16
|
+
class Evilution::Mutator::Operator::BinaryOperandPromotion < Evilution::Mutator::Base
|
|
17
|
+
OPERATORS = %i[+ - * / % ** & | ^ << >>].to_set.freeze
|
|
18
|
+
private_constant :OPERATORS
|
|
19
|
+
|
|
20
|
+
# The literal on the right that makes `left <op> literal` equal `left`.
|
|
21
|
+
RIGHT_IDENTITIES = { :+ => 0, :- => 0, :* => 1, :/ => 1, :** => 1, :| => 0, :^ => 0 }.freeze
|
|
22
|
+
private_constant :RIGHT_IDENTITIES
|
|
23
|
+
|
|
24
|
+
# The literal on the left that makes `literal <op> right` equal `right`.
|
|
25
|
+
LEFT_IDENTITIES = { :+ => 0, :* => 1, :| => 0, :^ => 0 }.freeze
|
|
26
|
+
private_constant :LEFT_IDENTITIES
|
|
27
|
+
|
|
28
|
+
def call(subject, **)
|
|
29
|
+
@void_statements = Set.new
|
|
30
|
+
super
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def visit_statements_node(node)
|
|
34
|
+
@void_statements.merge(node.body[...-1])
|
|
35
|
+
super
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def visit_call_node(node)
|
|
39
|
+
right = right_operand(node)
|
|
40
|
+
promote_operands(node, node.receiver, right) if right && !@void_statements.include?(node)
|
|
41
|
+
super
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
# The single argument of a binary operator send; nil otherwise. An operator
|
|
47
|
+
# send always has a receiver — `+b` without one is the unary `+@`.
|
|
48
|
+
def right_operand(node)
|
|
49
|
+
return unless OPERATORS.include?(node.name)
|
|
50
|
+
return unless node.arguments in Prism::ArgumentsNode[arguments: [right]]
|
|
51
|
+
|
|
52
|
+
right
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def promote_operands(node, left, right)
|
|
56
|
+
promote_child(node, left) unless integer_literal?(right, RIGHT_IDENTITIES[node.name])
|
|
57
|
+
promote_child(node, right) unless integer_literal?(left, LEFT_IDENTITIES[node.name])
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def integer_literal?(operand, value)
|
|
61
|
+
operand.is_a?(Prism::IntegerNode) && operand.value == value
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../operator"
|
|
4
|
+
|
|
5
|
+
# Replace a call with its parameter-less block's body, run once in place:
|
|
6
|
+
# `Base.transaction { account.save! }` becomes `account.save!`, and
|
|
7
|
+
# `3.times { retry_call }` becomes `retry_call`. A multi-statement body is
|
|
8
|
+
# grouped in parentheses so it still evaluates to its last statement where
|
|
9
|
+
# the call's value is used.
|
|
10
|
+
#
|
|
11
|
+
# A survivor means the wrapping call is never observed: a transaction whose
|
|
12
|
+
# rollback no test triggers, a lock nothing contends for, an iteration count
|
|
13
|
+
# nothing checks.
|
|
14
|
+
#
|
|
15
|
+
# Blocks with parameters (including `_1` / `it` and block-locals) are
|
|
16
|
+
# skipped — the unwrapped body would reference a variable that no longer
|
|
17
|
+
# exists; explicitly empty pipes `{ || ... }` count as parameter-less. So are
|
|
18
|
+
# bodies with a rescue / ensure clause, and bodies using `break` / `next` /
|
|
19
|
+
# `redo`, which do not parse outside a block and are dropped by the parse
|
|
20
|
+
# guard.
|
|
21
|
+
class Evilution::Mutator::Operator::BlockBodyPromotion < Evilution::Mutator::Base
|
|
22
|
+
def visit_call_node(node)
|
|
23
|
+
block = node.block
|
|
24
|
+
unwrap(node, block.body) if block.is_a?(Prism::BlockNode) && parameterless?(block.parameters)
|
|
25
|
+
super
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
# No parameters at all, or explicitly empty pipes `{ || ... }`. Block-local
|
|
31
|
+
# variables (`{ |;tmp| ... }`) count as parameters: unwrapping would turn
|
|
32
|
+
# them into method locals.
|
|
33
|
+
def parameterless?(parameters)
|
|
34
|
+
parameters.nil? ||
|
|
35
|
+
(parameters.is_a?(Prism::BlockParametersNode) && parameters.parameters.nil? && parameters.locals.empty?)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def unwrap(node, body)
|
|
39
|
+
return unless body.is_a?(Prism::StatementsNode)
|
|
40
|
+
|
|
41
|
+
statements = body.body
|
|
42
|
+
replacement = statements.length == 1 ? source_of(statements.first) : "(#{source_of(body)})"
|
|
43
|
+
replace_span(node: node, target: node, replacement: replacement)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../operator"
|
|
4
|
+
|
|
5
|
+
# Replace a block's body with `nil`: `items.map { |item| item * 2 }` becomes
|
|
6
|
+
# `items.map { |item| nil }`. Unlike block_removal, the iteration itself
|
|
7
|
+
# stays, so a survivor means the block runs but nothing observes what it
|
|
8
|
+
# does.
|
|
9
|
+
#
|
|
10
|
+
# A `do ... rescue ... end` block keeps its rescue / ensure clauses; only
|
|
11
|
+
# the main statements become nil.
|
|
12
|
+
#
|
|
13
|
+
# Blocks that are themselves the loop are skipped — `loop { }` and an
|
|
14
|
+
# endless `cycle { }` or `cycle(nil) { }` — since with a nil body nothing
|
|
15
|
+
# breaks out and the mutant would hang until the per-mutation timeout (see
|
|
16
|
+
# EV-170m.7).
|
|
17
|
+
class Evilution::Mutator::Operator::BlockBodyToNil < Evilution::Mutator::Base
|
|
18
|
+
def visit_call_node(node)
|
|
19
|
+
block = node.block
|
|
20
|
+
empty_body(node, block) if block.is_a?(Prism::BlockNode) && !endless_iteration?(node)
|
|
21
|
+
super
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def empty_body(node, block)
|
|
27
|
+
body = block.body
|
|
28
|
+
statements = body.is_a?(Prism::BeginNode) ? body.statements : body
|
|
29
|
+
mutate_to_nil(node, target: statements)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def endless_iteration?(node)
|
|
33
|
+
case node.name
|
|
34
|
+
when :loop then kernel_receiver?(node.receiver)
|
|
35
|
+
when :cycle then endless_count?(node.arguments)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# `loop`, `Kernel.loop` or `::Kernel.loop` — not a namespaced `Acme::Kernel`.
|
|
40
|
+
def kernel_receiver?(receiver)
|
|
41
|
+
case receiver
|
|
42
|
+
when nil then true
|
|
43
|
+
when Prism::ConstantReadNode then receiver.name == :Kernel
|
|
44
|
+
when Prism::ConstantPathNode then receiver.parent.nil? && receiver.name == :Kernel
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# `cycle` and `cycle(nil)` both repeat forever.
|
|
49
|
+
def endless_count?(arguments)
|
|
50
|
+
arguments.nil? || (arguments in Prism::ArgumentsNode[arguments: [Prism::NilNode]])
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../operator"
|
|
4
|
+
|
|
5
|
+
# Replace a block's body with a bare `raise`: `items.map { |item| item * 2 }`
|
|
6
|
+
# becomes `items.map { |item| raise }`.
|
|
7
|
+
#
|
|
8
|
+
# A survivor means no test ever invokes the block — every collection it
|
|
9
|
+
# iterates is empty, or the callback is never called. The raise ends the
|
|
10
|
+
# block on its first call, so unlike block_body_to_nil a `loop { }` is safe
|
|
11
|
+
# here.
|
|
12
|
+
#
|
|
13
|
+
# A block whose body has a `rescue` clause is skipped: that clause would
|
|
14
|
+
# catch the raise, and the mutant would exercise the rescue path instead of
|
|
15
|
+
# the invocation. An `ensure` clause does not swallow it and stays.
|
|
16
|
+
class Evilution::Mutator::Operator::BlockBodyToRaise < Evilution::Mutator::Base
|
|
17
|
+
def visit_call_node(node)
|
|
18
|
+
block = node.block
|
|
19
|
+
raise_in_body(node, block.body) if block.is_a?(Prism::BlockNode)
|
|
20
|
+
super
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def raise_in_body(node, body)
|
|
26
|
+
if body.is_a?(Prism::BeginNode)
|
|
27
|
+
return if body.rescue_clause
|
|
28
|
+
|
|
29
|
+
body = body.statements
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
replace_span(node: node, target: body, replacement: "raise")
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../operator"
|
|
4
|
+
|
|
5
|
+
# Replace a method call with `nil`: `user.name` becomes `nil`, and the same
|
|
6
|
+
# for implicit-self calls, operator calls and calls carrying a block.
|
|
7
|
+
#
|
|
8
|
+
# The broadest value-level probe on a call site. The survivor it targets is a
|
|
9
|
+
# call whose result is never asserted — the test runs the code but checks
|
|
10
|
+
# nothing that depends on what the call returned.
|
|
11
|
+
#
|
|
12
|
+
# Three positions are skipped as noise. A call in void statement position
|
|
13
|
+
# (every statement of a body except the last) has no value to replace;
|
|
14
|
+
# deleting it is statement_deletion's job. A call used as the receiver of
|
|
15
|
+
# another call turns into `nil.foo`, a NoMethodError any test that reaches it
|
|
16
|
+
# kills; the outer call is nil-ified instead. An attribute or index write
|
|
17
|
+
# (`a.b = c`, `a[i] = c`) is a side effect, not a value.
|
|
18
|
+
class Evilution::Mutator::Operator::CallToNil < Evilution::Mutator::Base
|
|
19
|
+
def call(subject, **)
|
|
20
|
+
@skipped_calls = Set.new
|
|
21
|
+
super
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def visit_statements_node(node)
|
|
25
|
+
@skipped_calls.merge(node.body[...-1])
|
|
26
|
+
super
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def visit_call_node(node)
|
|
30
|
+
@skipped_calls.add(node.receiver)
|
|
31
|
+
mutate_to_nil(node) unless @skipped_calls.include?(node) || node.attribute_write?
|
|
32
|
+
super
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../operator"
|
|
4
|
+
|
|
5
|
+
# Replace a conversion with an empty value of its target type: `value.to_a`
|
|
6
|
+
# becomes `[]`, `value.to_h` becomes `{}`, `value.to_s` becomes `""`, and the
|
|
7
|
+
# same for the implicit `to_ary` / `to_hash` / `to_str` forms.
|
|
8
|
+
#
|
|
9
|
+
# The type stays right, so a survivor means tests check what kind of value
|
|
10
|
+
# comes out but never what it contains.
|
|
11
|
+
#
|
|
12
|
+
# An empty hash that is the first argument of an unparenthesized call,
|
|
13
|
+
# `yield` or `super` is written `({})`: bare `{}` there would be parsed as a
|
|
14
|
+
# block. A nil receiver is skipped — its conversion is already empty.
|
|
15
|
+
class Evilution::Mutator::Operator::CoercionEmptying < Evilution::Mutator::Base
|
|
16
|
+
EMPTY_VALUES = {
|
|
17
|
+
to_a: "[]", to_ary: "[]",
|
|
18
|
+
to_h: "{}", to_hash: "{}",
|
|
19
|
+
to_s: '""', to_str: '""'
|
|
20
|
+
}.freeze
|
|
21
|
+
private_constant :EMPTY_VALUES
|
|
22
|
+
|
|
23
|
+
def call(subject, **)
|
|
24
|
+
@bare_first_arguments = Set.new
|
|
25
|
+
super
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def visit_call_node(node)
|
|
29
|
+
note_bare_first_argument(node, node.opening_loc)
|
|
30
|
+
empty(node) if emptiable?(node)
|
|
31
|
+
super
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def visit_yield_node(node)
|
|
35
|
+
note_bare_first_argument(node, node.lparen_loc)
|
|
36
|
+
super
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def visit_super_node(node)
|
|
40
|
+
note_bare_first_argument(node, node.lparen_loc)
|
|
41
|
+
super
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def note_bare_first_argument(node, opening)
|
|
47
|
+
@bare_first_arguments.add(node.arguments.arguments.first) if opening.nil? && node.arguments
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def emptiable?(node)
|
|
51
|
+
EMPTY_VALUES.key?(node.name) && node.receiver && !node.receiver.is_a?(Prism::NilNode) &&
|
|
52
|
+
node.arguments.nil? && node.block.nil?
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def empty(node)
|
|
56
|
+
value = EMPTY_VALUES.fetch(node.name)
|
|
57
|
+
value = "(#{value})" if value == "{}" && @bare_first_arguments.include?(node)
|
|
58
|
+
replace_span(node: node, target: node, replacement: value)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -12,10 +12,10 @@ class Evilution::Mutator::Operator::CollectionReplacement < Evilution::Mutator::
|
|
|
12
12
|
collect: [:each],
|
|
13
13
|
sort: [:sort_by],
|
|
14
14
|
sort_by: [:sort],
|
|
15
|
-
find: [
|
|
16
|
-
detect: [
|
|
17
|
-
any?: [
|
|
18
|
-
all?: [
|
|
15
|
+
find: %i[detect first last],
|
|
16
|
+
detect: %i[find first last],
|
|
17
|
+
any?: %i[all? empty? none?],
|
|
18
|
+
all?: %i[any? none?],
|
|
19
19
|
count: [:length],
|
|
20
20
|
length: [:count],
|
|
21
21
|
pop: [:shift],
|
|
@@ -30,10 +30,10 @@ class Evilution::Mutator::Operator::CollectionReplacement < Evilution::Mutator::
|
|
|
30
30
|
grep_v: [:grep],
|
|
31
31
|
take: [:drop],
|
|
32
32
|
drop: [:take],
|
|
33
|
-
min: [
|
|
34
|
-
max: [
|
|
35
|
-
min_by: [
|
|
36
|
-
max_by: [
|
|
33
|
+
min: %i[max first last],
|
|
34
|
+
max: %i[min first last],
|
|
35
|
+
min_by: %i[max_by first last],
|
|
36
|
+
max_by: %i[min_by first last],
|
|
37
37
|
compact: [:flatten],
|
|
38
38
|
flatten: [:compact],
|
|
39
39
|
zip: [:product],
|
|
@@ -41,7 +41,17 @@ class Evilution::Mutator::Operator::CollectionReplacement < Evilution::Mutator::
|
|
|
41
41
|
first: [:last],
|
|
42
42
|
last: [:first],
|
|
43
43
|
keys: [:values],
|
|
44
|
-
values: [:keys]
|
|
44
|
+
values: [:keys],
|
|
45
|
+
sample: %i[first last],
|
|
46
|
+
fetch: [:key?],
|
|
47
|
+
at: %i[fetch key?],
|
|
48
|
+
delete_if: [:reject],
|
|
49
|
+
keep_if: [:select],
|
|
50
|
+
filter_map: [:map],
|
|
51
|
+
chunk: [:each],
|
|
52
|
+
chunk_while: [:each],
|
|
53
|
+
each_with_index: [:each],
|
|
54
|
+
slice_when: [:each]
|
|
45
55
|
}.freeze
|
|
46
56
|
|
|
47
57
|
def visit_call_node(node)
|
|
@@ -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
|
|
@@ -12,22 +12,47 @@ class Evilution::Mutator::Operator::ComparisonReplacement < Evilution::Mutator::
|
|
|
12
12
|
:!= => [:==]
|
|
13
13
|
}.freeze
|
|
14
14
|
|
|
15
|
+
# Operators rewritten into a method call, since the method cannot be
|
|
16
|
+
# swapped in as an operator (`a eql? b` does not parse). Ordering
|
|
17
|
+
# comparisons collapse to the stricter equalities — `a < b` becomes
|
|
18
|
+
# `a.eql?(b)` and `a.equal?(b)` — and `a =~ b` becomes `a.match?(b)`,
|
|
19
|
+
# which returns a boolean and leaves `$~` / `$1` unset.
|
|
20
|
+
EQUALITY_METHODS = %i[eql? equal?].freeze
|
|
21
|
+
private_constant :EQUALITY_METHODS
|
|
22
|
+
|
|
23
|
+
METHOD_REPLACEMENTS = {
|
|
24
|
+
:< => EQUALITY_METHODS,
|
|
25
|
+
:<= => EQUALITY_METHODS,
|
|
26
|
+
:> => EQUALITY_METHODS,
|
|
27
|
+
:>= => EQUALITY_METHODS,
|
|
28
|
+
:=~ => [:match?]
|
|
29
|
+
}.freeze
|
|
30
|
+
private_constant :METHOD_REPLACEMENTS
|
|
31
|
+
|
|
15
32
|
def visit_call_node(node)
|
|
16
|
-
|
|
17
|
-
|
|
33
|
+
replace_operator(node)
|
|
34
|
+
replace_with_methods(node)
|
|
35
|
+
super
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
18
39
|
|
|
40
|
+
def replace_operator(node)
|
|
19
41
|
loc = node.message_loc
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
add_mutation(
|
|
24
|
-
offset: loc.start_offset,
|
|
25
|
-
length: loc.length,
|
|
26
|
-
replacement: replacement.to_s,
|
|
27
|
-
node: node
|
|
28
|
-
)
|
|
42
|
+
|
|
43
|
+
REPLACEMENTS.fetch(node.name, []).each do |replacement|
|
|
44
|
+
add_mutation(offset: loc.start_offset, length: loc.length, replacement: replacement.to_s, node: node)
|
|
29
45
|
end
|
|
46
|
+
end
|
|
30
47
|
|
|
31
|
-
|
|
48
|
+
def replace_with_methods(node)
|
|
49
|
+
methods = METHOD_REPLACEMENTS[node.name]
|
|
50
|
+
return unless methods && (node.arguments in Prism::ArgumentsNode[arguments: [argument]])
|
|
51
|
+
|
|
52
|
+
left = receiver_source(node.receiver)
|
|
53
|
+
methods.each do |method|
|
|
54
|
+
add_mutation(offset: node.start_offset, length: node.location.length,
|
|
55
|
+
replacement: "#{left}.#{method}(#{source_of(argument)})", node: node)
|
|
56
|
+
end
|
|
32
57
|
end
|
|
33
58
|
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../operator"
|
|
4
|
+
|
|
5
|
+
# Resolve a dynamic constant lookup with a literal name into a constant path:
|
|
6
|
+
# `Registry.const_get(:Handler)` becomes `Registry::Handler`. A literal
|
|
7
|
+
# inherit flag is dropped; a receiverless call resolves against `self`.
|
|
8
|
+
#
|
|
9
|
+
# Both forms find the same own, inherited and mixed-in constants and raise the
|
|
10
|
+
# same NameError for a missing one. They differ on top-level constants (found
|
|
11
|
+
# by `const_get` through Object, rejected by `::`), on private constants
|
|
12
|
+
# (returned by `const_get` only) and with `inherit = false` (ancestors skipped
|
|
13
|
+
# by `const_get`, searched by `::`). A survivor means none of those paths is
|
|
14
|
+
# exercised, so the dynamic lookup may not be needed.
|
|
15
|
+
#
|
|
16
|
+
# Safe navigation is skipped: a constant path has no nil-safe form.
|
|
17
|
+
class Evilution::Mutator::Operator::ConstGetToConstantPath < Evilution::Mutator::Base
|
|
18
|
+
CONSTANT_NAME = /\A[A-Z]\w*\z/
|
|
19
|
+
private_constant :CONSTANT_NAME
|
|
20
|
+
|
|
21
|
+
# `nil` behaves like `false`.
|
|
22
|
+
INHERIT_FLAGS = [Prism::TrueNode, Prism::FalseNode, Prism::NilNode].freeze
|
|
23
|
+
private_constant :INHERIT_FLAGS
|
|
24
|
+
|
|
25
|
+
def visit_call_node(node)
|
|
26
|
+
rewrite(node) if node.name == :const_get && node.block.nil? && !node.safe_navigation?
|
|
27
|
+
super
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def rewrite(node)
|
|
33
|
+
name = constant_name(node)
|
|
34
|
+
return if name.nil?
|
|
35
|
+
|
|
36
|
+
receiver = node.receiver ? source_of(node.receiver) : "self"
|
|
37
|
+
add_mutation(offset: node.start_offset, length: node.location.length, replacement: "#{receiver}::#{name}", node: node)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def constant_name(node)
|
|
41
|
+
return unless node.arguments in Prism::ArgumentsNode[arguments: [Prism::SymbolNode => symbol, *flags]]
|
|
42
|
+
return unless flags.empty? || literal_inherit_flag?(flags)
|
|
43
|
+
|
|
44
|
+
symbol.unescaped if symbol.unescaped.match?(CONSTANT_NAME)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def literal_inherit_flag?(flags)
|
|
48
|
+
flags.length == 1 && INHERIT_FLAGS.any? { |type| flags.first.is_a?(type) }
|
|
49
|
+
end
|
|
50
|
+
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
|