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
|
@@ -2,6 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
require "prism"
|
|
4
4
|
require_relative "../loading"
|
|
5
|
+
require_relative "../../ast/aasm_declaration"
|
|
6
|
+
require_relative "../../ast/included_block"
|
|
7
|
+
require_relative "../../ast/callback_declaration"
|
|
8
|
+
require_relative "callback_redeclaration"
|
|
9
|
+
require_relative "concern_redeclaration"
|
|
5
10
|
|
|
6
11
|
# Strip non-idempotent class/module-body side-effect calls from a mutated
|
|
7
12
|
# source before re-eval. Such calls (e.g. dry-monads `register_mixin`, plugin
|
|
@@ -13,7 +18,10 @@ require_relative "../loading"
|
|
|
13
18
|
# Strategy: walk Prism tree, find CallNodes that sit directly under a class
|
|
14
19
|
# or module body (not inside a def). Calls on a small allowlist of patterns
|
|
15
20
|
# known to be idempotent (`include`, `attr_*`, visibility modifiers, etc.)
|
|
16
|
-
# are preserved; everything else
|
|
21
|
+
# are preserved; everything else has its source range replaced with `()`.
|
|
22
|
+
# A call spanning several lines leaves its line breaks behind, so the source
|
|
23
|
+
# keeps its line count. Edits are applied from the end of the source backwards
|
|
24
|
+
# to keep earlier offsets valid.
|
|
17
25
|
class Evilution::Integration::Loading::BodyCallNeutralizer
|
|
18
26
|
IDEMPOTENT_CALLS = %i[
|
|
19
27
|
include extend prepend using
|
|
@@ -25,6 +33,17 @@ class Evilution::Integration::Loading::BodyCallNeutralizer
|
|
|
25
33
|
require require_relative autoload
|
|
26
34
|
].to_set.freeze
|
|
27
35
|
|
|
36
|
+
# `()` rather than `nil`: under -w Ruby flags a bare `nil` statement as
|
|
37
|
+
# "possibly useless use of nil in void context", which a project raising on
|
|
38
|
+
# warnings turns into an error for every mutation of the file. `()` evaluates
|
|
39
|
+
# to nil without the warning on every supported Ruby (`(nil)` still warns on 3.3).
|
|
40
|
+
REPLACEMENT = "()"
|
|
41
|
+
|
|
42
|
+
REDECLARATION = "::Evilution::Integration::Loading::ConcernRedeclaration"
|
|
43
|
+
GUARD = "#{REDECLARATION}.skipping? or ".freeze
|
|
44
|
+
REDECLARE = "; #{REDECLARATION}.call(self)".freeze
|
|
45
|
+
CALLBACKS = "::Evilution::Integration::Loading::CallbackRedeclaration"
|
|
46
|
+
|
|
28
47
|
class << self
|
|
29
48
|
attr_writer :preloaded_features
|
|
30
49
|
|
|
@@ -52,13 +71,33 @@ class Evilution::Integration::Loading::BodyCallNeutralizer
|
|
|
52
71
|
# strips method definitions that subsequent sibling statements (alias, etc.)
|
|
53
72
|
# depend on, producing cascading NameError. Callers that don't pass a path
|
|
54
73
|
# get the legacy always-neutralize behavior.
|
|
55
|
-
|
|
74
|
+
#
|
|
75
|
+
# `keep_offset` (optional) spares the one class-body call whose source
|
|
76
|
+
# contains that byte offset: a mutation inside a scope body only takes
|
|
77
|
+
# effect if the mutated file re-runs that `scope` call to replace it.
|
|
78
|
+
# When that call is an `aasm` machine, only the `event` or `state` holding
|
|
79
|
+
# the offset is spared inside its block: re-running one declaration replaces
|
|
80
|
+
# it, while re-running the whole machine would register its
|
|
81
|
+
# `after_all_transitions`-style callbacks a second time.
|
|
82
|
+
# When it is a concern's `included` block, the block is kept whole, since
|
|
83
|
+
# classes that include the concern later run all of it; every other call in
|
|
84
|
+
# it is guarded and the block is followed by a ConcernRedeclaration call,
|
|
85
|
+
# which re-declares the spared statement alone on the classes that include
|
|
86
|
+
# the concern already. Both are added on existing lines, so no line moves.
|
|
87
|
+
# An `aasm` machine inside such a block is treated the same way one level
|
|
88
|
+
# down: its other declarations are guarded, not blanked.
|
|
89
|
+
# When it is a callback declaration (`validate ... if: -> { }`), it is
|
|
90
|
+
# wrapped in a CallbackRedeclaration call, which puts what the re-run
|
|
91
|
+
# registers in the place of what the declaration registered before.
|
|
92
|
+
# `keep_lines` is where that was: the declaration's lines in the file as it
|
|
93
|
+
# was loaded.
|
|
94
|
+
def call(source, file_path: nil, keep_offset: nil, keep_lines: nil)
|
|
56
95
|
return source if file_path && !preloaded?(file_path)
|
|
57
96
|
|
|
58
97
|
result = Prism.parse(source)
|
|
59
98
|
return source if result.failure?
|
|
60
99
|
|
|
61
|
-
edits = collect_edits(result.value)
|
|
100
|
+
edits = collect_edits(source, result.value, keep_offset, keep_lines)
|
|
62
101
|
return source if edits.empty?
|
|
63
102
|
|
|
64
103
|
apply_edits(source, edits)
|
|
@@ -70,26 +109,29 @@ class Evilution::Integration::Loading::BodyCallNeutralizer
|
|
|
70
109
|
self.class.preloaded_features.include?(File.expand_path(file_path))
|
|
71
110
|
end
|
|
72
111
|
|
|
73
|
-
def collect_edits(tree)
|
|
112
|
+
def collect_edits(source, tree, keep_offset, keep_lines)
|
|
74
113
|
edits = []
|
|
75
|
-
walker = Walker.new(IDEMPOTENT_CALLS, edits)
|
|
114
|
+
walker = Walker.new(source, IDEMPOTENT_CALLS, edits, keep_offset, keep_lines)
|
|
76
115
|
walker.visit(tree)
|
|
77
116
|
edits
|
|
78
117
|
end
|
|
79
118
|
|
|
80
119
|
def apply_edits(source, edits)
|
|
81
120
|
bytes = source.b
|
|
82
|
-
edits.sort_by!(&:first).reverse_each do |start_offset, end_offset|
|
|
83
|
-
bytes[start_offset, end_offset - start_offset] =
|
|
121
|
+
edits.sort_by!(&:first).reverse_each do |start_offset, end_offset, replacement|
|
|
122
|
+
bytes[start_offset, end_offset - start_offset] = replacement.b
|
|
84
123
|
end
|
|
85
124
|
bytes.force_encoding(source.encoding)
|
|
86
125
|
end
|
|
87
126
|
|
|
88
127
|
class Walker < Prism::Visitor
|
|
89
|
-
def initialize(allowlist, edits)
|
|
128
|
+
def initialize(source, allowlist, edits, keep_offset, keep_lines)
|
|
90
129
|
super()
|
|
130
|
+
@source = source
|
|
91
131
|
@allowlist = allowlist
|
|
92
132
|
@edits = edits
|
|
133
|
+
@keep_offset = keep_offset
|
|
134
|
+
@keep_lines = keep_lines
|
|
93
135
|
end
|
|
94
136
|
|
|
95
137
|
def visit_class_node(node)
|
|
@@ -118,14 +160,99 @@ class Evilution::Integration::Loading::BodyCallNeutralizer
|
|
|
118
160
|
return unless body_node.is_a?(Prism::StatementsNode)
|
|
119
161
|
|
|
120
162
|
body_node.body.each do |stmt|
|
|
121
|
-
next unless
|
|
122
|
-
|
|
123
|
-
|
|
163
|
+
next unless neutralizable?(stmt)
|
|
164
|
+
|
|
165
|
+
edit = edit_for(stmt)
|
|
166
|
+
if kept?(edit)
|
|
167
|
+
scan_kept(stmt)
|
|
168
|
+
else
|
|
169
|
+
@edits << edit
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def scan_kept(stmt)
|
|
175
|
+
scan_machine(stmt)
|
|
176
|
+
scan_included(stmt)
|
|
177
|
+
wrap_callbacks(stmt)
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# A kept callback declaration runs inside a block handed to
|
|
181
|
+
# CallbackRedeclaration, opened and closed on the declaration's own lines.
|
|
182
|
+
def wrap_callbacks(stmt)
|
|
183
|
+
return unless @keep_lines && Evilution::AST::CallbackDeclaration.match?(stmt)
|
|
184
|
+
|
|
185
|
+
start_offset = stmt.location.start_offset
|
|
186
|
+
end_offset = stmt.location.end_offset
|
|
187
|
+
lines = "#{@keep_lines.first}..#{@keep_lines.last}"
|
|
188
|
+
@edits << [start_offset, start_offset, "#{CALLBACKS}.call(self, __FILE__, #{lines}) { "]
|
|
189
|
+
@edits << [end_offset, end_offset, " }"]
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# A kept `included` block stays whole. Its other calls are guarded rather
|
|
193
|
+
# than blanked, and what follows it re-declares the spared one on the
|
|
194
|
+
# classes that include the concern already.
|
|
195
|
+
def scan_included(stmt)
|
|
196
|
+
block = Evilution::AST::IncludedBlock.of(stmt)
|
|
197
|
+
return unless block && block.body.is_a?(Prism::StatementsNode)
|
|
198
|
+
|
|
199
|
+
guard_others(block)
|
|
200
|
+
end_offset = stmt.location.end_offset
|
|
201
|
+
@edits << [end_offset, end_offset, REDECLARE]
|
|
202
|
+
end
|
|
124
203
|
|
|
125
|
-
|
|
204
|
+
# Guards every call of a block but the one holding the offset. When that
|
|
205
|
+
# one is an `aasm` machine, its own declarations are guarded the same way
|
|
206
|
+
# rather than blanked: a class that includes the concern later needs the
|
|
207
|
+
# whole machine, and one that includes it already only the mutated event.
|
|
208
|
+
# When it is a callback declaration, it is wrapped as it would be in a
|
|
209
|
+
# class body: the block runs as the including class, so that is whose
|
|
210
|
+
# chains CallbackRedeclaration rebuilds.
|
|
211
|
+
def guard_others(block)
|
|
212
|
+
block.body.body.grep(Prism::CallNode).each do |call|
|
|
213
|
+
if kept?(edit_for(call))
|
|
214
|
+
machine = Evilution::AST::AasmDeclaration.machine_block(call)
|
|
215
|
+
guard_others(machine) if machine && machine.body.is_a?(Prism::StatementsNode)
|
|
216
|
+
wrap_callbacks(call)
|
|
217
|
+
else
|
|
218
|
+
start_offset = call.location.start_offset
|
|
219
|
+
@edits << [start_offset, start_offset, GUARD]
|
|
220
|
+
end
|
|
126
221
|
end
|
|
127
222
|
end
|
|
128
223
|
|
|
224
|
+
def neutralizable?(stmt)
|
|
225
|
+
return false unless stmt.is_a?(Prism::CallNode)
|
|
226
|
+
return false if @allowlist.include?(stmt.name)
|
|
227
|
+
|
|
228
|
+
stmt.receiver.nil? || stmt.receiver.is_a?(Prism::SelfNode)
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
# Inside a kept `aasm` block every call is a declaration of the machine,
|
|
232
|
+
# so all of them but the one holding the offset go.
|
|
233
|
+
def scan_machine(stmt)
|
|
234
|
+
block = Evilution::AST::AasmDeclaration.machine_block(stmt)
|
|
235
|
+
return unless block && block.body.is_a?(Prism::StatementsNode)
|
|
236
|
+
|
|
237
|
+
block.body.body.grep(Prism::CallNode).each do |declaration|
|
|
238
|
+
edit = edit_for(declaration)
|
|
239
|
+
@edits << edit unless kept?(edit)
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
# The lines a blanked call took up are kept as empty lines: everything
|
|
244
|
+
# after it then sits on the line it has in the file, which is where a proc
|
|
245
|
+
# created by this source says it was written.
|
|
246
|
+
def edit_for(call)
|
|
247
|
+
start_offset = call.location.start_offset
|
|
248
|
+
end_offset = replacement_end_offset(call)
|
|
249
|
+
[start_offset, end_offset, REPLACEMENT + ("\n" * @source.byteslice(start_offset...end_offset).count("\n"))]
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def kept?((start_offset, end_offset, _replacement))
|
|
253
|
+
@keep_offset && @keep_offset >= start_offset && @keep_offset < end_offset
|
|
254
|
+
end
|
|
255
|
+
|
|
129
256
|
# Prism CallNode location ends at the close of the call syntax (e.g. the
|
|
130
257
|
# closing `)` or the `<<~MARKER` opener for a heredoc argument). It does
|
|
131
258
|
# NOT include the heredoc body lines or the trailing terminator. Replacing
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../loading"
|
|
4
|
+
|
|
5
|
+
# Runs a callback declaration again and puts what it registers where the
|
|
6
|
+
# callbacks it registered before were.
|
|
7
|
+
#
|
|
8
|
+
# ActiveSupport::Callbacks does not replace on re-declaration. A callback
|
|
9
|
+
# named by a symbol is dropped and added again at the end of its chain; one
|
|
10
|
+
# that is a block or an object (a validator) is simply added, next to the
|
|
11
|
+
# first. Either way a mutated `if:` would run in a chain that is no longer
|
|
12
|
+
# the one the class was written with -- reordered, or with the unmutated
|
|
13
|
+
# original still answering.
|
|
14
|
+
#
|
|
15
|
+
# So the chains are read before the declaration runs and rebuilt afterwards:
|
|
16
|
+
# the callbacks this declaration registered last time give their place to the
|
|
17
|
+
# new ones, and everything else stays where it was, including what subclasses
|
|
18
|
+
# added themselves. See BodyCallNeutralizer for the source that calls this.
|
|
19
|
+
#
|
|
20
|
+
# "Last time" is the file's own load the first time round, and those
|
|
21
|
+
# callbacks are recognised by where their procs were written (or by having
|
|
22
|
+
# been dropped as duplicates). After that the module goes by what it
|
|
23
|
+
# registered itself: a mutation can add or remove lines, so a proc created by
|
|
24
|
+
# the mutated source may report a line that belongs to another declaration of
|
|
25
|
+
# the real file.
|
|
26
|
+
module Evilution::Integration::Loading::CallbackRedeclaration
|
|
27
|
+
CONDITIONS = %i[@if @unless].freeze
|
|
28
|
+
private_constant :CONDITIONS
|
|
29
|
+
|
|
30
|
+
# klass: the class whose body the declaration is written in. file, lines:
|
|
31
|
+
# where the declaration is in the file as it was loaded.
|
|
32
|
+
def self.call(klass, file, lines)
|
|
33
|
+
return yield unless klass.respond_to?(:__callbacks)
|
|
34
|
+
|
|
35
|
+
before = klass.__callbacks.transform_values(&:to_a)
|
|
36
|
+
result = yield
|
|
37
|
+
swaps = Settlement.new(klass, Origin.new(file, lines), registered).call(before)
|
|
38
|
+
swap_validators(klass, swaps)
|
|
39
|
+
result
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# What earlier calls registered, by declaration:
|
|
43
|
+
# { [class, file, first line] => callbacks }.
|
|
44
|
+
def self.registered
|
|
45
|
+
@registered ||= {}
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# `validates` also lists its validator for reflection (`Model.validators`),
|
|
49
|
+
# once per class; the old one is exchanged for the new there too.
|
|
50
|
+
def self.swap_validators(klass, swaps)
|
|
51
|
+
return unless klass.respond_to?(:_validators)
|
|
52
|
+
|
|
53
|
+
pairs = swaps.select { |old, new| old && new }.map { |old, new| [old.filter, new.filter] }
|
|
54
|
+
[klass, *descendants(klass)].each do |target|
|
|
55
|
+
target._validators.each_value do |validators|
|
|
56
|
+
pairs.each { |old, new| exchange(validators, old, new) }
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def self.exchange(validators, old, new)
|
|
62
|
+
return unless validators.any? { |validator| validator.equal?(old) }
|
|
63
|
+
|
|
64
|
+
validators.reject! { |validator| validator.equal?(new) }
|
|
65
|
+
validators[validators.index { |validator| validator.equal?(old) }] = new
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def self.descendants(klass)
|
|
69
|
+
klass.subclasses.flat_map { |subclass| [subclass, *descendants(subclass)] }
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private_class_method :registered, :swap_validators, :exchange, :descendants
|
|
73
|
+
|
|
74
|
+
# Callbacks are told apart by identity throughout: two that compare equal
|
|
75
|
+
# are still two registrations.
|
|
76
|
+
module Identity
|
|
77
|
+
def self.member?(callbacks, callback)
|
|
78
|
+
callbacks.any? { |candidate| candidate.equal?(callback) }
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def self.without(callbacks, others)
|
|
82
|
+
callbacks.reject { |callback| member?(others, callback) }
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
private_constant :Identity
|
|
86
|
+
|
|
87
|
+
# Rebuilds the chains of a class and its descendants after one declaration
|
|
88
|
+
# has run again.
|
|
89
|
+
class Settlement
|
|
90
|
+
def initialize(klass, origin, registered)
|
|
91
|
+
@klass = klass
|
|
92
|
+
@origin = origin
|
|
93
|
+
@registered = registered
|
|
94
|
+
@key = [klass, origin.file, origin.lines.first]
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# before: { chain name => callbacks } as they were. Returns [old, new]
|
|
98
|
+
# callback pairs for what changed hands.
|
|
99
|
+
def call(before)
|
|
100
|
+
previous = @registered[@key]
|
|
101
|
+
mine = []
|
|
102
|
+
swaps = before.flat_map do |name, callbacks|
|
|
103
|
+
stale, fresh = settle(name, callbacks, previous)
|
|
104
|
+
mine.concat(fresh)
|
|
105
|
+
stale.zip(fresh)
|
|
106
|
+
end
|
|
107
|
+
@registered[@key] = mine unless mine.empty?
|
|
108
|
+
swaps
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
private
|
|
112
|
+
|
|
113
|
+
def settle(name, before, previous)
|
|
114
|
+
after = @klass.__callbacks[name].to_a
|
|
115
|
+
fresh = Identity.without(after, before)
|
|
116
|
+
return [[], []] if fresh.empty?
|
|
117
|
+
|
|
118
|
+
stale = before.select { |callback| stale?(callback, previous) || !Identity.member?(after, callback) }
|
|
119
|
+
order = reorder(before, stale, fresh)
|
|
120
|
+
@klass.send(:__update_callbacks, name) { |target, chain| rebuild(target, name, chain, order, after) }
|
|
121
|
+
[stale, fresh]
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# What this declaration registered before: what an earlier call recorded
|
|
125
|
+
# or, the first time, what was written on its lines -- unless an earlier
|
|
126
|
+
# call for another declaration put it there.
|
|
127
|
+
def stale?(callback, previous)
|
|
128
|
+
return Identity.member?(previous, callback) if previous
|
|
129
|
+
|
|
130
|
+
@origin.declared?(callback) && !Identity.member?(@registered.values.flatten, callback)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# The new callbacks take the place of the first old one; with no old one
|
|
134
|
+
# to replace they stay where the declaration put them, at the end.
|
|
135
|
+
def reorder(before, stale, fresh)
|
|
136
|
+
return before + fresh if stale.empty?
|
|
137
|
+
|
|
138
|
+
before.flat_map do |callback|
|
|
139
|
+
next fresh if callback.equal?(stale.first)
|
|
140
|
+
|
|
141
|
+
Identity.member?(stale, callback) ? [] : [callback]
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def rebuild(target, name, chain, order, after)
|
|
146
|
+
own = Identity.without(chain.to_a, after)
|
|
147
|
+
chain.clear
|
|
148
|
+
(order + own).each { |callback| chain.append(callback) }
|
|
149
|
+
target.send(:set_callbacks, name, chain)
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
private_constant :Settlement
|
|
153
|
+
|
|
154
|
+
# Where a declaration is written, and whether a callback came from there.
|
|
155
|
+
class Origin
|
|
156
|
+
attr_reader :file, :lines
|
|
157
|
+
|
|
158
|
+
def initialize(file, lines)
|
|
159
|
+
@file = canonical(file)
|
|
160
|
+
@lines = lines
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def declared?(callback)
|
|
164
|
+
procs_of(callback).any? { |callable| here?(callable.source_location) }
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
private
|
|
168
|
+
|
|
169
|
+
def procs_of(callback)
|
|
170
|
+
conditions = CONDITIONS.flat_map { |ivar| Array(callback.instance_variable_get(ivar)) }
|
|
171
|
+
[callback.filter, *conditions].grep(Proc)
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def here?(location)
|
|
175
|
+
!location.nil? && canonical(location.first) == @file && @lines.cover?(location.last)
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# The same file may have been loaded through a symlinked path.
|
|
179
|
+
def canonical(path)
|
|
180
|
+
File.realpath(path)
|
|
181
|
+
rescue SystemCallError
|
|
182
|
+
File.expand_path(path)
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
private_constant :Origin
|
|
186
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../loading"
|
|
4
|
+
|
|
5
|
+
# Re-declares one statement of a concern's `included` block on the classes
|
|
6
|
+
# that already include the concern.
|
|
7
|
+
#
|
|
8
|
+
# A mutation inside `included do ... scope :published, -> { } ... end` has two
|
|
9
|
+
# audiences. Classes that include the concern later run the whole block, so
|
|
10
|
+
# registering the mutated block is enough for them. Classes that already
|
|
11
|
+
# include it ran the original, and running the whole block on them again
|
|
12
|
+
# would add its validations and callbacks a second time.
|
|
13
|
+
#
|
|
14
|
+
# So the source evilution evaluates guards every other call of the block with
|
|
15
|
+
# `skipping?` and ends the block with `call(self)`: the block is run once more
|
|
16
|
+
# on each existing includer with the guard up, and only the mutated
|
|
17
|
+
# declaration takes effect there. See BodyCallNeutralizer.
|
|
18
|
+
module Evilution::Integration::Loading::ConcernRedeclaration
|
|
19
|
+
KEY = :evilution_concern_redeclaration
|
|
20
|
+
INCLUDED_BLOCK = :@_included_block
|
|
21
|
+
|
|
22
|
+
# Called through UnboundMethods: ObjectSpace hands over every class in the
|
|
23
|
+
# VM, and one that defines `include?` or `superclass` of its own for its
|
|
24
|
+
# own purposes must not be asked that question.
|
|
25
|
+
INCLUDES = Module.instance_method(:include?)
|
|
26
|
+
SUPERCLASS = Class.instance_method(:superclass)
|
|
27
|
+
SINGLETON = Module.instance_method(:singleton_class?)
|
|
28
|
+
private_constant :KEY, :INCLUDED_BLOCK, :INCLUDES, :SUPERCLASS, :SINGLETON
|
|
29
|
+
|
|
30
|
+
def self.skipping?
|
|
31
|
+
Thread.current[KEY] == true
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def self.call(concern)
|
|
35
|
+
return unless concern.instance_variable_defined?(INCLUDED_BLOCK)
|
|
36
|
+
|
|
37
|
+
block = concern.instance_variable_get(INCLUDED_BLOCK)
|
|
38
|
+
includers(concern).each { |klass| redeclare(klass, block) }
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# The classes that include the concern themselves; a subclass inherits what
|
|
42
|
+
# its parent declares.
|
|
43
|
+
def self.includers(concern)
|
|
44
|
+
ObjectSpace.each_object(Class).select do |klass|
|
|
45
|
+
next false if SINGLETON.bind_call(klass) || !INCLUDES.bind_call(klass, concern)
|
|
46
|
+
|
|
47
|
+
parent = SUPERCLASS.bind_call(klass)
|
|
48
|
+
parent.nil? || !INCLUDES.bind_call(parent, concern)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def self.redeclare(klass, block)
|
|
53
|
+
previous = Thread.current[KEY]
|
|
54
|
+
Thread.current[KEY] = true
|
|
55
|
+
klass.class_eval(&block)
|
|
56
|
+
ensure
|
|
57
|
+
Thread.current[KEY] = previous
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private_class_method :includers, :redeclare
|
|
61
|
+
end
|
|
@@ -13,19 +13,23 @@ class Evilution::Integration::Loading::ConcernStateCleaner
|
|
|
13
13
|
|
|
14
14
|
# ObjectSpace hands us every Module in the VM, including ones that answer no
|
|
15
15
|
# message honestly. ActiveSupport::Deprecation::DeprecatedConstantProxy
|
|
16
|
-
# subclasses Module and undefines all of its instance methods, so
|
|
17
|
-
#
|
|
18
|
-
#
|
|
19
|
-
#
|
|
20
|
-
#
|
|
21
|
-
#
|
|
22
|
-
# for any method_missing-based wrapper, not just ActiveSupport's.
|
|
16
|
+
# subclasses Module and undefines all of its instance methods, so asking it
|
|
17
|
+
# anything lands in its method_missing and emits the deprecation; where the
|
|
18
|
+
# deprecator's behaviour is :raise (grape's spec_helper sets that), the
|
|
19
|
+
# sweep blows up on a module it merely walked past. So the module is never
|
|
20
|
+
# asked: `Concern === mod` is answered by Concern, and the ivars are reached
|
|
21
|
+
# through UnboundMethods, which bypass the object's own method table -- this
|
|
22
|
+
# holds for any method_missing-based wrapper, not just ActiveSupport's.
|
|
23
23
|
# EV-v2rc / GH #1581.
|
|
24
|
-
|
|
24
|
+
#
|
|
25
|
+
# `===` also leaves the module as it found it. Asking for its
|
|
26
|
+
# singleton_class instead would create one for every module in the VM, each
|
|
27
|
+
# of them a module the next sweep has to visit and give a singleton class
|
|
28
|
+
# of its own; a long in-process run doubles its module count per mutation.
|
|
25
29
|
IVAR_DEFINED = Object.instance_method(:instance_variable_defined?)
|
|
26
30
|
IVAR_GET = Object.instance_method(:instance_variable_get)
|
|
27
31
|
REMOVE_IVAR = Object.instance_method(:remove_instance_variable)
|
|
28
|
-
private_constant :
|
|
32
|
+
private_constant :IVAR_DEFINED, :IVAR_GET, :REMOVE_IVAR
|
|
29
33
|
|
|
30
34
|
def initialize(subpath_resolver: Evilution::LoadPath::SubpathResolver.new)
|
|
31
35
|
@subpath_resolver = subpath_resolver
|
|
@@ -34,11 +38,14 @@ class Evilution::Integration::Loading::ConcernStateCleaner
|
|
|
34
38
|
def call(file_path)
|
|
35
39
|
return unless defined?(ActiveSupport::Concern)
|
|
36
40
|
|
|
37
|
-
|
|
38
|
-
|
|
41
|
+
# Anchored on the project, not Dir.pwd: an isolated worker has chdir'd
|
|
42
|
+
# into a per-mutation sandbox, where a relative path names nothing and no
|
|
43
|
+
# concern would ever match.
|
|
44
|
+
absolute = File.expand_path(file_path, Evilution.project_base_dir)
|
|
45
|
+
subpath = @subpath_resolver.call(absolute)
|
|
39
46
|
|
|
40
47
|
ObjectSpace.each_object(Module) do |mod|
|
|
41
|
-
next unless
|
|
48
|
+
next unless ActiveSupport::Concern === mod # rubocop:disable Style/CaseEquality
|
|
42
49
|
|
|
43
50
|
clear_concern_ivars(mod, absolute, subpath)
|
|
44
51
|
end
|
|
@@ -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) ->
|
|
@@ -45,6 +46,21 @@ class Evilution::Integration::Loading::MutationApplier
|
|
|
45
46
|
failure_result(e, "#{e.class}: #{e.message}")
|
|
46
47
|
end
|
|
47
48
|
|
|
49
|
+
# Evaluates the mutation's restore_source, if it has one. Runs after the
|
|
50
|
+
# tests: a failure here must not change the mutation's result, so it is
|
|
51
|
+
# reported and swallowed. Concern state is cleared first, as it is before
|
|
52
|
+
# applying: a concern ignores an `included` block handed to it twice from
|
|
53
|
+
# the same place, and would keep the mutated one.
|
|
54
|
+
def restore(mutation)
|
|
55
|
+
source = mutation.respond_to?(:restore_source) ? mutation.restore_source : nil
|
|
56
|
+
return unless source
|
|
57
|
+
|
|
58
|
+
@concern_state_cleaner.call(mutation.file_path)
|
|
59
|
+
@source_evaluator.call(source, mutation.file_path)
|
|
60
|
+
rescue ScriptError, StandardError => e
|
|
61
|
+
Evilution::Diagnostic.warn("[evilution] could not restore #{mutation.file_path}: #{e.class}: #{e.message}")
|
|
62
|
+
end
|
|
63
|
+
|
|
48
64
|
private
|
|
49
65
|
|
|
50
66
|
def resolve_eval_target(mutation)
|
|
@@ -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
|
|
@@ -22,29 +22,37 @@ module Evilution::Integration::Loading::TestLoadPath
|
|
|
22
22
|
# Prepend every relevant test directory to $LOAD_PATH (idempotently).
|
|
23
23
|
# Iterate in reverse so the first entry from #dirs_for ends up frontmost,
|
|
24
24
|
# preserving its order (mirrors how `ruby -Ia -Ib` lands a before b).
|
|
25
|
-
def add!(files, base: Evilution.project_base_dir)
|
|
26
|
-
dirs_for(files, base).reverse_each do |dir|
|
|
25
|
+
def add!(files, base: Evilution.project_base_dir, outside_root_dirs: true)
|
|
26
|
+
dirs_for(files, base, outside_root_dirs: outside_root_dirs).reverse_each do |dir|
|
|
27
27
|
$LOAD_PATH.unshift(dir) unless $LOAD_PATH.include?(dir)
|
|
28
28
|
end
|
|
29
29
|
end
|
|
30
30
|
|
|
31
|
-
# The directories to put on $LOAD_PATH for the given
|
|
32
|
-
#
|
|
33
|
-
#
|
|
34
|
-
#
|
|
35
|
-
#
|
|
36
|
-
#
|
|
37
|
-
#
|
|
38
|
-
|
|
31
|
+
# The directories to put on $LOAD_PATH for the given files: the
|
|
32
|
+
# conventional test/ and spec/ roots under base, each file's own directory,
|
|
33
|
+
# and the topmost test/spec ancestor of each file (covers nested layouts
|
|
34
|
+
# like test/unit, spec/lib, spec/unit). Existing directories only, and only
|
|
35
|
+
# those inside the project base -- never a broad outside-project dir (e.g. a
|
|
36
|
+
# /tmp test file), which would over-widen $LOAD_PATH for the whole process
|
|
37
|
+
# (the baseline runs in the long-lived parent).
|
|
38
|
+
#
|
|
39
|
+
# A resolved test file outside every test root (a tests/ layout, a test
|
|
40
|
+
# reached through --spec or spec_mappings) still contributes its own
|
|
41
|
+
# directory, mirroring `ruby -Itests`. A preload does not: with
|
|
42
|
+
# `outside_root_dirs: false`, a `--preload config/evilution_preload.rb`
|
|
43
|
+
# would otherwise put config/ ahead of the gems, and `require "puma"` during
|
|
44
|
+
# Bundler.require would load the app's config/puma.rb (GH #1597).
|
|
45
|
+
def dirs_for(files, base, outside_root_dirs: true)
|
|
39
46
|
base = File.expand_path(base)
|
|
40
47
|
dirs = conventional_roots(base)
|
|
41
48
|
Array(files).each do |file|
|
|
42
49
|
file_dir = File.dirname(File.expand_path(file, base))
|
|
43
|
-
dirs << file_dir
|
|
44
50
|
root = root_ancestor(file_dir, base)
|
|
45
|
-
|
|
51
|
+
next unless root || outside_root_dirs
|
|
52
|
+
|
|
53
|
+
dirs << file_dir << root
|
|
46
54
|
end
|
|
47
|
-
dirs.uniq.select { |dir| File.directory?(dir) && within?(dir, base) }
|
|
55
|
+
dirs.compact.uniq.select { |dir| File.directory?(dir) && within?(dir, base) }
|
|
48
56
|
end
|
|
49
57
|
|
|
50
58
|
def within?(dir, base)
|
|
@@ -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
|