evilution 0.23.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 (106) hide show
  1. checksums.yaml +4 -4
  2. data/.beads/interactions.jsonl +210 -0
  3. data/CHANGELOG.md +51 -0
  4. data/README.md +81 -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 +78 -0
  9. data/lib/evilution/cli/parser/file_args.rb +41 -0
  10. data/lib/evilution/cli/parser/options_builder.rb +123 -0
  11. data/lib/evilution/cli/parser/stdin_reader.rb +28 -0
  12. data/lib/evilution/cli/parser.rb +27 -196
  13. data/lib/evilution/cli/printers/compare.rb +159 -0
  14. data/lib/evilution/cli.rb +1 -0
  15. data/lib/evilution/compare/categorizer.rb +109 -0
  16. data/lib/evilution/compare/detector.rb +21 -0
  17. data/lib/evilution/compare/fingerprint.rb +83 -0
  18. data/lib/evilution/compare/normalizer.rb +106 -0
  19. data/lib/evilution/compare/record.rb +16 -0
  20. data/lib/evilution/compare.rb +15 -0
  21. data/lib/evilution/config.rb +178 -3
  22. data/lib/evilution/example_filter.rb +143 -0
  23. data/lib/evilution/integration/base.rb +11 -57
  24. data/lib/evilution/integration/crash_detector.rb +5 -2
  25. data/lib/evilution/integration/minitest.rb +25 -7
  26. data/lib/evilution/integration/minitest_crash_detector.rb +5 -2
  27. data/lib/evilution/integration/rspec.rb +99 -12
  28. data/lib/evilution/isolation/fork.rb +26 -0
  29. data/lib/evilution/isolation/in_process.rb +1 -0
  30. data/lib/evilution/mcp/info_tool.rb +77 -5
  31. data/lib/evilution/mcp/mutate_tool/config_builder.rb +20 -0
  32. data/lib/evilution/mcp/mutate_tool/error_payload.rb +17 -0
  33. data/lib/evilution/mcp/mutate_tool/option_parser.rb +54 -0
  34. data/lib/evilution/mcp/mutate_tool/progress_streamer.rb +37 -0
  35. data/lib/evilution/mcp/mutate_tool/report_trimmer.rb +31 -0
  36. data/lib/evilution/mcp/mutate_tool/survived_enricher.rb +52 -0
  37. data/lib/evilution/mcp/mutate_tool.rb +34 -186
  38. data/lib/evilution/mutation.rb +43 -3
  39. data/lib/evilution/mutator/base.rb +39 -1
  40. data/lib/evilution/mutator/operator/argument_nil_substitution.rb +5 -1
  41. data/lib/evilution/mutator/operator/argument_removal.rb +5 -1
  42. data/lib/evilution/parallel/work_queue.rb +149 -31
  43. data/lib/evilution/parallel_db_warning.rb +68 -0
  44. data/lib/evilution/reporter/cli.rb +38 -11
  45. data/lib/evilution/reporter/html/assets/style.css +85 -0
  46. data/lib/evilution/reporter/html/baseline_keys.rb +28 -0
  47. data/lib/evilution/reporter/html/diff_formatter.rb +27 -0
  48. data/lib/evilution/reporter/html/escape.rb +12 -0
  49. data/lib/evilution/reporter/html/namespace.rb +11 -0
  50. data/lib/evilution/reporter/html/report.rb +68 -0
  51. data/lib/evilution/reporter/html/section.rb +21 -0
  52. data/lib/evilution/reporter/html/sections/baseline_comparison.rb +46 -0
  53. data/lib/evilution/reporter/html/sections/error_details.rb +30 -0
  54. data/lib/evilution/reporter/html/sections/error_entry.rb +22 -0
  55. data/lib/evilution/reporter/html/sections/file_section.rb +62 -0
  56. data/lib/evilution/reporter/html/sections/header.rb +29 -0
  57. data/lib/evilution/reporter/html/sections/mutation_map.rb +32 -0
  58. data/lib/evilution/reporter/html/sections/neutral_details.rb +25 -0
  59. data/lib/evilution/reporter/html/sections/summary_cards.rb +11 -0
  60. data/lib/evilution/reporter/html/sections/survived_details.rb +35 -0
  61. data/lib/evilution/reporter/html/sections/survived_entry.rb +36 -0
  62. data/lib/evilution/reporter/html/sections/truncation_notice.rb +17 -0
  63. data/lib/evilution/reporter/html/sections/unparseable_details.rb +25 -0
  64. data/lib/evilution/reporter/html/sections/unresolved_details.rb +25 -0
  65. data/lib/evilution/reporter/html/sections.rb +4 -0
  66. data/lib/evilution/reporter/html/stylesheet.rb +14 -0
  67. data/lib/evilution/reporter/html/templates/baseline_comparison.html.erb +8 -0
  68. data/lib/evilution/reporter/html/templates/error_details.html.erb +6 -0
  69. data/lib/evilution/reporter/html/templates/error_entry.html.erb +10 -0
  70. data/lib/evilution/reporter/html/templates/file_section.html.erb +12 -0
  71. data/lib/evilution/reporter/html/templates/header.html.erb +4 -0
  72. data/lib/evilution/reporter/html/templates/mutation_map.html.erb +6 -0
  73. data/lib/evilution/reporter/html/templates/neutral_details.html.erb +14 -0
  74. data/lib/evilution/reporter/html/templates/report.html.erb +17 -0
  75. data/lib/evilution/reporter/html/templates/summary_cards.html.erb +26 -0
  76. data/lib/evilution/reporter/html/templates/survived_details.html.erb +21 -0
  77. data/lib/evilution/reporter/html/templates/survived_entry.html.erb +8 -0
  78. data/lib/evilution/reporter/html/templates/truncation_notice.html.erb +1 -0
  79. data/lib/evilution/reporter/html/templates/unparseable_details.html.erb +11 -0
  80. data/lib/evilution/reporter/html/templates/unresolved_details.html.erb +11 -0
  81. data/lib/evilution/reporter/html.rb +11 -390
  82. data/lib/evilution/reporter/json.rb +19 -9
  83. data/lib/evilution/reporter/suggestion/diff_helpers.rb +28 -0
  84. data/lib/evilution/reporter/suggestion/registry.rb +64 -0
  85. data/lib/evilution/reporter/suggestion/templates/generic.rb +55 -0
  86. data/lib/evilution/reporter/suggestion/templates/minitest.rb +659 -0
  87. data/lib/evilution/reporter/suggestion/templates/rspec.rb +613 -0
  88. data/lib/evilution/reporter/suggestion.rb +8 -1327
  89. data/lib/evilution/result/mutation_result.rb +9 -1
  90. data/lib/evilution/result/summary.rb +21 -1
  91. data/lib/evilution/runner/baseline_runner.rb +92 -0
  92. data/lib/evilution/runner/diagnostics.rb +105 -0
  93. data/lib/evilution/runner/isolation_resolver.rb +134 -0
  94. data/lib/evilution/runner/mutation_executor.rb +325 -0
  95. data/lib/evilution/runner/mutation_planner.rb +126 -0
  96. data/lib/evilution/runner/report_publisher.rb +60 -0
  97. data/lib/evilution/runner/subject_pipeline.rb +121 -0
  98. data/lib/evilution/runner.rb +61 -692
  99. data/lib/evilution/source_ast_cache.rb +39 -0
  100. data/lib/evilution/spec_ast_cache.rb +166 -0
  101. data/lib/evilution/spec_resolver.rb +6 -1
  102. data/lib/evilution/spec_selector.rb +39 -0
  103. data/lib/evilution/temp_dir_tracker.rb +23 -3
  104. data/lib/evilution/version.rb +1 -1
  105. data/script/memory_check +7 -5
  106. metadata +75 -2
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../compare"
4
+ require_relative "record"
5
+ require_relative "fingerprint"
6
+
7
+ class Evilution::Compare::Normalizer
8
+ EVILUTION_BUCKETS = %w[killed survived timed_out errors neutral equivalent unresolved unparseable].freeze
9
+ EVILUTION_STATUS_MAP = {
10
+ "killed" => :killed,
11
+ "survived" => :survived,
12
+ "timeout" => :timeout,
13
+ "error" => :error,
14
+ "neutral" => :neutral,
15
+ "equivalent" => :equivalent,
16
+ "unresolved" => :unresolved,
17
+ "unparseable" => :unparseable
18
+ }.freeze
19
+
20
+ def from_evilution(json)
21
+ records = []
22
+ EVILUTION_BUCKETS.each do |bucket|
23
+ Array(json[bucket]).each do |entry|
24
+ records << build_evilution_record(entry, index: records.size)
25
+ end
26
+ end
27
+ records
28
+ end
29
+
30
+ def from_mutant(json)
31
+ records = []
32
+ Array(json["subject_results"]).each do |subject|
33
+ source_path = subject["source_path"] or
34
+ raise Evilution::Compare::InvalidInput.new("missing 'source_path' on subject", index: records.size)
35
+ Array(subject["coverage_results"]).each do |cov|
36
+ records << build_mutant_record(cov, source_path: source_path, index: records.size)
37
+ end
38
+ end
39
+ records
40
+ end
41
+
42
+ private
43
+
44
+ def build_evilution_record(entry, index:)
45
+ file_path = entry["file"] or raise Evilution::Compare::InvalidInput.new("missing 'file' in record", index: index)
46
+ line = entry["line"] or raise Evilution::Compare::InvalidInput.new("missing 'line' in record", index: index)
47
+ diff = entry["diff"].to_s
48
+ status = EVILUTION_STATUS_MAP[entry["status"]] ||
49
+ raise(Evilution::Compare::InvalidInput.new("unknown status #{entry["status"].inspect}", index: index))
50
+ body = Evilution::Compare::Fingerprint.extract_from_evilution_diff(diff)
51
+ Evilution::Compare::Record.new(
52
+ source: :evilution,
53
+ file_path: file_path,
54
+ line: line,
55
+ status: status,
56
+ fingerprint: Evilution::Compare::Fingerprint.compute(file_path: file_path, line: line, body: body),
57
+ operator: entry["operator"],
58
+ diff_body: diff,
59
+ raw: entry
60
+ )
61
+ end
62
+
63
+ def build_mutant_record(cov, source_path:, index:)
64
+ mr = cov["mutation_result"] or raise Evilution::Compare::InvalidInput.new("missing mutation_result", index: index)
65
+ cr = cov["criteria_result"] or raise Evilution::Compare::InvalidInput.new("missing criteria_result", index: index)
66
+ ident = mr["mutation_identification"].to_s
67
+ line = parse_mutant_line(ident, index)
68
+ diff = mr["mutation_diff"].to_s
69
+ status = derive_mutant_status(mr, cr, index)
70
+ body = Evilution::Compare::Fingerprint.extract_from_mutant_diff(diff)
71
+ Evilution::Compare::Record.new(
72
+ source: :mutant,
73
+ file_path: source_path,
74
+ line: line,
75
+ status: status,
76
+ fingerprint: Evilution::Compare::Fingerprint.compute(file_path: source_path, line: line, body: body),
77
+ operator: nil,
78
+ diff_body: diff,
79
+ raw: { "mutation_result" => mr, "criteria_result" => cr, "source_path" => source_path }
80
+ )
81
+ end
82
+
83
+ # mutant_identification format: <type>:<subject>:<path>:<line>:<sha1[0..4]>.
84
+ # Line is always the second-to-last colon-separated field. Works with paths
85
+ # containing colons (e.g. Windows drive letters) because we index from the
86
+ # right, but a malformed path-less identification will raise InvalidInput.
87
+ def parse_mutant_line(ident, index)
88
+ parts = ident.split(":")
89
+ raise Evilution::Compare::InvalidInput.new("cannot parse line from #{ident.inspect}", index: index) if parts.length < 5
90
+
91
+ Integer(parts[-2])
92
+ rescue ArgumentError
93
+ raise Evilution::Compare::InvalidInput.new("non-integer line in #{ident.inspect}", index: index)
94
+ end
95
+
96
+ def derive_mutant_status(mr, cr, index)
97
+ type = mr["mutation_type"]
98
+ return :neutral if %w[neutral noop].include?(type)
99
+ return :timeout if cr["timeout"]
100
+ return :error if cr["process_abort"]
101
+ return :killed if cr["test_result"]
102
+ return :survived if type == "evil"
103
+
104
+ raise Evilution::Compare::InvalidInput.new("unknown mutant result shape: type=#{type.inspect} cr=#{cr.inspect}", index: index)
105
+ end
106
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../compare"
4
+
5
+ module Evilution::Compare
6
+ Record = Data.define(
7
+ :source,
8
+ :file_path,
9
+ :line,
10
+ :status,
11
+ :fingerprint,
12
+ :operator,
13
+ :diff_body,
14
+ :raw
15
+ )
16
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ # rubocop:disable Style/OneClassPerFile
4
+ module Evilution::Compare
5
+ end
6
+
7
+ class Evilution::Compare::InvalidInput < StandardError
8
+ attr_reader :index
9
+
10
+ def initialize(message, index: nil)
11
+ super(message)
12
+ @index = index
13
+ end
14
+ end
15
+ # rubocop:enable Style/OneClassPerFile
@@ -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
@@ -28,19 +30,32 @@ class Evilution::Config
28
30
  baseline_session: nil,
