evilution 0.29.0 → 0.30.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 +54 -0
- data/.rubocop_todo.yml +7 -0
- data/CHANGELOG.md +42 -0
- data/README.md +194 -8
- data/docs/versioning.md +53 -0
- data/lib/evilution/ast/heredoc_span.rb +99 -0
- data/lib/evilution/baseline.rb +15 -2
- data/lib/evilution/cli/commands/compare.rb +13 -0
- data/lib/evilution/cli/parser/command_extractor.rb +3 -1
- data/lib/evilution/cli/parser/options_builder.rb +2 -2
- data/lib/evilution/config/file_loader.rb +40 -1
- data/lib/evilution/config.rb +11 -1
- data/lib/evilution/equivalent/heuristic/dead_code.rb +8 -1
- data/lib/evilution/feedback/setup_warning.rb +79 -0
- data/lib/evilution/gem_detector.rb +132 -0
- data/lib/evilution/integration/loading/body_call_neutralizer.rb +190 -0
- data/lib/evilution/integration/loading/mutation_applier.rb +20 -5
- data/lib/evilution/integration/loading/redefinition_recovery.rb +58 -1
- data/lib/evilution/integration/minitest.rb +37 -2
- data/lib/evilution/integration/rspec/result_builder.rb +20 -1
- data/lib/evilution/integration/rspec.rb +16 -1
- data/lib/evilution/isolation/fork.rb +77 -10
- data/lib/evilution/mcp/info_tool/response_formatter.rb +14 -1
- data/lib/evilution/mcp/info_tool.rb +3 -1
- data/lib/evilution/mcp/mutate_tool/option_parser.rb +1 -1
- data/lib/evilution/mcp/mutate_tool/report_trimmer.rb +58 -1
- data/lib/evilution/mcp/mutate_tool.rb +22 -3
- data/lib/evilution/mcp/session_tool.rb +7 -4
- data/lib/evilution/mcp.rb +6 -0
- data/lib/evilution/mutation.rb +13 -1
- data/lib/evilution/mutator/base.rb +49 -1
- data/lib/evilution/mutator/operator/argument_method_call_replacement.rb +59 -0
- data/lib/evilution/mutator/operator/block_param_removal.rb +32 -0
- data/lib/evilution/mutator/operator/explicit_super_mutation.rb +20 -2
- data/lib/evilution/mutator/operator/index_to_at.rb +13 -1
- data/lib/evilution/mutator/operator/last_expression_removal.rb +46 -0
- data/lib/evilution/mutator/operator/receiver_replacement.rb +29 -1
- data/lib/evilution/mutator/operator/rescue_removal.rb +59 -10
- data/lib/evilution/mutator/operator/splat_operator.rb +28 -1
- data/lib/evilution/mutator/operator/string_literal.rb +83 -6
- data/lib/evilution/mutator/registry.rb +2 -0
- data/lib/evilution/reporter/cli/line_formatters/error_rate_warning.rb +29 -0
- data/lib/evilution/reporter/cli/metrics_block.rb +2 -0
- data/lib/evilution/reporter/json.rb +2 -0
- data/lib/evilution/result/mutation_result.rb +12 -6
- data/lib/evilution/runner/baseline_runner.rb +5 -1
- data/lib/evilution/runner/isolation_resolver.rb +69 -8
- data/lib/evilution/runner/mutation_planner.rb +18 -1
- data/lib/evilution/session/schema.rb +44 -0
- data/lib/evilution/session/store.rb +5 -1
- data/lib/evilution/version.rb +1 -1
- data/lib/evilution.rb +2 -0
- data/schema/evilution.config.schema.json +205 -0
- data/script/build_runtime_snapshot +88 -0
- data/script/run_self_baseline +79 -0
- data/script/run_self_validation +54 -0
- metadata +15 -2
|
@@ -14,8 +14,31 @@ class Evilution::Mutator::Operator::SplatOperator < Evilution::Mutator::Base
|
|
|
14
14
|
super
|
|
15
15
|
end
|
|
16
16
|
|
|
17
|
+
# KeywordHashNode wraps call-arg kwargs + `**splat`. When an explicit
|
|
18
|
+
# `k: v` precedes a `**opts` splat in the same call, demoting `**opts` to
|
|
19
|
+
# bare `opts` puts a positional after a keyword and Ruby rejects it
|
|
20
|
+
# (`bar(k: v, opts)` is a syntax error). Mark such splats so
|
|
21
|
+
# `visit_assoc_splat_node` skips them. Splats that come BEFORE any kwarg
|
|
22
|
+
# (`bar(**opts, k: v)`) are still safe — positional-before-keyword is fine.
|
|
23
|
+
def visit_keyword_hash_node(node)
|
|
24
|
+
seen_kwarg = false
|
|
25
|
+
node.elements.each do |el|
|
|
26
|
+
if el.is_a?(Prism::AssocSplatNode) && seen_kwarg
|
|
27
|
+
kwarg_preceded_splats.add(el)
|
|
28
|
+
elsif el.is_a?(Prism::AssocNode)
|
|
29
|
+
seen_kwarg = true
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
super
|
|
34
|
+
end
|
|
35
|
+
|
|
17
36
|
def visit_assoc_splat_node(node)
|
|
18
|
-
|
|
37
|
+
return super if node.value.nil?
|
|
38
|
+
return super if hash_elements.include?(node)
|
|
39
|
+
return super if kwarg_preceded_splats.include?(node)
|
|
40
|
+
|
|
41
|
+
mutate_remove_double_splat(node)
|
|
19
42
|
|
|
20
43
|
super
|
|
21
44
|
end
|
|
@@ -26,6 +49,10 @@ class Evilution::Mutator::Operator::SplatOperator < Evilution::Mutator::Base
|
|
|
26
49
|
@hash_elements ||= Set.new.compare_by_identity
|
|
27
50
|
end
|
|
28
51
|
|
|
52
|
+
def kwarg_preceded_splats
|
|
53
|
+
@kwarg_preceded_splats ||= Set.new.compare_by_identity
|
|
54
|
+
end
|
|
55
|
+
|
|
29
56
|
def mutate_remove_splat(node)
|
|
30
57
|
add_mutation(
|
|
31
58
|
offset: node.location.start_offset,
|
|
@@ -9,9 +9,62 @@ class Evilution::Mutator::Operator::StringLiteral < Evilution::Mutator::Base
|
|
|
9
9
|
end
|
|
10
10
|
|
|
11
11
|
def visit_interpolated_string_node(node)
|
|
12
|
-
|
|
13
|
-
|
|
12
|
+
if node.heredoc?
|
|
13
|
+
return if @skip_heredoc_literals
|
|
14
14
|
|
|
15
|
+
node.parts.each do |part|
|
|
16
|
+
next if part.is_a?(Prism::StringNode)
|
|
17
|
+
|
|
18
|
+
visit(part)
|
|
19
|
+
end
|
|
20
|
+
return
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Adjacent-string concatenation — both `"foo" "bar"` and the line-continued
|
|
24
|
+
# form `"foo" \\\n "bar"` — lands in an InterpolatedStringNode whose parts
|
|
25
|
+
# are all StringNodes. Mutating chunks individually splices the wrong span:
|
|
26
|
+
# for the continued form Ruby treats `nil \\\n "rest"` as a confusing
|
|
27
|
+
# parse rather than a clean nil; for both forms the result is one StringNode
|
|
28
|
+
# plus an orphaned adjacent literal, not a meaningful mutation of the whole
|
|
29
|
+
# expression. Replace the entire concatenation in one shot instead.
|
|
30
|
+
if adjacent_string_concat?(node)
|
|
31
|
+
emit_string_mutations(node)
|
|
32
|
+
return
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
super
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def visit_string_node(node)
|
|
39
|
+
return super if node.heredoc?
|
|
40
|
+
|
|
41
|
+
emit_string_mutations(node)
|
|
42
|
+
|
|
43
|
+
super
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Inner StringNode chunks of an interpolated symbol (`:"visit_#{type}"`),
|
|
47
|
+
# interpolated regular expression (`/^#{needle}/`), or interpolated x-string
|
|
48
|
+
# (`` `echo #{cmd}` ``) are not free string literals — they are fragments of
|
|
49
|
+
# a different literal kind. Mutating them splices empty-string bytes into the
|
|
50
|
+
# middle of a `:"..."` / `/.../` / `` `...` `` token, producing unparseable
|
|
51
|
+
# code. Visit only the interpolation parts (which may contain mutatable
|
|
52
|
+
# expressions); skip the raw StringNode chunks.
|
|
53
|
+
def visit_interpolated_symbol_node(node)
|
|
54
|
+
visit_non_string_parts(node)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def visit_interpolated_regular_expression_node(node)
|
|
58
|
+
visit_non_string_parts(node)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def visit_interpolated_x_string_node(node)
|
|
62
|
+
visit_non_string_parts(node)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
def visit_non_string_parts(node)
|
|
15
68
|
node.parts.each do |part|
|
|
16
69
|
next if part.is_a?(Prism::StringNode)
|
|
17
70
|
|
|
@@ -19,10 +72,30 @@ class Evilution::Mutator::Operator::StringLiteral < Evilution::Mutator::Base
|
|
|
19
72
|
end
|
|
20
73
|
end
|
|
21
74
|
|
|
22
|
-
|
|
23
|
-
|
|
75
|
+
# Adjacent-string concatenation differs from a single interpolated string by
|
|
76
|
+
# the shape of its parts: each adjacent chunk is a full quoted literal of its
|
|
77
|
+
# own — a StringNode or InterpolatedStringNode that owns its own opening
|
|
78
|
+
# quote (`opening_loc`). A plain interpolated string `"a #{x} b"` decomposes
|
|
79
|
+
# into chunk StringNodes (no `opening_loc`) interleaved with
|
|
80
|
+
# EmbeddedStatementsNode parts (whose `opening_loc` is the `#{` delimiter,
|
|
81
|
+
# not a quote). Requiring StringNode/InterpolatedStringNode AND `opening_loc`
|
|
82
|
+
# rejects pure-interpolation cases like `"#{a}#{b}"` (whose parts are all
|
|
83
|
+
# EmbeddedStatementsNodes) while accepting mixed plain+interpolated adjacency
|
|
84
|
+
# like `"foo" "bar #{x}"` and its line-continued cousin.
|
|
85
|
+
QUOTED_LITERAL_TYPES = [Prism::StringNode, Prism::InterpolatedStringNode].freeze
|
|
86
|
+
private_constant :QUOTED_LITERAL_TYPES
|
|
24
87
|
|
|
25
|
-
|
|
88
|
+
def adjacent_string_concat?(node)
|
|
89
|
+
return false unless node.parts.length > 1
|
|
90
|
+
|
|
91
|
+
node.parts.all? do |part|
|
|
92
|
+
QUOTED_LITERAL_TYPES.any? { |type| part.is_a?(type) } && part.opening_loc
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def emit_string_mutations(node)
|
|
97
|
+
empty = node_content_empty?(node)
|
|
98
|
+
replacement = empty ? '"mutation"' : '""'
|
|
26
99
|
|
|
27
100
|
add_mutation(
|
|
28
101
|
offset: node.location.start_offset,
|
|
@@ -37,7 +110,11 @@ class Evilution::Mutator::Operator::StringLiteral < Evilution::Mutator::Base
|
|
|
37
110
|
replacement: "nil",
|
|
38
111
|
node: node
|
|
39
112
|
)
|
|
113
|
+
end
|
|
40
114
|
|
|
41
|
-
|
|
115
|
+
def node_content_empty?(node)
|
|
116
|
+
return node.content.empty? if node.is_a?(Prism::StringNode)
|
|
117
|
+
|
|
118
|
+
node.parts.all? { |part| part.is_a?(Prism::StringNode) && part.content.empty? }
|
|
42
119
|
end
|
|
43
120
|
end
|
|
@@ -39,11 +39,13 @@ class Evilution::Mutator::Registry
|
|
|
39
39
|
Evilution::Mutator::Operator::SymbolLiteral,
|
|
40
40
|
Evilution::Mutator::Operator::ConditionalNegation,
|
|
41
41
|
Evilution::Mutator::Operator::ConditionalBranch,
|
|
42
|
+
Evilution::Mutator::Operator::LastExpressionRemoval,
|
|
42
43
|
Evilution::Mutator::Operator::StatementDeletion,
|
|
43
44
|
Evilution::Mutator::Operator::MethodBodyReplacement,
|
|
44
45
|
Evilution::Mutator::Operator::NegationInsertion,
|
|
45
46
|
Evilution::Mutator::Operator::ReturnValueRemoval,
|
|
46
47
|
Evilution::Mutator::Operator::CollectionReplacement,
|
|
48
|
+
Evilution::Mutator::Operator::ArgumentMethodCallReplacement,
|
|
47
49
|
Evilution::Mutator::Operator::MethodCallRemoval,
|
|
48
50
|
Evilution::Mutator::Operator::ArgumentRemoval,
|
|
49
51
|
Evilution::Mutator::Operator::BlockRemoval,
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../line_formatters"
|
|
4
|
+
|
|
5
|
+
# EV-nrgw / GH #1168: Score is `killed / score_denominator` where
|
|
6
|
+
# `score_denominator = total - errors - neutral - equivalent - unresolved -
|
|
7
|
+
# unparseable` — errors are excluded from the denominator entirely. A run
|
|
8
|
+
# can read PASS at 100% while 16 of 19 mutations silently errored. This
|
|
9
|
+
# formatter surfaces a warning right under the metrics block when the error
|
|
10
|
+
# rate crosses the threshold so the silent failure mode becomes loud.
|
|
11
|
+
class Evilution::Reporter::CLI::LineFormatters::ErrorRateWarning
|
|
12
|
+
DEFAULT_THRESHOLD = 0.25
|
|
13
|
+
|
|
14
|
+
def initialize(threshold: DEFAULT_THRESHOLD)
|
|
15
|
+
@threshold = threshold
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def format(summary)
|
|
19
|
+
return nil if summary.total.zero?
|
|
20
|
+
return nil if summary.errors.zero?
|
|
21
|
+
|
|
22
|
+
rate = summary.errors.to_f / summary.total
|
|
23
|
+
return nil if rate <= @threshold
|
|
24
|
+
|
|
25
|
+
pct = (rate * 100).round(1)
|
|
26
|
+
"! High error rate: #{summary.errors}/#{summary.total} (#{pct}%) mutations errored — " \
|
|
27
|
+
"score may be unreliable. See the \"Errored mutations:\" section for the underlying cause."
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require_relative "../cli"
|
|
4
4
|
require_relative "line_formatters/mutations"
|
|
5
5
|
require_relative "line_formatters/score"
|
|
6
|
+
require_relative "line_formatters/error_rate_warning"
|
|
6
7
|
require_relative "line_formatters/duration"
|
|
7
8
|
require_relative "line_formatters/efficiency"
|
|
8
9
|
require_relative "line_formatters/peak_memory"
|
|
@@ -11,6 +12,7 @@ class Evilution::Reporter::CLI::MetricsBlock
|
|
|
11
12
|
DEFAULT_LINES = [
|
|
12
13
|
Evilution::Reporter::CLI::LineFormatters::Mutations.new,
|
|
13
14
|
Evilution::Reporter::CLI::LineFormatters::Score.new,
|
|
15
|
+
Evilution::Reporter::CLI::LineFormatters::ErrorRateWarning.new,
|
|
14
16
|
Evilution::Reporter::CLI::LineFormatters::Duration.new,
|
|
15
17
|
Evilution::Reporter::CLI::LineFormatters::Efficiency.new,
|
|
16
18
|
Evilution::Reporter::CLI::LineFormatters::PeakMemory.new
|
|
@@ -5,6 +5,7 @@ require "time"
|
|
|
5
5
|
require_relative "suggestion"
|
|
6
6
|
|
|
7
7
|
require_relative "../reporter"
|
|
8
|
+
require_relative "../session/schema"
|
|
8
9
|
|
|
9
10
|
class Evilution::Reporter::JSON
|
|
10
11
|
def initialize(suggest_tests: false, integration: :rspec)
|
|
@@ -19,6 +20,7 @@ class Evilution::Reporter::JSON
|
|
|
19
20
|
|
|
20
21
|
def build_report(summary)
|
|
21
22
|
report = {
|
|
23
|
+
schema_version: Evilution::Session::Schema::CURRENT_VERSION,
|
|
22
24
|
version: Evilution::VERSION,
|
|
23
25
|
timestamp: Time.now.iso8601,
|
|
24
26
|
summary: build_summary(summary),
|
|
@@ -23,28 +23,34 @@ class Evilution::Result::MutationResult
|
|
|
23
23
|
freeze
|
|
24
24
|
end
|
|
25
25
|
|
|
26
|
+
# Positive type checks, not nil checks. EV-s5br / GH #1174: the
|
|
27
|
+
# nil_replacement mutator can swap a nil default into `false` (or some other
|
|
28
|
+
# non-typed value), and `nil?` then returns false, sending a missing method
|
|
29
|
+
# to the wrong receiver and crashing the parent worker. Asking the field
|
|
30
|
+
# explicitly whether it is the expected struct keeps the parent process
|
|
31
|
+
# alive and lets the mutation count as a measured (errored) result.
|
|
26
32
|
def child_rss_kb
|
|
27
|
-
@memory.
|
|
33
|
+
@memory.is_a?(Evilution::Result::MemoryStats) ? @memory.child_rss_kb : nil
|
|
28
34
|
end
|
|
29
35
|
|
|
30
36
|
def memory_delta_kb
|
|
31
|
-
@memory.
|
|
37
|
+
@memory.is_a?(Evilution::Result::MemoryStats) ? @memory.memory_delta_kb : nil
|
|
32
38
|
end
|
|
33
39
|
|
|
34
40
|
def parent_rss_kb
|
|
35
|
-
@memory.
|
|
41
|
+
@memory.is_a?(Evilution::Result::MemoryStats) ? @memory.parent_rss_kb : nil
|
|
36
42
|
end
|
|
37
43
|
|
|
38
44
|
def error_message
|
|
39
|
-
@error.
|
|
45
|
+
@error.is_a?(Evilution::Result::ErrorInfo) ? @error.message : nil
|
|
40
46
|
end
|
|
41
47
|
|
|
42
48
|
def error_class
|
|
43
|
-
@error.
|
|
49
|
+
@error.is_a?(Evilution::Result::ErrorInfo) ? @error.klass : nil
|
|
44
50
|
end
|
|
45
51
|
|
|
46
52
|
def error_backtrace
|
|
47
|
-
@error.
|
|
53
|
+
@error.is_a?(Evilution::Result::ErrorInfo) ? @error.backtrace : nil
|
|
48
54
|
end
|
|
49
55
|
|
|
50
56
|
def killed?
|
|
@@ -55,7 +55,11 @@ class Evilution::Runner::BaselineRunner
|
|
|
55
55
|
return nil unless config.baseline? && subjects.any?
|
|
56
56
|
|
|
57
57
|
log_start
|
|
58
|
-
baseline = Evilution::Baseline.new(
|
|
58
|
+
baseline = Evilution::Baseline.new(
|
|
59
|
+
timeout: config.timeout,
|
|
60
|
+
test_files: config.spec_files.empty? ? nil : config.spec_files,
|
|
61
|
+
**integration_class.baseline_options
|
|
62
|
+
)
|
|
59
63
|
result = baseline.call(subjects)
|
|
60
64
|
log_complete(result)
|
|
61
65
|
result
|
|
@@ -4,6 +4,7 @@ require_relative "../runner"
|
|
|
4
4
|
require_relative "../isolation/fork"
|
|
5
5
|
require_relative "../isolation/in_process"
|
|
6
6
|
require_relative "../rails_detector"
|
|
7
|
+
require_relative "../gem_detector"
|
|
7
8
|
|
|
8
9
|
class Evilution::Runner::IsolationResolver
|
|
9
10
|
PRELOAD_CANDIDATES = [
|
|
@@ -108,18 +109,52 @@ class Evilution::Runner::IsolationResolver
|
|
|
108
109
|
end
|
|
109
110
|
|
|
110
111
|
def resolve_preload_path
|
|
111
|
-
return
|
|
112
|
+
return resolve_explicit_with_fallback(config.preload) if config.preload.is_a?(String)
|
|
112
113
|
|
|
113
114
|
resolve_autodetected_preload
|
|
114
115
|
end
|
|
115
116
|
|
|
116
|
-
|
|
117
|
-
|
|
117
|
+
# Explicit preload path resolution with auto-detect fallthrough under :fork.
|
|
118
|
+
# When the user-configured path is missing, surface a stderr warning naming
|
|
119
|
+
# the missing path and try the auto-detect chain so a stale .evilution.yml
|
|
120
|
+
# entry doesn't silently disable preloading. Fallthrough requires both
|
|
121
|
+
# :fork isolation AND a detected Rails root (otherwise the chain has nowhere
|
|
122
|
+
# to look and the explicit-missing error is raised directly).
|
|
123
|
+
def resolve_explicit_with_fallback(explicit)
|
|
124
|
+
return explicit if File.file?(explicit)
|
|
118
125
|
|
|
119
|
-
|
|
126
|
+
raise_explicit_preload_missing(explicit) unless can_fallthrough_to_autodetect?
|
|
127
|
+
|
|
128
|
+
warn_missing_explicit_preload(explicit)
|
|
129
|
+
fallback = find_first_existing_candidate
|
|
130
|
+
return fallback if fallback
|
|
131
|
+
|
|
132
|
+
raise build_combined_missing_error(explicit)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def can_fallthrough_to_autodetect?
|
|
136
|
+
resolve_isolation == :fork && !detected_rails_root.nil?
|
|
120
137
|
end
|
|
121
138
|
|
|
122
139
|
def resolve_autodetected_preload
|
|
140
|
+
if detected_rails_root
|
|
141
|
+
fallback = find_first_existing_candidate
|
|
142
|
+
return fallback if fallback
|
|
143
|
+
|
|
144
|
+
raise Evilution::ConfigError, autodetect_missing_message
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
detected_gem_entry
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def detected_gem_entry
|
|
151
|
+
return @detected_gem_entry if defined?(@detected_gem_entry)
|
|
152
|
+
|
|
153
|
+
root = Evilution::GemDetector.gem_root_for_any(target_files)
|
|
154
|
+
@detected_gem_entry = root && Evilution::GemDetector.gem_entry_for(root, target_paths: target_files)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def find_first_existing_candidate
|
|
123
158
|
root = detected_rails_root
|
|
124
159
|
return nil unless root
|
|
125
160
|
|
|
@@ -127,11 +162,37 @@ class Evilution::Runner::IsolationResolver
|
|
|
127
162
|
abs = File.join(root, rel)
|
|
128
163
|
return abs if File.file?(abs)
|
|
129
164
|
end
|
|
165
|
+
nil
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def autodetect_missing_message
|
|
169
|
+
"Preload file not found. Tried: [#{PRELOAD_CANDIDATES.join(", ")}]. " \
|
|
170
|
+
"Pass --preload <file> or set preload: in .evilution.yml. " \
|
|
171
|
+
"Use --no-preload (or preload: false) to disable preloading entirely."
|
|
172
|
+
end
|
|
130
173
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
174
|
+
def build_combined_missing_error(explicit)
|
|
175
|
+
Evilution::ConfigError.new(
|
|
176
|
+
"Preload file not found. Configured preload #{explicit.inspect} does not exist, " \
|
|
177
|
+
"and none of the auto-detect candidates exist either. " \
|
|
178
|
+
"Tried: [#{PRELOAD_CANDIDATES.join(", ")}]. " \
|
|
179
|
+
"Pass --preload <file> or set preload: in .evilution.yml. " \
|
|
180
|
+
"Use --no-preload (or preload: false) to disable preloading entirely.",
|
|
181
|
+
file: explicit
|
|
182
|
+
)
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def raise_explicit_preload_missing(path)
|
|
186
|
+
raise Evilution::ConfigError.new("preload file not found: #{path.inspect}", file: path)
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def warn_missing_explicit_preload(path)
|
|
190
|
+
return if config.quiet
|
|
191
|
+
|
|
192
|
+
$stderr.write(
|
|
193
|
+
"[evilution] warning: configured preload #{path.inspect} not found; " \
|
|
194
|
+
"falling through to auto-detect chain.\n"
|
|
195
|
+
)
|
|
135
196
|
end
|
|
136
197
|
|
|
137
198
|
# When the user explicitly requests InProcess on a Rails project, warn once
|
|
@@ -27,7 +27,8 @@ class Evilution::Runner::MutationPlanner
|
|
|
27
27
|
|
|
28
28
|
def call(subjects)
|
|
29
29
|
generation = generate(subjects)
|
|
30
|
-
|
|
30
|
+
deduped = deduplicate(generation.mutations)
|
|
31
|
+
disabled_filter = filter_disabled(deduped)
|
|
31
32
|
disabled_mutations = compute_disabled_mutations(disabled_filter)
|
|
32
33
|
sig_filter = filter_sig_blocks(disabled_filter.enabled)
|
|
33
34
|
equivalent_filter = filter_equivalent(sig_filter.enabled)
|
|
@@ -59,6 +60,22 @@ class Evilution::Runner::MutationPlanner
|
|
|
59
60
|
)
|
|
60
61
|
end
|
|
61
62
|
|
|
63
|
+
# Two operators can independently emit the same byte-level mutation
|
|
64
|
+
# (statement_deletion deleting a trailing literal AND last_expression_removal
|
|
65
|
+
# producing the same result, for example). Running both is wasted compute
|
|
66
|
+
# and inflates the denominator. Key is (file_path, mutated_source) — two
|
|
67
|
+
# mutations producing the same resulting source are operationally identical
|
|
68
|
+
# regardless of operator name. Order-stable: first occurrence wins, so
|
|
69
|
+
# operator-name ordering in the registry determines which name surfaces.
|
|
70
|
+
def deduplicate(mutations)
|
|
71
|
+
seen = {}
|
|
72
|
+
mutations.each do |mutation|
|
|
73
|
+
key = [mutation.file_path, mutation.mutated_source]
|
|
74
|
+
seen[key] ||= mutation
|
|
75
|
+
end
|
|
76
|
+
seen.values
|
|
77
|
+
end
|
|
78
|
+
|
|
62
79
|
def generate(subjects)
|
|
63
80
|
filter = build_ignore_filter
|
|
64
81
|
operator_options = build_operator_options
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../session"
|
|
4
|
+
|
|
5
|
+
module Evilution::Session::Schema
|
|
6
|
+
CURRENT_VERSION = 1
|
|
7
|
+
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
# Validates the schema_version of a parsed session JSON Hash.
|
|
11
|
+
#
|
|
12
|
+
# Sessions written before schema_version was introduced (key absent
|
|
13
|
+
# entirely) are treated as CURRENT_VERSION — the JSON shape that defined
|
|
14
|
+
# version 1. A key that is explicitly present but null/non-positive/non-
|
|
15
|
+
# integer is rejected as invalid; "missing" and "corrupted" must not
|
|
16
|
+
# collapse into the same lenient bucket. A schema_version newer than this
|
|
17
|
+
# gem supports raises Evilution::Error with an explicit "upgrade the gem"
|
|
18
|
+
# message so future writers cannot be silently misread.
|
|
19
|
+
def validate!(data, source: nil)
|
|
20
|
+
return unless data.key?("schema_version") || data.key?(:schema_version)
|
|
21
|
+
|
|
22
|
+
raw = data.fetch("schema_version") { data[:schema_version] }
|
|
23
|
+
raise_invalid!(raw, source) unless raw.is_a?(Integer) && raw.positive?
|
|
24
|
+
return if raw <= CURRENT_VERSION
|
|
25
|
+
|
|
26
|
+
raise_future!(raw, source)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def raise_invalid!(value, source)
|
|
30
|
+
raise Evilution::Error,
|
|
31
|
+
"invalid schema_version #{value.inspect}#{location_clause(source)}: must be a positive Integer"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def raise_future!(value, source)
|
|
35
|
+
raise Evilution::Error,
|
|
36
|
+
"session file#{location_clause(source)} has schema_version #{value}, " \
|
|
37
|
+
"newer than this evilution gem supports (current: #{CURRENT_VERSION}). " \
|
|
38
|
+
"Upgrade the evilution gem."
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def location_clause(source)
|
|
42
|
+
source ? " at #{source}" : ""
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -6,6 +6,7 @@ require "time"
|
|
|
6
6
|
require "fileutils"
|
|
7
7
|
|
|
8
8
|
require_relative "../session"
|
|
9
|
+
require_relative "schema"
|
|
9
10
|
|
|
10
11
|
class Evilution::Session::Store
|
|
11
12
|
DEFAULT_DIR = ".evilution/results"
|
|
@@ -38,7 +39,9 @@ class Evilution::Session::Store
|
|
|
38
39
|
def load(path)
|
|
39
40
|
raise Evilution::Error, "session file not found: #{path}" unless File.exist?(path)
|
|
40
41
|
|
|
41
|
-
JSON.parse(File.read(path))
|
|
42
|
+
data = JSON.parse(File.read(path))
|
|
43
|
+
Evilution::Session::Schema.validate!(data, source: path) if data.is_a?(Hash)
|
|
44
|
+
data
|
|
42
45
|
end
|
|
43
46
|
|
|
44
47
|
def gc(older_than:)
|
|
@@ -60,6 +63,7 @@ class Evilution::Session::Store
|
|
|
60
63
|
|
|
61
64
|
def build_session_data(summary, now)
|
|
62
65
|
{
|
|
66
|
+
schema_version: Evilution::Session::Schema::CURRENT_VERSION,
|
|
63
67
|
version: Evilution::VERSION,
|
|
64
68
|
timestamp: now.iso8601,
|
|
65
69
|
git: git_context,
|
data/lib/evilution/version.rb
CHANGED
data/lib/evilution.rb
CHANGED
|
@@ -94,6 +94,8 @@ require_relative "evilution/mutator/operator/equality_to_identity"
|
|
|
94
94
|
require_relative "evilution/mutator/operator/lambda_body"
|
|
95
95
|
require_relative "evilution/mutator/operator/begin_unwrap"
|
|
96
96
|
require_relative "evilution/mutator/operator/block_param_removal"
|
|
97
|
+
require_relative "evilution/mutator/operator/last_expression_removal"
|
|
98
|
+
require_relative "evilution/mutator/operator/argument_method_call_replacement"
|
|
97
99
|
require_relative "evilution/mutator/registry"
|
|
98
100
|
require_relative "evilution/equivalent"
|
|
99
101
|
require_relative "evilution/equivalent/heuristic"
|