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.
- checksums.yaml +4 -4
- data/.beads/interactions.jsonl +205 -0
- data/CHANGELOG.md +35 -0
- data/README.md +80 -4
- data/exe/evil +6 -0
- data/lib/evilution/ast/source_surgeon.rb +15 -1
- data/lib/evilution/cli/commands/compare.rb +68 -0
- data/lib/evilution/cli/parser/command_extractor.rb +2 -1
- data/lib/evilution/cli/parser/options_builder.rb +21 -1
- data/lib/evilution/cli/printers/compare.rb +159 -0
- data/lib/evilution/cli.rb +1 -0
- data/lib/evilution/compare/categorizer.rb +109 -0
- data/lib/evilution/compare/detector.rb +21 -0
- data/lib/evilution/compare/fingerprint.rb +83 -0
- data/lib/evilution/compare/normalizer.rb +106 -0
- data/lib/evilution/compare/record.rb +16 -0
- data/lib/evilution/compare.rb +15 -0
- data/lib/evilution/config.rb +165 -3
- data/lib/evilution/example_filter.rb +143 -0
- data/lib/evilution/integration/crash_detector.rb +5 -2
- data/lib/evilution/integration/minitest.rb +10 -5
- data/lib/evilution/integration/minitest_crash_detector.rb +5 -2
- data/lib/evilution/integration/rspec.rb +82 -7
- data/lib/evilution/isolation/fork.rb +25 -0
- data/lib/evilution/mcp/info_tool.rb +77 -5
- data/lib/evilution/mcp/mutate_tool/config_builder.rb +20 -0
- data/lib/evilution/mcp/mutate_tool/error_payload.rb +17 -0
- data/lib/evilution/mcp/mutate_tool/option_parser.rb +54 -0
- data/lib/evilution/mcp/mutate_tool/progress_streamer.rb +37 -0
- data/lib/evilution/mcp/mutate_tool/report_trimmer.rb +31 -0
- data/lib/evilution/mcp/mutate_tool/survived_enricher.rb +52 -0
- data/lib/evilution/mcp/mutate_tool.rb +34 -186
- data/lib/evilution/mutation.rb +43 -3
- data/lib/evilution/mutator/base.rb +39 -1
- data/lib/evilution/mutator/operator/argument_nil_substitution.rb +5 -1
- data/lib/evilution/mutator/operator/argument_removal.rb +5 -1
- data/lib/evilution/parallel/work_queue.rb +149 -31
- data/lib/evilution/parallel_db_warning.rb +68 -0
- data/lib/evilution/reporter/cli.rb +37 -11
- data/lib/evilution/reporter/html/assets/style.css +17 -0
- data/lib/evilution/reporter/html/sections/file_section.rb +15 -0
- data/lib/evilution/reporter/html/sections/neutral_details.rb +25 -0
- data/lib/evilution/reporter/html/sections/unparseable_details.rb +25 -0
- data/lib/evilution/reporter/html/sections/unresolved_details.rb +25 -0
- data/lib/evilution/reporter/html/templates/file_section.html.erb +3 -0
- data/lib/evilution/reporter/html/templates/neutral_details.html.erb +14 -0
- data/lib/evilution/reporter/html/templates/summary_cards.html.erb +3 -0
- data/lib/evilution/reporter/html/templates/unparseable_details.html.erb +11 -0
- data/lib/evilution/reporter/html/templates/unresolved_details.html.erb +11 -0
- data/lib/evilution/reporter/json.rb +8 -2
- data/lib/evilution/reporter/suggestion/diff_helpers.rb +28 -0
- data/lib/evilution/reporter/suggestion/registry.rb +64 -0
- data/lib/evilution/reporter/suggestion/templates/generic.rb +55 -0
- data/lib/evilution/reporter/suggestion/templates/minitest.rb +659 -0
- data/lib/evilution/reporter/suggestion/templates/rspec.rb +613 -0
- data/lib/evilution/reporter/suggestion.rb +8 -1327
- data/lib/evilution/result/mutation_result.rb +5 -1
- data/lib/evilution/result/summary.rb +13 -1
- data/lib/evilution/runner/baseline_runner.rb +23 -2
- data/lib/evilution/runner/mutation_executor.rb +83 -13
- data/lib/evilution/runner.rb +6 -0
- data/lib/evilution/source_ast_cache.rb +39 -0
- data/lib/evilution/spec_ast_cache.rb +166 -0
- data/lib/evilution/spec_resolver.rb +6 -1
- data/lib/evilution/spec_selector.rb +39 -0
- data/lib/evilution/temp_dir_tracker.rb +23 -3
- data/lib/evilution/version.rb +1 -1
- data/script/memory_check +7 -5
- metadata +34 -2
data/lib/evilution/config.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
45
|
-
|
|
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
|
-
@
|
|
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 = @
|
|
142
|
-
|
|
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
|
-
|
|
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
|
-
|
|
53
|
-
|
|
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
|
-
@
|
|
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(
|
|
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 = @
|
|
170
|
-
|
|
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
|
|
250
|
+
return resolved unless @related_specs_heuristic_enabled
|
|
176
251
|
|
|
177
252
|
related = @related_spec_heuristic.call(mutation)
|
|
178
|
-
(
|
|
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)
|