29
31
  skip_heredoc_literals: false,
30
32
  related_specs_heuristic: false,
31
- preload: nil
33
+ fallback_to_full_suite: false,
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 }
32
40
  }.freeze
33
41
 
42
+ EXAMPLE_TARGETING_FALLBACKS = %i[full_file unresolved].freeze
43
+ private_constant :EXAMPLE_TARGETING_FALLBACKS
44
+
34
45
  attr_reader :target_files, :timeout, :format,
35
46
  :target, :min_score, :integration, :verbose, :quiet,
36
47
  :jobs, :fail_fast, :baseline, :isolation, :incremental, :suggest_tests,
37
48
  :progress, :save_session, :line_ranges, :spec_files, :hooks,
38
49
  :ignore_patterns, :show_disabled, :baseline_session,
39
- :skip_heredoc_literals, :related_specs_heuristic, :preload
50
+ :skip_heredoc_literals, :related_specs_heuristic,
51
+ :fallback_to_full_suite, :preload, :spec_mappings, :spec_pattern,
52
+ :example_targeting, :example_targeting_fallback, :example_targeting_cache,
53
+ :spec_selector
40
54
 
41
55
  def initialize(**options)
42
56
  file_options = options.delete(:skip_config_file) ? {} : load_config_file
43
- merged = DEFAULTS.merge(file_options).merge(options)
57
+ env_options = load_env_options
58
+ merged = DEFAULTS.merge(file_options).merge(env_options).merge(options)
44
59
  assign_attributes(merged)
