evilution 0.24.0 → 0.25.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 (69) hide show
  1. checksums.yaml +4 -4
  2. data/.beads/interactions.jsonl +205 -0
  3. data/CHANGELOG.md +35 -0
  4. data/README.md +80 -4
  5. data/exe/evil +6 -0
  6. data/lib/evilution/ast/source_surgeon.rb +15 -1
  7. data/lib/evilution/cli/commands/compare.rb +68 -0
  8. data/lib/evilution/cli/parser/command_extractor.rb +2 -1
  9. data/lib/evilution/cli/parser/options_builder.rb +21 -1
  10. data/lib/evilution/cli/printers/compare.rb +159 -0
  11. data/lib/evilution/cli.rb +1 -0
  12. data/lib/evilution/compare/categorizer.rb +109 -0
  13. data/lib/evilution/compare/detector.rb +21 -0
  14. data/lib/evilution/compare/fingerprint.rb +83 -0
  15. data/lib/evilution/compare/normalizer.rb +106 -0
  16. data/lib/evilution/compare/record.rb +16 -0
  17. data/lib/evilution/compare.rb +15 -0
  18. data/lib/evilution/config.rb +165 -3
  19. data/lib/evilution/example_filter.rb +143 -0
  20. data/lib/evilution/integration/crash_detector.rb +5 -2
  21. data/lib/evilution/integration/minitest.rb +10 -5
  22. data/lib/evilution/integration/minitest_crash_detector.rb +5 -2
  23. data/lib/evilution/integration/rspec.rb +82 -7
  24. data/lib/evilution/isolation/fork.rb +25 -0
  25. data/lib/evilution/mcp/info_tool.rb +77 -5
  26. data/lib/evilution/mcp/mutate_tool/config_builder.rb +20 -0
  27. data/lib/evilution/mcp/mutate_tool/error_payload.rb +17 -0
  28. data/lib/evilution/mcp/mutate_tool/option_parser.rb +54 -0
  29. data/lib/evilution/mcp/mutate_tool/progress_streamer.rb +37 -0
  30. data/lib/evilution/mcp/mutate_tool/report_trimmer.rb +31 -0
  31. data/lib/evilution/mcp/mutate_tool/survived_enricher.rb +52 -0
  32. data/lib/evilution/mcp/mutate_tool.rb +34 -186
  33. data/lib/evilution/mutation.rb +43 -3
  34. data/lib/evilution/mutator/base.rb +39 -1
  35. data/lib/evilution/mutator/operator/argument_nil_substitution.rb +5 -1
  36. data/lib/evilution/mutator/operator/argument_removal.rb +5 -1
  37. data/lib/evilution/parallel/work_queue.rb +149 -31
  38. data/lib/evilution/parallel_db_warning.rb +68 -0
  39. data/lib/evilution/reporter/cli.rb +37 -11
  40. data/lib/evilution/reporter/html/assets/style.css +17 -0
  41. data/lib/evilution/reporter/html/sections/file_section.rb +15 -0
  42. data/lib/evilution/reporter/html/sections/neutral_details.rb +25 -0
  43. data/lib/evilution/reporter/html/sections/unparseable_details.rb +25 -0
  44. data/lib/evilution/reporter/html/sections/unresolved_details.rb +25 -0
  45. data/lib/evilution/reporter/html/templates/file_section.html.erb +3 -0
  46. data/lib/evilution/reporter/html/templates/neutral_details.html.erb +14 -0
  47. data/lib/evilution/reporter/html/templates/summary_cards.html.erb +3 -0
  48. data/lib/evilution/reporter/html/templates/unparseable_details.html.erb +11 -0
  49. data/lib/evilution/reporter/html/templates/unresolved_details.html.erb +11 -0
  50. data/lib/evilution/reporter/json.rb +8 -2
  51. data/lib/evilution/reporter/suggestion/diff_helpers.rb +28 -0
  52. data/lib/evilution/reporter/suggestion/registry.rb +64 -0
  53. data/lib/evilution/reporter/suggestion/templates/generic.rb +55 -0
  54. data/lib/evilution/reporter/suggestion/templates/minitest.rb +659 -0
  55. data/lib/evilution/reporter/suggestion/templates/rspec.rb +613 -0
  56. data/lib/evilution/reporter/suggestion.rb +8 -1327
  57. data/lib/evilution/result/mutation_result.rb +5 -1
  58. data/lib/evilution/result/summary.rb +13 -1
  59. data/lib/evilution/runner/baseline_runner.rb +23 -2
  60. data/lib/evilution/runner/mutation_executor.rb +83 -13
  61. data/lib/evilution/runner.rb +6 -0
  62. data/lib/evilution/source_ast_cache.rb +39 -0
  63. data/lib/evilution/spec_ast_cache.rb +166 -0
  64. data/lib/evilution/spec_resolver.rb +6 -1
  65. data/lib/evilution/spec_selector.rb +39 -0
  66. data/lib/evilution/temp_dir_tracker.rb +23 -3
  67. data/lib/evilution/version.rb +1 -1
  68. data/script/memory_check +7 -5
  69. metadata +34 -2
