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.
Files changed (112) hide show
  1. checksums.yaml +4 -4
  2. data/.beads/interactions.jsonl +106 -0
  3. data/.rubocop_todo.yml +7 -0
  4. data/CHANGELOG.md +49 -0
  5. data/README.md +194 -8
  6. data/docs/versioning.md +53 -0
  7. data/lib/evilution/ast/constant_names.rb +28 -11
  8. data/lib/evilution/ast/heredoc_span.rb +99 -0
  9. data/lib/evilution/ast/pattern/parser.rb +29 -17
  10. data/lib/evilution/baseline.rb +15 -2
  11. data/lib/evilution/cli/commands/compare.rb +13 -0
  12. data/lib/evilution/cli/commands/session_diff.rb +6 -4
  13. data/lib/evilution/cli/commands/subjects.rb +6 -3
  14. data/lib/evilution/cli/commands/util_mutation.rb +24 -19
  15. data/lib/evilution/cli/parser/command_extractor.rb +12 -12
  16. data/lib/evilution/cli/parser/file_args.rb +3 -1
  17. data/lib/evilution/cli/parser/options_builder.rb +31 -3
  18. data/lib/evilution/cli/parser/stdin_reader.rb +2 -2
  19. data/lib/evilution/cli/parser.rb +18 -20
  20. data/lib/evilution/cli/printers/environment.rb +19 -19
  21. data/lib/evilution/cli/printers/session_diff.rb +8 -8
  22. data/lib/evilution/compare/normalizer.rb +10 -5
  23. data/lib/evilution/config/file_loader.rb +40 -1
  24. data/lib/evilution/config.rb +21 -11
  25. data/lib/evilution/disable_comment.rb +21 -12
  26. data/lib/evilution/equivalent/heuristic/dead_code.rb +8 -1
  27. data/lib/evilution/feedback/setup_warning.rb +79 -0
  28. data/lib/evilution/gem_detector.rb +132 -0
  29. data/lib/evilution/integration/loading/body_call_neutralizer.rb +190 -0
  30. data/lib/evilution/integration/loading/mutation_applier.rb +35 -15
  31. data/lib/evilution/integration/loading/redefinition_recovery.rb +58 -1
  32. data/lib/evilution/integration/minitest.rb +60 -16
  33. data/lib/evilution/integration/rspec/result_builder.rb +20 -1
  34. data/lib/evilution/integration/rspec.rb +20 -1
  35. data/lib/evilution/isolation/fork.rb +104 -27
  36. data/lib/evilution/mcp/info_tool/actions/subjects.rb +32 -23
  37. data/lib/evilution/mcp/info_tool/actions/tests.rb +22 -12
  38. data/lib/evilution/mcp/info_tool/request_parser.rb +3 -1
  39. data/lib/evilution/mcp/info_tool/response_formatter.rb +14 -1
  40. data/lib/evilution/mcp/info_tool.rb +10 -2
  41. data/lib/evilution/mcp/mutate_tool/option_parser.rb +4 -2
  42. data/lib/evilution/mcp/mutate_tool/report_trimmer.rb +58 -1
  43. data/lib/evilution/mcp/mutate_tool/survived_enricher.rb +19 -9
  44. data/lib/evilution/mcp/mutate_tool.rb +49 -17
  45. data/lib/evilution/mcp/session_tool.rb +34 -22
  46. data/lib/evilution/mcp.rb +6 -0
  47. data/lib/evilution/mutation.rb +26 -16
  48. data/lib/evilution/mutator/base.rb +66 -16
  49. data/lib/evilution/mutator/operator/argument_method_call_replacement.rb +59 -0
  50. data/lib/evilution/mutator/operator/argument_nil_substitution.rb +11 -14
  51. data/lib/evilution/mutator/operator/argument_removal.rb +11 -14
  52. data/lib/evilution/mutator/operator/begin_unwrap.rb +17 -5
  53. data/lib/evilution/mutator/operator/bitwise_complement.rb +26 -19
  54. data/lib/evilution/mutator/operator/block_param_removal.rb +50 -8
  55. data/lib/evilution/mutator/operator/block_pass_removal.rb +19 -15
  56. data/lib/evilution/mutator/operator/case_when.rb +7 -5
  57. data/lib/evilution/mutator/operator/conditional_branch.rb +22 -22
  58. data/lib/evilution/mutator/operator/equality_to_identity.rb +8 -3
  59. data/lib/evilution/mutator/operator/explicit_super_mutation.rb +36 -14
  60. data/lib/evilution/mutator/operator/index_to_at.rb +18 -5
  61. data/lib/evilution/mutator/operator/index_to_dig.rb +12 -6
  62. data/lib/evilution/mutator/operator/index_to_fetch.rb +5 -4
  63. data/lib/evilution/mutator/operator/keyword_argument.rb +30 -25
  64. data/lib/evilution/mutator/operator/last_expression_removal.rb +46 -0
  65. data/lib/evilution/mutator/operator/mixin_removal.rb +20 -14
  66. data/lib/evilution/mutator/operator/multiple_assignment.rb +12 -13
  67. data/lib/evilution/mutator/operator/receiver_replacement.rb +38 -7
  68. data/lib/evilution/mutator/operator/regex_simplification.rb +62 -67
  69. data/lib/evilution/mutator/operator/rescue_body_replacement.rb +9 -8
  70. data/lib/evilution/mutator/operator/rescue_removal.rb +58 -12
  71. data/lib/evilution/mutator/operator/splat_operator.rb +28 -1
  72. data/lib/evilution/mutator/operator/string_literal.rb +83 -6
  73. data/lib/evilution/mutator/operator/superclass_removal.rb +21 -15
  74. data/lib/evilution/mutator/registry.rb +2 -0
  75. data/lib/evilution/parallel/work_queue/dispatcher.rb +15 -8
  76. data/lib/evilution/parallel/work_queue/worker.rb +10 -7
  77. data/lib/evilution/parallel/work_queue.rb +35 -18
  78. data/lib/evilution/reporter/cli/item_formatters/coverage_gap.rb +13 -8
  79. data/lib/evilution/reporter/cli/line_formatters/error_rate_warning.rb +29 -0
  80. data/lib/evilution/reporter/cli/line_formatters/mutations.rb +17 -8
  81. data/lib/evilution/reporter/cli/metrics_block.rb +2 -0
  82. data/lib/evilution/reporter/json.rb +54 -18
  83. data/lib/evilution/reporter/suggestion/diff_helpers.rb +0 -13
  84. data/lib/evilution/reporter/suggestion/diff_lines.rb +28 -0
  85. data/lib/evilution/reporter/suggestion/templates/minitest.rb +20 -14
  86. data/lib/evilution/reporter/suggestion/templates/rspec.rb +19 -13
  87. data/lib/evilution/result/mutation_result.rb +12 -6
  88. data/lib/evilution/runner/baseline_runner.rb +20 -9
  89. data/lib/evilution/runner/diagnostics.rb +13 -9
  90. data/lib/evilution/runner/isolation_resolver.rb +75 -12
  91. data/lib/evilution/runner/mutation_executor/result_cache.rb +3 -1
  92. data/lib/evilution/runner/mutation_executor/strategy/parallel.rb +32 -10
  93. data/lib/evilution/runner/mutation_executor/strategy/sequential.rb +1 -1
  94. data/lib/evilution/runner/mutation_executor.rb +2 -0
  95. data/lib/evilution/runner/mutation_planner.rb +53 -16
  96. data/lib/evilution/runner/subject_pipeline.rb +21 -11
  97. data/lib/evilution/runner.rb +3 -3
  98. data/lib/evilution/session/diff.rb +15 -6
  99. data/lib/evilution/session/schema.rb +44 -0
  100. data/lib/evilution/session/store.rb +5 -1
  101. data/lib/evilution/spec_ast_cache.rb +26 -12
  102. data/lib/evilution/version.rb +1 -1
  103. data/lib/evilution.rb +2 -0
  104. data/schema/evilution.config.schema.json +205 -0
  105. data/script/build_runtime_snapshot +88 -0
  106. data/script/memory_check +11 -5
  107. data/script/run_self_baseline +79 -0
  108. data/script/run_self_validation +54 -0
  109. data/scripts/benchmark_density +10 -9
  110. data/scripts/compare_mutations +38 -21
  111. data/scripts/mutant_json_adapter +7 -4
  112. 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
- # Returns nil on success or a failure-shaped hash on any error.
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
- syntax_error = @syntax_validator.call(mutation.mutated_source)
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(mutation.mutated_source, mutation.file_path)
60
+ @source_evaluator.call(eval_target, mutation.file_path)
35
61
  end
36
- nil
37
- rescue SyntaxError => e
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: "#{e.class}: #{e.message}",
48
- error_class: e.class.name,
49
- error_backtrace: Array(e.backtrace).first(5)
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
- raise unless redefinition_conflict?(e)
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
- lambda { |test_file|
14
- require "minitest"
15
- require "stringio"
16
- ::Minitest::Runnable.runnables.clear
17
- files = File.directory?(test_file) ? Dir.glob(File.join(test_file, "**/*_test.rb")) : [test_file]
18
- files.each { |f| load(File.expand_path(f)) }
19
- out = StringIO.new
20
- options = ::Minitest.process_args(["--seed", "0"])
21
- options[:io] = out
22
- reporter = ::Minitest::CompositeReporter.new
23
- reporter << ::Minitest::SummaryReporter.new(out, options)
24
- reporter.start
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
- reporter.report
27
- reporter.passed?
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
- ::Minitest.__run(reporter, options)
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