45
60
  freeze
46
61
  end
@@ -101,6 +116,14 @@ class Evilution::Config
101
116
  related_specs_heuristic
102
117
  end
103
118
 
119
+ def example_targeting?
120
+ example_targeting
121
+ end
122
+
123
+ def fallback_to_full_suite?
124
+ fallback_to_full_suite
125
+ end
126
+
104
127
  def self.file_options
105
128
  CONFIG_FILES.each do |path|
106
129
  next unless File.exist?(path)
@@ -155,6 +178,12 @@ class Evilution::Config
155
178
  # of N+1 regressions that only surface in higher-level specs.
156
179
  # related_specs_heuristic: true
157
180
 
181
+ # When no matching spec resolves for a mutation's source file, the
182
+ # default is to skip that mutation and mark it :unresolved in the
183
+ # report (a coverage gap signal). Set to true to fall back to running
184
+ # the entire test suite for such mutations instead (slow, high memory).
185
+ # fallback_to_full_suite: false
186
+
158
187
  # Preload file required in the parent process before forking workers.
159
188
  # For Rails projects, spec/rails_helper.rb or test/test_helper.rb is
160
189
  # auto-detected when isolation resolves to :fork. Set to false to disable.
@@ -165,6 +194,23 @@ class Evilution::Config
165
194
  # worker_process_start: config/evilution_hooks/worker_start.rb