@@ -1,6 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "yaml"
4
+ require_relative "spec_resolver"
5
+ require_relative "spec_selector"
4
6
 
5
7
  class Evilution::Config
6
8
  CONFIG_FILES = %w[.evilution.yml config/evilution.yml].freeze
@@ -29,20 +31,31 @@ class Evilution::Config
29
31
  skip_heredoc_literals: false,
30
32
  related_specs_heuristic: false,
31
33
  fallback_to_full_suite: false,
32
- preload: nil
34
+ preload: nil,
35
+ spec_mappings: {},
36
+ spec_pattern: nil,
37
+ example_targeting: true,
38
+ example_targeting_fallback: :full_file,
39
+ example_targeting_cache: { max_files: 50, max_blocks: 10_000 }
33
40
  }.freeze
34
41
 
42
+ EXAMPLE_TARGETING_FALLBACKS = %i[full_file unresolved].freeze
43
+ private_constant :EXAMPLE_TARGETING_FALLBACKS
44
+
35
45
  attr_reader :target_files, :timeout, :format,
36
46
  :target, :min_score, :integration, :verbose, :quiet,
37
47
  :jobs, :fail_fast, :baseline, :isolation, :incremental, :suggest_tests,
38
48
  :progress, :save_session, :line_ranges, :spec_files, :hooks,
39
49
  :ignore_patterns, :show_disabled, :baseline_session,
40
50
  :skip_heredoc_literals, :related_specs_heuristic,
41
- :fallback_to_full_suite, :preload
51
+ :fallback_to_full_suite, :preload, :spec_mappings, :spec_pattern,
52
+ :example_targeting, :example_targeting_fallback, :example_targeting_cache,
53
+ :spec_selector
42
54
 
43
55
  def initialize(**options)
44
56
  file_options = options.delete(:skip_config_file) ? {} : load_config_file
45
- merged = DEFAULTS.merge(file_options).merge(options)
57
+ env_options = load_env_options
58
+ merged = DEFAULTS.merge(file_options).merge(env_options).merge(options)
46
59
  assign_attributes(merged)
47
60
  freeze
48
61
  end
@@ -103,6 +116,10 @@ class Evilution::Config
103
116
  related_specs_heuristic
104
117
  end
105
118
 
119
+ def example_targeting?
120
+ example_targeting
121
+ end
122
+
106
123
  def fallback_to_full_suite?
107
124
  fallback_to_full_suite
108
125
  end
@@ -177,6 +194,23 @@ class Evilution::Config
177
194
  # worker_process_start: config/evilution_hooks/worker_start.rb
178
195
  # mutation_insert_pre: config/evilution_hooks/mutation_pre.rb
179
196
 
197
+ # Per-mutation example targeting (default: true). When enabled, Evilution
198
+ # parses resolved spec files and restricts each mutation run to examples
199
+ # whose bodies reference the mutated method/class token. Set to false
200
+ # to run every example in the resolved spec files. You can also disable
201
+ # without editing the file by exporting EV_DISABLE_EXAMPLE_TARGETING=1.
202
+ # example_targeting: true
203
+
204
+ # Behavior when targeting finds no matching example (default: full_file).
205
+ # full_file - run every example in the resolved spec files
206
+ # unresolved - mark the mutation :unresolved and skip
207
+ # example_targeting_fallback: full_file
208
+
209
+ # LRU cache bounds for the spec AST parser that powers example targeting.
210
+ # example_targeting_cache:
211
+ # max_files: 50
212
+ # max_blocks: 10000
213
+
180
214
  # AST patterns to skip during mutation generation (default: [])
