evilution 1.3.0 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.beads/interactions.jsonl +132 -0
- data/.rubocop_todo.yml +5 -0
- data/CHANGELOG.md +127 -0
- data/README.md +136 -38
- data/docs/architecture.md +135 -11
- data/docs/ast_pattern_syntax.md +50 -5
- data/docs/isolation.md +121 -2
- data/docs/migration-from-mutant.md +1 -1
- data/lib/evilution/ast/aasm_declaration.rb +133 -0
- data/lib/evilution/ast/callback_declaration.rb +91 -0
- data/lib/evilution/ast/class_body.rb +101 -0
- data/lib/evilution/ast/included_block.rb +41 -0
- data/lib/evilution/ast/literal_callable.rb +21 -0
- data/lib/evilution/ast/never_nil.rb +36 -0
- data/lib/evilution/ast/parser.rb +187 -21
- data/lib/evilution/ast/pattern/method_name.rb +63 -0
- data/lib/evilution/ast/pattern/parser.rb +14 -15
- data/lib/evilution/ast/regexp_pattern.rb +104 -0
- data/lib/evilution/ast/round_half_mode.rb +38 -0
- data/lib/evilution/ast/scope_declaration.rb +45 -0
- data/lib/evilution/ast/source_surgeon.rb +2 -3
- data/lib/evilution/ast/syntax_check.rb +56 -0
- data/lib/evilution/ast/uncovered_code.rb +88 -0
- data/lib/evilution/ast/value_object_definition.rb +25 -0
- data/lib/evilution/baseline/failure_formatter.rb +27 -0
- data/lib/evilution/baseline/report.rb +66 -0
- data/lib/evilution/baseline/spec_failure.rb +38 -0
- data/lib/evilution/baseline.rb +67 -30
- data/lib/evilution/cli/exit_guard.rb +26 -4
- data/lib/evilution/cli/parser/file_args.rb +2 -1
- data/lib/evilution/cli/parser/options_builder.rb +1 -1
- data/lib/evilution/cli.rb +3 -2
- data/lib/evilution/config/validators/spec_mappings.rb +2 -1
- data/lib/evilution/config.rb +3 -2
- data/lib/evilution/equivalent/detector.rb +3 -1
- data/lib/evilution/equivalent/heuristic/guarded_index_fetch/condition.rb +66 -0
- data/lib/evilution/equivalent/heuristic/guarded_index_fetch/disturbance.rb +54 -0
- data/lib/evilution/equivalent/heuristic/guarded_index_fetch/early_exit.rb +73 -0
- data/lib/evilution/equivalent/heuristic/guarded_index_fetch/guard.rb +36 -0
- data/lib/evilution/equivalent/heuristic/guarded_index_fetch/index_read.rb +82 -0
- data/lib/evilution/equivalent/heuristic/guarded_index_fetch/node_path.rb +43 -0
- data/lib/evilution/equivalent/heuristic/guarded_index_fetch.rb +65 -0
- data/lib/evilution/example_filter.rb +45 -0
- data/lib/evilution/hooks/registry.rb +2 -1
- data/lib/evilution/integration/base.rb +11 -1
- data/lib/evilution/integration/known_failures.rb +26 -0
- data/lib/evilution/integration/loading/body_call_neutralizer.rb +177 -13
- data/lib/evilution/integration/loading/callback_redeclaration.rb +186 -0
- data/lib/evilution/integration/loading/concern_redeclaration.rb +80 -0
- data/lib/evilution/integration/loading/concern_state_cleaner.rb +22 -15
- data/lib/evilution/integration/loading/mutation_applier.rb +44 -1
- data/lib/evilution/integration/loading/own_methods.rb +87 -0
- data/lib/evilution/integration/loading/redefinition_recovery.rb +2 -3
- data/lib/evilution/integration/loading/source_evaluator.rb +9 -2
- data/lib/evilution/integration/loading/superseded_modules.rb +103 -0
- data/lib/evilution/integration/loading/syntax_validator.rb +2 -2
- data/lib/evilution/integration/loading/test_class_cache.rb +46 -0
- data/lib/evilution/integration/minitest/test_ids.rb +21 -0
- data/lib/evilution/integration/minitest.rb +45 -6
- data/lib/evilution/integration/rspec/baseline_runner.rb +47 -3
- data/lib/evilution/integration/rspec/example_ids.rb +35 -0
- data/lib/evilution/integration/rspec/result_builder.rb +6 -1
- data/lib/evilution/integration/rspec/state_guard/anonymous_example_group_examples.rb +36 -0
- data/lib/evilution/integration/rspec/state_guard.rb +4 -1
- data/lib/evilution/integration/rspec.rb +22 -4
- data/lib/evilution/integration/test_unit/result_builder.rb +6 -0
- data/lib/evilution/integration/test_unit/test_ids.rb +18 -0
- data/lib/evilution/integration/test_unit.rb +37 -9
- data/lib/evilution/isolation/fork.rb +84 -11
- data/lib/evilution/isolation/in_process.rb +100 -8
- data/lib/evilution/mcp/info_tool/status_glossary.rb +15 -4
- data/lib/evilution/mcp/info_tool.rb +3 -2
- data/lib/evilution/mcp/mutate_tool/progress_streamer.rb +2 -1
- data/lib/evilution/memory/leak_check.rb +27 -3
- data/lib/evilution/mutation.rb +21 -9
- data/lib/evilution/mutator/base.rb +69 -5
- data/lib/evilution/mutator/constant_value_subjects.rb +13 -0
- data/lib/evilution/mutator/operator/alias_removal.rb +56 -0
- data/lib/evilution/mutator/operator/argument_nil_substitution.rb +2 -2
- data/lib/evilution/mutator/operator/argument_order_permutation.rb +101 -0
- data/lib/evilution/mutator/operator/argument_removal.rb +10 -2
- data/lib/evilution/mutator/operator/array_literal.rb +29 -0
- data/lib/evilution/mutator/operator/attr_accessor_swap.rb +68 -0
- data/lib/evilution/mutator/operator/boolean_literal_replacement.rb +2 -0
- data/lib/evilution/mutator/operator/call_to_nil.rb +12 -3
- data/lib/evilution/mutator/operator/comparison_operand_swap.rb +54 -0
- data/lib/evilution/mutator/operator/complex_literal.rb +39 -0
- data/lib/evilution/mutator/operator/compound_assignment.rb +47 -0
- data/lib/evilution/mutator/operator/constant_namespace_strip.rb +57 -0
- data/lib/evilution/mutator/operator/constant_read_to_nil.rb +58 -0
- data/lib/evilution/mutator/operator/constant_write_to_nil.rb +32 -0
- data/lib/evilution/mutator/operator/data_struct_member.rb +82 -0
- data/lib/evilution/mutator/operator/exception_swallow.rb +84 -0
- data/lib/evilution/mutator/operator/explicit_super_mutation.rb +2 -2
- data/lib/evilution/mutator/operator/float_literal.rb +54 -19
- data/lib/evilution/mutator/operator/format_specifier_swap.rb +100 -0
- data/lib/evilution/mutator/operator/forwarded_argument_drop.rb +142 -0
- data/lib/evilution/mutator/operator/freeze_removal.rb +86 -0
- data/lib/evilution/mutator/operator/hash_literal.rb +51 -0
- data/lib/evilution/mutator/operator/index_range_to_drop.rb +67 -0
- data/lib/evilution/mutator/operator/index_receiver_to_self.rb +20 -0
- data/lib/evilution/mutator/operator/index_to_key_predicate.rb +58 -0
- data/lib/evilution/mutator/operator/index_write_to_value.rb +37 -0
- data/lib/evilution/mutator/operator/integer_division_to_fdiv.rb +62 -0
- data/lib/evilution/mutator/operator/integer_literal.rb +39 -9
- data/lib/evilution/mutator/operator/keyword_argument.rb +25 -2
- data/lib/evilution/mutator/operator/keyword_value_swap.rb +75 -0
- data/lib/evilution/mutator/operator/memoization_break.rb +18 -0
- data/lib/evilution/mutator/operator/mixin_removal.rb +18 -55
- data/lib/evilution/mutator/operator/nil_replacement.rb +2 -0
- data/lib/evilution/mutator/operator/no_matching_pattern_else.rb +47 -0
- data/lib/evilution/mutator/operator/numbered_parameter_swap.rb +78 -0
- data/lib/evilution/mutator/operator/off_by_one_boundary.rb +68 -0
- data/lib/evilution/mutator/operator/operator_write_receiver_promotion.rb +38 -0
- data/lib/evilution/mutator/operator/optional_parameter_to_required.rb +14 -3
- data/lib/evilution/mutator/operator/pattern_matching_array.rb +12 -1
- data/lib/evilution/mutator/operator/pattern_wildcard_widening.rb +117 -0
- data/lib/evilution/mutator/operator/pin_operator_removal.rb +48 -0
- data/lib/evilution/mutator/operator/range_replacement.rb +2 -0
- data/lib/evilution/mutator/operator/rational_literal.rb +40 -0
- data/lib/evilution/mutator/operator/reduce_to_sum.rb +1 -1
- data/lib/evilution/mutator/operator/regex_simplification.rb +53 -132
- data/lib/evilution/mutator/operator/regexp_alternation_branch_deletion.rb +54 -0
- data/lib/evilution/mutator/operator/regexp_anchor_promotion.rb +43 -0
- data/lib/evilution/mutator/operator/regexp_capture_to_passive.rb +61 -0
- data/lib/evilution/mutator/operator/regexp_character_type_complement.rb +54 -0
- data/lib/evilution/mutator/operator/regexp_named_group_rename.rb +106 -0
- data/lib/evilution/mutator/operator/regexp_option_removal.rb +51 -0
- data/lib/evilution/mutator/operator/regexp_quantifier_minimum_swap.rb +45 -0
- data/lib/evilution/mutator/operator/rescue_class_widening.rb +54 -0
- data/lib/evilution/mutator/operator/rescue_else_concatenation.rb +62 -0
- data/lib/evilution/mutator/operator/rescue_handler_concatenation.rb +69 -0
- data/lib/evilution/mutator/operator/rescue_handler_promotion.rb +65 -0
- data/lib/evilution/mutator/operator/return_keyword_removal.rb +79 -0
- data/lib/evilution/mutator/operator/rightward_assignment.rb +46 -0
- data/lib/evilution/mutator/operator/round_half_mode_swap.rb +36 -0
- data/lib/evilution/mutator/operator/safe_navigation_insertion.rb +57 -0
- data/lib/evilution/mutator/operator/safe_navigation_removal.rb +2 -24
- data/lib/evilution/mutator/operator/send_mutation.rb +2 -0
- data/lib/evilution/mutator/operator/short_circuit_to_eager.rb +34 -0
- data/lib/evilution/mutator/operator/sleep_to_zero.rb +43 -0
- data/lib/evilution/mutator/operator/splat_operator.rb +41 -24
- data/lib/evilution/mutator/operator/statement_reorder.rb +153 -0
- data/lib/evilution/mutator/operator/string_literal.rb +29 -0
- data/lib/evilution/mutator/operator/superclass_removal.rb +14 -40
- data/lib/evilution/mutator/operator/symbol_literal.rb +47 -2
- data/lib/evilution/mutator/operator/to_i_to_integer.rb +1 -1
- data/lib/evilution/mutator/operator/variable_read_to_nil.rb +113 -0
- data/lib/evilution/mutator/operator/visibility_removal.rb +74 -0
- data/lib/evilution/mutator/primitives.rb +24 -2
- data/lib/evilution/mutator/receiver_positions.rb +79 -0
- data/lib/evilution/mutator/registry.rb +58 -2
- data/lib/evilution/mutator/rescue_handlers.rb +81 -0
- data/lib/evilution/parallel/work_queue/channel/frame.rb +6 -0
- data/lib/evilution/parallel/work_queue/channel.rb +58 -5
- data/lib/evilution/parallel/work_queue/dispatcher/reply.rb +41 -0
- data/lib/evilution/parallel/work_queue/dispatcher.rb +14 -1
- data/lib/evilution/parallel/work_queue/worker/loop.rb +5 -0
- data/lib/evilution/parallel/work_queue/worker.rb +13 -4
- data/lib/evilution/parallel/work_queue.rb +15 -9
- data/lib/evilution/process_supervisor.rb +3 -2
- data/lib/evilution/reporter/cli/line_formatters/baseline_neutralized_notice.rb +55 -0
- data/lib/evilution/reporter/cli/metrics_block.rb +2 -0
- data/lib/evilution/reporter/json/baseline.rb +15 -0
- data/lib/evilution/reporter/json.rb +7 -2
- data/lib/evilution/result/baseline_neutralization.rb +10 -0
- data/lib/evilution/result/mutation_result.rb +9 -1
- data/lib/evilution/result/summary.rb +42 -2
- data/lib/evilution/runner/baseline_runner.rb +9 -4
- data/lib/evilution/runner/canary.rb +2 -57
- data/lib/evilution/runner/canary_failure_message.rb +101 -0
- data/lib/evilution/runner/mutation_executor/neutralizer/baseline_failed.rb +22 -8
- data/lib/evilution/runner/mutation_executor/result_cache.rb +3 -0
- data/lib/evilution/runner/mutation_executor/result_packer.rb +4 -2
- data/lib/evilution/runner/mutation_executor/strategy/parallel.rb +10 -0
- data/lib/evilution/runner/mutation_executor.rb +1 -1
- data/lib/evilution/runner/mutation_planner.rb +11 -2
- data/lib/evilution/runner/report_publisher.rb +3 -2
- data/lib/evilution/runner/subject_pipeline/target.rb +86 -0
- data/lib/evilution/runner/subject_pipeline.rb +57 -43
- data/lib/evilution/runner.rb +5 -2
- data/lib/evilution/subject.rb +25 -2
- data/lib/evilution/version.rb +1 -1
- data/lib/evilution.rb +90 -0
- data/script/memory_check +62 -16
- metadata +113 -4
|
@@ -6,6 +6,7 @@ require_relative "constant_pinner"
|
|
|
6
6
|
require_relative "concern_state_cleaner"
|
|
7
7
|
require_relative "source_evaluator"
|
|
8
8
|
require_relative "redefinition_recovery"
|
|
9
|
+
require_relative "../../diagnostic"
|
|
9
10
|
|
|
10
11
|
# Composes the load-time pipeline that applies a mutation's new source to the
|
|
11
12
|
# running VM: syntax-validate -> pin top-level constants (beats Zeitwerk) ->
|
|
@@ -19,6 +20,14 @@ require_relative "redefinition_recovery"
|
|
|
19
20
|
# RedefinitionRecovery stays as a safety net for cases the neutralizer's
|
|
20
21
|
# allowlist heuristic misses. Returns nil on success or a failure-shaped
|
|
21
22
|
# hash on any error.
|
|
23
|
+
#
|
|
24
|
+
# A mutated source that raises while it is evaluated never reaches the tests.
|
|
25
|
+
# When the original still evaluates right after it, the mutation is what broke
|
|
26
|
+
# the load, and the failure is marked `load_crashed`: the mutant is detected,
|
|
27
|
+
# as one that crashes a test is. When the original fails too, or there is none
|
|
28
|
+
# to compare with, the failure says nothing about the mutant and stays an
|
|
29
|
+
# error. A syntax error is never the mutant's doing in that sense: it means
|
|
30
|
+
# the mutation was not valid Ruby to begin with.
|
|
22
31
|
class Evilution::Integration::Loading::MutationApplier
|
|
23
32
|
def initialize(syntax_validator: Evilution::Integration::Loading::SyntaxValidator.new,
|
|
24
33
|
constant_pinner: Evilution::Integration::Loading::ConstantPinner.new,
|
|
@@ -42,11 +51,45 @@ class Evilution::Integration::Loading::MutationApplier
|
|
|
42
51
|
rescue SyntaxError => e
|
|
43
52
|
failure_result(e, "syntax error in mutated source: #{e.message}")
|
|
44
53
|
rescue ScriptError, StandardError => e
|
|
45
|
-
|
|
54
|
+
load_failure_result(mutation, e)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Evaluates the mutation's restore_source: the original file. Runs after the
|
|
58
|
+
# tests: a failure here must not change the mutation's result, so it is
|
|
59
|
+
# reported and swallowed. Concern state is cleared first, as it is before
|
|
60
|
+
# applying: a concern ignores an `included` block handed to it twice from
|
|
61
|
+
# the same place, and would keep the mutated one.
|
|
62
|
+
def restore(mutation)
|
|
63
|
+
evaluate_original(mutation)
|
|
64
|
+
rescue ScriptError, StandardError => e
|
|
65
|
+
Evilution::Diagnostic.warn("[evilution] could not restore #{mutation.file_path}: #{e.class}: #{e.message}")
|
|
46
66
|
end
|
|
47
67
|
|
|
48
68
|
private
|
|
49
69
|
|
|
70
|
+
# Whether there was an original to evaluate.
|
|
71
|
+
def evaluate_original(mutation)
|
|
72
|
+
source = mutation.respond_to?(:restore_source) ? mutation.restore_source : nil
|
|
73
|
+
return false unless source
|
|
74
|
+
|
|
75
|
+
@concern_state_cleaner.call(mutation.file_path)
|
|
76
|
+
@source_evaluator.call(source, mutation.file_path)
|
|
77
|
+
true
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def original_loads?(mutation)
|
|
81
|
+
evaluate_original(mutation)
|
|
82
|
+
rescue ScriptError, StandardError
|
|
83
|
+
false
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def load_failure_result(mutation, error)
|
|
87
|
+
message = "#{error.class}: #{error.message}"
|
|
88
|
+
return failure_result(error, message) unless original_loads?(mutation)
|
|
89
|
+
|
|
90
|
+
failure_result(error, "load failed: #{message}").merge(load_crashed: true)
|
|
91
|
+
end
|
|
92
|
+
|
|
50
93
|
def resolve_eval_target(mutation)
|
|
51
94
|
return mutation.eval_source if mutation.respond_to?(:eval_source)
|
|
52
95
|
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../loading"
|
|
4
|
+
|
|
5
|
+
# The methods a class defined itself, held while a concern's hook block is run
|
|
6
|
+
# on it a second time.
|
|
7
|
+
#
|
|
8
|
+
# The first time, the block ran when the class took the concern, and whatever
|
|
9
|
+
# the class went on to define under the same name replaced the block's method.
|
|
10
|
+
# Run again, the block would put its own back in that place: code that is dead
|
|
11
|
+
# for the class would come alive, and its tests would fail whatever the mutant.
|
|
12
|
+
#
|
|
13
|
+
# A method counts as the class's own when it was not written in the block's
|
|
14
|
+
# file. One that is found written there afterwards was replaced by the block,
|
|
15
|
+
# and is put back. A class that defines such a method in the concern's own file
|
|
16
|
+
# cannot be told apart and is not protected.
|
|
17
|
+
class Evilution::Integration::Loading::OwnMethods
|
|
18
|
+
# Called through UnboundMethods: a class may define any of these for its
|
|
19
|
+
# own purposes.
|
|
20
|
+
PUBLIC = Module.instance_method(:public_instance_methods)
|
|
21
|
+
PROTECTED = Module.instance_method(:protected_instance_methods)
|
|
22
|
+
PRIVATE = Module.instance_method(:private_instance_methods)
|
|
23
|
+
LOOKUP = Module.instance_method(:instance_method)
|
|
24
|
+
DEFINED = Module.instance_method(:method_defined?)
|
|
25
|
+
PRIVATELY_DEFINED = Module.instance_method(:private_method_defined?)
|
|
26
|
+
DEFINE = Module.instance_method(:define_method)
|
|
27
|
+
SINGLETON_CLASS = Kernel.instance_method(:singleton_class)
|
|
28
|
+
VISIBILITIES = { public: PUBLIC, protected: PROTECTED, private: PRIVATE }.freeze
|
|
29
|
+
SETTERS = VISIBILITIES.keys.to_h { |visibility| [visibility, Module.instance_method(visibility)] }.freeze
|
|
30
|
+
private_constant :PUBLIC, :PROTECTED, :PRIVATE, :LOOKUP, :DEFINED, :PRIVATELY_DEFINED, :DEFINE, :SINGLETON_CLASS,
|
|
31
|
+
:VISIBILITIES, :SETTERS
|
|
32
|
+
|
|
33
|
+
Kept = Struct.new(:owner, :name, :definition, :visibility)
|
|
34
|
+
private_constant :Kept
|
|
35
|
+
|
|
36
|
+
# Runs the block, then puts back the methods of `klass` that the code of
|
|
37
|
+
# `file` replaced meanwhile -- also when the block raises.
|
|
38
|
+
def self.keeping(klass, file)
|
|
39
|
+
own = new(klass, file)
|
|
40
|
+
begin
|
|
41
|
+
yield
|
|
42
|
+
ensure
|
|
43
|
+
own.put_back
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def initialize(klass, file)
|
|
48
|
+
@file = file
|
|
49
|
+
@kept = [klass, SINGLETON_CLASS.bind_call(klass)].flat_map { |owner| own_methods_of(owner) }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def put_back
|
|
53
|
+
@kept.each { |kept| restore(kept) if replaced?(kept) }
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
def own_methods_of(owner)
|
|
59
|
+
VISIBILITIES.flat_map do |visibility, list|
|
|
60
|
+
list.bind_call(owner, false).filter_map do |name|
|
|
61
|
+
definition = LOOKUP.bind_call(owner, name)
|
|
62
|
+
Kept.new(owner, name, definition, visibility) unless written_in_file?(definition)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def written_in_file?(definition)
|
|
68
|
+
location = definition.source_location
|
|
69
|
+
!location.nil? && location.first == @file
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# A method the block removed stays removed.
|
|
73
|
+
def replaced?(kept)
|
|
74
|
+
return false unless defined_on?(kept.owner, kept.name)
|
|
75
|
+
|
|
76
|
+
written_in_file?(LOOKUP.bind_call(kept.owner, kept.name))
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def defined_on?(owner, name)
|
|
80
|
+
DEFINED.bind_call(owner, name) || PRIVATELY_DEFINED.bind_call(owner, name)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def restore(kept)
|
|
84
|
+
DEFINE.bind_call(kept.owner, kept.name, kept.definition)
|
|
85
|
+
SETTERS.fetch(kept.visibility).bind_call(kept.owner, kept.name)
|
|
86
|
+
end
|
|
87
|
+
end
|
|
@@ -24,6 +24,7 @@ class Evilution::Integration::Loading::RedefinitionRecovery
|
|
|
24
24
|
|
|
25
25
|
def initialize(constant_names: Evilution::AST::ConstantNames.new)
|
|
26
26
|
@constant_names = constant_names
|
|
27
|
+
@warned_messages = Set.new
|
|
27
28
|
end
|
|
28
29
|
|
|
29
30
|
def call(source, &block)
|
|
@@ -73,10 +74,8 @@ class Evilution::Integration::Loading::RedefinitionRecovery
|
|
|
73
74
|
end
|
|
74
75
|
|
|
75
76
|
def warn_once_for(error)
|
|
76
|
-
return
|
|
77
|
+
return unless @warned_messages.add?(error.message)
|
|
77
78
|
|
|
78
|
-
@warned_messages ||= []
|
|
79
|
-
@warned_messages << error.message
|
|
80
79
|
$stderr.write(
|
|
81
80
|
"[evilution] swallowed idempotency violation on re-eval: " \
|
|
82
81
|
"#{error.class}: #{error.message}. " \
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative "../loading"
|
|
4
4
|
require_relative "reeval_warning_filter"
|
|
5
|
+
require_relative "superseded_modules"
|
|
5
6
|
|
|
6
7
|
# Evaluate source with __FILE__ set to the absolute original path so that
|
|
7
8
|
# `require_relative` and `__dir__` resolve against the real source tree, where
|
|
@@ -13,13 +14,19 @@ require_relative "reeval_warning_filter"
|
|
|
13
14
|
# between this eval path and a plain `require` of the same file is that we
|
|
14
15
|
# substitute the mutated bytes — the privilege level is identical.
|
|
15
16
|
class Evilution::Integration::Loading::SourceEvaluator
|
|
17
|
+
def initialize(superseded_modules: Evilution::Integration::Loading::SupersededModules.new)
|
|
18
|
+
@superseded_modules = superseded_modules
|
|
19
|
+
end
|
|
20
|
+
|
|
16
21
|
def call(source, file_path)
|
|
17
22
|
# When the isolator has chdir'd into a per-mutation sandbox (EV-wqxu /
|
|
18
23
|
# GH #1278), anchor the eval __FILE__ against PROJECT_ROOT so siblings
|
|
19
24
|
# `require_relative` can find each other from the real source tree.
|
|
20
25
|
absolute = File.expand_path(file_path, Evilution.project_base_dir)
|
|
21
|
-
|
|
22
|
-
|
|
26
|
+
@superseded_modules.track(absolute) do
|
|
27
|
+
Evilution::Integration::Loading::ReevalWarningFilter.suppress do
|
|
28
|
+
eval(source, TOPLEVEL_BINDING, absolute, 1)
|
|
29
|
+
end
|
|
23
30
|
end
|
|
24
31
|
end
|
|
25
32
|
end
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../loading"
|
|
4
|
+
|
|
5
|
+
# Empties the anonymous modules an earlier evaluation of a file left behind.
|
|
6
|
+
#
|
|
7
|
+
# A file can install a module it never names:
|
|
8
|
+
#
|
|
9
|
+
# handler = Module.new { def const_missing(name) = ... }
|
|
10
|
+
# Object.extend(handler)
|
|
11
|
+
#
|
|
12
|
+
# Evaluating the file again builds a new module and extends with it again, and
|
|
13
|
+
# Ruby has no way to take the old one off. Under in-process isolation the file
|
|
14
|
+
# is evaluated for every mutation and every restore, so the modules pile up on
|
|
15
|
+
# their host, and a `super` in the mutation under test runs the code of every
|
|
16
|
+
# mutation before it. Its tests then fail on a predecessor's bug, and the
|
|
17
|
+
# mutation is reported killed. Fork isolation never sees this: a child holds
|
|
18
|
+
# one mutation.
|
|
19
|
+
#
|
|
20
|
+
# A module cannot be taken off, but its methods can be removed, and method
|
|
21
|
+
# lookup passes through a module that has none. So the modules created while a
|
|
22
|
+
# file is evaluated are remembered, and emptied when the same file is evaluated
|
|
23
|
+
# next.
|
|
24
|
+
#
|
|
25
|
+
# Two limits keep this to what the new evaluation really replaces. Only
|
|
26
|
+
# modules are emptied, not anonymous classes, whose instances may be held
|
|
27
|
+
# elsewhere. And only methods written in the file itself are removed: a module
|
|
28
|
+
# some library built on the file's behalf holds methods written in the
|
|
29
|
+
# library, which evaluating the file again does not define anew.
|
|
30
|
+
class Evilution::Integration::Loading::SupersededModules
|
|
31
|
+
@registry = {}
|
|
32
|
+
|
|
33
|
+
class << self
|
|
34
|
+
# Created modules by file, for the process: the evaluations of one file
|
|
35
|
+
# are not all made through the same instance.
|
|
36
|
+
attr_reader :registry
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
MODULE_NAME = Module.instance_method(:name)
|
|
40
|
+
INSTANCE_OF = Kernel.instance_method(:instance_of?)
|
|
41
|
+
private_constant :MODULE_NAME, :INSTANCE_OF
|
|
42
|
+
|
|
43
|
+
def initialize(registry: self.class.registry)
|
|
44
|
+
@registry = registry
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Evaluates the block as an evaluation of `file_path`. The modules of the
|
|
48
|
+
# evaluation before are emptied first, so that the new one starts from what
|
|
49
|
+
# a fresh process would hold; the modules this one creates are remembered
|
|
50
|
+
# whether or not it completes.
|
|
51
|
+
#
|
|
52
|
+
# A fork child holds one mutation and ends with it, so there is nothing for
|
|
53
|
+
# it to empty later, and walking the heap of a process just forked is dear:
|
|
54
|
+
# every page touched has to be mapped first. It evaluates and no more.
|
|
55
|
+
def track(file_path)
|
|
56
|
+
return yield if Evilution.single_mutation_process?
|
|
57
|
+
|
|
58
|
+
retire(file_path)
|
|
59
|
+
known = anonymous_modules
|
|
60
|
+
yield
|
|
61
|
+
ensure
|
|
62
|
+
remember(file_path, known) if known
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
# Nothing is kept for a file whose evaluation created none, which is most
|
|
68
|
+
# files: an entry is only worth carrying when there is a module to empty.
|
|
69
|
+
def remember(file_path, known)
|
|
70
|
+
created = anonymous_modules.reject { |mod| known.key?(mod) }.keys
|
|
71
|
+
@registry[file_path] = created unless created.empty?
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def retire(file_path)
|
|
75
|
+
Array(@registry.delete(file_path)).each { |mod| empty(mod, file_path) }
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def empty(mod, file_path)
|
|
79
|
+
return if mod.frozen?
|
|
80
|
+
|
|
81
|
+
own = mod.instance_methods(false) + mod.private_instance_methods(false)
|
|
82
|
+
own.each { |name| mod.send(:remove_method, name) if written_in?(mod.instance_method(name), file_path) }
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def written_in?(method, file_path)
|
|
86
|
+
location = method.source_location
|
|
87
|
+
!location.nil? && location.first == file_path
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# No message is sent to the modules walked. ObjectSpace yields everything
|
|
91
|
+
# that is a Module, and some of it answers messages in its own way: a class
|
|
92
|
+
# may define `name`, `hash` or `==` for itself, and ActiveSupport's
|
|
93
|
+
# DeprecatedConstantProxy, a Module subclass, turns any message into a
|
|
94
|
+
# deprecation, which a project may have set to raise. Hence the identity
|
|
95
|
+
# keys and the methods bound from Module and Kernel.
|
|
96
|
+
def anonymous_modules
|
|
97
|
+
found = {}.compare_by_identity
|
|
98
|
+
ObjectSpace.each_object(Module) do |mod|
|
|
99
|
+
found[mod] = true if INSTANCE_OF.bind_call(mod, Module) && MODULE_NAME.bind_call(mod).nil?
|
|
100
|
+
end
|
|
101
|
+
found
|
|
102
|
+
end
|
|
103
|
+
end
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "prism"
|
|
4
3
|
require_relative "../loading"
|
|
4
|
+
require_relative "../../ast/syntax_check"
|
|
5
5
|
|
|
6
6
|
class Evilution::Integration::Loading::SyntaxValidator
|
|
7
7
|
ERROR_MESSAGE = "mutated source has syntax errors"
|
|
8
8
|
|
|
9
9
|
def call(source)
|
|
10
|
-
return nil if
|
|
10
|
+
return nil if Evilution::AST::SyntaxCheck.valid?(source)
|
|
11
11
|
|
|
12
12
|
{
|
|
13
13
|
passed: false,
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../loading"
|
|
4
|
+
|
|
5
|
+
# Remembers which test classes each test file registered when it was loaded,
|
|
6
|
+
# so a process that runs many mutations loads a test file once.
|
|
7
|
+
#
|
|
8
|
+
# Minitest and Test::Unit learn of a test class when it is first defined.
|
|
9
|
+
# Loading its file again reopens the class and registers nothing, so the
|
|
10
|
+
# second mutation run in a process would find no tests. The classes of the
|
|
11
|
+
# first load are handed back instead.
|
|
12
|
+
#
|
|
13
|
+
# The block given to .new returns the classes registered so far; what a load
|
|
14
|
+
# adds to them is taken to be that file's.
|
|
15
|
+
class Evilution::Integration::Loading::TestClassCache
|
|
16
|
+
def initialize(®istered)
|
|
17
|
+
@registered = registered
|
|
18
|
+
@classes = {}
|
|
19
|
+
@partial = {}
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# The classes the given files registered. Yields each file that has not
|
|
23
|
+
# been loaded yet; the block loads it.
|
|
24
|
+
def fetch(files, &)
|
|
25
|
+
files.flat_map { |file| @classes[file] ||= load_once(file, &) }.uniq
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def clear
|
|
29
|
+
@classes.clear
|
|
30
|
+
@partial.clear
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
# A load that raises is tried again next time. Classes it defined before
|
|
36
|
+
# raising are already registered and will not register again, so they are
|
|
37
|
+
# kept for that next attempt.
|
|
38
|
+
def load_once(file)
|
|
39
|
+
before = @registered.call
|
|
40
|
+
yield file
|
|
41
|
+
@partial.fetch(file, []) | (@registered.call - before)
|
|
42
|
+
rescue ScriptError, StandardError
|
|
43
|
+
@partial[file] = @partial.fetch(file, []) | (@registered.call - before)
|
|
44
|
+
raise
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../minitest"
|
|
4
|
+
|
|
5
|
+
# Names a Minitest test the same way in the baseline and in a mutation run:
|
|
6
|
+
# its class and its method, `CalcTest#test_adds`.
|
|
7
|
+
#
|
|
8
|
+
# A test class without a name has only its method to go by, so two such
|
|
9
|
+
# classes with a method of the same name are told apart by neither run.
|
|
10
|
+
module Evilution::Integration::Minitest::TestIds
|
|
11
|
+
def self.of(result)
|
|
12
|
+
class_name = result.respond_to?(:class_name) ? result.class_name : result.class.name
|
|
13
|
+
"#{class_name}##{result.name}"
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# The ids of the tests that failed, from the summary reporter of the run.
|
|
17
|
+
# It keeps every result that did not pass, skips included.
|
|
18
|
+
def self.failed(summary)
|
|
19
|
+
summary.results.reject(&:skipped?).map { |result| of(result) }
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -4,9 +4,12 @@ require "stringio"
|
|
|
4
4
|
require_relative "base"
|
|
5
5
|
require_relative "../diagnostic"
|
|
6
6
|
require_relative "minitest_crash_detector"
|
|
7
|
+
require_relative "known_failures"
|
|
7
8
|
require_relative "loading/test_load_path"
|
|
9
|
+
require_relative "loading/test_class_cache"
|
|
8
10
|
require_relative "../spec_resolver"
|
|
9
11
|
require_relative "../spec_selector"
|
|
12
|
+
require_relative "../baseline"
|
|
10
13
|
|
|
11
14
|
require_relative "../integration"
|
|
12
15
|
|
|
@@ -47,12 +50,22 @@ class Evilution::Integration::Minitest < Evilution::Integration::Base
|
|
|
47
50
|
options = ::Minitest.process_args(["--seed", "0"])
|
|
48
51
|
options[:io] = out
|
|
49
52
|
reporter = ::Minitest::CompositeReporter.new
|
|
50
|
-
|
|
53
|
+
summary = ::Minitest::SummaryReporter.new(out, options)
|
|
54
|
+
reporter << summary
|
|
51
55
|
initialize_minitest_state(reporter, options)
|
|
52
56
|
reporter.start
|
|
53
57
|
dispatch_minitest_suites(reporter, options)
|
|
54
58
|
reporter.report
|
|
55
|
-
|
|
59
|
+
Evilution::Baseline::Report.build(
|
|
60
|
+
passed: reporter.passed?, failures: baseline_failures(summary), failed_ids: TestIds.failed(summary)
|
|
61
|
+
)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# The summary reporter keeps every result that did not pass, skips included.
|
|
65
|
+
def self.baseline_failures(summary)
|
|
66
|
+
summary.results.reject(&:skipped?).map do |result|
|
|
67
|
+
{ id: TestIds.of(result), description: "", message: result.failure.message }
|
|
68
|
+
end
|
|
56
69
|
end
|
|
57
70
|
|
|
58
71
|
# Mirror Minitest.run's preamble: seed setup + plugin init. Without seeding
|
|
@@ -84,6 +97,12 @@ class Evilution::Integration::Minitest < Evilution::Integration::Base
|
|
|
84
97
|
end
|
|
85
98
|
end
|
|
86
99
|
|
|
100
|
+
# Shared by every instance: the canary and the mutation runs build their
|
|
101
|
+
# own, and a file loaded for one must not be loaded again for the other.
|
|
102
|
+
def self.test_classes
|
|
103
|
+
@test_classes ||= Evilution::Integration::Loading::TestClassCache.new { ::Minitest::Runnable.runnables.dup }
|
|
104
|
+
end
|
|
105
|
+
|
|
87
106
|
def self.baseline_options
|
|
88
107
|
{
|
|
89
108
|
runner: baseline_runner,
|
|
@@ -92,8 +111,10 @@ class Evilution::Integration::Minitest < Evilution::Integration::Base
|
|
|
92
111
|
}
|
|
93
112
|
end
|
|
94
113
|
|
|
95
|
-
|
|
114
|
+
# known_failures: the ids of the tests the baseline saw failing (see TestIds).
|
|
115
|
+
def initialize(test_files: nil, hooks: nil, fallback_to_full_suite: false, spec_selector: nil, known_failures: [])
|
|
96
116
|
@test_files = test_files
|
|
117
|
+
@known_failures = Evilution::Integration::KnownFailures.new(known_failures)
|
|
97
118
|
@minitest_loaded = false
|
|
98
119
|
@spec_selector = spec_selector || Evilution::SpecSelector.new(
|
|
99
120
|
spec_resolver: Evilution::SpecResolver.new(test_dir: "test", test_suffix: "_test.rb", request_dir: "integration")
|
|
@@ -132,17 +153,27 @@ class Evilution::Integration::Minitest < Evilution::Integration::Base
|
|
|
132
153
|
end
|
|
133
154
|
|
|
134
155
|
def execute_minitest(mutation, files, command)
|
|
135
|
-
|
|
136
|
-
files.each { |f| load(File.expand_path(f, Evilution.project_base_dir)) }
|
|
156
|
+
load_test_classes(files)
|
|
137
157
|
|
|
138
158
|
detector = reset_crash_detector
|
|
139
159
|
run = run_minitest(build_args(mutation), detector)
|
|
140
160
|
|
|
141
161
|
return no_tests_ran_result(command) if run[:count].zero?
|
|
162
|
+
# Before the crash check: a test that was already failing may fail by
|
|
163
|
+
# raising now, and a crash in it is no more a kill than its failure was.
|
|
164
|
+
return known_failures_only_result(command) if @known_failures.only?(run[:failed_ids])
|
|
142
165
|
|
|
143
166
|
build_minitest_result(run[:passed], command, detector)
|
|
144
167
|
end
|
|
145
168
|
|
|
169
|
+
# Leaves Minitest with the test classes of these files and no others.
|
|
170
|
+
def load_test_classes(files)
|
|
171
|
+
Evilution::Integration::Loading::TestLoadPath.add!(files)
|
|
172
|
+
paths = files.map { |f| File.expand_path(f, Evilution.project_base_dir) }
|
|
173
|
+
classes = self.class.test_classes.fetch(paths) { |path| load(path) }
|
|
174
|
+
::Minitest::Runnable.runnables.replace(classes)
|
|
175
|
+
end
|
|
176
|
+
|
|
146
177
|
# Zero dispatched test methods means the run carries no signal — the result
|
|
147
178
|
# is neither survived nor killed. Most common cause: the project's tests use
|
|
148
179
|
# a framework other than Minitest (e.g. the test-unit gem, whose
|
|
@@ -160,6 +191,12 @@ class Evilution::Integration::Minitest < Evilution::Integration::Base
|
|
|
160
191
|
}
|
|
161
192
|
end
|
|
162
193
|
|
|
194
|
+
# Every test that failed was already failing in the baseline. Still a
|
|
195
|
+
# failed run, but flagged, so it is not taken for a kill.
|
|
196
|
+
def known_failures_only_result(command)
|
|
197
|
+
{ passed: false, known_failures_only: true, test_command: command }
|
|
198
|
+
end
|
|
199
|
+
|
|
163
200
|
def unresolved_result(mutation)
|
|
164
201
|
{
|
|
165
202
|
passed: false,
|
|
@@ -190,7 +227,7 @@ class Evilution::Integration::Minitest < Evilution::Integration::Base
|
|
|
190
227
|
dispatch_minitest_suites(reporter, options)
|
|
191
228
|
reporter.report
|
|
192
229
|
|
|
193
|
-
{ passed: summary.passed?, count: minitest_method_count }
|
|
230
|
+
{ passed: summary.passed?, count: minitest_method_count, failed_ids: TestIds.failed(summary) }
|
|
194
231
|
end
|
|
195
232
|
|
|
196
233
|
# Attach evilution's own reporters to the composite AFTER plugin init, and
|
|
@@ -293,3 +330,5 @@ class Evilution::Integration::Minitest < Evilution::Integration::Base
|
|
|
293
330
|
end
|
|
294
331
|
end
|
|
295
332
|
end
|
|
333
|
+
|
|
334
|
+
require_relative "minitest/test_ids"
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "stringio"
|
|
3
4
|
require_relative "../rspec"
|
|
5
|
+
require_relative "../../baseline"
|
|
6
|
+
require_relative "example_ids"
|
|
4
7
|
|
|
5
8
|
class Evilution::Integration::RSpec::BaselineRunner
|
|
6
9
|
def call(spec_file)
|
|
@@ -9,10 +12,51 @@ class Evilution::Integration::RSpec::BaselineRunner
|
|
|
9
12
|
# FrameworkLoader#add_spec_load_path for rationale.
|
|
10
13
|
spec_dir = File.expand_path("spec", Evilution.project_base_dir)
|
|
11
14
|
$LOAD_PATH.unshift(spec_dir) unless $LOAD_PATH.include?(spec_dir)
|
|
12
|
-
|
|
15
|
+
reset_examples
|
|
16
|
+
output = StringIO.new
|
|
13
17
|
status = ::RSpec::Core::Runner.run(
|
|
14
|
-
["--format", "progress", "--no-color", "--order", "defined", spec_file]
|
|
18
|
+
["--format", "progress", "--no-color", "--order", "defined", spec_file], output, output
|
|
15
19
|
)
|
|
16
|
-
status.zero
|
|
20
|
+
report(status.zero?, output.string)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
# Examples are cleared, the configuration is kept: a helper loaded by
|
|
26
|
+
# --preload is not loaded again by the spec's own require, so an
|
|
27
|
+
# RSpec.configure block dropped here never comes back, and a spec relying on
|
|
28
|
+
# it goes red in the baseline alone.
|
|
29
|
+
def reset_examples
|
|
30
|
+
::RSpec.respond_to?(:clear_examples) ? ::RSpec.clear_examples : ::RSpec.reset
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# What RSpec printed is the only account of a failure outside any example --
|
|
34
|
+
# a file that does not load, a suite hook that raises.
|
|
35
|
+
def report(passed, output)
|
|
36
|
+
return Evilution::Baseline::Report.build(passed: true) if passed
|
|
37
|
+
|
|
38
|
+
failures = failed_examples
|
|
39
|
+
Evilution::Baseline::Report.build(
|
|
40
|
+
passed: false, failures: failures, error: failures.empty? ? output : nil,
|
|
41
|
+
failed_ids: Evilution::Integration::RSpec::ExampleIds.failed(::RSpec.world)
|
|
42
|
+
)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def failed_examples
|
|
46
|
+
world = ::RSpec.world
|
|
47
|
+
return [] unless world.respond_to?(:all_examples)
|
|
48
|
+
|
|
49
|
+
world.all_examples
|
|
50
|
+
.select { |example| example.execution_result.status == :failed }
|
|
51
|
+
.map { |example| failure(example) }
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def failure(example)
|
|
55
|
+
exception = example.execution_result.exception
|
|
56
|
+
{
|
|
57
|
+
id: example.id,
|
|
58
|
+
description: example.full_description,
|
|
59
|
+
message: exception ? "#{exception.class}: #{exception.message}" : nil
|
|
60
|
+
}
|
|
17
61
|
end
|
|
18
62
|
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../rspec"
|
|
4
|
+
|
|
5
|
+
# Names an example the same way wherever it runs.
|
|
6
|
+
#
|
|
7
|
+
# RSpec's own id starts with the spec file as it was given: `./spec/a_spec.rb`
|
|
8
|
+
# from the project root, an absolute path from an isolated worker that has
|
|
9
|
+
# changed directory. The baseline and a mutation run can therefore disagree
|
|
10
|
+
# about the same example, so the path is made absolute and symlinks are
|
|
11
|
+
# resolved before the position within the file is added.
|
|
12
|
+
module Evilution::Integration::RSpec::ExampleIds
|
|
13
|
+
FAILED = :failed
|
|
14
|
+
|
|
15
|
+
def self.of(example)
|
|
16
|
+
metadata = example.metadata
|
|
17
|
+
"#{canonical(metadata[:rerun_file_path])}[#{metadata[:scoped_id]}]"
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# The ids of the examples that failed in the run RSpec.world still holds.
|
|
21
|
+
def self.failed(world)
|
|
22
|
+
return [] unless world.respond_to?(:all_examples)
|
|
23
|
+
|
|
24
|
+
world.all_examples
|
|
25
|
+
.select { |example| example.execution_result.status == FAILED }
|
|
26
|
+
.map { |example| of(example) }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def self.canonical(path)
|
|
30
|
+
File.realpath(path)
|
|
31
|
+
rescue SystemCallError
|
|
32
|
+
File.expand_path(path)
|
|
33
|
+
end
|
|
34
|
+
private_class_method :canonical
|
|
35
|
+
end
|
|
@@ -21,8 +21,13 @@ class Evilution::Integration::RSpec::ResultBuilder
|
|
|
21
21
|
}
|
|
22
22
|
end
|
|
23
23
|
|
|
24
|
-
|
|
24
|
+
# known_failures_only: every example that failed was already failing in the
|
|
25
|
+
# baseline. Still a failed run, but flagged, so it is not taken for a kill.
|
|
26
|
+
def from_run(status, command, detector, examples_loaded: nil, known_failures_only: false)
|
|
25
27
|
return { passed: true, test_command: command } if status.zero?
|
|
28
|
+
# Before the crash check: an example that was already failing may fail by
|
|
29
|
+
# raising now, and a crash in it is no more a kill than its failure was.
|
|
30
|
+
return { passed: false, known_failures_only: true, test_command: command } if known_failures_only
|
|
26
31
|
|
|
27
32
|
if detector.only_crashes?
|
|
28
33
|
classes = detector.unique_crash_classes
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../state_guard"
|
|
4
|
+
require_relative "../../rspec"
|
|
5
|
+
|
|
6
|
+
# Drops the examples a run registers on RSpec::Core::AnonymousExampleGroup.
|
|
7
|
+
#
|
|
8
|
+
# Example#initialize appends every example to its group's `examples`. Most
|
|
9
|
+
# groups are thrown away with the run, but AnonymousExampleGroup is a constant:
|
|
10
|
+
# whatever lands there stays for the life of the process. A run with a suite
|
|
11
|
+
# hook puts a SuiteHookContext there, and the reporter notifying a non-example
|
|
12
|
+
# exception puts an Example there. Each holds that run's reporter, and the
|
|
13
|
+
# reporter holds every example the run loaded, so one in-process run after
|
|
14
|
+
# another retained them all, about 1 MB per mutation on the memory_check
|
|
15
|
+
# fixture.
|
|
16
|
+
#
|
|
17
|
+
# Forked runs never leak this (the child dies), only the in-process path.
|
|
18
|
+
class Evilution::Integration::RSpec::StateGuard::AnonymousExampleGroupExamples
|
|
19
|
+
def snapshot
|
|
20
|
+
examples&.length
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def release(length)
|
|
24
|
+
return unless length
|
|
25
|
+
|
|
26
|
+
examples&.slice!(length..)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def examples
|
|
32
|
+
return nil unless defined?(::RSpec::Core::AnonymousExampleGroup)
|
|
33
|
+
|
|
34
|
+
::RSpec::Core::AnonymousExampleGroup.examples
|
|
35
|
+
end
|
|
36
|
+
end
|