166
195
  # mutation_insert_pre: config/evilution_hooks/mutation_pre.rb
167
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
+
168
214
  # AST patterns to skip during mutation generation (default: [])
169
215
  # See docs/ast_pattern_syntax.md for pattern syntax
170
216
  # ignore_patterns:
@@ -210,8 +256,91 @@ class Evilution::Config
210
256
  @baseline_session = merged[:baseline_session]
211
257
  @skip_heredoc_literals = merged[:skip_heredoc_literals]
212
258
  @related_specs_heuristic = merged[:related_specs_heuristic]
259
+ @fallback_to_full_suite = merged[:fallback_to_full_suite]
213
260
  @hooks = validate_hooks(merged[:hooks])
214
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}"
215
344
  end
216
345
 
217
346
  def validate_preload(value)
@@ -266,6 +395,52 @@ class Evilution::Config
266
395
  patterns
267
396
  end
268
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
+
269
444
  def validate_hooks(value)
270
445
  return {} if value.nil?
271
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
@@ -1,10 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "fileutils"
4
3
  require "prism"
5
- require "tmpdir"
6
4
  require_relative "../integration"
7
- require_relative "../temp_dir_tracker"
8
5
 
9
6
  class Evilution::Integration::Base
10
7
  def self.baseline_runner
@@ -20,7 +17,6 @@ class Evilution::Integration::Base
20
17
  end
21
18
 
22
19
  def call(mutation)
23
- @temp_dir = nil
24
20
  ensure_framework_loaded