181
215
  # See docs/ast_pattern_syntax.md for pattern syntax
182
216
  # ignore_patterns:
@@ -225,6 +259,88 @@ class Evilution::Config
225
259
  @fallback_to_full_suite = merged[:fallback_to_full_suite]
226
260
  @hooks = validate_hooks(merged[:hooks])
227
261
  @preload = validate_preload(merged[:preload])
262
+ @spec_mappings = validate_spec_mappings(merged[:spec_mappings])
263
+ @spec_pattern = validate_spec_pattern(merged[:spec_pattern])
264
+ assign_example_targeting(merged)
265
+ @spec_selector = build_spec_selector
266
+ end
267
+
268
+ def assign_example_targeting(merged)
269
+ @example_targeting = merged[:example_targeting] ? true : false
270
+ @example_targeting_fallback = validate_example_targeting_fallback(merged[:example_targeting_fallback])
271
+ @example_targeting_cache = validate_example_targeting_cache(merged[:example_targeting_cache])
272
+ end
273
+
274
+ def build_spec_selector
275
+ Evilution::SpecSelector.new(
276
+ spec_files: @spec_files,
277
+ spec_mappings: @spec_mappings,
278
+ spec_pattern: @spec_pattern,
279
+ spec_resolver: build_spec_resolver
280
+ )
281
+ end
282
+
283
+ def build_spec_resolver
284
+ case @integration
285
+ when :minitest
286
+ Evilution::SpecResolver.new(test_dir: "test", test_suffix: "_test.rb", request_dir: "integration")
287
+ else
288
+ Evilution::SpecResolver.new
289
+ end
290
+ end
291
+
292
+ def validate_spec_mappings(value)
293
+ return {} if value.nil?
294
+
295
+ raise Evilution::ConfigError, "spec_mappings must be a Hash, got #{value.class}" unless value.is_a?(Hash)
296
+
297
+ normalized = value.each_with_object({}) do |(source, specs), acc|
298
+ key = normalize_spec_mappings_key(source)
299
+ acc[key] = normalize_spec_mappings_value(key, specs)
300
+ end
301
+
302
+ warn_missing_spec_mappings(normalized)
303
+ normalized
304
+ end
305
+
306
+ def normalize_spec_mappings_key(source)
307
+ key = source.to_s
308
+ key = key.delete_prefix("#{Dir.pwd}/") if key.start_with?("/")
309
+ key.delete_prefix("./")
310
+ end
311
+
312
+ def normalize_spec_mappings_value(source, specs)
313
+ case specs
314
+ when String then [specs]
315
+ when Array
316
+ specs.each do |entry|
317
+ unless entry.is_a?(String)
318
+ raise Evilution::ConfigError,
319
+ "spec_mappings[#{source.inspect}] entries must be string paths, got #{entry.class}"
320
+ end
321
+ end
322
+ specs
323
+ else
324
+ raise Evilution::ConfigError,
325
+ "spec_mappings[#{source.inspect}] must be a string or array of strings, got #{specs.class}"
326
+ end
327
+ end
328
+
329
+ def warn_missing_spec_mappings(mappings)
330
+ mappings.each do |source, specs|
331
+ specs.each do |spec_path|
332
+ next if File.exist?(spec_path)
333
+
334
+ warn "[evilution] spec_mappings[#{source.inspect}]: #{spec_path} not found, skipping"
335
+ end
336
+ end
337
+ end
338
+
339
+ def validate_spec_pattern(value)
340
+ return nil if value.nil?
341
+ return value if value.is_a?(String)
342
+
343
+ raise Evilution::ConfigError, "spec_pattern must be nil or a String glob, got #{value.class}"
228
344
  end
229
345
 
