evilution 1.1.0 → 1.3.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 +58 -0
- data/.rubocop_todo.yml +9 -0
- data/CHANGELOG.md +86 -0
- data/README.md +202 -14
- data/docs/architecture.md +54 -5
- data/docs/isolation.md +3 -4
- data/exe/evil +8 -1
- data/exe/evilution +8 -1
- data/lib/evilution/ast/local_reads.rb +43 -0
- data/lib/evilution/ast/parser.rb +1 -0
- data/lib/evilution/ast/pattern/filter.rb +1 -0
- data/lib/evilution/ast/pattern/parser.rb +1 -0
- data/lib/evilution/ast/pattern.rb +2 -0
- data/lib/evilution/ast/sorbet_sig_detector.rb +1 -0
- data/lib/evilution/baseline.rb +37 -15
- 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 +61 -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 +2 -0
- data/lib/evilution/cli/parser/options_builder.rb +4 -0
- 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 +6 -0
- 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 +1 -0
- 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 +22 -0
- data/lib/evilution/example_filter.rb +1 -1
- data/lib/evilution/integration/loading/body_call_neutralizer.rb +10 -2
- data/lib/evilution/integration/loading/concern_state_cleaner.rb +20 -4
- data/lib/evilution/integration/loading/reeval_warning_filter.rb +72 -0
- data/lib/evilution/integration/loading/source_evaluator.rb +4 -1
- data/lib/evilution/integration/loading/test_load_path.rb +21 -13
- data/lib/evilution/integration/minitest.rb +2 -1
- data/lib/evilution/integration/rspec/crash_detector_lifecycle.rb +9 -1
- data/lib/evilution/integration/rspec/state_guard/configuration_state.rb +1 -0
- data/lib/evilution/integration/rspec/state_guard/configuration_streams.rb +5 -1
- 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 +6 -0
- data/lib/evilution/integration/rspec/unresolved_spec_warner.rb +2 -1
- data/lib/evilution/integration/rspec.rb +48 -2
- data/lib/evilution/integration/test_unit/test_file_resolver.rb +2 -1
- data/lib/evilution/isolation/fork.rb +29 -8
- 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/mutation.rb +1 -1
- data/lib/evilution/mutator/operator/argument_list_removal.rb +52 -0
- data/lib/evilution/mutator/operator/argument_propagation.rb +54 -0
- 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/block_destructuring_expansion.rb +85 -0
- data/lib/evilution/mutator/operator/block_parameter_drop.rb +96 -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_replacement.rb +37 -12
- data/lib/evilution/mutator/operator/const_get_to_constant_path.rb +50 -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/forwarding_super_to_explicit.rb +71 -0
- data/lib/evilution/mutator/operator/inequality_to_negated_identity.rb +43 -0
- data/lib/evilution/mutator/operator/keyword_argument_removal.rb +47 -0
- data/lib/evilution/mutator/operator/method_body_replacement.rb +10 -1
- data/lib/evilution/mutator/operator/method_body_to_raise.rb +59 -0
- data/lib/evilution/mutator/operator/method_body_to_super.rb +150 -0
- data/lib/evilution/mutator/operator/optional_default_injection.rb +71 -0
- data/lib/evilution/mutator/operator/optional_parameter_to_required.rb +41 -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/regexp_anchor_to_predicate.rb +136 -0
- data/lib/evilution/mutator/operator/safe_navigation_removal.rb +67 -0
- data/lib/evilution/mutator/operator/send_mutation.rb +42 -6
- 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/operator/typed_default_return.rb +84 -0
- data/lib/evilution/mutator/primitives.rb +25 -0
- data/lib/evilution/mutator/registry.rb +32 -1
- data/lib/evilution/parallel/pool.rb +1 -0
- data/lib/evilution/parallel_db_warning.rb +1 -1
- data/lib/evilution/process_supervisor.rb +20 -8
- data/lib/evilution/rails_warmup.rb +61 -0
- data/lib/evilution/reporter/cli/item_formatters/neutral_group.rb +23 -0
- data/lib/evilution/reporter/cli/item_formatters/subject_score.rb +42 -0
- data/lib/evilution/reporter/cli/item_formatters/subject_score_group.rb +16 -0
- data/lib/evilution/reporter/cli/line_formatters/infra_retry_notice.rb +19 -0
- data/lib/evilution/reporter/cli/line_formatters/result_line.rb +27 -3
- data/lib/evilution/reporter/cli/line_formatters/score.rb +19 -1
- data/lib/evilution/reporter/cli/line_formatters/unresolved_targets.rb +35 -0
- data/lib/evilution/reporter/cli/metrics_block.rb +4 -0
- data/lib/evilution/reporter/cli/trailer.rb +11 -7
- data/lib/evilution/reporter/cli.rb +20 -4
- data/lib/evilution/reporter/html/report.rb +1 -0
- data/lib/evilution/reporter/json/subjects.rb +29 -0
- data/lib/evilution/reporter/json.rb +23 -1
- data/lib/evilution/result/coverage_gap_grouper.rb +1 -0
- data/lib/evilution/result/mutation_result.rb +3 -2
- data/lib/evilution/result/neutral_reason.rb +35 -0
- data/lib/evilution/result/subject_score.rb +28 -0
- data/lib/evilution/result/subject_scorer.rb +38 -0
- data/lib/evilution/result/summary.rb +44 -2
- data/lib/evilution/runner/baseline_runner.rb +17 -7
- data/lib/evilution/runner/canary.rb +52 -5
- data/lib/evilution/runner/isolation_resolver.rb +23 -2
- data/lib/evilution/runner/mutation_executor/infra_retry.rb +54 -0
- data/lib/evilution/runner/mutation_executor/neutralizer/baseline_failed.rb +23 -10
- data/lib/evilution/runner/mutation_executor/neutralizer/infra_error.rb +18 -1
- data/lib/evilution/runner/mutation_executor/result_cache.rb +11 -0
- data/lib/evilution/runner/mutation_executor/strategy/parallel.rb +19 -1
- data/lib/evilution/runner/mutation_executor.rb +32 -4
- data/lib/evilution/runner/report_publisher.rb +31 -9
- data/lib/evilution/runner/target_spec_audit.rb +43 -0
- data/lib/evilution/runner.rb +10 -1
- data/lib/evilution/source_ast_cache.rb +1 -1
- data/lib/evilution/spec_ast_cache.rb +1 -1
- data/lib/evilution/version.rb +1 -1
- data/lib/evilution.rb +35 -0
- metadata +50 -2
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
require_relative "../operator"
|
|
6
|
+
|
|
7
|
+
# Reassign an optional parameter to its own default at the top of the body:
|
|
8
|
+
# `def f(a = 1); body; end` becomes `def f(a = 1); a = 1; body; end`.
|
|
9
|
+
#
|
|
10
|
+
# Whatever a caller passed is overwritten, so the mutant survives only where
|
|
11
|
+
# every example already passes the default — or passes a value the assertions
|
|
12
|
+
# never distinguish from it. Where OptionalParameterToRequired asks whether the
|
|
13
|
+
# default is ever used, this asks the opposite question: whether any other value
|
|
14
|
+
# ever is.
|
|
15
|
+
#
|
|
16
|
+
# The parameter must be read somewhere in the body, otherwise overwriting it
|
|
17
|
+
# changes nothing and the mutant would survive every suite. An endless method is
|
|
18
|
+
# left alone: its body is a single expression, and putting a second statement in
|
|
19
|
+
# front of it would change what the method returns rather than what it computes.
|
|
20
|
+
# KeywordArgument owns optional keyword parameters, and a block's optional
|
|
21
|
+
# parameter is left alone for the same reason it is elsewhere — a block ignores
|
|
22
|
+
# arity, so the default is not a contract.
|
|
23
|
+
class Evilution::Mutator::Operator::OptionalDefaultInjection < Evilution::Mutator::Base
|
|
24
|
+
def visit_def_node(node)
|
|
25
|
+
inject_defaults(node)
|
|
26
|
+
super
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def inject_defaults(node)
|
|
32
|
+
return if node.equal_loc
|
|
33
|
+
return unless node.parameters
|
|
34
|
+
|
|
35
|
+
statements = body_statements(node.body)
|
|
36
|
+
return if statements.nil?
|
|
37
|
+
|
|
38
|
+
node.parameters.optionals.each { |optional| inject_default(optional, node.body, statements) }
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# The read is looked for across the whole body, rescue and ensure clauses
|
|
42
|
+
# included, since a parameter read only in a rescue clause is still read. The
|
|
43
|
+
# injection itself goes ahead of the leading statements, which is the only
|
|
44
|
+
# place a statement can be added.
|
|
45
|
+
def inject_default(optional, body, statements)
|
|
46
|
+
name = optional.name.to_s
|
|
47
|
+
return if name.start_with?("_")
|
|
48
|
+
return unless Evilution::AST::LocalReads.new.call(body, name)
|
|
49
|
+
|
|
50
|
+
first = statements.body.first.location
|
|
51
|
+
|
|
52
|
+
add_mutation(
|
|
53
|
+
offset: first.start_offset,
|
|
54
|
+
length: 0,
|
|
55
|
+
replacement: "#{source_of(optional)}; ",
|
|
56
|
+
node: optional
|
|
57
|
+
)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# A method-level rescue/ensure (`def foo; stmts; rescue; ...; end`) makes the
|
|
61
|
+
# body a BeginNode whose location spans the entire `def...end`. The injection
|
|
62
|
+
# belongs ahead of the leading statements; returns nil for a rescue/ensure-only
|
|
63
|
+
# body and for an empty method, whose body is nil. A body that holds no
|
|
64
|
+
# statements is always nil rather than an empty StatementsNode, so a present
|
|
65
|
+
# node always has a first statement to inject ahead of.
|
|
66
|
+
def body_statements(body)
|
|
67
|
+
return body unless body.is_a?(Prism::BeginNode)
|
|
68
|
+
|
|
69
|
+
body.statements
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
require_relative "../operator"
|
|
6
|
+
|
|
7
|
+
# Drop the default of an optional positional parameter, making it required:
|
|
8
|
+
# `def f(a = 1)` becomes `def f(a)`.
|
|
9
|
+
#
|
|
10
|
+
# A survivor means no example calls the method without that argument, so the
|
|
11
|
+
# default is never exercised and the value it supplies is unasserted. Callers
|
|
12
|
+
# that pass the argument are unaffected, which is what separates this from a
|
|
13
|
+
# mutation that breaks every call site.
|
|
14
|
+
#
|
|
15
|
+
# Ruby allows a required parameter to follow an optional one, so each optional
|
|
16
|
+
# is mutated on its own and the rest of the signature stays as written.
|
|
17
|
+
# KeywordArgument owns optional keyword parameters. Blocks are left alone: a
|
|
18
|
+
# block ignores arity, so a missing argument arrives as nil rather than raising,
|
|
19
|
+
# and making its parameter required would change nothing.
|
|
20
|
+
class Evilution::Mutator::Operator::OptionalParameterToRequired < Evilution::Mutator::Base
|
|
21
|
+
def visit_def_node(node)
|
|
22
|
+
parameters = node.parameters
|
|
23
|
+
parameters.optionals.each { |optional| require_parameter(optional) } if parameters
|
|
24
|
+
|
|
25
|
+
super
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def require_parameter(node)
|
|
31
|
+
location = node.location
|
|
32
|
+
name_loc = node.name_loc
|
|
33
|
+
|
|
34
|
+
add_mutation(
|
|
35
|
+
offset: location.start_offset,
|
|
36
|
+
length: location.length,
|
|
37
|
+
replacement: byteslice_source(name_loc.start_offset, name_loc.length),
|
|
38
|
+
node: node
|
|
39
|
+
)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../operator"
|
|
4
|
+
|
|
5
|
+
# Turn a proc into a lambda: `proc { |a, b| ... }` and `Proc.new { ... }`
|
|
6
|
+
# become `lambda { ... }`, with the block itself untouched.
|
|
7
|
+
#
|
|
8
|
+
# A lambda checks arity (wrong argument counts raise ArgumentError instead
|
|
9
|
+
# of padding with nil or dropping extras), does not auto-splat a single
|
|
10
|
+
# array argument, and `return` inside it returns from the lambda rather than
|
|
11
|
+
# from the enclosing method. A survivor means no test relies on any of that.
|
|
12
|
+
#
|
|
13
|
+
# Only a literal block with no arguments qualifies: `proc(&blk)` and
|
|
14
|
+
# `Proc.new(x) { }` are skipped.
|
|
15
|
+
class Evilution::Mutator::Operator::ProcToLambda < Evilution::Mutator::Base
|
|
16
|
+
def visit_call_node(node)
|
|
17
|
+
rewrite(node) if proc_constructor?(node) && node.block.is_a?(Prism::BlockNode) && node.arguments.nil?
|
|
18
|
+
super
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def proc_constructor?(node)
|
|
24
|
+
case node.name
|
|
25
|
+
when :proc then node.receiver.nil?
|
|
26
|
+
when :new then proc_constant?(node.receiver)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def proc_constant?(receiver)
|
|
31
|
+
case receiver
|
|
32
|
+
when Prism::ConstantReadNode then receiver.name == :Proc
|
|
33
|
+
when Prism::ConstantPathNode then receiver.parent.nil? && receiver.name == :Proc
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Replace the call head — `proc`, `Proc.new` or `Proc.new()` — leaving the
|
|
38
|
+
# block in place.
|
|
39
|
+
def rewrite(node)
|
|
40
|
+
head_end = (node.closing_loc || node.message_loc).end_offset
|
|
41
|
+
add_mutation(offset: node.start_offset, length: head_end - node.start_offset, replacement: "lambda", node: node)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../operator"
|
|
4
|
+
|
|
5
|
+
# Swap a constructor for a sibling on the same receiver constant:
|
|
6
|
+
# `Date.parse(value)` becomes `Date.iso8601(value)`, `Date.strptime(value)`,
|
|
7
|
+
# `Date.rfc2822(value)` and the rest. Keyed on receiver and selector, which
|
|
8
|
+
# the plain selector tables cannot express — `JSON.parse` is untouched.
|
|
9
|
+
#
|
|
10
|
+
# `parse` accepts almost any date-like string; the siblings accept one
|
|
11
|
+
# format (strptime's default is ISO 8601) or, like `jd` / `civil`, numbers
|
|
12
|
+
# only. A survivor means the tests only ever feed input every parser agrees
|
|
13
|
+
# on, so an overly lenient parse goes unnoticed.
|
|
14
|
+
#
|
|
15
|
+
# Only the constant itself (`Date`, `::Date`) matches, not a namespaced
|
|
16
|
+
# `Acme::Date`.
|
|
17
|
+
class Evilution::Mutator::Operator::ReceiverConstructorSwap < Evilution::Mutator::Base
|
|
18
|
+
DATE_PARSE_SIBLINGS = %i[jd civil strptime iso8601 rfc3339 xmlschema rfc2822 rfc822 httpdate jisx0301].freeze
|
|
19
|
+
private_constant :DATE_PARSE_SIBLINGS
|
|
20
|
+
|
|
21
|
+
# receiver constant => { selector => replacements }. Time has no jd / civil
|
|
22
|
+
# / rfc3339 / jisx0301, and its strptime requires a format argument.
|
|
23
|
+
REPLACEMENTS = {
|
|
24
|
+
Date: { parse: DATE_PARSE_SIBLINGS },
|
|
25
|
+
DateTime: { parse: DATE_PARSE_SIBLINGS },
|
|
26
|
+
Time: { parse: %i[iso8601 xmlschema rfc2822 rfc822 httpdate] }
|
|
27
|
+
}.freeze
|
|
28
|
+
private_constant :REPLACEMENTS
|
|
29
|
+
|
|
30
|
+
def visit_call_node(node)
|
|
31
|
+
replacements_for(node).each do |replacement|
|
|
32
|
+
location = node.message_loc
|
|
33
|
+
add_mutation(offset: location.start_offset, length: location.length, replacement: replacement.to_s, node: node)
|
|
34
|
+
end
|
|
35
|
+
super
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def replacements_for(node)
|
|
41
|
+
selectors = REPLACEMENTS[receiver_constant(node.receiver)]
|
|
42
|
+
selectors ? selectors.fetch(node.name, []) : []
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def receiver_constant(receiver)
|
|
46
|
+
case receiver
|
|
47
|
+
when Prism::ConstantReadNode then receiver.name
|
|
48
|
+
when Prism::ConstantPathNode then receiver.name if receiver.parent.nil?
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../operator"
|
|
4
|
+
|
|
5
|
+
# Rewrite a `+` reduction into `sum`: `values.reduce(:+)` becomes
|
|
6
|
+
# `values.sum`, `values.inject(10, :+)` becomes `values.sum(10)`, and the
|
|
7
|
+
# same for the block-pass form `inject(&:+)`.
|
|
8
|
+
#
|
|
9
|
+
# The two differ on an empty collection (nil vs 0), on non-numeric elements
|
|
10
|
+
# (`reduce` concatenates strings and arrays, `sum` raises TypeError without a
|
|
11
|
+
# matching initial value) and on float precision (`sum` compensates rounding
|
|
12
|
+
# error). A survivor means none of those edges is tested.
|
|
13
|
+
class Evilution::Mutator::Operator::ReduceToSum < Evilution::Mutator::Base
|
|
14
|
+
SELECTORS = %i[reduce inject].freeze
|
|
15
|
+
private_constant :SELECTORS
|
|
16
|
+
|
|
17
|
+
NON_VALUE_ARGUMENTS = [Prism::SplatNode, Prism::KeywordHashNode].freeze
|
|
18
|
+
private_constant :NON_VALUE_ARGUMENTS
|
|
19
|
+
|
|
20
|
+
def visit_call_node(node)
|
|
21
|
+
rewrite(node) if node.receiver && SELECTORS.include?(node.name)
|
|
22
|
+
super
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def rewrite(node)
|
|
28
|
+
initial = initial_arguments(node)
|
|
29
|
+
return unless initial && initial.length <= 1
|
|
30
|
+
return if initial.any? { |argument| NON_VALUE_ARGUMENTS.any? { |type| argument.is_a?(type) } }
|
|
31
|
+
|
|
32
|
+
# A literal block never reaches here, so the call ends at its argument
|
|
33
|
+
# list or block-pass: cut from the selector to the end of the call.
|
|
34
|
+
start = node.message_loc.start_offset
|
|
35
|
+
replacement = initial.empty? ? "sum" : "sum(#{source_of(initial.first)})"
|
|
36
|
+
add_mutation(offset: start, length: node.end_offset - start, replacement: replacement, node: node)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# The arguments besides the `+` operand — at most an initial value — when
|
|
40
|
+
# the call is a `+` reduction; nil otherwise.
|
|
41
|
+
def initial_arguments(node)
|
|
42
|
+
arguments = node.arguments ? node.arguments.arguments : []
|
|
43
|
+
|
|
44
|
+
if plus_block_pass?(node.block)
|
|
45
|
+
arguments unless plus_symbol?(arguments.last)
|
|
46
|
+
elsif node.block.nil? && plus_symbol?(arguments.last)
|
|
47
|
+
arguments[...-1]
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def plus_block_pass?(block)
|
|
52
|
+
block.is_a?(Prism::BlockArgumentNode) && plus_symbol?(block.expression)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def plus_symbol?(node)
|
|
56
|
+
node.is_a?(Prism::SymbolNode) && node.unescaped == "+"
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../operator"
|
|
4
|
+
|
|
5
|
+
# Rewrite a match against an anchored literal regexp into a prefix or suffix
|
|
6
|
+
# predicate: `line =~ /^foo/` becomes `line.start_with?("foo")`, and
|
|
7
|
+
# `line.match?(/bar$/)` becomes `line.end_with?("bar")`. Handles `=~`,
|
|
8
|
+
# `match` and `match?`, with the regexp on either side.
|
|
9
|
+
#
|
|
10
|
+
# Line anchors carry the signal: `^` also matches after any newline, and `$`
|
|
11
|
+
# / `\Z` before a trailing one, where the predicates do not — a survivor
|
|
12
|
+
# means no test feeds multi-line input, or the anchor should have been `\A` /
|
|
13
|
+
# `\z`. With the string anchors `\A` / `\z` the truth value is identical, so
|
|
14
|
+
# only `=~` and `match` are rewritten there (their return value — an index
|
|
15
|
+
# or MatchData — still differs from a boolean); `match?` would be an exact
|
|
16
|
+
# equivalent and is skipped.
|
|
17
|
+
#
|
|
18
|
+
# Only a flag-free pattern of one anchor plus literal characters qualifies,
|
|
19
|
+
# and only on a call without a block: `match` yields its MatchData to one,
|
|
20
|
+
# which the predicate would silently drop.
|
|
21
|
+
class Evilution::Mutator::Operator::RegexpAnchorToPredicate < Evilution::Mutator::Base
|
|
22
|
+
SELECTORS = %i[=~ match match?].freeze
|
|
23
|
+
private_constant :SELECTORS
|
|
24
|
+
|
|
25
|
+
START_ANCHORS = { "^" => :line, "\\A" => :string }.freeze
|
|
26
|
+
private_constant :START_ANCHORS
|
|
27
|
+
|
|
28
|
+
END_ANCHORS = { "$" => :line, "\\Z" => :line, "\\z" => :string }.freeze
|
|
29
|
+
private_constant :END_ANCHORS
|
|
30
|
+
|
|
31
|
+
METACHARACTERS = ".*+?()[]{}|^$".chars.to_set.freeze
|
|
32
|
+
private_constant :METACHARACTERS
|
|
33
|
+
|
|
34
|
+
# Operands that need no parentheses in front of `.start_with?`.
|
|
35
|
+
SIMPLE_OPERANDS = [
|
|
36
|
+
Prism::LocalVariableReadNode,
|
|
37
|
+
Prism::InstanceVariableReadNode,
|
|
38
|
+
Prism::ClassVariableReadNode,
|
|
39
|
+
Prism::GlobalVariableReadNode,
|
|
40
|
+
Prism::ConstantReadNode,
|
|
41
|
+
Prism::ConstantPathNode,
|
|
42
|
+
Prism::SelfNode,
|
|
43
|
+
Prism::StringNode,
|
|
44
|
+
Prism::ParenthesesNode
|
|
45
|
+
].freeze
|
|
46
|
+
private_constant :SIMPLE_OPERANDS
|
|
47
|
+
|
|
48
|
+
# A backslash pair or a single character. A literal cannot end in a lone
|
|
49
|
+
# backslash, since that would escape its closing delimiter.
|
|
50
|
+
TOKEN = /\\.|./m
|
|
51
|
+
private_constant :TOKEN
|
|
52
|
+
|
|
53
|
+
IDENTIFIER = /\A[[:alpha:]_]/
|
|
54
|
+
private_constant :IDENTIFIER
|
|
55
|
+
|
|
56
|
+
def visit_call_node(node)
|
|
57
|
+
rewrite(node) if SELECTORS.include?(node.name)
|
|
58
|
+
super
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
def rewrite(node)
|
|
64
|
+
return unless node.arguments in Prism::ArgumentsNode[arguments: [argument]]
|
|
65
|
+
return if node.receiver.nil? || node.block
|
|
66
|
+
|
|
67
|
+
regexp, operand = split_operands(node.receiver, argument)
|
|
68
|
+
return if regexp.nil?
|
|
69
|
+
|
|
70
|
+
predicate, literal = anchored_literal(regexp, node.name)
|
|
71
|
+
return if predicate.nil?
|
|
72
|
+
|
|
73
|
+
replacement = "#{operand_source(operand)}#{call_operator(node)}#{predicate}(#{literal.inspect})"
|
|
74
|
+
replace_span(node: node, target: node, replacement: replacement)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def split_operands(receiver, argument)
|
|
78
|
+
if argument.is_a?(Prism::RegularExpressionNode)
|
|
79
|
+
[argument, receiver]
|
|
80
|
+
elsif receiver.is_a?(Prism::RegularExpressionNode)
|
|
81
|
+
[receiver, argument]
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# [predicate, literal] when the pattern is one anchor plus literal
|
|
86
|
+
# characters and the rewrite is not an exact equivalent; nil otherwise.
|
|
87
|
+
def anchored_literal(regexp, selector)
|
|
88
|
+
return if regexp.closing_loc.length > 1
|
|
89
|
+
|
|
90
|
+
predicate, kind, body = split_anchor(regexp.content.scan(TOKEN))
|
|
91
|
+
return if predicate.nil? || (kind == :string && selector == :match?)
|
|
92
|
+
|
|
93
|
+
literal = literal_text(body)
|
|
94
|
+
[predicate, literal] if literal && !literal.empty?
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# A pattern anchored at both ends needs no check here: the other anchor
|
|
98
|
+
# (`\z`, `\Z`, `$`, `^`, `\A`) is never a literal character, so
|
|
99
|
+
# literal_text rejects the body.
|
|
100
|
+
def split_anchor(tokens)
|
|
101
|
+
if (start_kind = START_ANCHORS[tokens.first])
|
|
102
|
+
[:start_with?, start_kind, tokens.drop(1)]
|
|
103
|
+
elsif (end_kind = END_ANCHORS[tokens.last])
|
|
104
|
+
[:end_with?, end_kind, tokens[...-1]]
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def literal_text(tokens)
|
|
109
|
+
chars = tokens.map { |token| literal_char(token) }
|
|
110
|
+
chars.join if chars.all?
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def literal_char(token)
|
|
114
|
+
if token.start_with?("\\")
|
|
115
|
+
escaped = token[1]
|
|
116
|
+
escaped unless escaped.match?(/[[:alnum:]]/)
|
|
117
|
+
elsif !METACHARACTERS.include?(token)
|
|
118
|
+
token
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def operand_source(operand)
|
|
123
|
+
source = source_of(operand)
|
|
124
|
+
simple_operand?(operand) ? source : "(#{source})"
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def simple_operand?(operand)
|
|
128
|
+
return true if SIMPLE_OPERANDS.any? { |type| operand.is_a?(type) }
|
|
129
|
+
|
|
130
|
+
operand.is_a?(Prism::CallNode) && (operand.name.match?(IDENTIFIER) || operand.name == :[])
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def call_operator(node)
|
|
134
|
+
node.call_operator_loc ? node.call_operator_loc.slice : "."
|
|
135
|
+
end
|
|
136
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../operator"
|
|
4
|
+
|
|
5
|
+
# Replace safe navigation with a plain call: `user&.name` becomes
|
|
6
|
+
# `user.name`, and the same for the `||=`, `&&=` and operator-write forms.
|
|
7
|
+
#
|
|
8
|
+
# The mutant only differs when the receiver is nil, where it raises
|
|
9
|
+
# NoMethodError instead of short-circuiting. A survivor means no test ever
|
|
10
|
+
# reaches the call with a nil receiver, so the `&.` is unexercised.
|
|
11
|
+
#
|
|
12
|
+
# Receivers that can never be nil — `self` and literals — are skipped, since
|
|
13
|
+
# the mutant is equivalent there.
|
|
14
|
+
class Evilution::Mutator::Operator::SafeNavigationRemoval < Evilution::Mutator::Base
|
|
15
|
+
NEVER_NIL_RECEIVERS = [
|
|
16
|
+
Prism::SelfNode,
|
|
17
|
+
Prism::StringNode,
|
|
18
|
+
Prism::InterpolatedStringNode,
|
|
19
|
+
Prism::XStringNode,
|
|
20
|
+
Prism::InterpolatedXStringNode,
|
|
21
|
+
Prism::SymbolNode,
|
|
22
|
+
Prism::InterpolatedSymbolNode,
|
|
23
|
+
Prism::IntegerNode,
|
|
24
|
+
Prism::FloatNode,
|
|
25
|
+
Prism::RationalNode,
|
|
26
|
+
Prism::ImaginaryNode,
|
|
27
|
+
Prism::ArrayNode,
|
|
28
|
+
Prism::HashNode,
|
|
29
|
+
Prism::RangeNode,
|
|
30
|
+
Prism::RegularExpressionNode,
|
|
31
|
+
Prism::InterpolatedRegularExpressionNode,
|
|
32
|
+
Prism::TrueNode,
|
|
33
|
+
Prism::FalseNode,
|
|
34
|
+
Prism::LambdaNode
|
|
35
|
+
].freeze
|
|
36
|
+
private_constant :NEVER_NIL_RECEIVERS
|
|
37
|
+
|
|
38
|
+
def visit_call_node(node)
|
|
39
|
+
mutate_safe_navigation(node)
|
|
40
|
+
super
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def visit_call_or_write_node(node)
|
|
44
|
+
mutate_safe_navigation(node)
|
|
45
|
+
super
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def visit_call_and_write_node(node)
|
|
49
|
+
mutate_safe_navigation(node)
|
|
50
|
+
super
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def visit_call_operator_write_node(node)
|
|
54
|
+
mutate_safe_navigation(node)
|
|
55
|
+
super
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
def mutate_safe_navigation(node)
|
|
61
|
+
return unless node.safe_navigation?
|
|
62
|
+
return if NEVER_NIL_RECEIVERS.any? { |type| node.receiver.is_a?(type) }
|
|
63
|
+
|
|
64
|
+
operator = node.call_operator_loc
|
|
65
|
+
add_mutation(offset: operator.start_offset, length: operator.length, replacement: ".", node: node)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -25,22 +25,50 @@ class Evilution::Mutator::Operator::SendMutation < Evilution::Mutator::Base
|
|
|
25
25
|
count: [:size],
|
|
26
26
|
select: [:filter],
|
|
27
27
|
filter: [:select],
|
|
28
|
-
to_s: [
|
|
29
|
-
to_i: [
|
|
28
|
+
to_s: %i[to_i to_str],
|
|
29
|
+
to_i: %i[to_s to_int],
|
|
30
30
|
to_f: [:to_i],
|
|
31
|
-
to_a: [
|
|
32
|
-
to_h: [
|
|
31
|
+
to_a: %i[to_h to_ary],
|
|
32
|
+
to_h: %i[to_a to_hash],
|
|
33
33
|
downcase: [:upcase],
|
|
34
34
|
upcase: [:downcase],
|
|
35
35
|
strip: %i[lstrip rstrip],
|
|
36
36
|
lstrip: [:strip],
|
|
37
37
|
rstrip: [:strip],
|
|
38
38
|
chomp: [:chop],
|
|
39
|
-
chop: [:chomp]
|
|
39
|
+
chop: [:chomp],
|
|
40
|
+
bytes: [:chars],
|
|
41
|
+
chars: [:bytes],
|
|
42
|
+
start_with?: [:end_with?],
|
|
43
|
+
end_with?: [:start_with?],
|
|
44
|
+
ceil: [:floor],
|
|
45
|
+
floor: [:ceil],
|
|
46
|
+
transform_keys: [:transform_values],
|
|
47
|
+
transform_values: [:transform_keys],
|
|
48
|
+
append: [:prepend],
|
|
49
|
+
prepend: [:append],
|
|
50
|
+
reverse_merge: [:merge],
|
|
51
|
+
even?: [:odd?],
|
|
52
|
+
odd?: [:even?],
|
|
53
|
+
zero?: [:nonzero?],
|
|
54
|
+
negative?: [:positive?],
|
|
55
|
+
positive?: [:negative?],
|
|
56
|
+
pred: [:succ],
|
|
57
|
+
succ: [:pred],
|
|
58
|
+
is_a?: [:instance_of?],
|
|
59
|
+
kind_of?: [:instance_of?]
|
|
60
|
+
}.freeze
|
|
61
|
+
|
|
62
|
+
# Swapped only when called with exactly one argument. Kept out of
|
|
63
|
+
# REPLACEMENTS, which symbol_to_proc_replacement also reads: `&:method`
|
|
64
|
+
# always calls with none, and a zero-argument `method` is usually an
|
|
65
|
+
# accessor such as `request.method` (the HTTP verb), not Object#method.
|
|
66
|
+
ONE_ARGUMENT_REPLACEMENTS = {
|
|
67
|
+
method: [:public_method]
|
|
40
68
|
}.freeze
|
|
41
69
|
|
|
42
70
|
def visit_call_node(node)
|
|
43
|
-
replacements =
|
|
71
|
+
replacements = replacements_for(node)
|
|
44
72
|
return super unless replacements
|
|
45
73
|
return super unless node.receiver
|
|
46
74
|
|
|
@@ -58,4 +86,12 @@ class Evilution::Mutator::Operator::SendMutation < Evilution::Mutator::Base
|
|
|
58
86
|
|
|
59
87
|
super
|
|
60
88
|
end
|
|
89
|
+
|
|
90
|
+
private
|
|
91
|
+
|
|
92
|
+
def replacements_for(node)
|
|
93
|
+
REPLACEMENTS.fetch(node.name) do
|
|
94
|
+
ONE_ARGUMENT_REPLACEMENTS[node.name] if node.arguments && node.arguments.arguments.length == 1
|
|
95
|
+
end
|
|
96
|
+
end
|
|
61
97
|
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../operator"
|
|
4
|
+
require_relative "send_mutation"
|
|
5
|
+
require_relative "collection_replacement"
|
|
6
|
+
|
|
7
|
+
# Apply the call-site selector tables to a symbol block-pass: `map(&:to_s)`
|
|
8
|
+
# becomes `map(&:to_i)`, `map(&:first)` becomes `map(&:last)`.
|
|
9
|
+
#
|
|
10
|
+
# send_mutation and collection_replacement only see explicit calls, so a
|
|
11
|
+
# selector passed as `&:sym` was never swapped — only removed. This reads
|
|
12
|
+
# both tables live, so selectors added to them apply here too.
|
|
13
|
+
#
|
|
14
|
+
# Replacements that are Ruby aliases of the original (`collect` / `map`,
|
|
15
|
+
# `find` / `detect`, `select` / `filter`, `length` / `size` / `count`) are
|
|
16
|
+
# skipped: on the receivers these selectors are normally mapped over, the
|
|
17
|
+
# mutant behaves identically.
|
|
18
|
+
class Evilution::Mutator::Operator::SymbolToProcReplacement < Evilution::Mutator::Base
|
|
19
|
+
TABLES = [
|
|
20
|
+
Evilution::Mutator::Operator::SendMutation::REPLACEMENTS,
|
|
21
|
+
Evilution::Mutator::Operator::CollectionReplacement::REPLACEMENTS
|
|
22
|
+
].freeze
|
|
23
|
+
private_constant :TABLES
|
|
24
|
+
|
|
25
|
+
ALIAS_GROUPS = [
|
|
26
|
+
%i[map collect],
|
|
27
|
+
%i[find detect],
|
|
28
|
+
%i[select filter],
|
|
29
|
+
%i[length size count]
|
|
30
|
+
].freeze
|
|
31
|
+
private_constant :ALIAS_GROUPS
|
|
32
|
+
|
|
33
|
+
def visit_block_argument_node(node)
|
|
34
|
+
symbol = node.expression
|
|
35
|
+
replace_selector(node, symbol) if symbol.is_a?(Prism::SymbolNode)
|
|
36
|
+
super
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def replace_selector(node, symbol)
|
|
42
|
+
selector = symbol.unescaped.to_sym
|
|
43
|
+
location = symbol.value_loc
|
|
44
|
+
|
|
45
|
+
replacements_for(selector).each do |replacement|
|
|
46
|
+
add_mutation(
|
|
47
|
+
offset: location.start_offset,
|
|
48
|
+
length: location.length,
|
|
49
|
+
replacement: replacement.to_s,
|
|
50
|
+
node: node
|
|
51
|
+
)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def replacements_for(selector)
|
|
56
|
+
aliases = ALIAS_GROUPS.find { |group| group.include?(selector) } || []
|
|
57
|
+
TABLES.flat_map { |table| table.fetch(selector, []) }.uniq - aliases
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../operator"
|
|
4
|
+
|
|
5
|
+
# Replace a lenient `to_i` with the strict `Integer()` conversion:
|
|
6
|
+
# `value.to_i` becomes `Integer(value)`, `value.to_i(16)` becomes
|
|
7
|
+
# `Integer(value, 16)`.
|
|
8
|
+
#
|
|
9
|
+
# `to_i` never fails — malformed input ("abc", "12abc", "") and nil become 0
|
|
10
|
+
# or a leading-digits prefix — where `Integer()` raises. `Integer()` also
|
|
11
|
+
# honours radix prefixes ("0x1A" is 26, "012" is octal 10) that `to_i`
|
|
12
|
+
# ignores. A survivor means no test feeds invalid or prefixed input.
|
|
13
|
+
#
|
|
14
|
+
# Numeric literal receivers are skipped: both conversions agree on them.
|
|
15
|
+
class Evilution::Mutator::Operator::ToIToInteger < Evilution::Mutator::Base
|
|
16
|
+
NUMERIC_LITERALS = [Prism::IntegerNode, Prism::FloatNode, Prism::RationalNode, Prism::ImaginaryNode].freeze
|
|
17
|
+
private_constant :NUMERIC_LITERALS
|
|
18
|
+
|
|
19
|
+
def visit_call_node(node)
|
|
20
|
+
rewrite(node) if node.name == :to_i && convertible?(node)
|
|
21
|
+
super
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def convertible?(node)
|
|
27
|
+
receiver = node.receiver
|
|
28
|
+
receiver && node.block.nil? && NUMERIC_LITERALS.none? { |type| receiver.is_a?(type) }
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def rewrite(node)
|
|
32
|
+
arguments = node.arguments ? node.arguments.arguments : []
|
|
33
|
+
return if arguments.length > 1 || arguments.any?(Prism::SplatNode)
|
|
34
|
+
|
|
35
|
+
parts = [node.receiver, *arguments].map { |part| source_of(part) }
|
|
36
|
+
replace_span(node: node, target: node, replacement: "Integer(#{parts.join(", ")})")
|
|
37
|
+
end
|
|
38
|
+
end
|