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
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../result"
|
|
4
|
+
|
|
5
|
+
# The kills one red spec file left uncounted -- mutations whose tests failed
|
|
6
|
+
# on nothing but examples that were already failing -- with why it was red.
|
|
7
|
+
#
|
|
8
|
+
# spec_file is nil when no single red file can be named, as in a run given
|
|
9
|
+
# its spec files explicitly; failures then holds all of them.
|
|
10
|
+
Evilution::Result::BaselineNeutralization = Data.define(:spec_file, :count, :failures)
|
|
@@ -9,8 +9,11 @@ class Evilution::Result::MutationResult
|
|
|
9
9
|
|
|
10
10
|
attr_reader :mutation, :status, :duration, :killing_test, :test_command, :memory, :error, :neutral_reason
|
|
11
11
|
|
|
12
|
+
# known_failures_only: the tests failed, but every example that did was
|
|
13
|
+
# already failing before any mutation ran, so the failure says nothing
|
|
14
|
+
# about this one.
|
|
12
15
|
def initialize(mutation:, status:, duration: 0.0, killing_test: nil,
|
|
13
|
-
test_command: nil, memory: nil, error: nil, neutral_reason: nil)
|
|
16
|
+
test_command: nil, memory: nil, error: nil, neutral_reason: nil, known_failures_only: false)
|
|
14
17
|
raise ArgumentError, "invalid status: #{status}" unless STATUSES.include?(status)
|
|
15
18
|
|
|
16
19
|
@mutation = mutation
|
|
@@ -21,6 +24,7 @@ class Evilution::Result::MutationResult
|
|
|
21
24
|
@memory = memory
|
|
22
25
|
@error = error
|
|
23
26
|
@neutral_reason = neutral_reason
|
|
27
|
+
@known_failures_only = known_failures_only == true
|
|
24
28
|
freeze
|
|
25
29
|
end
|
|
26
30
|
|
|
@@ -54,6 +58,10 @@ class Evilution::Result::MutationResult
|
|
|
54
58
|
@error.is_a?(Evilution::Result::ErrorInfo) ? @error.backtrace : nil
|
|
55
59
|
end
|
|
56
60
|
|
|
61
|
+
def known_failures_only?
|
|
62
|
+
@known_failures_only
|
|
63
|
+
end
|
|
64
|
+
|
|
57
65
|
def killed?
|
|
58
66
|
status == :killed
|
|
59
67
|
end
|
|
@@ -3,13 +3,15 @@
|
|
|
3
3
|
require_relative "../result"
|
|
4
4
|
require_relative "coverage_gap_grouper"
|
|
5
5
|
require_relative "subject_scorer"
|
|
6
|
+
require_relative "baseline_neutralization"
|
|
6
7
|
|
|
7
8
|
class Evilution::Result::Summary
|
|
8
9
|
attr_reader :results, :duration, :skipped, :disabled_mutations, :unresolved_target_files,
|
|
9
|
-
:target_file_count, :infra_retried
|
|
10
|
+
:target_file_count, :infra_retried, :uncovered_code, :baseline_failures
|
|
10
11
|
|
|
11
12
|
def initialize(results:, duration: 0.0, truncated: false, skipped: 0, disabled_mutations: [],
|
|
12
|
-
unresolved_target_files: [], target_file_count: nil, infra_retried: 0
|
|
13
|
+
unresolved_target_files: [], target_file_count: nil, infra_retried: 0, uncovered_code: [],
|
|
14
|
+
baseline_failures: [])
|
|
13
15
|
@results = results
|
|
14
16
|
@duration = duration
|
|
15
17
|
@truncated = truncated
|
|
@@ -18,9 +20,27 @@ class Evilution::Result::Summary
|
|
|
18
20
|
@unresolved_target_files = unresolved_target_files.freeze
|
|
19
21
|
@target_file_count = target_file_count
|
|
20
22
|
@infra_retried = infra_retried
|
|
23
|
+
@uncovered_code = uncovered_code.freeze
|
|
24
|
+
@baseline_failures = baseline_failures.freeze
|
|
21
25
|
freeze
|
|
22
26
|
end
|
|
23
27
|
|
|
28
|
+
# The kills not counted because only examples already failing in the
|
|
29
|
+
# baseline failed, gathered under the spec file that was red and paired with
|
|
30
|
+
# why it was. They are out of the score, so a red spec file would read as
|
|
31
|
+
# full marks unless the report says they are there.
|
|
32
|
+
def baseline_neutralizations
|
|
33
|
+
baseline_neutralized_results.group_by { |result| result.neutral_reason.detail }.map do |spec_file, results|
|
|
34
|
+
Evilution::Result::BaselineNeutralization.new(
|
|
35
|
+
spec_file: spec_file, count: results.length, failures: baseline_failures_for(spec_file)
|
|
36
|
+
)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def baseline_neutralized
|
|
41
|
+
baseline_neutralized_results.length
|
|
42
|
+
end
|
|
43
|
+
|
|
24
44
|
# Neutral results gathered by the reason they were recorded, which is what the
|
|
25
45
|
# report needs to say which kind of neutral a reader is looking at
|
|
26
46
|
# (EV-5pob / GH #1606).
|
|
@@ -53,6 +73,12 @@ class Evilution::Result::Summary
|
|
|
53
73
|
!unresolved_target_files.empty?
|
|
54
74
|
end
|
|
55
75
|
|
|
76
|
+
# Targeted lines that hold code outside every subject, so no mutation was
|
|
77
|
+
# ever generated for them: `{ file:, lines: ["2-4", "9"] }` per file.
|
|
78
|
+
def uncovered_code?
|
|
79
|
+
!uncovered_code.empty?
|
|
80
|
+
end
|
|
81
|
+
|
|
56
82
|
def truncated?
|
|
57
83
|
@truncated
|
|
58
84
|
end
|
|
@@ -167,4 +193,18 @@ class Evilution::Result::Summary
|
|
|
167
193
|
|
|
168
194
|
max_rss && (max_rss / 1024.0)
|
|
169
195
|
end
|
|
196
|
+
|
|
197
|
+
private
|
|
198
|
+
|
|
199
|
+
def baseline_neutralized_results
|
|
200
|
+
neutral_results.select { |result| result.neutral_reason && result.neutral_reason.kind == :baseline_failure }
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# No spec file named means the run was given its spec files explicitly, and
|
|
204
|
+
# any of them being red neutralizes the survivor.
|
|
205
|
+
def baseline_failures_for(spec_file)
|
|
206
|
+
return baseline_failures if spec_file.nil?
|
|
207
|
+
|
|
208
|
+
baseline_failures.select { |failure| failure.spec_file == spec_file }
|
|
209
|
+
end
|
|
170
210
|
end
|
|
@@ -12,6 +12,7 @@ require_relative "../integration/test_unit"
|
|
|
12
12
|
require_relative "../example_filter"
|
|
13
13
|
require_relative "../spec_ast_cache"
|
|
14
14
|
require_relative "../source_ast_cache"
|
|
15
|
+
require_relative "../diagnostic"
|
|
15
16
|
|
|
16
17
|
unless defined?(Evilution::Runner::INTEGRATIONS)
|
|
17
18
|
Evilution::Runner::INTEGRATIONS = {
|
|
@@ -33,15 +34,19 @@ class Evilution::Runner::BaselineRunner
|
|
|
33
34
|
end
|
|
34
35
|
end
|
|
35
36
|
|
|
36
|
-
|
|
37
|
+
# baseline_result: what the baseline found, when it ran. The tests it saw
|
|
38
|
+
# failing are handed to the integration, which can then tell a mutation run
|
|
39
|
+
# that fails on nothing else from one that kills.
|
|
40
|
+
def build_integration(baseline_result = nil)
|
|
37
41
|
klass = integration_class
|
|
38
|
-
kwargs = base_integration_kwargs
|
|
42
|
+
kwargs = base_integration_kwargs(baseline_result)
|
|
39
43
|
kwargs.merge!(rspec_integration_kwargs) if klass == Evilution::Integration::RSpec
|
|
40
44
|
klass.new(**kwargs)
|
|
41
45
|
end
|
|
42
46
|
|
|
43
|
-
def base_integration_kwargs
|
|
47
|
+
def base_integration_kwargs(baseline_result = nil)
|
|
44
48
|
{
|
|
49
|
+
known_failures: baseline_result ? baseline_result.failed_example_ids : [],
|
|
45
50
|
test_files: config.spec_files.empty? ? nil : config.spec_files,
|
|
46
51
|
hooks: hooks,
|
|
47
52
|
fallback_to_full_suite: config.fallback_to_full_suite?,
|
|
@@ -119,7 +124,7 @@ class Evilution::Runner::BaselineRunner
|
|
|
119
124
|
|
|
120
125
|
Evilution::CoverageExampleFilter.new(map: map, lexical: lexical)
|
|
121
126
|
rescue StandardError => e
|
|
122
|
-
warn
|
|
127
|
+
Evilution::Diagnostic.warn("evilution: coverage targeting unavailable (#{e.class}: #{e.message}); using lexical targeting")
|
|
123
128
|
lexical
|
|
124
129
|
end
|
|
125
130
|
|
|
@@ -6,6 +6,7 @@ require "securerandom"
|
|
|
6
6
|
require_relative "../runner"
|
|
7
7
|
require_relative "../mutation"
|
|
8
8
|
require_relative "../subject"
|
|
9
|
+
require_relative "canary_failure_message"
|
|
9
10
|
|
|
10
11
|
# Runs one guaranteed-unobservable synthetic mutation through the configured
|
|
11
12
|
# integration + isolation at session start. The synthetic spec never references
|
|
@@ -34,7 +35,7 @@ class Evilution::Runner::Canary
|
|
|
34
35
|
test_command: ->(mutation) { build_integration(spec_path).call(mutation) },
|
|
35
36
|
timeout: @config.timeout
|
|
36
37
|
)
|
|
37
|
-
raise Failed,
|
|
38
|
+
raise Failed, Evilution::Runner::CanaryFailureMessage.new(result).to_s unless result.status == :survived
|
|
38
39
|
|
|
39
40
|
nil
|
|
40
41
|
ensure
|
|
@@ -145,60 +146,4 @@ class Evilution::Runner::Canary
|
|
|
145
146
|
def build_integration(spec_path)
|
|
146
147
|
@integration_class.new(test_files: [spec_path], hooks: @hooks)
|
|
147
148
|
end
|
|
148
|
-
|
|
149
|
-
# When the child reported an error, that error IS the diagnosis -- naming it
|
|
150
|
-
# beats guessing. The speculative list stays only for the cases that carry no
|
|
151
|
-
# error at all (:killed, :timeout), where guesses are the only help there is.
|
|
152
|
-
# Diagnosing GH #1581 meant rebuilding this canary by hand to read the field
|
|
153
|
-
# this message used to drop, and none of the four guesses was the cause.
|
|
154
|
-
# EV-65nf / GH #1586.
|
|
155
|
-
def failure_message(result)
|
|
156
|
-
"#{failure_preamble(result.status)} #{diagnosis(result)} " \
|
|
157
|
-
"Re-run with --no-canary to bypass this check."
|
|
158
|
-
end
|
|
159
|
-
|
|
160
|
-
def failure_preamble(status)
|
|
161
|
-
"evilution proof-of-life canary failed: a guaranteed-unobservable synthetic " \
|
|
162
|
-
"mutation was scored #{status.inspect} instead of :survived. The mutation " \
|
|
163
|
-
"pipeline is misreporting — every score this run would produce is unreliable."
|
|
164
|
-
end
|
|
165
|
-
|
|
166
|
-
def diagnosis(result)
|
|
167
|
-
reported = reported_error(result)
|
|
168
|
-
return speculative_causes if reported.nil?
|
|
169
|
-
|
|
170
|
-
"The child reported: #{reported}"
|
|
171
|
-
end
|
|
172
|
-
|
|
173
|
-
def reported_error(result)
|
|
174
|
-
message = result.error_message
|
|
175
|
-
return nil if message.nil? || message.empty?
|
|
176
|
-
|
|
177
|
-
described = with_class_prefix(message, result.error_class)
|
|
178
|
-
frame = first_frame(result)
|
|
179
|
-
frame.nil? ? described : "#{described} (at #{frame})"
|
|
180
|
-
end
|
|
181
|
-
|
|
182
|
-
# MutationApplier already prefixes the class onto the message it packs
|
|
183
|
-
# ("#{e.class}: #{e.message}"), while other paths pack the bare message and
|
|
184
|
-
# leave the class in its own field. Prefixing unconditionally would print
|
|
185
|
-
# "NameError: NameError: ..." for the former.
|
|
186
|
-
def with_class_prefix(message, klass)
|
|
187
|
-
return message if klass.nil? || klass.empty? || message.start_with?("#{klass}:")
|
|
188
|
-
|
|
189
|
-
"#{klass}: #{message}"
|
|
190
|
-
end
|
|
191
|
-
|
|
192
|
-
def first_frame(result)
|
|
193
|
-
backtrace = result.error_backtrace
|
|
194
|
-
return nil if backtrace.nil? || backtrace.empty?
|
|
195
|
-
|
|
196
|
-
backtrace.first
|
|
197
|
-
end
|
|
198
|
-
|
|
199
|
-
def speculative_causes
|
|
200
|
-
"Likely causes: Rails/Zeitwerk autoloading breaking child eval; an env-specific " \
|
|
201
|
-
"RSpec config (e.g. fail_if_no_examples); a classify_status fallback defect; or " \
|
|
202
|
-
"an isolation-mode defect."
|
|
203
|
-
end
|
|
204
149
|
end
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../runner"
|
|
4
|
+
|
|
5
|
+
# Explains why the proof-of-life canary failed, from the result its synthetic
|
|
6
|
+
# mutation came back with. Kept apart from Canary, which only runs the probe:
|
|
7
|
+
# the wording changes for different reasons than the mechanics do.
|
|
8
|
+
class Evilution::Runner::CanaryFailureMessage
|
|
9
|
+
# Raised by RubyGems when it cannot load a gem at a version that satisfies
|
|
10
|
+
# every requirement already in play.
|
|
11
|
+
GEM_ACTIVATION_ERRORS = %w[
|
|
12
|
+
Gem::ConflictError Gem::LoadError Gem::MissingSpecError Gem::MissingSpecVersionError
|
|
13
|
+
].freeze
|
|
14
|
+
|
|
15
|
+
def initialize(result)
|
|
16
|
+
@result = result
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# When the child reported an error, that error IS the diagnosis -- naming it
|
|
20
|
+
# beats guessing. The speculative list stays only for the cases that carry no
|
|
21
|
+
# error at all (:killed, :timeout), where guesses are the only help there is.
|
|
22
|
+
# Diagnosing GH #1581 meant rebuilding this canary by hand to read the field
|
|
23
|
+
# this message used to drop, and none of the four guesses was the cause.
|
|
24
|
+
# EV-65nf / GH #1586.
|
|
25
|
+
def to_s
|
|
26
|
+
return gem_activation_message(@result) if gem_activation_failure?(@result)
|
|
27
|
+
|
|
28
|
+
"#{failure_preamble(@result.status)} #{diagnosis(@result)} " \
|
|
29
|
+
"Re-run with --no-canary to bypass this check."
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
# The usual cause is evilution running outside `bundle exec`: RubyGems then
|
|
35
|
+
# activates the newest installed copy of a shared dependency, which the test
|
|
36
|
+
# framework may not accept. Every mutation would fail the same way, so
|
|
37
|
+
# skipping the canary would not help.
|
|
38
|
+
def gem_activation_message(result)
|
|
39
|
+
"evilution proof-of-life canary failed: the test framework could not be loaded " \
|
|
40
|
+
"because of a gem activation conflict. This is an environment problem, not a " \
|
|
41
|
+
"mutation-pipeline defect, and every mutation would fail the same way. Run " \
|
|
42
|
+
"evilution under `bundle exec` so the project's Gemfile.lock decides gem versions " \
|
|
43
|
+
"(for the MCP server: command `bundle`, args [\"exec\", \"evilution\", \"mcp\"]). " \
|
|
44
|
+
"The child reported: #{reported_error(result)}"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# The class may arrive in its own field or only as the message prefix,
|
|
48
|
+
# depending on which path packed the error.
|
|
49
|
+
def gem_activation_failure?(result)
|
|
50
|
+
message = result.error_message.to_s
|
|
51
|
+
|
|
52
|
+
GEM_ACTIVATION_ERRORS.any? do |klass|
|
|
53
|
+
result.error_class == klass || message.start_with?("#{klass}:")
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def failure_preamble(status)
|
|
58
|
+
"evilution proof-of-life canary failed: a guaranteed-unobservable synthetic " \
|
|
59
|
+
"mutation was scored #{status.inspect} instead of :survived. The mutation " \
|
|
60
|
+
"pipeline is misreporting — every score this run would produce is unreliable."
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def diagnosis(result)
|
|
64
|
+
reported = reported_error(result)
|
|
65
|
+
return speculative_causes if reported.nil?
|
|
66
|
+
|
|
67
|
+
"The child reported: #{reported}"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def reported_error(result)
|
|
71
|
+
message = result.error_message
|
|
72
|
+
return nil if message.nil? || message.empty?
|
|
73
|
+
|
|
74
|
+
described = with_class_prefix(message, result.error_class)
|
|
75
|
+
frame = first_frame(result)
|
|
76
|
+
frame.nil? ? described : "#{described} (at #{frame})"
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# MutationApplier already prefixes the class onto the message it packs
|
|
80
|
+
# ("#{e.class}: #{e.message}"), while other paths pack the bare message and
|
|
81
|
+
# leave the class in its own field. Prefixing unconditionally would print
|
|
82
|
+
# "NameError: NameError: ..." for the former.
|
|
83
|
+
def with_class_prefix(message, klass)
|
|
84
|
+
return message if klass.nil? || klass.empty? || message.start_with?("#{klass}:")
|
|
85
|
+
|
|
86
|
+
"#{klass}: #{message}"
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def first_frame(result)
|
|
90
|
+
backtrace = result.error_backtrace
|
|
91
|
+
return nil if backtrace.nil? || backtrace.empty?
|
|
92
|
+
|
|
93
|
+
backtrace.first
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def speculative_causes
|
|
97
|
+
"Likely causes: Rails/Zeitwerk autoloading breaking child eval; an env-specific " \
|
|
98
|
+
"RSpec config (e.g. fail_if_no_examples); a classify_status fallback defect; or " \
|
|
99
|
+
"an isolation-mode defect."
|
|
100
|
+
end
|
|
101
|
+
end
|
|
@@ -4,6 +4,17 @@ require_relative "../neutralizer"
|
|
|
4
4
|
require_relative "../../../result/mutation_result"
|
|
5
5
|
require_relative "../../../result/neutral_reason"
|
|
6
6
|
|
|
7
|
+
# Reclassifies a kill as :neutral when the tests failed on nothing but
|
|
8
|
+
# examples that were already failing before any mutation ran.
|
|
9
|
+
#
|
|
10
|
+
# Such an example fails whatever the mutation is, so every mutation it runs
|
|
11
|
+
# against would count as killed and a red spec file would score full marks.
|
|
12
|
+
# The failure says nothing about the mutation: it has no verdict.
|
|
13
|
+
#
|
|
14
|
+
# A survivor is left alone, whatever the baseline did. Its tests passed, the
|
|
15
|
+
# examples that were red in the baseline included, so the gap it reports is
|
|
16
|
+
# real -- and neutralizing it, as used to happen for every survivor a red spec
|
|
17
|
+
# file covered, hid exactly the mutations a reader needs to see.
|
|
7
18
|
class Evilution::Runner::MutationExecutor::Neutralizer::BaselineFailed
|
|
8
19
|
def initialize(config:, spec_resolver:, fallback_dir:)
|
|
9
20
|
@config = config
|
|
@@ -12,19 +23,22 @@ class Evilution::Runner::MutationExecutor::Neutralizer::BaselineFailed
|
|
|
12
23
|
end
|
|
13
24
|
|
|
14
25
|
def call(result, baseline_result:)
|
|
15
|
-
return result unless result.
|
|
26
|
+
return result unless result.killed? && result.known_failures_only?
|
|
16
27
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
failed = baseline_result.failed_spec_files
|
|
20
|
-
spec_file = covering_specs(result.mutation.file_path).find { |spec| failed.include?(spec) }
|
|
21
|
-
return result unless spec_file
|
|
22
|
-
|
|
23
|
-
neutralize(result, spec_file)
|
|
28
|
+
neutralize(result, red_spec(result, baseline_result))
|
|
24
29
|
end
|
|
25
30
|
|
|
26
31
|
private
|
|
27
32
|
|
|
33
|
+
# The red spec file to name: one covering the mutated source. A run given
|
|
34
|
+
# its spec files explicitly has no single one to point at.
|
|
35
|
+
def red_spec(result, baseline_result)
|
|
36
|
+
return nil if @config.spec_files.any? || baseline_result.nil?
|
|
37
|
+
|
|
38
|
+
failed = baseline_result.failed_spec_files
|
|
39
|
+
covering_specs(result.mutation.file_path).find { |spec| failed.include?(spec) }
|
|
40
|
+
end
|
|
41
|
+
|
|
28
42
|
# The spec files the baseline ran for this source: every file the resolver
|
|
29
43
|
# returns (a spec_selector may map one source to several), or the fallback
|
|
30
44
|
# directory when nothing resolves -- but only if the run falls back to the
|
|
@@ -34,6 +34,9 @@ class Evilution::Runner::MutationExecutor::ResultCache
|
|
|
34
34
|
return unless @backend
|
|
35
35
|
return unless result.killed? || result.timeout?
|
|
36
36
|
return if infra_crash?(result)
|
|
37
|
+
# Nor is one where only already-failing examples failed: the baseline
|
|
38
|
+
# that says so belongs to this run, and the cache keeps no trace of it.
|
|
39
|
+
return if result.known_failures_only?
|
|
37
40
|
|
|
38
41
|
@backend.store(mutation,
|
|
39
42
|
status: result.status,
|
|
@@ -15,7 +15,8 @@ class Evilution::Runner::MutationExecutor::ResultPacker
|
|
|
15
15
|
parent_rss_kb: result.parent_rss_kb,
|
|
16
16
|
error_message: result.error_message,
|
|
17
17
|
error_class: result.error_class,
|
|
18
|
-
error_backtrace: result.error_backtrace
|
|
18
|
+
error_backtrace: result.error_backtrace,
|
|
19
|
+
known_failures_only: result.known_failures_only?
|
|
19
20
|
}
|
|
20
21
|
end
|
|
21
22
|
|
|
@@ -35,7 +36,8 @@ class Evilution::Runner::MutationExecutor::ResultPacker
|
|
|
35
36
|
message: data[:error_message],
|
|
36
37
|
klass: data[:error_class],
|
|
37
38
|
backtrace: data[:error_backtrace]
|
|
38
|
-
)
|
|
39
|
+
),
|
|
40
|
+
known_failures_only: data[:known_failures_only]
|
|
39
41
|
)
|
|
40
42
|
end
|
|
41
43
|
end
|
|
@@ -13,6 +13,15 @@ class Evilution::Runner::MutationExecutor::Strategy::Parallel
|
|
|
13
13
|
error_class: "Evilution::Error"
|
|
14
14
|
}.freeze
|
|
15
15
|
|
|
16
|
+
# Compact result for an item whose worker sent a frame that was not its own:
|
|
17
|
+
# the worker was given up, and what it had to say about the item with it.
|
|
18
|
+
UNREADABLE_COMPACT = {
|
|
19
|
+
status: :error,
|
|
20
|
+
duration: 0.0,
|
|
21
|
+
error_message: "worker's result could not be read; the worker was replaced",
|
|
22
|
+
error_class: "Evilution::Error"
|
|
23
|
+
}.freeze
|
|
24
|
+
|
|
16
25
|
def initialize(cache:, isolator:, packer:, pipeline:, notifier:, pool_factory:, config:, diagnostics: nil)
|
|
17
26
|
@cache = cache
|
|
18
27
|
@isolator = isolator
|
|
@@ -113,6 +122,7 @@ class Evilution::Runner::MutationExecutor::Strategy::Parallel
|
|
|
113
122
|
case result.reason
|
|
114
123
|
when :timeout then timeout_compact
|
|
115
124
|
when :died then DIED_COMPACT
|
|
125
|
+
when :unreadable then UNREADABLE_COMPACT
|
|
116
126
|
end
|
|
117
127
|
end
|
|
118
128
|
|
|
@@ -30,7 +30,7 @@ class Evilution::Runner::MutationExecutor
|
|
|
30
30
|
end
|
|
31
31
|
|
|
32
32
|
def call(mutations, baseline_result = nil)
|
|
33
|
-
integration = @baseline_runner.build_integration
|
|
33
|
+
integration = @baseline_runner.build_integration(baseline_result)
|
|
34
34
|
spec_resolver = baseline_failed?(baseline_result) ? @baseline_runner.neutralization_resolver : nil
|
|
35
35
|
notifier = build_notifier
|
|
36
36
|
pipeline = build_pipeline(spec_resolver)
|
|
@@ -7,7 +7,7 @@ require_relative "../ast/pattern/filter"
|
|
|
7
7
|
require_relative "../equivalent/detector"
|
|
8
8
|
|
|
9
9
|
class Evilution::Runner::MutationPlanner
|
|
10
|
-
Plan = Struct.new(:enabled, :equivalent, :skipped_count, :disabled_mutations, keyword_init: true)
|
|
10
|
+
Plan = Struct.new(:enabled, :equivalent, :skipped_count, :disabled_mutations, :mutated_lines, keyword_init: true)
|
|
11
11
|
|
|
12
12
|
GenerationResult = Data.define(:mutations, :skipped)
|
|
13
13
|
DisabledFilterResult = Data.define(:enabled, :disabled)
|
|
@@ -33,13 +33,22 @@ class Evilution::Runner::MutationPlanner
|
|
|
33
33
|
sig_filter = filter_sig_blocks(disabled_filter.enabled)
|
|
34
34
|
equivalent_filter = filter_equivalent(sig_filter.enabled)
|
|
35
35
|
|
|
36
|
-
build_plan(equivalent_filter, disabled_mutations, total_skipped(generation, disabled_filter, sig_filter))
|
|
36
|
+
plan = build_plan(equivalent_filter, disabled_mutations, total_skipped(generation, disabled_filter, sig_filter))
|
|
37
|
+
plan.mutated_lines = mutated_lines(deduped)
|
|
38
|
+
plan
|
|
37
39
|
end
|
|
38
40
|
|
|
39
41
|
private
|
|
40
42
|
|
|
41
43
|
attr_reader :config, :registry
|
|
42
44
|
|
|
45
|
+
# The lines mutations were generated for, per file. Taken before any filter:
|
|
46
|
+
# a line whose mutations were all disabled or judged equivalent was still
|
|
47
|
+
# reached by an operator.
|
|
48
|
+
def mutated_lines(mutations)
|
|
49
|
+
mutations.group_by(&:file_path).transform_values { |group| group.map(&:line).uniq.sort }
|
|
50
|
+
end
|
|
51
|
+
|
|
43
52
|
def compute_disabled_mutations(disabled_filter)
|
|
44
53
|
return [] unless config.show_disabled?
|
|
45
54
|
|
|
@@ -5,6 +5,7 @@ require_relative "../reporter/json"
|
|
|
5
5
|
require_relative "../reporter/cli"
|
|
6
6
|
require_relative "../reporter/html"
|
|
7
7
|
require_relative "../session/store"
|
|
8
|
+
require_relative "../diagnostic"
|
|
8
9
|
|
|
9
10
|
class Evilution::Runner::ReportPublisher
|
|
10
11
|
def initialize(config)
|
|
@@ -46,7 +47,7 @@ class Evilution::Runner::ReportPublisher
|
|
|
46
47
|
def publish_html(output)
|
|
47
48
|
path = "evilution-report.html"
|
|
48
49
|
File.write(path, output)
|
|
49
|
-
warn
|
|
50
|
+
Evilution::Diagnostic.warn("HTML report written to #{path}")
|
|
50
51
|
end
|
|
51
52
|
|
|
52
53
|
def save_session(summary)
|
|
@@ -54,7 +55,7 @@ class Evilution::Runner::ReportPublisher
|
|
|
54
55
|
|
|
55
56
|
Evilution::Session::Store.new.save(summary)
|
|
56
57
|
rescue StandardError => e
|
|
57
|
-
warn
|
|
58
|
+
Evilution::Diagnostic.warn("[evilution] failed to save session: #{e.message}") unless config.quiet
|
|
58
59
|
end
|
|
59
60
|
|
|
60
61
|
private
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../subject_pipeline"
|
|
4
|
+
|
|
5
|
+
# What `--target` asks for, read once from the text the user gave:
|
|
6
|
+
#
|
|
7
|
+
# source:lib/**/*.rb the files of a glob
|
|
8
|
+
# descendants:Base a class and everything inheriting from it
|
|
9
|
+
# User#adult? a method; `User`, `User#`, `Billing::*` for several
|
|
10
|
+
#
|
|
11
|
+
# NONE stands in when no target was given: it is no kind of target and
|
|
12
|
+
# selects every subject, so the pipeline never has to ask whether there is
|
|
13
|
+
# one.
|
|
14
|
+
class Evilution::Runner::SubjectPipeline::Target
|
|
15
|
+
PREFIXES = { "source:" => :source_glob, "descendants:" => :descendants }.freeze
|
|
16
|
+
private_constant :PREFIXES
|
|
17
|
+
|
|
18
|
+
def self.parse(text)
|
|
19
|
+
return NONE if text.nil?
|
|
20
|
+
|
|
21
|
+
prefix, kind = PREFIXES.find { |candidate, _| text.start_with?(candidate) }
|
|
22
|
+
return new(kind: :method, text: text, value: text) if prefix.nil?
|
|
23
|
+
|
|
24
|
+
new(kind: kind, text: text, value: text.delete_prefix(prefix))
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# The part after the prefix: the glob, the base class name, or the method
|
|
28
|
+
# name as written.
|
|
29
|
+
attr_reader :value
|
|
30
|
+
|
|
31
|
+
def initialize(kind:, text:, value:)
|
|
32
|
+
@kind = kind
|
|
33
|
+
@text = text
|
|
34
|
+
@value = value
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def source_glob?
|
|
38
|
+
@kind == :source_glob
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def descendants?
|
|
42
|
+
@kind == :descendants
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def method?
|
|
46
|
+
@kind == :method
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# The target as the user wrote it, for messages.
|
|
50
|
+
def to_s
|
|
51
|
+
@text.to_s
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Only a method target narrows subjects by name.
|
|
55
|
+
def selects?(subject)
|
|
56
|
+
!method? || matcher.call(subject.name)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
NONE = new(kind: :none, text: nil, value: nil)
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
def matcher
|
|
64
|
+
@matcher ||= build_matcher
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def build_matcher
|
|
68
|
+
return wildcard_matcher(value.chomp("*")) if value.end_with?("*")
|
|
69
|
+
return prefix_matcher(value) if value.end_with?("#", ".")
|
|
70
|
+
|
|
71
|
+
name_matcher(value)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def wildcard_matcher(prefix)
|
|
75
|
+
->(name) { name.split(/[#.]/).first.start_with?(prefix) }
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def prefix_matcher(prefix)
|
|
79
|
+
->(name) { name.start_with?(prefix) }
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# A full method name matches itself; a class name, each of its methods.
|
|
83
|
+
def name_matcher(target)
|
|
84
|
+
->(name) { name == target || name.start_with?("#{target}#") || name.start_with?("#{target}.") }
|
|
85
|
+
end
|
|
86
|
+
end
|