230
346
  def validate_preload(value)
@@ -279,6 +395,52 @@ class Evilution::Config
279
395
  patterns
280
396
  end
281
397
 
398
+ def validate_example_targeting_fallback(value)
399
+ unless value.is_a?(String) || value.is_a?(Symbol)
400
+ raise Evilution::ConfigError,
401
+ "example_targeting_fallback must be full_file or unresolved, got #{value.inspect}"
402
+ end
403
+
404
+ sym = value.to_sym
405
+ unless EXAMPLE_TARGETING_FALLBACKS.include?(sym)
406
+ raise Evilution::ConfigError,
407
+ "example_targeting_fallback must be full_file or unresolved, got #{sym.inspect}"
408
+ end
409
+
410
+ sym
411
+ end
412
+
413
+ def validate_example_targeting_cache(value)
414
+ raise Evilution::ConfigError, "example_targeting_cache must be a Hash, got #{value.class}" unless value.is_a?(Hash)
415
+
416
+ normalized = value.each_with_object({}) do |(k, v), acc|
417
+ unless k.is_a?(String) || k.is_a?(Symbol)
418
+ raise Evilution::ConfigError,
419
+ "example_targeting_cache keys must be Strings or Symbols, got #{k.inspect}"
420
+ end
421
+ acc[k.to_sym] = v
422
+ end
423
+ merged = DEFAULTS[:example_targeting_cache].merge(normalized)
424
+ validate_positive_int!(merged, :max_files)
425
+ validate_positive_int!(merged, :max_blocks)
426
+ merged
427
+ end
428
+
429
+ def validate_positive_int!(cache, key)
430
+ v = cache[key]
431
+ return if v.is_a?(Integer) && v >= 1
432
+
433
+ raise Evilution::ConfigError,
434
+ "example_targeting_cache.#{key} must be a positive integer, got #{v.inspect}"
435
+ end
436
+
437
+ def load_env_options
438
+ opts = {}
439
+ val = ENV.fetch("EV_DISABLE_EXAMPLE_TARGETING", nil)
440
+ opts[:example_targeting] = false if val && !val.empty? && val != "0"
441
+ opts
442
+ end
443
+
282
444
  def validate_hooks(value)
283
445
  return {} if value.nil?
284
446
  raise Evilution::ConfigError, "hooks must be a mapping of event names to file paths, got #{value.class}" unless value.is_a?(Hash)
