evilution 0.28.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 +106 -0
- data/.rubocop_todo.yml +7 -0
- data/CHANGELOG.md +49 -0
- data/README.md +194 -8
- data/docs/versioning.md +53 -0
- data/lib/evilution/ast/constant_names.rb +28 -11
- data/lib/evilution/ast/heredoc_span.rb +99 -0
- data/lib/evilution/ast/pattern/parser.rb +29 -17
- data/lib/evilution/baseline.rb +15 -2
- data/lib/evilution/cli/commands/compare.rb +13 -0
- data/lib/evilution/cli/commands/session_diff.rb +6 -4
- data/lib/evilution/cli/commands/subjects.rb +6 -3
- data/lib/evilution/cli/commands/util_mutation.rb +24 -19
- data/lib/evilution/cli/parser/command_extractor.rb +12 -12
- data/lib/evilution/cli/parser/file_args.rb +3 -1
- data/lib/evilution/cli/parser/options_builder.rb +31 -3
- data/lib/evilution/cli/parser/stdin_reader.rb +2 -2
- data/lib/evilution/cli/parser.rb +18 -20
- data/lib/evilution/cli/printers/environment.rb +19 -19
- data/lib/evilution/cli/printers/session_diff.rb +8 -8
- data/lib/evilution/compare/normalizer.rb +10 -5
- data/lib/evilution/config/file_loader.rb +40 -1
- data/lib/evilution/config.rb +21 -11
- data/lib/evilution/disable_comment.rb +21 -12
- 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 +35 -15
- data/lib/evilution/integration/loading/redefinition_recovery.rb +58 -1
- data/lib/evilution/integration/minitest.rb +60 -16
- data/lib/evilution/integration/rspec/result_builder.rb +20 -1
- data/lib/evilution/integration/rspec.rb +20 -1
- data/lib/evilution/isolation/fork.rb +104 -27
- data/lib/evilution/mcp/info_tool/actions/subjects.rb +32 -23
- data/lib/evilution/mcp/info_tool/actions/tests.rb +22 -12
- data/lib/evilution/mcp/info_tool/request_parser.rb +3 -1
- data/lib/evilution/mcp/info_tool/response_formatter.rb +14 -1
- data/lib/evilution/mcp/info_tool.rb +10 -2
- data/lib/evilution/mcp/mutate_tool/option_parser.rb +4 -2
- data/lib/evilution/mcp/mutate_tool/report_trimmer.rb +58 -1
- data/lib/evilution/mcp/mutate_tool/survived_enricher.rb +19 -9
- data/lib/evilution/mcp/mutate_tool.rb +49 -17
- data/lib/evilution/mcp/session_tool.rb +34 -22
- data/lib/evilution/mcp.rb +6 -0
- data/lib/evilution/mutation.rb +26 -16
- data/lib/evilution/mutator/base.rb +66 -16
- data/lib/evilution/mutator/operator/argument_method_call_replacement.rb +59 -0
- data/lib/evilution/mutator/operator/argument_nil_substitution.rb +11 -14
- data/lib/evilution/mutator/operator/argument_removal.rb +11 -14
- data/lib/evilution/mutator/operator/begin_unwrap.rb +17 -5
- data/lib/evilution/mutator/operator/bitwise_complement.rb +26 -19
- data/lib/evilution/mutator/operator/block_param_removal.rb +50 -8
- data/lib/evilution/mutator/operator/block_pass_removal.rb +19 -15
- data/lib/evilution/mutator/operator/case_when.rb +7 -5
- data/lib/evilution/mutator/operator/conditional_branch.rb +22 -22
- data/lib/evilution/mutator/operator/equality_to_identity.rb +8 -3
- data/lib/evilution/mutator/operator/explicit_super_mutation.rb +36 -14
- data/lib/evilution/mutator/operator/index_to_at.rb +18 -5
- data/lib/evilution/mutator/operator/index_to_dig.rb +12 -6
- data/lib/evilution/mutator/operator/index_to_fetch.rb +5 -4
- data/lib/evilution/mutator/operator/keyword_argument.rb +30 -25
- data/lib/evilution/mutator/operator/last_expression_removal.rb +46 -0
- data/lib/evilution/mutator/operator/mixin_removal.rb +20 -14
- data/lib/evilution/mutator/operator/multiple_assignment.rb +12 -13
- data/lib/evilution/mutator/operator/receiver_replacement.rb +38 -7
- data/lib/evilution/mutator/operator/regex_simplification.rb +62 -67
- data/lib/evilution/mutator/operator/rescue_body_replacement.rb +9 -8
- data/lib/evilution/mutator/operator/rescue_removal.rb +58 -12
- data/lib/evilution/mutator/operator/splat_operator.rb +28 -1
- data/lib/evilution/mutator/operator/string_literal.rb +83 -6
- data/lib/evilution/mutator/operator/superclass_removal.rb +21 -15
- data/lib/evilution/mutator/registry.rb +2 -0
- data/lib/evilution/parallel/work_queue/dispatcher.rb +15 -8
- data/lib/evilution/parallel/work_queue/worker.rb +10 -7
- data/lib/evilution/parallel/work_queue.rb +35 -18
- data/lib/evilution/reporter/cli/item_formatters/coverage_gap.rb +13 -8
- data/lib/evilution/reporter/cli/line_formatters/error_rate_warning.rb +29 -0
- data/lib/evilution/reporter/cli/line_formatters/mutations.rb +17 -8
- data/lib/evilution/reporter/cli/metrics_block.rb +2 -0
- data/lib/evilution/reporter/json.rb +54 -18
- data/lib/evilution/reporter/suggestion/diff_helpers.rb +0 -13
- data/lib/evilution/reporter/suggestion/diff_lines.rb +28 -0
- data/lib/evilution/reporter/suggestion/templates/minitest.rb +20 -14
- data/lib/evilution/reporter/suggestion/templates/rspec.rb +19 -13
- data/lib/evilution/result/mutation_result.rb +12 -6
- data/lib/evilution/runner/baseline_runner.rb +20 -9
- data/lib/evilution/runner/diagnostics.rb +13 -9
- data/lib/evilution/runner/isolation_resolver.rb +75 -12
- data/lib/evilution/runner/mutation_executor/result_cache.rb +3 -1
- data/lib/evilution/runner/mutation_executor/strategy/parallel.rb +32 -10
- data/lib/evilution/runner/mutation_executor/strategy/sequential.rb +1 -1
- data/lib/evilution/runner/mutation_executor.rb +2 -0
- data/lib/evilution/runner/mutation_planner.rb +53 -16
- data/lib/evilution/runner/subject_pipeline.rb +21 -11
- data/lib/evilution/runner.rb +3 -3
- data/lib/evilution/session/diff.rb +15 -6
- data/lib/evilution/session/schema.rb +44 -0
- data/lib/evilution/session/store.rb +5 -1
- data/lib/evilution/spec_ast_cache.rb +26 -12
- 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/memory_check +11 -5
- data/script/run_self_baseline +79 -0
- data/script/run_self_validation +54 -0
- data/scripts/benchmark_density +10 -9
- data/scripts/compare_mutations +38 -21
- data/scripts/mutant_json_adapter +7 -4
- metadata +16 -2
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../feedback"
|
|
4
|
+
|
|
5
|
+
# Detects "setup misconfiguration" patterns where mutation testing returned a
|
|
6
|
+
# result that is technically valid (score 0.0 with all-errored mutations) but
|
|
7
|
+
# is almost certainly the wrong score because the worker process couldn't
|
|
8
|
+
# evaluate any mutated source.
|
|
9
|
+
#
|
|
10
|
+
# Most common cause: MCP runs default to `preload: false` to keep the long-lived
|
|
11
|
+
# MCP server clean. Rails / Zeitwerk projects that depend on autoload then fail
|
|
12
|
+
# in every worker with `NameError: uninitialized constant ...`. The user sees
|
|
13
|
+
# "0% PASS-but-FAIL" with no obvious hint that they need to pass an explicit
|
|
14
|
+
# `preload: spec/rails_helper.rb` option.
|
|
15
|
+
#
|
|
16
|
+
# When triggered, this returns a warning string the MCP response can surface
|
|
17
|
+
# alongside the trimmed report — turning a silent wrong score into a loud
|
|
18
|
+
# pointer at the likely fix.
|
|
19
|
+
module Evilution::Feedback::SetupWarning
|
|
20
|
+
module_function
|
|
21
|
+
|
|
22
|
+
ERROR_DOMINANCE_THRESHOLD = 0.8
|
|
23
|
+
ERROR_CLASS_CLUSTER_THRESHOLD = 0.8
|
|
24
|
+
|
|
25
|
+
def call(summary)
|
|
26
|
+
return nil if summary.nil?
|
|
27
|
+
return nil unless errors_dominate?(summary)
|
|
28
|
+
|
|
29
|
+
errored = summary.results.select(&:error?)
|
|
30
|
+
dominant_class = dominant_error_class(errored)
|
|
31
|
+
return nil unless dominant_class
|
|
32
|
+
|
|
33
|
+
message_for(dominant_class, errored.size, summary.total)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def errors_dominate?(summary)
|
|
37
|
+
return false if summary.total.zero?
|
|
38
|
+
|
|
39
|
+
summary.errors.to_f / summary.total >= ERROR_DOMINANCE_THRESHOLD
|
|
40
|
+
end
|
|
41
|
+
private_class_method :errors_dominate?
|
|
42
|
+
|
|
43
|
+
def dominant_error_class(errored_results)
|
|
44
|
+
return nil if errored_results.empty?
|
|
45
|
+
|
|
46
|
+
counts = errored_results.each_with_object(Hash.new(0)) do |result, acc|
|
|
47
|
+
acc[result.error_class] += 1 if result.error_class
|
|
48
|
+
end
|
|
49
|
+
return nil if counts.empty?
|
|
50
|
+
|
|
51
|
+
klass, count = counts.max_by { |_, v| v }
|
|
52
|
+
return nil if count.to_f / errored_results.size < ERROR_CLASS_CLUSTER_THRESHOLD
|
|
53
|
+
|
|
54
|
+
klass
|
|
55
|
+
end
|
|
56
|
+
private_class_method :dominant_error_class
|
|
57
|
+
|
|
58
|
+
NAME_ERROR_HINT = "Most mutations errored with NameError. This usually means autoloaded constants " \
|
|
59
|
+
"(Rails / Zeitwerk) weren't available when the mutation re-evaluated the source. " \
|
|
60
|
+
"Pass `preload: 'spec/rails_helper.rb'` (or your project's preload entry) so the " \
|
|
61
|
+
"MCP server requires it before forking workers."
|
|
62
|
+
|
|
63
|
+
LOAD_ERROR_HINT = "Most mutations errored with LoadError. A `require` in the mutated source path " \
|
|
64
|
+
"failed before any test ran. Check that the file's dependencies are reachable from " \
|
|
65
|
+
"the MCP server's load path, or pass `preload: '<entrypoint>'` to set them up."
|
|
66
|
+
|
|
67
|
+
GENERIC_HINT_TEMPLATE = "Most mutations errored with %<klass>s (%<count>d / %<total>d). The mutation " \
|
|
68
|
+
"score reflects this setup failure, not the test suite. Try the CLI for an " \
|
|
69
|
+
"independent reading, or pass `preload: '<path>'` if the failure is autoload-related."
|
|
70
|
+
|
|
71
|
+
def message_for(klass, count, total)
|
|
72
|
+
case klass.to_s
|
|
73
|
+
when "NameError" then NAME_ERROR_HINT
|
|
74
|
+
when "LoadError" then LOAD_ERROR_HINT
|
|
75
|
+
else format(GENERIC_HINT_TEMPLATE, klass: klass, count: count, total: total)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
private_class_method :message_for
|
|
79
|
+
end
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Evilution::GemDetector
|
|
4
|
+
@cache = {}
|
|
5
|
+
@mutex = Mutex.new
|
|
6
|
+
|
|
7
|
+
class << self
|
|
8
|
+
def gem_root_for(path)
|
|
9
|
+
return nil if path.nil?
|
|
10
|
+
|
|
11
|
+
dir = starting_dir(path)
|
|
12
|
+
return nil if dir.nil?
|
|
13
|
+
|
|
14
|
+
@mutex.synchronize do
|
|
15
|
+
return @cache[dir] if @cache.key?(dir)
|
|
16
|
+
|
|
17
|
+
@cache[dir] = walk_up(dir)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def gem_root_for_any(paths)
|
|
22
|
+
Array(paths).each do |path|
|
|
23
|
+
root = gem_root_for(path)
|
|
24
|
+
return root if root
|
|
25
|
+
end
|
|
26
|
+
nil
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def gem_entry_for(root, target_paths: nil)
|
|
30
|
+
gem_name = gem_name_for(root, target_paths: target_paths)
|
|
31
|
+
return nil unless gem_name
|
|
32
|
+
|
|
33
|
+
dotted = File.join(root, "lib", "#{gem_name.tr("-", "/")}.rb")
|
|
34
|
+
return dotted if File.file?(dotted)
|
|
35
|
+
|
|
36
|
+
flat = File.join(root, "lib", "#{gem_name}.rb")
|
|
37
|
+
return flat if File.file?(flat)
|
|
38
|
+
|
|
39
|
+
nil
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def reset_cache!
|
|
43
|
+
@mutex.synchronize { @cache.clear }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def starting_dir(path)
|
|
49
|
+
return File.expand_path(path) if File.directory?(path)
|
|
50
|
+
return File.expand_path(File.dirname(path)) if File.file?(path)
|
|
51
|
+
|
|
52
|
+
parent = File.expand_path(File.dirname(path))
|
|
53
|
+
File.directory?(parent) ? parent : nil
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def walk_up(dir)
|
|
57
|
+
current = dir
|
|
58
|
+
loop do
|
|
59
|
+
return current unless Dir.glob(File.join(current, "*.gemspec")).empty?
|
|
60
|
+
|
|
61
|
+
parent = File.dirname(current)
|
|
62
|
+
return nil if parent == current
|
|
63
|
+
|
|
64
|
+
current = parent
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# When the root has multiple gemspecs (e.g. dotenv ships dotenv.gemspec
|
|
69
|
+
# alongside dotenv-rails.gemspec), `Dir.glob.first` is filesystem-order-
|
|
70
|
+
# dependent and often picks the wrong one — preloading the rails companion
|
|
71
|
+
# then raises `uninitialized constant Rails`. Disambiguate by:
|
|
72
|
+
# 1. exact-entry match — if a target is *exactly* the lib entry for a
|
|
73
|
+
# gemspec (`lib/dotenv/rails.rb` for `dotenv-rails.gemspec`), use it
|
|
74
|
+
# 2. first-lib-subdir match — `lib/dotenv/parser.rb` matches `dotenv`
|
|
75
|
+
# 3. fall back to the shortest gemspec basename — `dotenv` <
|
|
76
|
+
# `dotenv-rails`, which is the conventional "parent" gem.
|
|
77
|
+
def gem_name_for(root, target_paths: nil)
|
|
78
|
+
names = Dir.glob(File.join(root, "*.gemspec")).map { |p| File.basename(p, ".gemspec") }
|
|
79
|
+
return nil if names.empty?
|
|
80
|
+
return names.first if names.length == 1
|
|
81
|
+
|
|
82
|
+
paths = Array(target_paths)
|
|
83
|
+
match_by_exact_entry(root, names, paths) ||
|
|
84
|
+
match_by_subdir(root, names, paths) ||
|
|
85
|
+
names.min_by(&:length)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def match_by_exact_entry(root, names, paths)
|
|
89
|
+
paths.each do |path|
|
|
90
|
+
next if path.nil?
|
|
91
|
+
|
|
92
|
+
expanded = File.expand_path(path)
|
|
93
|
+
match = names.find { |n| entry_paths_for(root, n).include?(expanded) }
|
|
94
|
+
return match if match
|
|
95
|
+
end
|
|
96
|
+
nil
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def entry_paths_for(root, gem_name)
|
|
100
|
+
[
|
|
101
|
+
File.join(root, "lib", "#{gem_name.tr("-", "/")}.rb"),
|
|
102
|
+
File.join(root, "lib", "#{gem_name}.rb")
|
|
103
|
+
]
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def match_by_subdir(root, names, paths)
|
|
107
|
+
paths.each do |path|
|
|
108
|
+
subdir = lib_subdir_for(root, path)
|
|
109
|
+
next if subdir.nil?
|
|
110
|
+
|
|
111
|
+
match = names.find { |n| n == subdir }
|
|
112
|
+
return match if match
|
|
113
|
+
end
|
|
114
|
+
nil
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# For `<root>/lib/dotenv/parser.rb` returns "dotenv". For
|
|
118
|
+
# `<root>/lib/dotenv-rails.rb` returns "dotenv-rails". Returns nil when
|
|
119
|
+
# the target isn't under `<root>/lib/`.
|
|
120
|
+
def lib_subdir_for(root, path)
|
|
121
|
+
return nil if path.nil?
|
|
122
|
+
|
|
123
|
+
expanded = File.expand_path(path)
|
|
124
|
+
lib_root = File.join(File.expand_path(root), "lib")
|
|
125
|
+
return nil unless expanded.start_with?("#{lib_root}/")
|
|
126
|
+
|
|
127
|
+
relative = expanded[(lib_root.length + 1)..]
|
|
128
|
+
first_segment = relative.split("/", 2).first
|
|
129
|
+
File.basename(first_segment, ".rb")
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
require_relative "../loading"
|
|
5
|
+
|
|
6
|
+
# Strip non-idempotent class/module-body side-effect calls from a mutated
|
|
7
|
+
# source before re-eval. Such calls (e.g. dry-monads `register_mixin`, plugin
|
|
8
|
+
# registries) raise on second invocation because they assume single-eval
|
|
9
|
+
# semantics. The first invocation already ran in the parent process during
|
|
10
|
+
# preload — the child fork inherits the resulting state, so re-running them
|
|
11
|
+
# is wasted work that aborts the eval before the mutated method takes effect.
|
|
12
|
+
#
|
|
13
|
+
# Strategy: walk Prism tree, find CallNodes that sit directly under a class
|
|
14
|
+
# or module body (not inside a def). Calls on a small allowlist of patterns
|
|
15
|
+
# known to be idempotent (`include`, `attr_*`, visibility modifiers, etc.)
|
|
16
|
+
# are preserved; everything else is replaced byte-for-byte with `nil`.
|
|
17
|
+
class Evilution::Integration::Loading::BodyCallNeutralizer
|
|
18
|
+
IDEMPOTENT_CALLS = %i[
|
|
19
|
+
include extend prepend using
|
|
20
|
+
attr_reader attr_writer attr_accessor
|
|
21
|
+
private public protected module_function private_class_method public_class_method
|
|
22
|
+
alias_method
|
|
23
|
+
define_method define_singleton_method
|
|
24
|
+
delegate
|
|
25
|
+
require require_relative autoload
|
|
26
|
+
].to_set.freeze
|
|
27
|
+
|
|
28
|
+
class << self
|
|
29
|
+
attr_writer :preloaded_features
|
|
30
|
+
|
|
31
|
+
# Snapshot of `$LOADED_FEATURES` captured at parent preload-end (or lazily
|
|
32
|
+
# initialised on first access). Forks inherit this set via copy-on-write,
|
|
33
|
+
# so worker processes see the same membership the parent saw when it
|
|
34
|
+
# finished its preload phase. Frozen so in-place mutation cannot silently
|
|
35
|
+
# change neutralization semantics or force child forks to copy the page.
|
|
36
|
+
def preloaded_features
|
|
37
|
+
@preloaded_features ||= $LOADED_FEATURES.to_set.freeze
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def reset_preload_snapshot!
|
|
41
|
+
@preloaded_features = nil
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# `file_path` (optional) lets callers gate neutralization on whether the
|
|
46
|
+
# target file was actually preloaded into the parent. The neutralizer's
|
|
47
|
+
# premise — "this body has already run once, re-running it would double-
|
|
48
|
+
# register" — only holds when the parent loaded the file. Lazy-loaded
|
|
49
|
+
# plugin files (e.g. roda's `lib/roda/plugins/typecast_params.rb`, which
|
|
50
|
+
# the gem only requires when the user opts in via `plugin :typecast_params`)
|
|
51
|
+
# are first-loaded inside the child fork, so neutralizing their DSL calls
|
|
52
|
+
# strips method definitions that subsequent sibling statements (alias, etc.)
|
|
53
|
+
# depend on, producing cascading NameError. Callers that don't pass a path
|
|
54
|
+
# get the legacy always-neutralize behavior.
|
|
55
|
+
def call(source, file_path: nil)
|
|
56
|
+
return source if file_path && !preloaded?(file_path)
|
|
57
|
+
|
|
58
|
+
result = Prism.parse(source)
|
|
59
|
+
return source if result.failure?
|
|
60
|
+
|
|
61
|
+
edits = collect_edits(result.value)
|
|
62
|
+
return source if edits.empty?
|
|
63
|
+
|
|
64
|
+
apply_edits(source, edits)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
def preloaded?(file_path)
|
|
70
|
+
self.class.preloaded_features.include?(File.expand_path(file_path))
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def collect_edits(tree)
|
|
74
|
+
edits = []
|
|
75
|
+
walker = Walker.new(IDEMPOTENT_CALLS, edits)
|
|
76
|
+
walker.visit(tree)
|
|
77
|
+
edits
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def apply_edits(source, edits)
|
|
81
|
+
bytes = source.b
|
|
82
|
+
edits.sort_by!(&:first).reverse_each do |start_offset, end_offset|
|
|
83
|
+
bytes[start_offset, end_offset - start_offset] = "nil"
|
|
84
|
+
end
|
|
85
|
+
bytes.force_encoding(source.encoding)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
class Walker < Prism::Visitor
|
|
89
|
+
def initialize(allowlist, edits)
|
|
90
|
+
super()
|
|
91
|
+
@allowlist = allowlist
|
|
92
|
+
@edits = edits
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def visit_class_node(node)
|
|
96
|
+
scan_body(node.body)
|
|
97
|
+
super
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def visit_module_node(node)
|
|
101
|
+
scan_body(node.body)
|
|
102
|
+
super
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def visit_singleton_class_node(node)
|
|
106
|
+
scan_body(node.body)
|
|
107
|
+
super
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
private
|
|
111
|
+
|
|
112
|
+
# Examine top-level statements in a class/module body. Only direct-child
|
|
113
|
+
# CallNodes are candidates for neutralization. Calls nested inside any
|
|
114
|
+
# other expression (constant assignments, conditionals, etc.) are left
|
|
115
|
+
# alone — neutralizing them would break the surrounding expression.
|
|
116
|
+
# Nested class/module bodies are walked through the normal visitor.
|
|
117
|
+
def scan_body(body_node)
|
|
118
|
+
return unless body_node.is_a?(Prism::StatementsNode)
|
|
119
|
+
|
|
120
|
+
body_node.body.each do |stmt|
|
|
121
|
+
next unless stmt.is_a?(Prism::CallNode)
|
|
122
|
+
next if @allowlist.include?(stmt.name)
|
|
123
|
+
next if stmt.receiver && !stmt.receiver.is_a?(Prism::SelfNode)
|
|
124
|
+
|
|
125
|
+
@edits << [stmt.location.start_offset, replacement_end_offset(stmt)]
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Prism CallNode location ends at the close of the call syntax (e.g. the
|
|
130
|
+
# closing `)` or the `<<~MARKER` opener for a heredoc argument). It does
|
|
131
|
+
# NOT include the heredoc body lines or the trailing terminator. Replacing
|
|
132
|
+
# only the CallNode range leaves the heredoc body orphaned, producing a
|
|
133
|
+
# parse error. Extend the range to cover any heredoc terminators inside
|
|
134
|
+
# the call.
|
|
135
|
+
def replacement_end_offset(call)
|
|
136
|
+
collector = HeredocEndCollector.new
|
|
137
|
+
collector.visit(call)
|
|
138
|
+
[call.location.end_offset, *collector.end_offsets].max
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
private_constant :Walker
|
|
142
|
+
|
|
143
|
+
class HeredocEndCollector < Prism::Visitor
|
|
144
|
+
attr_reader :end_offsets
|
|
145
|
+
|
|
146
|
+
def initialize
|
|
147
|
+
super
|
|
148
|
+
@end_offsets = []
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def visit_string_node(node)
|
|
152
|
+
record_if_heredoc(node)
|
|
153
|
+
super
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def visit_interpolated_string_node(node)
|
|
157
|
+
record_if_heredoc(node)
|
|
158
|
+
super
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def visit_interpolated_x_string_node(node)
|
|
162
|
+
record_if_heredoc(node)
|
|
163
|
+
super
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def visit_x_string_node(node)
|
|
167
|
+
record_if_heredoc(node)
|
|
168
|
+
super
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
private
|
|
172
|
+
|
|
173
|
+
def record_if_heredoc(node)
|
|
174
|
+
return unless node.respond_to?(:heredoc?) && node.heredoc?
|
|
175
|
+
|
|
176
|
+
closing = node.closing_loc
|
|
177
|
+
return unless closing
|
|
178
|
+
|
|
179
|
+
# Prism's heredoc closing_loc includes the leading whitespace and the
|
|
180
|
+
# trailing newline of the terminator line (e.g. " CODE\n"). Excluding
|
|
181
|
+
# that newline preserves line structure so subsequent code lands on its
|
|
182
|
+
# own line after the replacement.
|
|
183
|
+
end_off = closing.end_offset
|
|
184
|
+
slice = closing.slice
|
|
185
|
+
end_off -= 1 if slice && slice.end_with?("\n")
|
|
186
|
+
@end_offsets << end_off
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
private_constant :HeredocEndCollector
|
|
190
|
+
end
|
|
@@ -10,7 +10,15 @@ require_relative "redefinition_recovery"
|
|
|
10
10
|
# Composes the load-time pipeline that applies a mutation's new source to the
|
|
11
11
|
# running VM: syntax-validate -> pin top-level constants (beats Zeitwerk) ->
|
|
12
12
|
# clear AS::Concern state -> eval inside a redefinition-recovery wrapper.
|
|
13
|
-
#
|
|
13
|
+
# The eval target is mutation.eval_source, which Mutator::Base pre-populates
|
|
14
|
+
# with the neutralized form (non-idempotent class-body calls replaced with
|
|
15
|
+
# `nil`). The neutralization itself happens once at mutation-generation time
|
|
16
|
+
# rather than per-iter — SyntaxValidator still runs Prism per mutation, but
|
|
17
|
+
# the extra neutralizer parse stays out of the hot path. Falls back to
|
|
18
|
+
# mutation.mutated_source when no pre-eval transform was attached.
|
|
19
|
+
# RedefinitionRecovery stays as a safety net for cases the neutralizer's
|
|
20
|
+
# allowlist heuristic misses. Returns nil on success or a failure-shaped
|
|
21
|
+
# hash on any error.
|
|
14
22
|
class Evilution::Integration::Loading::MutationApplier
|
|
15
23
|
def initialize(syntax_validator: Evilution::Integration::Loading::SyntaxValidator.new,
|
|
16
24
|
constant_pinner: Evilution::Integration::Loading::ConstantPinner.new,
|
|
@@ -25,28 +33,40 @@ class Evilution::Integration::Loading::MutationApplier
|
|
|
25
33
|
end
|
|
26
34
|
|
|
27
35
|
def call(mutation)
|
|
28
|
-
|
|
36
|
+
eval_target = resolve_eval_target(mutation)
|
|
37
|
+
syntax_error = @syntax_validator.call(eval_target)
|
|
29
38
|
return syntax_error if syntax_error
|
|
30
39
|
|
|
40
|
+
apply(mutation, eval_target)
|
|
41
|
+
nil
|
|
42
|
+
rescue SyntaxError => e
|
|
43
|
+
failure_result(e, "syntax error in mutated source: #{e.message}")
|
|
44
|
+
rescue ScriptError, StandardError => e
|
|
45
|
+
failure_result(e, "#{e.class}: #{e.message}")
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def resolve_eval_target(mutation)
|
|
51
|
+
return mutation.eval_source if mutation.respond_to?(:eval_source)
|
|
52
|
+
|
|
53
|
+
mutation.mutated_source
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def apply(mutation, eval_target)
|
|
31
57
|
@constant_pinner.call(mutation.original_source)
|
|
32
58
|
@concern_state_cleaner.call(mutation.file_path)
|
|
33
59
|
@redefinition_recovery.call(mutation.original_source) do
|
|
34
|
-
@source_evaluator.call(
|
|
60
|
+
@source_evaluator.call(eval_target, mutation.file_path)
|
|
35
61
|
end
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
passed: false,
|
|
40
|
-
error: "syntax error in mutated source: #{e.message}",
|
|
41
|
-
error_class: e.class.name,
|
|
42
|
-
error_backtrace: Array(e.backtrace).first(5)
|
|
43
|
-
}
|
|
44
|
-
rescue ScriptError, StandardError => e
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def failure_result(error, message)
|
|
45
65
|
{
|
|
46
66
|
passed: false,
|
|
47
|
-
error:
|
|
48
|
-
error_class:
|
|
49
|
-
error_backtrace: Array(
|
|
67
|
+
error: message,
|
|
68
|
+
error_class: error.class.name,
|
|
69
|
+
error_backtrace: Array(error.backtrace).first(5)
|
|
50
70
|
}
|
|
51
71
|
end
|
|
52
72
|
end
|
|
@@ -6,7 +6,22 @@ require_relative "../../ast/constant_names"
|
|
|
6
6
|
# Some DSLs (Rails 8 enum, define_method guards) raise ArgumentError on
|
|
7
7
|
# re-declaration. On such a conflict we strip constants declared in the source
|
|
8
8
|
# and retry the load once against a fresh namespace.
|
|
9
|
+
#
|
|
10
|
+
# A second class of idempotency violation comes from gem-internal registries
|
|
11
|
+
# (dry-monads `register_mixin`, Rails plugins, etc.) which raise when called
|
|
12
|
+
# a second time in the same process. For these we swallow the error: the
|
|
13
|
+
# class body executed up to the raise point — method defs preceding the
|
|
14
|
+
# registry call are already in place — and any state change blocked by the
|
|
15
|
+
# guard was intentional duplicate-prevention from the gem's side. Mutations
|
|
16
|
+
# that target a def *after* such a class-body call would not be applied;
|
|
17
|
+
# emit a one-shot warning so that mode is visible.
|
|
9
18
|
class Evilution::Integration::Loading::RedefinitionRecovery
|
|
19
|
+
IDEMPOTENCY_PATTERNS = [
|
|
20
|
+
"already registered",
|
|
21
|
+
"already initialized",
|
|
22
|
+
"already exists"
|
|
23
|
+
].freeze
|
|
24
|
+
|
|
10
25
|
def initialize(constant_names: Evilution::AST::ConstantNames.new)
|
|
11
26
|
@constant_names = constant_names
|
|
12
27
|
end
|
|
@@ -14,8 +29,28 @@ class Evilution::Integration::Loading::RedefinitionRecovery
|
|
|
14
29
|
def call(source, &block)
|
|
15
30
|
block.call
|
|
16
31
|
rescue ArgumentError => e
|
|
17
|
-
|
|
32
|
+
if redefinition_conflict?(e)
|
|
33
|
+
remove_defined_constants(source)
|
|
34
|
+
block.call
|
|
35
|
+
elsif idempotency_violation?(e)
|
|
36
|
+
warn_once_for(e)
|
|
37
|
+
nil
|
|
38
|
+
else
|
|
39
|
+
raise
|
|
40
|
+
end
|
|
41
|
+
rescue TypeError => e
|
|
42
|
+
raise unless superclass_mismatch?(e)
|
|
18
43
|
|
|
44
|
+
# `class X < Struct.new(...)` (or Data.define / Class.new in the same
|
|
45
|
+
# position) returns a fresh anonymous parent class on every call, so the
|
|
46
|
+
# recorded superclass of the existing X differs from the re-eval's. Ruby
|
|
47
|
+
# raises TypeError. Simply swallowing would leave the *original* class
|
|
48
|
+
# in place and silently report the mutation as survived — a false
|
|
49
|
+
# negative. Instead, strip the constants this source declares and retry
|
|
50
|
+
# exactly once, mirroring the ArgumentError 'already defined' path. If
|
|
51
|
+
# the retry still mismatches (genuine inheritance conflict the mutation
|
|
52
|
+
# cannot resolve), propagate so the mutation reports :error rather than
|
|
53
|
+
# being silently miscounted.
|
|
19
54
|
remove_defined_constants(source)
|
|
20
55
|
block.call
|
|
21
56
|
end
|
|
@@ -26,6 +61,28 @@ class Evilution::Integration::Loading::RedefinitionRecovery
|
|
|
26
61
|
error.message.include?("already defined")
|
|
27
62
|
end
|
|
28
63
|
|
|
64
|
+
def idempotency_violation?(error)
|
|
65
|
+
msg = error.message
|
|
66
|
+
IDEMPOTENCY_PATTERNS.any? { |pat| msg.include?(pat) }
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def superclass_mismatch?(error)
|
|
70
|
+
error.message.include?("superclass mismatch")
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def warn_once_for(error)
|
|
74
|
+
return if @warned_messages&.include?(error.message)
|
|
75
|
+
|
|
76
|
+
@warned_messages ||= []
|
|
77
|
+
@warned_messages << error.message
|
|
78
|
+
$stderr.write(
|
|
79
|
+
"[evilution] swallowed idempotency violation on re-eval: " \
|
|
80
|
+
"#{error.class}: #{error.message}. " \
|
|
81
|
+
"Method defs preceding the raise point were re-applied; " \
|
|
82
|
+
"mutations targeting code after the raise will not take effect.\n"
|
|
83
|
+
)
|
|
84
|
+
end
|
|
85
|
+
|
|
29
86
|
def remove_defined_constants(source)
|
|
30
87
|
@constant_names.call(source).reverse_each do |name|
|
|
31
88
|
parent_name, _, local_name = name.rpartition("::")
|
|
@@ -10,22 +10,61 @@ require_relative "../integration"
|
|
|
10
10
|
|
|
11
11
|
class Evilution::Integration::Minitest < Evilution::Integration::Base
|
|
12
12
|
def self.baseline_runner
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
13
|
+
->(test_file) { run_baseline_test_file(test_file) }
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def self.run_baseline_test_file(test_file)
|
|
17
|
+
require "minitest"
|
|
18
|
+
require "stringio"
|
|
19
|
+
::Minitest::Runnable.runnables.clear
|
|
20
|
+
baseline_test_files(test_file).each { |f| load(File.expand_path(f)) }
|
|
21
|
+
run_baseline_minitest
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def self.baseline_test_files(test_file)
|
|
25
|
+
File.directory?(test_file) ? Dir.glob(File.join(test_file, "**/*_test.rb")) : [test_file]
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def self.run_baseline_minitest
|
|
29
|
+
out = StringIO.new
|
|
30
|
+
options = ::Minitest.process_args(["--seed", "0"])
|
|
31
|
+
options[:io] = out
|
|
32
|
+
reporter = ::Minitest::CompositeReporter.new
|
|
33
|
+
reporter << ::Minitest::SummaryReporter.new(out, options)
|
|
34
|
+
initialize_minitest_state(reporter, options)
|
|
35
|
+
reporter.start
|
|
36
|
+
dispatch_minitest_suites(reporter, options)
|
|
37
|
+
reporter.report
|
|
38
|
+
reporter.passed?
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Mirror Minitest.run's preamble: seed setup + plugin init. Without seeding
|
|
42
|
+
# Minitest.seed before dispatching suites, Minitest 5.x raises
|
|
43
|
+
# `TypeError: no implicit conversion of nil into Integer` from
|
|
44
|
+
# Minitest::Test.runnable_methods calling `srand(Minitest.seed)` on nil.
|
|
45
|
+
# init_plugins also needs Minitest.reporter set first because some plugins
|
|
46
|
+
# (pride) read it during init.
|
|
47
|
+
def self.initialize_minitest_state(reporter, options)
|
|
48
|
+
::Minitest.seed = options[:seed]
|
|
49
|
+
srand(::Minitest.seed) if ::Minitest.seed
|
|
50
|
+
|
|
51
|
+
::Minitest.reporter = reporter
|
|
52
|
+
::Minitest.init_plugins(options) if ::Minitest.respond_to?(:init_plugins)
|
|
53
|
+
::Minitest.reporter = nil
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Dispatch to the version-appropriate suite runner. Minitest 6 removed
|
|
57
|
+
# ::Minitest.__run; the equivalent public entry point is run_all_suites.
|
|
58
|
+
# Minitest 5.x still exposes __run.
|
|
59
|
+
def self.dispatch_minitest_suites(reporter, options)
|
|
60
|
+
if ::Minitest.respond_to?(:run_all_suites)
|
|
61
|
+
::Minitest.run_all_suites(reporter, options)
|
|
62
|
+
elsif ::Minitest.respond_to?(:__run)
|
|
25
63
|
::Minitest.__run(reporter, options)
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
64
|
+
else
|
|
65
|
+
raise Evilution::Error,
|
|
66
|
+
"Minitest #{::Minitest::VERSION} has neither run_all_suites nor __run"
|
|
67
|
+
end
|
|
29
68
|
end
|
|
30
69
|
|
|
31
70
|
def self.baseline_options
|
|
@@ -107,13 +146,18 @@ class Evilution::Integration::Minitest < Evilution::Integration::Base
|
|
|
107
146
|
reporter << ::Minitest::SummaryReporter.new(out, options)
|
|
108
147
|
reporter << detector
|
|
109
148
|
|
|
149
|
+
self.class.initialize_minitest_state(reporter, options)
|
|
110
150
|
reporter.start
|
|
111
|
-
|
|
151
|
+
dispatch_minitest_suites(reporter, options)
|
|
112
152
|
reporter.report
|
|
113
153
|
|
|
114
154
|
reporter.passed?
|
|
115
155
|
end
|
|
116
156
|
|
|
157
|
+
def dispatch_minitest_suites(reporter, options)
|
|
158
|
+
self.class.dispatch_minitest_suites(reporter, options)
|
|
159
|
+
end
|
|
160
|
+
|
|
117
161
|
def reset_crash_detector
|
|
118
162
|
if @crash_detector
|
|
119
163
|
@crash_detector.reset
|
|
@@ -21,7 +21,7 @@ class Evilution::Integration::RSpec::ResultBuilder
|
|
|
21
21
|
}
|
|
22
22
|
end
|
|
23
23
|
|
|
24
|
-
def from_run(status, command, detector)
|
|
24
|
+
def from_run(status, command, detector, examples_loaded: nil)
|
|
25
25
|
return { passed: true, test_command: command } if status.zero?
|
|
26
26
|
|
|
27
27
|
if detector.only_crashes?
|
|
@@ -35,6 +35,25 @@ class Evilution::Integration::RSpec::ResultBuilder
|
|
|
35
35
|
}
|
|
36
36
|
end
|
|
37
37
|
|
|
38
|
+
# Nonzero exit + zero examples loaded = the spec file did not register any
|
|
39
|
+
# examples (load error, autoload mismatch, etc.), so nothing ran against
|
|
40
|
+
# the mutation. Surfacing this as a generic fail would let classify_status
|
|
41
|
+
# fall through to its :killed default and silently inflate the kill count
|
|
42
|
+
# even though no example ever observed the mutation (EV-720r: macOS Rails
|
|
43
|
+
# users hit autoload / fail_if_no_examples paths that yielded killed=100%
|
|
44
|
+
# with empty worker output). Note: this checks LOADED count, not executed
|
|
45
|
+
# count — filters/skip/--fail-fast can leave loaded > executed, but the
|
|
46
|
+
# "spec failed to load entirely" case is the failure mode this guard targets.
|
|
47
|
+
if !examples_loaded.nil? && examples_loaded.zero?
|
|
48
|
+
return {
|
|
49
|
+
passed: false,
|
|
50
|
+
error: "RSpec exited #{status} but loaded 0 examples — no examples ran against the mutation. " \
|
|
51
|
+
"Likely the spec file failed to load, --spec was misrouted, or RSpec is configured " \
|
|
52
|
+
"with fail_if_no_examples. The mutation cannot be counted as killed.",
|
|
53
|
+
test_command: command
|
|
54
|
+
}
|
|
55
|
+
end
|
|
56
|
+
|
|
38
57
|
{ passed: false, test_command: command }
|
|
39
58
|
end
|
|
40
59
|
end
|