25
21
  fire_hook(:mutation_insert_pre, mutation: mutation, file_path: mutation.file_path)
26
22
  load_error = apply_mutation(mutation)
@@ -28,8 +24,6 @@ class Evilution::Integration::Base
28
24
 
29
25
  fire_hook(:mutation_insert_post, mutation: mutation, file_path: mutation.file_path)
30
26
  run_tests(mutation)
31
- ensure
32
- restore_original(mutation)
33
27
  end
34
28
 
35
29
  private
@@ -58,15 +52,10 @@ class Evilution::Integration::Base
58
52
  prism_error = validate_mutated_syntax(mutation.mutated_source)
59
53
  return prism_error if prism_error
60
54
 
61
- @temp_dir = Dir.mktmpdir("evilution")
62
- Evilution::TempDirTracker.register(@temp_dir)
63
- @displaced_feature = nil
64
- subpath = resolve_require_subpath(mutation.file_path)
65
-
66
- if subpath
67
- apply_via_require(mutation, subpath)
68
- else
69
- apply_via_load(mutation)
55
+ pin_autoloaded_constants(mutation.original_source)
56
+ clear_concern_state(mutation.file_path)
57
+ with_redefinition_recovery(mutation.original_source) do
58
+ eval_mutated_source(mutation)
70
59
  end
71
60
  nil
72
61
  rescue SyntaxError => e
@@ -96,29 +85,14 @@ class Evilution::Integration::Base
96
85
  }
97
86
  end
98
87
 
99
- def apply_via_require(mutation, subpath)
100
- dest = File.join(@temp_dir, subpath)
101
- FileUtils.mkdir_p(File.dirname(dest))
102
- File.write(dest, mutation.mutated_source)
103
- $LOAD_PATH.unshift(@temp_dir)
104
- displace_loaded_feature(mutation.file_path)
105
- pin_autoloaded_constants(mutation.original_source)
106
- clear_concern_state(mutation.file_path)
107
- with_redefinition_recovery(mutation.original_source) do
108
- require(subpath.delete_suffix(".rb"))
109
- end
110
- end
111
-
112
- def apply_via_load(mutation)
88
+ # Evaluate the mutated source with __FILE__ set to the original path so
89
+ # that `require_relative` and `__dir__` resolve against the real source
90
+ # tree, where sibling files actually exist.
91
+ def eval_mutated_source(mutation)
113
92
  absolute = File.expand_path(mutation.file_path)
114
- dest = File.join(@temp_dir, absolute)
115
- FileUtils.mkdir_p(File.dirname(dest))
116
- File.write(dest, mutation.mutated_source)
117
- pin_autoloaded_constants(mutation.original_source)
118
- clear_concern_state(mutation.file_path)
119
- with_redefinition_recovery(mutation.original_source) do
120
- load(dest)
121
- end
93
+ # rubocop:disable Security/Eval
94
+ eval(mutation.mutated_source, TOPLEVEL_BINDING, absolute, 1)
95
+ # rubocop:enable Security/Eval
122
96
  end
123
97
 
124
98
  def with_redefinition_recovery(original_source)
@@ -134,18 +108,6 @@ class Evilution::Integration::Base
134
108
  error.message.include?("already defined")
135
109
  end
136
110
 
137
- def restore_original(_mutation)
138
- return unless @temp_dir
139
-
140
- $LOAD_PATH.delete(@temp_dir)
141
- $LOADED_FEATURES.reject! { |f| f.start_with?(@temp_dir) }
142
- $LOADED_FEATURES << @displaced_feature if @displaced_feature && !$LOADED_FEATURES.include?(@displaced_feature)
143
- @displaced_feature = nil
144
- FileUtils.rm_rf(@temp_dir)
145
- Evilution::TempDirTracker.unregister(@temp_dir)
146
- @temp_dir = nil
147
- end
148
-
149
111
  def pin_autoloaded_constants(source)
150
112
  collect_constant_names(Prism.parse(source).value).each do |name|
151
113
  Object.const_get(name) if Object.const_defined?(name, false)
@@ -237,12 +199,4 @@ class Evilution::Integration::Base
237
199
 
238
200
  best_subpath
239
201
  end
240
-
241
- def displace_loaded_feature(file_path)
242
- absolute = File.expand_path(file_path)
243
- return unless $LOADED_FEATURES.include?(absolute)
244
-
245
- @displaced_feature = absolute
246
- $LOADED_FEATURES.delete(absolute)
247
- end
248
202
  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