@@ -0,0 +1,143 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+ require_relative "../evilution"
5
+ require_relative "spec_ast_cache"
6
+
7
+ class Evilution::ExampleFilter
8
+ VALID_FALLBACKS = %i[full_file unresolved].freeze
9
+ private_constant :VALID_FALLBACKS
10
+
11
+ def initialize(cache:, fallback: :full_file, source_cache: nil)
12
+ raise ArgumentError, "invalid fallback: #{fallback.inspect}" unless VALID_FALLBACKS.include?(fallback)
13
+
14
+ @cache = cache
15
+ @fallback = fallback
16
+ @source_cache = source_cache
17
+ end
18
+
19
+ def call(mutation, spec_paths)
20
+ return fallback_result(spec_paths) if spec_paths.nil? || spec_paths.empty?
21
+
22
+ token = extract_token(mutation)
23
+ return fallback_result(spec_paths) unless token
24
+
25
+ locations = scan_specs(token, spec_paths)
26
+ return fallback_result(spec_paths) if locations.empty?
27
+
28
+ locations.sort
29
+ end
30
+
31
+ private
32
+
33
+ def fallback_result(spec_paths)
34
+ case @fallback
35
+ when :full_file then spec_paths
36
+ when :unresolved then nil
37
+ end
38
+ end
39
+
40
+ def extract_token(mutation)
41
+ result = if @source_cache.nil?
42
+ Prism.parse(mutation.original_source)
43
+ else
44
+ @source_cache.fetch(mutation.original_source)
45
+ end
46
+ return nil if result.failure?
47
+
48
+ finder = EnclosingNodeFinder.new(mutation.line)
49
+ finder.visit(result.value)
50
+ finder.token
51
+ end
52
+
53
+ def scan_specs(token, spec_paths)
54
+ pattern = /(?<!\w)#{Regexp.escape(token.downcase)}(?!\w)/
55
+ locations = []
56
+ spec_paths.each do |path|
57
+ blocks = @cache.fetch(path)
58
+ matches = blocks.select { |b| pattern.match?(b.body_text) }
59
+ innermost = filter_innermost(matches)
60
+ innermost.each { |b| locations << "#{path}:#{b.line}" }
61
+ end
62
+ locations.uniq
63
+ end
64
+
65
+ def filter_innermost(matches)
66
+ matches.reject do |outer|
67
+ matches.any? do |inner|
68
+ next false if inner.equal?(outer)
69
+
70
+ contained?(inner, outer)
71
+ end
72
+ end
73
+ end
74
+
75
+ def contained?(inner, outer)
76
+ inner.line >= outer.line && inner.end_line <= outer.end_line &&
77
+ !(inner.line == outer.line && inner.end_line == outer.end_line)
78
+ end
79
+
80
+ class EnclosingNodeFinder < Prism::Visitor
81
+ attr_reader :token
82
+
83
+ def initialize(target_line)
84
+ @target_line = target_line
85
+ @def_stack = []
86
+ @class_stack = []
87
+ @token = nil
88
+ @found = false
89
+ super()
90
+ end
91
+
92
+ def visit_def_node(node)
93
+ return if @found
94
+ return unless target_within?(node)
95
+
96
+ @def_stack.push(node.name.to_s)
97
+ capture_if_match(node)
98
+ super
99
+ @def_stack.pop
100
+ end
101
+
102
+ def visit_class_node(node)
103
+ return if @found
104
+ return unless target_within?(node)
105
+
106
+ @class_stack.push(unqualified_name(node.constant_path))
107
+ capture_if_match(node)
108
+ super
109
+ @class_stack.pop
110
+ end
111
+
112
+ def visit_module_node(node)
113
+ return if @found
114
+ return unless target_within?(node)
115
+
116
+ @class_stack.push(unqualified_name(node.constant_path))
117
+ capture_if_match(node)
118
+ super
119
+ @class_stack.pop
120
+ end
121
+
122
+ private
123
+
124
+ def capture_if_match(node)
125
+ return if @found
126
+ return unless target_within?(node)
127
+
128
+ @token = @def_stack.last || @class_stack.last
129
+ @found = true if @def_stack.any?
130
+ end
131
+
132
+ def target_within?(node)
133
+ loc = node.location
134
+ @target_line.between?(loc.start_line, loc.end_line)
135
+ end
136
+
137
+ def unqualified_name(constant_path)
138
+ raw = constant_path.respond_to?(:name) ? constant_path.name.to_s : constant_path.to_s
139
+ raw.split("::").last
140
+ end
141
+ end
142
+ private_constant :EnclosingNodeFinder
143
+ end
@@ -41,8 +41,11 @@ class Evilution::Integration::CrashDetector
41
41
  def crash_summary
42
42
  return nil if @crashes.empty?
43
43
 
44
- types = @crashes.map { |e| e.class.name }.uniq
45
- "#{types.join(", ")} (#{@crashes.length} crash#{"es" unless @crashes.length == 1})"
44
+ "#{unique_crash_classes.join(", ")} (#{@crashes.length} crash#{"es" unless @crashes.length == 1})"
45
+ end
46
+
47
+ def unique_crash_classes
48
+ @crashes.map { |e| e.class.name }.uniq
46
49
  end
47
50
 
48
51
  private
@@ -4,6 +4,7 @@ require "stringio"
4
4
  require_relative "base"
5
5
  require_relative "minitest_crash_detector"
6
6
  require_relative "../spec_resolver"
7
+ require_relative "../spec_selector"
7
8
 
8
9
  require_relative "../integration"
9
10
 
@@ -35,10 +36,12 @@ class Evilution::Integration::Minitest < Evilution::Integration::Base
35
36
  }
36
37
  end
37
38
 
38
- def initialize(test_files: nil, hooks: nil, fallback_to_full_suite: false)
39
+ def initialize(test_files: nil, hooks: nil, fallback_to_full_suite: false, spec_selector: nil)
39
40
  @test_files = test_files
