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
data/lib/evilution/config.rb
CHANGED
|
@@ -21,7 +21,7 @@ class Evilution::Config
|
|
|
21
21
|
example_targeting_fallback: :full_file, example_targeting_strategy: :lexical,
|
|
22
22
|
example_targeting_cache: { max_files: 50, max_blocks: 10_000 },
|
|
23
23
|
quiet_children: false, quiet_children_dir: "tmp/evilution_children",
|
|
24
|
-
profile: :default, canary: true
|
|
24
|
+
profile: :default, canary: true, warmup: :none
|
|
25
25
|
}.freeze
|
|
26
26
|
|
|
27
27
|
attr_reader :target_files, :schema_version, :timeout, :format,
|
|
@@ -33,7 +33,7 @@ class Evilution::Config
|
|
|
33
33
|
:fallback_to_full_suite, :preload, :spec_mappings, :spec_pattern,
|
|
34
34
|
:example_targeting, :example_targeting_fallback, :example_targeting_strategy,
|
|
35
35
|
:example_targeting_cache,
|
|
36
|
-
:spec_selector, :quiet_children, :quiet_children_dir, :profile, :canary
|
|
36
|
+
:spec_selector, :quiet_children, :quiet_children_dir, :profile, :canary, :warmup
|
|
37
37
|
|
|
38
38
|
def initialize(**options)
|
|
39
39
|
skip_file = options.delete(:skip_config_file) ? true : false
|
|
@@ -216,8 +216,9 @@ class Evilution::Config
|
|
|
216
216
|
# - "call{name=debug|warn}"
|
|
217
217
|
|
|
218
218
|
# Operator profile: default or strict (default: default).
|
|
219
|
-
# strict adds aggressive
|
|
220
|
-
# `
|
|
219
|
+
# strict adds aggressive mutators (e.g. replaces `x.predicate?` with
|
|
220
|
+
# `nil`, or appends `rescue nil` to a raising call) intended for
|
|
221
|
+
# pre-merge audits.
|
|
221
222
|
# profile: default
|
|
222
223
|
YAML
|
|
223
224
|
end
|
|
@@ -274,7 +275,7 @@ class Evilution::Config
|
|
|
274
275
|
|
|
275
276
|
VALIDATED_ATTRS = %i[
|
|
276
277
|
integration jobs fail_fast isolation ignore_patterns
|
|
277
|
-
hooks preload spec_mappings spec_pattern profile
|
|
278
|
+
hooks preload spec_mappings spec_pattern profile warmup
|
|
278
279
|
].freeze
|
|
279
280
|
private_constant :VALIDATED_ATTRS
|
|
280
281
|
|
|
@@ -311,6 +312,7 @@ require_relative "config/validators/example_targeting_fallback"
|
|
|
311
312
|
require_relative "config/validators/example_targeting_strategy"
|
|
312
313
|
require_relative "config/validators/example_targeting_cache"
|
|
313
314
|
require_relative "config/validators/profile"
|
|
315
|
+
require_relative "config/validators/warmup"
|
|
314
316
|
require_relative "config/builders"
|
|
315
317
|
require_relative "config/builders/spec_resolver"
|
|
316
318
|
require_relative "config/builders/spec_selector"
|
data/lib/evilution/coverage.rb
CHANGED
data/lib/evilution/diagnostic.rb
CHANGED
|
@@ -7,6 +7,7 @@ require_relative "heuristic/dead_code"
|
|
|
7
7
|
require_relative "heuristic/arithmetic_identity"
|
|
8
8
|
require_relative "heuristic/comment_marking"
|
|
9
9
|
require_relative "heuristic/void_context"
|
|
10
|
+
require_relative "heuristic/guarded_index_fetch"
|
|
10
11
|
|
|
11
12
|
require_relative "../equivalent"
|
|
12
13
|
|
|
@@ -40,7 +41,8 @@ class Evilution::Equivalent::Detector
|
|
|
40
41
|
Evilution::Equivalent::Heuristic::DeadCode.new,
|
|
41
42
|
Evilution::Equivalent::Heuristic::ArithmeticIdentity.new,
|
|
42
43
|
Evilution::Equivalent::Heuristic::CommentMarking.new,
|
|
43
|
-
Evilution::Equivalent::Heuristic::VoidContext.new
|
|
44
|
+
Evilution::Equivalent::Heuristic::VoidContext.new,
|
|
45
|
+
Evilution::Equivalent::Heuristic::GuardedIndexFetch.new
|
|
44
46
|
]
|
|
45
47
|
end
|
|
46
48
|
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../guarded_index_fetch"
|
|
4
|
+
|
|
5
|
+
# Whether a condition, when truthy, says the read's key is there.
|
|
6
|
+
#
|
|
7
|
+
# That is the read itself, `recv[key].present?`, `recv.dig(key)`, or a key
|
|
8
|
+
# predicate on the same receiver and key -- alone or as one side of an `&&`,
|
|
9
|
+
# since both sides hold when the whole does. `refutes?` answers the mirror
|
|
10
|
+
# question, for conditions that are truthy when the key is missing.
|
|
11
|
+
class Evilution::Equivalent::Heuristic::GuardedIndexFetch::Condition
|
|
12
|
+
KEY_PREDICATES = %i[key? has_key? include? member?].freeze
|
|
13
|
+
SINGLE_KEY_LOOKUPS = (KEY_PREDICATES + [:dig]).freeze
|
|
14
|
+
ABSENCE_TESTS = %i[nil? blank?].freeze
|
|
15
|
+
|
|
16
|
+
def initialize(read)
|
|
17
|
+
@read = read
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def proves?(node)
|
|
21
|
+
case node
|
|
22
|
+
when Prism::ParenthesesNode then parenthesized?(node)
|
|
23
|
+
when Prism::AndNode then proves?(node.left) || proves?(node.right)
|
|
24
|
+
when Prism::CallNode then read_present?(node)
|
|
25
|
+
else false
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Whether a condition, when falsy, says the read's key is there: the
|
|
30
|
+
# negation of something that proves it, `recv[key].nil?` / `.blank?`, or
|
|
31
|
+
# either as one side of an `||`, since both sides are falsy when the whole
|
|
32
|
+
# is. This is what an early exit tests: `return if config[:k].nil?`.
|
|
33
|
+
def refutes?(node)
|
|
34
|
+
case node
|
|
35
|
+
when Prism::ParenthesesNode then single_statement(node) { |inner| refutes?(inner) }
|
|
36
|
+
when Prism::OrNode then refutes?(node.left) || refutes?(node.right)
|
|
37
|
+
when Prism::CallNode then read_absent?(node)
|
|
38
|
+
else false
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def parenthesized?(node)
|
|
45
|
+
single_statement(node) { |inner| proves?(inner) }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def single_statement(node)
|
|
49
|
+
body = node.body
|
|
50
|
+
body.is_a?(Prism::StatementsNode) && body.body.length == 1 && yield(body.body.first)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# `!cond` and `not cond` are both a call to `!`.
|
|
54
|
+
def read_absent?(call)
|
|
55
|
+
return proves?(call.receiver) if call.name == :!
|
|
56
|
+
|
|
57
|
+
ABSENCE_TESTS.include?(call.name) && @read.same?(call.receiver)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def read_present?(call)
|
|
61
|
+
return true if @read.same?(call)
|
|
62
|
+
return @read.same?(call.receiver) if call.name == :present? && call.arguments.nil?
|
|
63
|
+
|
|
64
|
+
SINGLE_KEY_LOOKUPS.include?(call.name) && @read.on_receiver?(call) && @read.keyed?(call)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../guarded_index_fetch"
|
|
4
|
+
|
|
5
|
+
# Whether a guarded branch does anything that could take the key away again
|
|
6
|
+
# after the guard saw it: assigns the variable the receiver hangs off, or
|
|
7
|
+
# calls something on the receiver that changes it.
|
|
8
|
+
#
|
|
9
|
+
# The whole branch is searched, not just what precedes the read; inside a loop
|
|
10
|
+
# "before" and "after" trade places.
|
|
11
|
+
class Evilution::Equivalent::Heuristic::GuardedIndexFetch::Disturbance
|
|
12
|
+
LOCAL_WRITES = [
|
|
13
|
+
Prism::LocalVariableWriteNode, Prism::LocalVariableOperatorWriteNode, Prism::LocalVariableOrWriteNode,
|
|
14
|
+
Prism::LocalVariableAndWriteNode, Prism::LocalVariableTargetNode
|
|
15
|
+
].freeze
|
|
16
|
+
INSTANCE_VARIABLE_WRITES = [
|
|
17
|
+
Prism::InstanceVariableWriteNode, Prism::InstanceVariableOperatorWriteNode,
|
|
18
|
+
Prism::InstanceVariableOrWriteNode, Prism::InstanceVariableAndWriteNode, Prism::InstanceVariableTargetNode
|
|
19
|
+
].freeze
|
|
20
|
+
MUTATORS = %i[[]= delete clear replace store update shift pop delete_if keep_if].freeze
|
|
21
|
+
|
|
22
|
+
def initialize(read)
|
|
23
|
+
@read = read
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def in?(node)
|
|
27
|
+
disturbs?(node) || node.compact_child_nodes.any? { |child| in?(child) }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def disturbs?(node)
|
|
33
|
+
reassigns_root?(node) || mutates_receiver?(node)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def reassigns_root?(node)
|
|
37
|
+
writes = if @read.local_root?
|
|
38
|
+
LOCAL_WRITES
|
|
39
|
+
elsif @read.instance_variable_root?
|
|
40
|
+
INSTANCE_VARIABLE_WRITES
|
|
41
|
+
else
|
|
42
|
+
[]
|
|
43
|
+
end
|
|
44
|
+
writes.any? { |type| node.is_a?(type) } && node.name == @read.root_name
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def mutates_receiver?(node)
|
|
48
|
+
node.is_a?(Prism::CallNode) && @read.on_receiver?(node) && mutator?(node.name)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def mutator?(name)
|
|
52
|
+
MUTATORS.include?(name) || name.end_with?("!")
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../guarded_index_fetch"
|
|
4
|
+
|
|
5
|
+
# A guard that leaves when the key is missing protects what comes after it in
|
|
6
|
+
# the same statement list:
|
|
7
|
+
#
|
|
8
|
+
# return unless config[:k]
|
|
9
|
+
# style << config[:k] # fetch cannot raise here
|
|
10
|
+
#
|
|
11
|
+
# The guard is an `unless` whose condition says the key is there, an `if`
|
|
12
|
+
# whose condition says it is not, or `cond or return` -- with a body ending in
|
|
13
|
+
# `return`, `next`, `break`, `raise` or `fail`, and no else branch to fall
|
|
14
|
+
# through. `next` and `break` leave only the current iteration, which is all
|
|
15
|
+
# the statements after them in the block's body belong to.
|
|
16
|
+
#
|
|
17
|
+
# Nothing between the guard and the read, nor the statement holding the read,
|
|
18
|
+
# may change the receiver; that statement is checked whole, since a loop in it
|
|
19
|
+
# runs its end before its start the second time round.
|
|
20
|
+
class Evilution::Equivalent::Heuristic::GuardedIndexFetch::EarlyExit
|
|
21
|
+
EXIT_NODES = [Prism::ReturnNode, Prism::NextNode, Prism::BreakNode].freeze
|
|
22
|
+
EXIT_CALLS = %i[raise fail].freeze
|
|
23
|
+
|
|
24
|
+
def initialize(statements, read)
|
|
25
|
+
@statements = statements
|
|
26
|
+
@read = read
|
|
27
|
+
@condition = Evilution::Equivalent::Heuristic::GuardedIndexFetch::Condition.new(read)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# child: the statement of this list that holds the read.
|
|
31
|
+
def protects?(child)
|
|
32
|
+
return false unless @statements.is_a?(Prism::StatementsNode)
|
|
33
|
+
|
|
34
|
+
list = @statements.body
|
|
35
|
+
position = list.index { |statement| statement.equal?(child) }
|
|
36
|
+
guard = position && nearest_guard(list, position)
|
|
37
|
+
return false unless guard
|
|
38
|
+
|
|
39
|
+
list[(guard + 1)..position].none? { |statement| disturbance.in?(statement) }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
# The last guard before the read: an earlier one would have more statements
|
|
45
|
+
# after it to answer for, not fewer.
|
|
46
|
+
def nearest_guard(list, position)
|
|
47
|
+
(0...position).reverse_each.find { |index| guard?(list[index]) }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def guard?(statement)
|
|
51
|
+
case statement
|
|
52
|
+
when Prism::UnlessNode then leaves?(statement, statement.else_clause) && @condition.proves?(statement.predicate)
|
|
53
|
+
when Prism::IfNode then leaves?(statement, statement.subsequent) && @condition.refutes?(statement.predicate)
|
|
54
|
+
when Prism::OrNode then exit?(statement.right) && @condition.proves?(statement.left)
|
|
55
|
+
else false
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def leaves?(conditional, other_branch)
|
|
60
|
+
body = conditional.statements
|
|
61
|
+
other_branch.nil? && body.is_a?(Prism::StatementsNode) && exit?(body.body.last)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def exit?(node)
|
|
65
|
+
return true if EXIT_NODES.any? { |type| node.is_a?(type) }
|
|
66
|
+
|
|
67
|
+
node.is_a?(Prism::CallNode) && node.receiver.nil? && EXIT_CALLS.include?(node.name)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def disturbance
|
|
71
|
+
@disturbance ||= Evilution::Equivalent::Heuristic::GuardedIndexFetch::Disturbance.new(@read)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../guarded_index_fetch"
|
|
4
|
+
|
|
5
|
+
# One node that may guard a read: an `if` (ternary and modifier forms
|
|
6
|
+
# included), an `unless` with an else, or an `&&`. It protects the part that
|
|
7
|
+
# runs only when its condition held -- provided the condition says the key is
|
|
8
|
+
# there and that part leaves the receiver alone.
|
|
9
|
+
class Evilution::Equivalent::Heuristic::GuardedIndexFetch::Guard
|
|
10
|
+
def initialize(node, read)
|
|
11
|
+
@node = node
|
|
12
|
+
@read = read
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def protects?(child)
|
|
16
|
+
guarded = guarded_part
|
|
17
|
+
return false unless guarded && guarded.equal?(child)
|
|
18
|
+
|
|
19
|
+
Evilution::Equivalent::Heuristic::GuardedIndexFetch::Condition.new(@read).proves?(condition) &&
|
|
20
|
+
!Evilution::Equivalent::Heuristic::GuardedIndexFetch::Disturbance.new(@read).in?(guarded)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def guarded_part
|
|
26
|
+
case @node
|
|
27
|
+
when Prism::IfNode then @node.statements
|
|
28
|
+
when Prism::UnlessNode then @node.else_clause
|
|
29
|
+
when Prism::AndNode then @node.right
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def condition
|
|
34
|
+
@node.is_a?(Prism::AndNode) ? @node.left : @node.predicate
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../guarded_index_fetch"
|
|
4
|
+
|
|
5
|
+
# One `recv[key]` read, reduced to what makes another read "the same": the
|
|
6
|
+
# receiver's source and a literal key.
|
|
7
|
+
#
|
|
8
|
+
# The receiver has to be something that can be named twice and mean the same
|
|
9
|
+
# thing both times: a variable, a constant, or calls without arguments or
|
|
10
|
+
# block on one of those. `lookup(id)[:k]` is not.
|
|
11
|
+
class Evilution::Equivalent::Heuristic::GuardedIndexFetch::IndexRead
|
|
12
|
+
KEY_TYPES = [Prism::SymbolNode, Prism::StringNode, Prism::IntegerNode].freeze
|
|
13
|
+
NAMED_TYPES = [
|
|
14
|
+
Prism::LocalVariableReadNode, Prism::InstanceVariableReadNode, Prism::ClassVariableReadNode,
|
|
15
|
+
Prism::GlobalVariableReadNode, Prism::ConstantReadNode, Prism::ConstantPathNode, Prism::SelfNode
|
|
16
|
+
].freeze
|
|
17
|
+
|
|
18
|
+
attr_reader :receiver_source, :key_source, :root
|
|
19
|
+
|
|
20
|
+
def self.from(node)
|
|
21
|
+
key = single_argument(node)
|
|
22
|
+
return nil unless key && literal?(key) && node.receiver && stable?(node.receiver)
|
|
23
|
+
|
|
24
|
+
new(receiver_source: node.receiver.slice, key_source: key.slice, root: root_of(node.receiver))
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def self.single_argument(node)
|
|
28
|
+
return nil unless node.arguments && node.arguments.arguments.length == 1
|
|
29
|
+
|
|
30
|
+
node.arguments.arguments.first
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def self.literal?(node)
|
|
34
|
+
KEY_TYPES.any? { |type| node.is_a?(type) }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def self.stable?(node)
|
|
38
|
+
return true if NAMED_TYPES.any? { |type| node.is_a?(type) }
|
|
39
|
+
return false unless node.is_a?(Prism::CallNode) && node.arguments.nil? && node.block.nil?
|
|
40
|
+
|
|
41
|
+
node.receiver.nil? || stable?(node.receiver)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# The variable a receiver hangs off: `user` in `user.settings`.
|
|
45
|
+
def self.root_of(node)
|
|
46
|
+
node = node.receiver while node.is_a?(Prism::CallNode) && node.receiver
|
|
47
|
+
node
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def initialize(receiver_source:, key_source:, root:)
|
|
51
|
+
@receiver_source = receiver_source
|
|
52
|
+
@key_source = key_source
|
|
53
|
+
@root = root
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def root_name
|
|
57
|
+
root.respond_to?(:name) ? root.name : nil
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def local_root?
|
|
61
|
+
root.is_a?(Prism::LocalVariableReadNode)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def instance_variable_root?
|
|
65
|
+
root.is_a?(Prism::InstanceVariableReadNode)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Whether a node is this read again: `recv[key]`.
|
|
69
|
+
def same?(node)
|
|
70
|
+
node.is_a?(Prism::CallNode) && node.name == :[] && on_receiver?(node) && keyed?(node)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def on_receiver?(call)
|
|
74
|
+
!call.receiver.nil? && call.receiver.slice == receiver_source
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Whether a call's only argument is this read's key.
|
|
78
|
+
def keyed?(call)
|
|
79
|
+
key = self.class.single_argument(call)
|
|
80
|
+
!key.nil? && key.slice == key_source
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../guarded_index_fetch"
|
|
4
|
+
|
|
5
|
+
# The nodes from the subject down to the `recv[key]` call a mutation rewrote,
|
|
6
|
+
# outermost first; nil when it cannot be found.
|
|
7
|
+
#
|
|
8
|
+
# Line and column are not enough: in `a[:x][:y]` the outer and the inner read
|
|
9
|
+
# start at the same place. The mutated source settles it -- the rewritten read
|
|
10
|
+
# is the one whose receiver is now followed by `.fetch(`.
|
|
11
|
+
class Evilution::Equivalent::Heuristic::GuardedIndexFetch::NodePath
|
|
12
|
+
FETCH_CALL = ".fetch("
|
|
13
|
+
|
|
14
|
+
def call(mutation)
|
|
15
|
+
root = mutation.subject.node
|
|
16
|
+
mutated_source = mutation.mutated_source
|
|
17
|
+
return nil unless root && mutated_source
|
|
18
|
+
|
|
19
|
+
search(root, mutation, mutated_source, [])
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def search(node, mutation, mutated_source, ancestors)
|
|
25
|
+
path = ancestors + [node]
|
|
26
|
+
return path if rewritten?(node, mutation, mutated_source)
|
|
27
|
+
|
|
28
|
+
node.compact_child_nodes.each do |child|
|
|
29
|
+
found = search(child, mutation, mutated_source, path)
|
|
30
|
+
return found if found
|
|
31
|
+
end
|
|
32
|
+
nil
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def rewritten?(node, mutation, mutated_source)
|
|
36
|
+
return false unless node.is_a?(Prism::CallNode) && node.name == :[] && node.receiver
|
|
37
|
+
|
|
38
|
+
location = node.location
|
|
39
|
+
return false unless location.start_line == mutation.line && location.start_column == mutation.column
|
|
40
|
+
|
|
41
|
+
mutated_source.byteslice(node.receiver.location.end_offset, FETCH_CALL.bytesize) == FETCH_CALL
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
require_relative "../heuristic"
|
|
5
|
+
|
|
6
|
+
# `index_to_fetch` turns `recv[key]` into `recv.fetch(key)`, which differs only
|
|
7
|
+
# when the key is absent. Under a guard that is truthy only when the key is
|
|
8
|
+
# there -- `if config[:k]`, `config.key?(:k) && ...` -- it cannot be absent, so
|
|
9
|
+
# the mutant behaves like the original and no test can kill it.
|
|
10
|
+
#
|
|
11
|
+
# The read in the guard itself is a different matter: `if config.fetch(:k)`
|
|
12
|
+
# raises where `if config[:k]` took the other branch. Only reads inside the
|
|
13
|
+
# guarded part match -- the branch a guard opens, or the statements after a
|
|
14
|
+
# guard that leaves (`return unless config[:k]`).
|
|
15
|
+
#
|
|
16
|
+
# A heuristic, not a proof: a hash with a default value passes the guard with
|
|
17
|
+
# the key absent, and a method-call receiver may answer with another object
|
|
18
|
+
# the second time.
|
|
19
|
+
class Evilution::Equivalent::Heuristic::GuardedIndexFetch
|
|
20
|
+
OPERATOR = "index_to_fetch"
|
|
21
|
+
|
|
22
|
+
def match?(mutation)
|
|
23
|
+
return false unless mutation.operator_name == OPERATOR
|
|
24
|
+
|
|
25
|
+
path = NodePath.new.call(mutation)
|
|
26
|
+
return false unless path
|
|
27
|
+
|
|
28
|
+
read = IndexRead.from(path.last)
|
|
29
|
+
return false unless read
|
|
30
|
+
|
|
31
|
+
guarded?(path, read)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
# Walks outwards from the read. Each step asks whether the node just left
|
|
37
|
+
# was the guarded part of its parent, or a statement that follows an early
|
|
38
|
+
# exit in its parent's list; a scope the guard does not reach into ends the
|
|
39
|
+
# walk.
|
|
40
|
+
def guarded?(path, read)
|
|
41
|
+
path.each_cons(2).reverse_each do |parent, child|
|
|
42
|
+
return false if scope_boundary?(parent, read)
|
|
43
|
+
return true if Guard.new(parent, read).protects?(child) || EarlyExit.new(parent, read).protects?(child)
|
|
44
|
+
end
|
|
45
|
+
false
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# A nested method or lambda runs later, when the guard no longer speaks for
|
|
49
|
+
# the receiver. A block runs in place, unless it declares the receiver's
|
|
50
|
+
# name as its own variable.
|
|
51
|
+
def scope_boundary?(node, read)
|
|
52
|
+
case node
|
|
53
|
+
when Prism::DefNode, Prism::LambdaNode then true
|
|
54
|
+
when Prism::BlockNode then read.local_root? && node.locals.include?(read.root_name)
|
|
55
|
+
else false
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
require_relative "guarded_index_fetch/node_path"
|
|
61
|
+
require_relative "guarded_index_fetch/index_read"
|
|
62
|
+
require_relative "guarded_index_fetch/condition"
|
|
63
|
+
require_relative "guarded_index_fetch/disturbance"
|
|
64
|
+
require_relative "guarded_index_fetch/guard"
|
|
65
|
+
require_relative "guarded_index_fetch/early_exit"
|
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "prism"
|
|
4
|
-
require_relative "
|
|
4
|
+
require_relative "version"
|
|
5
5
|
require_relative "spec_ast_cache"
|
|
6
|
+
require_relative "ast/value_object_definition"
|
|
7
|
+
require_relative "ast/scope_declaration"
|
|
8
|
+
require_relative "ast/aasm_declaration"
|
|
6
9
|
|
|
7
10
|
class Evilution::ExampleFilter
|
|
8
11
|
VALID_FALLBACKS = %i[full_file unresolved].freeze
|
|
@@ -119,8 +122,50 @@ class Evilution::ExampleFilter
|
|
|
119
122
|
@class_stack.pop
|
|
120
123
|
end
|
|
121
124
|
|
|
125
|
+
# A value-object definition assigned to a constant names the type its
|
|
126
|
+
# specs build (`Point = Data.define(:x, :y)` -> `Point`), like a class.
|
|
127
|
+
def visit_constant_write_node(node)
|
|
128
|
+
within_value_object(node, node.name.to_s) { super }
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def visit_constant_path_write_node(node)
|
|
132
|
+
within_value_object(node, node.target.name.to_s) { super }
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# A scope declaration names the class method its specs call
|
|
136
|
+
# (`scope :for_owner, -> { }` -> `for_owner`), like a def; nothing inside
|
|
137
|
+
# its body names anything closer.
|
|
138
|
+
#
|
|
139
|
+
# An aasm event or state names the methods its specs call (`event :ship`
|
|
140
|
+
# -> `ship!`, `state :paid` -> `paid?`), which share its name as a word.
|
|
141
|
+
def visit_call_node(node)
|
|
142
|
+
return super unless target_within?(node)
|
|
143
|
+
|
|
144
|
+
token = declaration_token(node)
|
|
145
|
+
return super unless token
|
|
146
|
+
|
|
147
|
+
@token = token
|
|
148
|
+
end
|
|
149
|
+
|
|
122
150
|
private
|
|
123
151
|
|
|
152
|
+
def declaration_token(node)
|
|
153
|
+
if Evilution::AST::ScopeDeclaration.body_of(node)
|
|
154
|
+
Evilution::AST::ScopeDeclaration.scope_name(node)
|
|
155
|
+
else
|
|
156
|
+
Evilution::AST::AasmDeclaration.token_at(node, @target_line)
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def within_value_object(node, name)
|
|
161
|
+
return yield unless Evilution::AST::ValueObjectDefinition.match?(node.value)
|
|
162
|
+
|
|
163
|
+
@class_stack.push(name)
|
|
164
|
+
capture_if_match(node)
|
|
165
|
+
yield
|
|
166
|
+
@class_stack.pop
|
|
167
|
+
end
|
|
168
|
+
|
|
124
169
|
def capture_if_match(node)
|
|
125
170
|
return if @found
|
|
126
171
|
return unless target_within?(node)
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "../hooks"
|
|
4
|
+
require_relative "../diagnostic"
|
|
4
5
|
|
|
5
6
|
class Evilution::Hooks::Registry
|
|
6
7
|
def initialize(on_error: nil)
|
|
@@ -54,7 +55,7 @@ class Evilution::Hooks::Registry
|
|
|
54
55
|
if @on_error
|
|
55
56
|
@on_error.call(event, error)
|
|
56
57
|
else
|
|
57
|
-
warn
|
|
58
|
+
Evilution::Diagnostic.warn("[evilution] hook error in #{event}: #{error.message}")
|
|
58
59
|
end
|
|
59
60
|
end
|
|
60
61
|
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../integration"
|
|
4
|
+
|
|
5
|
+
# The tests the baseline saw failing before any mutation ran, by the ids the
|
|
6
|
+
# integration gives them.
|
|
7
|
+
#
|
|
8
|
+
# Such a test fails whatever the mutation is, so a mutation run in which
|
|
9
|
+
# nothing else failed has no verdict. The question is asked in the process
|
|
10
|
+
# that ran the tests: the names of everything that failed need not travel
|
|
11
|
+
# back, only the answer.
|
|
12
|
+
class Evilution::Integration::KnownFailures
|
|
13
|
+
def initialize(ids)
|
|
14
|
+
@ids = ids.to_set
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def empty?
|
|
18
|
+
@ids.empty?
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Whether failed_ids names at least one test, and none the baseline did not
|
|
22
|
+
# already see failing.
|
|
23
|
+
def only?(failed_ids)
|
|
24
|
+
!failed_ids.empty? && failed_ids.all? { |id| @ids.include?(id) }
|
|
25
|
+
end
|
|
26
|
+
end
|