40
41
  @minitest_loaded = false
41
- @spec_resolver = Evilution::SpecResolver.new(test_dir: "test", test_suffix: "_test.rb", request_dir: "integration")
42
+ @spec_selector = spec_selector || Evilution::SpecSelector.new(
43
+ spec_resolver: Evilution::SpecResolver.new(test_dir: "test", test_suffix: "_test.rb", request_dir: "integration")
44
+ )
42
45
  @fallback_to_full_suite = fallback_to_full_suite
43
46
  @crash_detector = nil
44
47
  @warned_files = Set.new
@@ -124,10 +127,12 @@ class Evilution::Integration::Minitest < Evilution::Integration::Base
124
127
  if passed
125
128
  { passed: true, test_command: command }
126
129
  elsif detector.only_crashes?
130
+ classes = detector.unique_crash_classes
127
131
  {
128
132
  passed: false,
129
133
  test_crashed: true,
130
134
  error: "test crashes: #{detector.crash_summary}",
135
+ error_class: (classes.first if classes.length == 1),
131
136
  test_command: command
132
137
  }
133
138
  else
@@ -138,13 +143,13 @@ class Evilution::Integration::Minitest < Evilution::Integration::Base
138
143
  def resolve_test_files(mutation)
139
144
  return test_files if test_files
140
145
 
141
- resolved = @spec_resolver.call(mutation.file_path)
142
- unless resolved
146
+ resolved = Array(@spec_selector.call(mutation.file_path))
147
+ if resolved.empty?
143
148
  warn_unresolved_test(mutation.file_path)
144
149
  return @fallback_to_full_suite ? glob_test_files : nil
145
150
  end
146
151
 
147
- [resolved]
152
+ resolved
148
153
  end
149
154
 
150
155
  def glob_test_files
@@ -49,7 +49,10 @@ class Evilution::Integration::MinitestCrashDetector
49
49
  def crash_summary
50
50
  return nil if @crashes.empty?
51
51
 
52
- types = @crashes.map { |e| e.class.name }.uniq
53
- "#{types.join(", ")} (#{@crashes.length} crash#{"es" unless @crashes.length == 1})"
52
+ "#{unique_crash_classes.join(", ")} (#{@crashes.length} crash#{"es" unless @crashes.length == 1})"
53
+ end
54
+
55
+ def unique_crash_classes
56
+ @crashes.map { |e| e.class.name }.uniq
54
57
  end
55
58
  end
@@ -4,6 +4,7 @@ require "stringio"
4
4
  require_relative "base"
5
5
  require_relative "crash_detector"
6
6
  require_relative "../spec_resolver"
7
+ require_relative "../spec_selector"
7
8
  require_relative "../related_spec_heuristic"
8
9
 
9
10
  require_relative "../integration"
@@ -26,13 +27,15 @@ class Evilution::Integration::RSpec < Evilution::Integration::Base
26
27
  { runner: baseline_runner }
27
28
  end
28
29
 
29
- def initialize(test_files: nil, hooks: nil, related_specs_heuristic: false, fallback_to_full_suite: false)
30
+ def initialize(test_files: nil, hooks: nil, related_specs_heuristic: false, fallback_to_full_suite: false,
31
+ spec_selector: nil, example_filter: nil)
30
32
  @test_files = test_files
31
33
  @rspec_loaded = false
32
- @spec_resolver = Evilution::SpecResolver.new
34
+ @spec_selector = spec_selector || Evilution::SpecSelector.new
33
35
  @related_spec_heuristic = Evilution::RelatedSpecHeuristic.new
34
36
  @related_specs_heuristic_enabled = related_specs_heuristic
35
37
  @fallback_to_full_suite = fallback_to_full_suite
38
+ @example_filter = example_filter
36
39
  @crash_detector = nil
37
40
  @warned_files = Set.new
38
41
  super(hooks: hooks)
@@ -61,13 +64,18 @@ class Evilution::Integration::RSpec < Evilution::Integration::Base
61
64
  files = resolve_test_files(mutation)
62
65
  return unresolved_result(mutation) if files.nil?
63
66
 
67
+ targets = apply_example_filter(mutation, files)
68
+ return unresolved_example_result(mutation) if targets.nil?
69
+
64
70
  out = StringIO.new
65
71
  err = StringIO.new
66
- args = build_args(files)
72
+ args = build_args(targets)
67
73
  command = "rspec #{args.join(" ")}"
68
74
 
69
75
  detector = reset_crash_detector
70
76
  eg_before = snapshot_example_groups
77
+ fe_before = snapshot_filtered_examples_keys
78
+ rep_before = snapshot_reporter_lengths
71
79
  status = ::RSpec::Core::Runner.run(args, out, err)
72
80
 
73
81
  build_rspec_result(status, command, detector)
@@ -75,12 +83,20 @@ class Evilution::Integration::RSpec < Evilution::Integration::Base
75
83
  { passed: false, error: e.message, test_command: command }
76
84
  ensure
77
85
  release_rspec_state(eg_before)
86
+ release_filtered_examples(fe_before)
87
+ release_reporter_state(rep_before)
78
88
  end
79
89
 
80
90
  def build_args(files)
81
91
  ["--format", "progress", "--no-color", "--order", "defined", *files]
82
92
  end
83
93
 
94
+ def apply_example_filter(mutation, files)
95
+ return files unless @example_filter
96
+
97
+ @example_filter.call(mutation, files)
98
+ end
99
+
84
100
  def unresolved_result(mutation)
85
101
  {
86
102
  passed: false,
@@ -90,6 +106,15 @@ class Evilution::Integration::RSpec < Evilution::Integration::Base
90
106
  }
91
107
  end
92
108
 
109
+ def unresolved_example_result(mutation)
110
+ {
111
+ passed: false,
112
+ unresolved: true,
113
+ error: "no matching example found for #{mutation.file_path}",
114
+ test_command: "rspec (skipped: no matching example for #{mutation.file_path})"
115
+ }
116
+ end
117
+
93
118
  def reset_state
94
119
  if ::RSpec.respond_to?(:clear_examples)
95
120
  ::RSpec.clear_examples
@@ -138,6 +163,54 @@ class Evilution::Integration::RSpec < Evilution::Integration::Base
138
163
  world.instance_variable_set(:@sources_by_path, {}) if world.instance_variable_defined?(:@sources_by_path)
139
164
  end
140
165
 
166
+ def snapshot_filtered_examples_keys
167
+ fe = rspec_world_ivar(:@filtered_examples)
168
+ fe ? Set.new(fe.keys.map(&:object_id)) : nil
169
+ end
170
+
171
+ def snapshot_reporter_lengths
172
+ reporter = rspec_config_ivar(:@reporter)
173
+ return nil unless reporter
174
+
175
+ %i[@examples @failed_examples @pending_examples].each_with_object({}) do |ivar, acc|
176
+ next unless reporter.instance_variable_defined?(ivar)
177
+
178
+ arr = reporter.instance_variable_get(ivar)
179
+ acc[ivar] = arr.length if arr.is_a?(Array)
180
+ end
181
+ end
182
+
183
+ def release_filtered_examples(snapshot_keys)
184
+ fe = rspec_world_ivar(:@filtered_examples)
185
+ return unless fe && snapshot_keys
186
+
187
+ fe.each_key.to_a.each do |k|
188
+ fe.delete(k) unless snapshot_keys.include?(k.object_id)
189
+ end
190
+ end
191
+
192
+ def release_reporter_state(lengths)
193
+ return unless lengths
194
+
195
+ reporter = rspec_config_ivar(:@reporter)
196
+ return unless reporter
197
+
198
+ lengths.each do |ivar, length|
199
+ arr = reporter.instance_variable_get(ivar)
200
+ arr.slice!(length..) if arr.is_a?(Array) && arr.length > length
201
+ end
202
+ end
203
+
204
+ def rspec_world_ivar(ivar)
205
+ world = ::RSpec.world
206
+ world.instance_variable_defined?(ivar) ? world.instance_variable_get(ivar) : nil
207
+ end
208
+
209
+ def rspec_config_ivar(ivar)
210
+ config = ::RSpec.configuration
211
+ config.instance_variable_defined?(ivar) ? config.instance_variable_get(ivar) : nil
212
+ end
213
+
141
214
  def reset_crash_detector
142
215
  if @crash_detector
143
216
  @crash_detector.reset
@@ -152,10 +225,12 @@ class Evilution::Integration::RSpec < Evilution::Integration::Base
152
225
  if status.zero?
153
226
  { passed: true, test_command: command }
154
227
  elsif detector.only_crashes?
228
+ classes = detector.unique_crash_classes
155
229
  {
156
230
  passed: false,
157
231
  test_crashed: true,
158
232
  error: "test crashes: #{detector.crash_summary}",
233
+ error_class: (classes.first if classes.length == 1),
159
234
  test_command: command
160
235
  }
161
236
  else
@@ -166,16 +241,16 @@ class Evilution::Integration::RSpec < Evilution::Integration::Base
166
241
  def resolve_test_files(mutation)
167
242
  return test_files if test_files
168
243
 
169
- resolved = @spec_resolver.call(mutation.file_path)
170
- unless resolved
244
+ resolved = Array(@spec_selector.call(mutation.file_path))
245
+ if resolved.empty?
171
246
  warn_unresolved_spec(mutation.file_path)
172
247
  return @fallback_to_full_suite ? ["spec"] : nil
173
248
  end
174
249
 
175
- return [resolved] unless @related_specs_heuristic_enabled
250
+ return resolved unless @related_specs_heuristic_enabled
176
251
 
177
252
  related = @related_spec_heuristic.call(mutation)
178
- ([resolved] + related).uniq
253
+ (resolved + related).uniq
179
254
  end
180
255
 
181
256
  def warn_unresolved_spec(file_path)
@@ -15,10 +15,18 @@ class Evilution::Isolation::Fork
15
15
  end
16
16
 
17
17
  def call(mutation:, test_command:, timeout:)
18
+ pid = nil
18
19
  sandbox_dir = Dir.mktmpdir("evilution-run")
19
20
  start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
20
21
  parent_rss = Evilution::Memory.rss_kb
21
22
  read_io, write_io = IO.pipe
23
+ # Marshal result payload is ASCII-8BIT; pipes default to text mode and may
24
+ # transcode according to their external/internal encodings (influenced by
25
+ # Encoding.default_external and/or Encoding.default_internal — Rails sets
26
+ # the latter to UTF-8), failing on bytes with no mapping. Force binmode on
27
+ # both ends.
28
+ read_io.binmode
29
+ write_io.binmode
22
30
 
23
31
  pid = ::Process.fork do
24
32
  ENV["TMPDIR"] = sandbox_dir
@@ -39,6 +47,7 @@ class Evilution::Isolation::Fork
39
47
  ensure
40
48
  read_io&.close
41
49
  write_io&.close
50
+ ensure_reaped(pid)
42
51
  restore_original_source(mutation)
43
52
  FileUtils.rm_rf(sandbox_dir) if sandbox_dir
44
53
  end
@@ -82,6 +91,22 @@ class Evilution::Isolation::Fork
82
91
  end
83
92
  end
84
93
 
94
+ # Defensive reap: if normal control flow raised before wait_for_result
95
+ # reaped the child (e.g. Marshal.load on corrupt payload), the child becomes
96
+ # a zombie. Reuse terminate_child for the bounded TERM + GRACE_PERIOD + KILL
97
+ # ladder so this never hangs the ensure path; swallow SystemCallError so
98
+ # cleanup can't mask the primary failure.
99
+ def ensure_reaped(pid)
100
+ return unless pid
101
+
102
+ reaped = ::Process.waitpid(pid, ::Process::WNOHANG)
103
+ return if reaped
104
+
105
+ terminate_child(pid)
106
+ rescue SystemCallError
107
+ nil
108
+ end
109
+
85
110
  def terminate_child(pid)
86
111
  ::Process.kill("TERM", pid) rescue nil # rubocop:disable Style/RescueModifier
87
112
  _, status = ::Process.waitpid2(pid, ::Process::WNOHANG)