evilution 0.24.0 → 0.26.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 (88) hide show
  1. checksums.yaml +4 -4
  2. data/.beads/interactions.jsonl +210 -0
  3. data/.claude/prompts/architect.md +14 -1
  4. data/.claude/skills/create-issue/SKILL.md +55 -0
  5. data/CHANGELOG.md +51 -0
  6. data/README.md +80 -4
  7. data/exe/evil +6 -0
  8. data/lib/evilution/ast/constant_names.rb +34 -0
  9. data/lib/evilution/ast/source_surgeon.rb +15 -1
  10. data/lib/evilution/cli/commands/compare.rb +68 -0
  11. data/lib/evilution/cli/parser/command_extractor.rb +2 -1
  12. data/lib/evilution/cli/parser/options_builder.rb +21 -1
  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/invalid_input.rb +12 -0
  19. data/lib/evilution/compare/normalizer.rb +106 -0
  20. data/lib/evilution/compare/record.rb +16 -0
  21. data/lib/evilution/compare.rb +6 -0
  22. data/lib/evilution/config.rb +165 -3
  23. data/lib/evilution/example_filter.rb +143 -0
  24. data/lib/evilution/integration/base.rb +4 -155
  25. data/lib/evilution/integration/crash_detector.rb +5 -2
  26. data/lib/evilution/integration/loading/concern_state_cleaner.rb +49 -0
  27. data/lib/evilution/integration/loading/constant_pinner.rb +24 -0
  28. data/lib/evilution/integration/loading/mutation_applier.rb +52 -0
  29. data/lib/evilution/integration/loading/redefinition_recovery.rb +54 -0
  30. data/lib/evilution/integration/loading/source_evaluator.rb +15 -0
  31. data/lib/evilution/integration/loading/syntax_validator.rb +19 -0
  32. data/lib/evilution/integration/loading.rb +6 -0
  33. data/lib/evilution/integration/minitest.rb +10 -5
  34. data/lib/evilution/integration/minitest_crash_detector.rb +5 -2
  35. data/lib/evilution/integration/rspec.rb +82 -7
  36. data/lib/evilution/isolation/fork.rb +25 -0
  37. data/lib/evilution/load_path/subpath_resolver.rb +25 -0
  38. data/lib/evilution/load_path.rb +4 -0
  39. data/lib/evilution/mcp/info_tool.rb +77 -5
  40. data/lib/evilution/mcp/mutate_tool/config_builder.rb +20 -0
  41. data/lib/evilution/mcp/mutate_tool/error_payload.rb +17 -0
  42. data/lib/evilution/mcp/mutate_tool/option_parser.rb +54 -0
  43. data/lib/evilution/mcp/mutate_tool/progress_streamer.rb +37 -0
  44. data/lib/evilution/mcp/mutate_tool/report_trimmer.rb +31 -0
  45. data/lib/evilution/mcp/mutate_tool/survived_enricher.rb +52 -0
  46. data/lib/evilution/mcp/mutate_tool.rb +34 -186
  47. data/lib/evilution/mutation.rb +43 -3
  48. data/lib/evilution/mutator/base.rb +39 -1
  49. data/lib/evilution/mutator/operator/argument_nil_substitution.rb +5 -1
  50. data/lib/evilution/mutator/operator/argument_removal.rb +5 -1
  51. data/lib/evilution/parallel/work_queue.rb +149 -31
  52. data/lib/evilution/parallel_db_warning.rb +68 -0
  53. data/lib/evilution/reporter/cli.rb +37 -11
  54. data/lib/evilution/reporter/html/assets/style.css +17 -0
  55. data/lib/evilution/reporter/html/sections/file_section.rb +15 -0
  56. data/lib/evilution/reporter/html/sections/neutral_details.rb +25 -0
  57. data/lib/evilution/reporter/html/sections/unparseable_details.rb +25 -0
  58. data/lib/evilution/reporter/html/sections/unresolved_details.rb +25 -0
  59. data/lib/evilution/reporter/html/templates/file_section.html.erb +3 -0
  60. data/lib/evilution/reporter/html/templates/neutral_details.html.erb +14 -0
  61. data/lib/evilution/reporter/html/templates/summary_cards.html.erb +3 -0
  62. data/lib/evilution/reporter/html/templates/unparseable_details.html.erb +11 -0
  63. data/lib/evilution/reporter/html/templates/unresolved_details.html.erb +11 -0
  64. data/lib/evilution/reporter/json.rb +8 -2
  65. data/lib/evilution/reporter/suggestion/diff_helpers.rb +28 -0
  66. data/lib/evilution/reporter/suggestion/registry.rb +64 -0
  67. data/lib/evilution/reporter/suggestion/templates/generic.rb +55 -0
  68. data/lib/evilution/reporter/suggestion/templates/minitest.rb +659 -0
  69. data/lib/evilution/reporter/suggestion/templates/rspec.rb +613 -0
  70. data/lib/evilution/reporter/suggestion.rb +8 -1327
  71. data/lib/evilution/result/mutation_result.rb +5 -1
  72. data/lib/evilution/result/summary.rb +13 -1
  73. data/lib/evilution/runner/baseline_runner.rb +23 -2
  74. data/lib/evilution/runner/isolation_resolver.rb +12 -1
  75. data/lib/evilution/runner/mutation_executor.rb +83 -13
  76. data/lib/evilution/runner/subject_pipeline.rb +18 -8
  77. data/lib/evilution/runner.rb +6 -0
  78. data/lib/evilution/source_ast_cache.rb +39 -0
  79. data/lib/evilution/spec_ast_cache.rb +166 -0
  80. data/lib/evilution/spec_resolver.rb +6 -1
  81. data/lib/evilution/spec_selector.rb +39 -0
  82. data/lib/evilution/temp_dir_tracker.rb +23 -3
  83. data/lib/evilution/version.rb +1 -1
  84. data/script/memory_check +7 -5
  85. metadata +46 -5
  86. data/lib/evilution/mcp/session_diff_tool.rb +0 -63
  87. data/lib/evilution/mcp/session_list_tool.rb +0 -50
  88. data/lib/evilution/mcp/session_show_tool.rb +0 -57
@@ -3,7 +3,7 @@
3
3
  require_relative "../result"
4
4
 
5
5
  class Evilution::Result::MutationResult
6
- STATUSES = %i[killed survived timeout error neutral equivalent unresolved].freeze
6
+ STATUSES = %i[killed survived timeout error neutral equivalent unresolved unparseable].freeze
7
7
 
8
8
  attr_reader :mutation, :status, :duration, :killing_test, :test_command,
9
9
  :child_rss_kb, :memory_delta_kb, :parent_rss_kb,
@@ -58,4 +58,8 @@ class Evilution::Result::MutationResult
58
58
  def unresolved?
59
59
  status == :unresolved
60
60
  end
61
+
62
+ def unparseable?
63
+ status == :unparseable
64
+ end
61
65
  end
@@ -51,8 +51,16 @@ class Evilution::Result::Summary
51
51
  results.count(&:unresolved?)
52
52
  end
53
53
 
54
+ def unparseable
55
+ results.count(&:unparseable?)
56
+ end
57
+
58
+ def score_denominator
59
+ total - errors - neutral - equivalent - unresolved - unparseable
60
+ end
61
+
54
62
  def score
55
- denominator = total - errors - neutral - equivalent - unresolved
63
+ denominator = score_denominator
56
64
  return 0.0 if denominator.zero?
57
65
 
58
66
  killed.to_f / denominator
@@ -82,6 +90,10 @@ class Evilution::Result::Summary
82
90
  results.select(&:unresolved?)
83
91
  end
84
92
 
93
+ def unparseable_results
94
+ results.select(&:unparseable?)
95
+ end
96
+
85
97
  def coverage_gaps
86
98
  Evilution::Result::CoverageGapGrouper.new.call(survived_results)
87
99
  end
@@ -4,6 +4,9 @@ require_relative "../baseline"
4
4
  require_relative "../spec_resolver"
5
5
  require_relative "../integration/rspec"
6
6
  require_relative "../integration/minitest"
7
+ require_relative "../example_filter"
8
+ require_relative "../spec_ast_cache"
9
+ require_relative "../source_ast_cache"
7
10
 
8
11
  class Evilution::Runner; end unless defined?(Evilution::Runner) # rubocop:disable Lint/EmptyClass
9
12
 
@@ -29,8 +32,16 @@ class Evilution::Runner::BaselineRunner
29
32
  def build_integration
30
33
  klass = integration_class
31
34
  test_files = config.spec_files.empty? ? nil : config.spec_files
32
- kwargs = { test_files: test_files, hooks: hooks, fallback_to_full_suite: config.fallback_to_full_suite? }
33
- kwargs[:related_specs_heuristic] = config.related_specs_heuristic? if klass == Evilution::Integration::RSpec
35
+ kwargs = {
36
+ test_files: test_files,
37
+ hooks: hooks,
38
+ fallback_to_full_suite: config.fallback_to_full_suite?,
39
+ spec_selector: config.spec_selector
40
+ }
41
+ if klass == Evilution::Integration::RSpec
42
+ kwargs[:related_specs_heuristic] = config.related_specs_heuristic?
43
+ kwargs[:example_filter] = build_example_filter
44
+ end
34
45
  klass.new(**kwargs)
35
46
  end
36
47
 
@@ -56,6 +67,16 @@ class Evilution::Runner::BaselineRunner
56
67
 
57
68
  attr_reader :config, :hooks
58
69
 
70
+ def build_example_filter
71
+ return nil unless config.example_targeting?
72
+
73
+ Evilution::ExampleFilter.new(
74
+ cache: Evilution::SpecAstCache.new(**config.example_targeting_cache),
75
+ fallback: config.example_targeting_fallback,
76
+ source_cache: Evilution::SourceAstCache.new
77
+ )
78
+ end
79
+
59
80
  def log_start
60
81
  return if config.quiet || !config.text? || !$stderr.tty?
61
82
 
@@ -30,10 +30,10 @@ class Evilution::Runner::IsolationResolver
30
30
 
31
31
  def perform_preload
32
32
  return if config.preload == false
33
- return unless resolve_isolation == :fork
34
33
 
35
34
  path = resolve_preload_path
36
35
  return unless path
36
+ return unless should_preload?
37
37
 
38
38
  prepare_load_path_for_preload
39
39
  require File.expand_path(path)
@@ -48,6 +48,17 @@ class Evilution::Runner::IsolationResolver
48
48
 
49
49
  attr_reader :config, :hooks
50
50
 
51
+ # Under :fork, allow preloading — caller resolves whether a path exists (an
52
+ # explicit --preload / preload: value, or an auto-detected rails_helper) and
53
+ # bails early when none does. Under :in_process, only allow preloading when
54
+ # the user explicitly asked via --preload or preload: in YAML — don't
55
+ # auto-load spec/rails_helper.rb for a user who opted out of fork.
56
+ def should_preload?
57
+ return true if resolve_isolation == :fork
58
+
59
+ config.preload.is_a?(String)
60
+ end
61
+
51
62
  def target_files
52
63
  @target_files ||= @target_files_callback.call
53
64
  end
@@ -40,11 +40,9 @@ class Evilution::Runner::MutationExecutor
40
40
  truncated = false
41
41
 
42
42
  mutations.each_with_index do |mutation, index|
43
- result = execute_or_fetch(mutation) do
44
- test_command = ->(m) { integration.call(m) }
45
- isolator.call(mutation: mutation, test_command: test_command, timeout: config.timeout)
46
- end
43
+ result = execute_one(mutation, integration)
47
44
  mutation.strip_sources!
45
+ result = neutralize_if_infra_error(result)
48
46
  result = neutralize_if_baseline_failed(result, baseline_result, spec_resolver)
49
47
  results << result
50
48
  survived_count += 1 if result.survived?
@@ -109,6 +107,7 @@ class Evilution::Runner::MutationExecutor
109
107
 
110
108
  def process_batch(batch_results, baseline_result, spec_resolver, state)
111
109
  batch_results.each do |result|
110
+ result = neutralize_if_infra_error(result)
112
111
  result = neutralize_if_baseline_failed(result, baseline_result, spec_resolver)
113
112
  state[:results] << result
114
113
  state[:survived_count] += 1 if result.survived?
@@ -120,17 +119,56 @@ class Evilution::Runner::MutationExecutor
120
119
  state[:truncated] = true if should_truncate?(state[:survived_count])
121
120
  end
122
121
 
123
- def neutralize_if_baseline_failed(result, baseline_result, spec_resolver)
124
- return result unless result.survived? && baseline_result && baseline_result.failed?
122
+ # Reclassify results as :neutral when the failure was caused by test
123
+ # infrastructure rather than by the mutation. Two independent paths:
124
+ #
125
+ # 1) :error from a missing require / spec_helper / rails_helper / spec/support
126
+ # initialization — detected by error_class ∈ INFRA_ERROR_CLASSES and
127
+ # first backtrace frame matching INFRA_BACKTRACE_PATHS. Origin-only match
128
+ # (not `any?`): Ruby backtraces typically carry spec_helper frames below
129
+ # mutation-caused errors, so matching any frame would misclassify real
130
+ # mutation NameError/LoadError as :neutral.
131
+ #
132
+ # 2) :killed from a CrashDetector test_crashed whose sole crash class is in
133
+ # INFRA_CRASH_CLASSES (ActiveRecord::StatementTimeout, Timeout::Error,
134
+ # etc.). These surface under parallel workers sharing a DB file or on a
135
+ # slow CI; fork.rb initially reports them as :killed, and without this
136
+ # demotion the kill count inflates with infra noise. No backtrace check:
137
+ # the single-class signal from CrashDetector already rules out mixed
138
+ # mutation-caused failures. See EV-toid / GH #814.
139
+ INFRA_ERROR_CLASSES = %w[LoadError NameError].freeze
140
+ INFRA_BACKTRACE_PATHS = %r{(?:^|/)(?:spec_helper\.rb|rails_helper\.rb|spec/support/)}
141
+ INFRA_CRASH_CLASSES = %w[
142
+ Timeout::Error
143
+ ActiveRecord::StatementTimeout
144
+ ActiveRecord::Deadlocked
145
+ ActiveRecord::ConnectionTimeoutError
146
+ ActiveRecord::LockWaitTimeout
147
+ SQLite3::BusyException
148
+ ].freeze
149
+ private_constant :INFRA_ERROR_CLASSES, :INFRA_BACKTRACE_PATHS, :INFRA_CRASH_CLASSES
150
+
151
+ def neutralize_if_infra_error(result)
152
+ return neutralize(result) if infra_crash?(result)
153
+ return result unless result.error?
154
+ return result unless INFRA_ERROR_CLASSES.include?(result.error_class)
155
+ return result unless infra_origin?(result.error_backtrace)
156
+
157
+ neutralize(result)
158
+ end
125
159
 
126
- if config.spec_files.any?
127
- neutralize = true
128
- else
129
- spec_file = spec_resolver.call(result.mutation.file_path) || baseline_runner.neutralization_fallback_dir
130
- neutralize = baseline_result.failed_spec_files.include?(spec_file)
131
- end
132
- return result unless neutralize
160
+ def infra_crash?(result)
161
+ result.killed? && INFRA_CRASH_CLASSES.include?(result.error_class)
162
+ end
163
+
164
+ def infra_origin?(backtrace)
165
+ frames = Array(backtrace)
166
+ return false if frames.empty?
167
+
168
+ frames.first =~ INFRA_BACKTRACE_PATHS ? true : false
169
+ end
133
170
 
171
+ def neutralize(result)
134
172
  Evilution::Result::MutationResult.new(
135
173
  mutation: result.mutation,
136
174
  status: :neutral,
@@ -145,6 +183,20 @@ class Evilution::Runner::MutationExecutor
145
183
  )
146
184
  end
147
185
 
186
+ def neutralize_if_baseline_failed(result, baseline_result, spec_resolver)
187
+ return result unless result.survived? && baseline_result && baseline_result.failed?
188
+
189
+ if config.spec_files.any?
190
+ should_neutralize = true
191
+ else
192
+ spec_file = spec_resolver.call(result.mutation.file_path) || baseline_runner.neutralization_fallback_dir
193
+ should_neutralize = baseline_result.failed_spec_files.include?(spec_file)
194
+ end
195
+ return result unless should_neutralize
196
+
197
+ neutralize(result)
198
+ end
199
+
148
200
  def compact_result(result)
149
201
  {
150
202
  status: result.status,
@@ -187,6 +239,11 @@ class Evilution::Runner::MutationExecutor
187
239
  cached_results = {}
188
240
 
189
241
  batch.each_with_index do |mutation, i|
242
+ if mutation.unparseable?
243
+ cached_results[i] = compact_result(build_unparseable_result(mutation))
244
+ next
245
+ end
246
+
190
247
  cached = fetch_cached_result(mutation)
191
248
  if cached
192
249
  cached_results[i] = compact_result(cached)
@@ -198,6 +255,19 @@ class Evilution::Runner::MutationExecutor
198
255
  [uncached_indices, cached_results]
199
256
  end
200
257
 
258
+ def execute_one(mutation, integration)
259
+ return build_unparseable_result(mutation) if mutation.unparseable?
260
+
261
+ execute_or_fetch(mutation) do
262
+ test_command = ->(m) { integration.call(m) }
263
+ isolator.call(mutation: mutation, test_command: test_command, timeout: config.timeout)
264
+ end
265
+ end
266
+
267
+ def build_unparseable_result(mutation)
268
+ Evilution::Result::MutationResult.new(mutation: mutation, status: :unparseable)
269
+ end
270
+
201
271
  def merge_parallel_results(batch, uncached_indices, cached_results, worker_results)
202
272
  result_map = cached_results.dup
203
273
  uncached_indices.each_with_index { |batch_idx, worker_idx| result_map[batch_idx] = worker_results[worker_idx] }
@@ -20,13 +20,7 @@ class Evilution::Runner::SubjectPipeline
20
20
  end
21
21
 
22
22
  def target_files
23
- @target_files ||= if source_glob_target?
24
- resolve_source_glob
25
- elsif !config.target_files.empty?
26
- config.target_files
27
- else
28
- Evilution::Git::ChangedFiles.new.call
29
- end
23
+ @target_files ||= resolve_target_files
30
24
  end
31
25
 
32
26
  private
@@ -88,11 +82,27 @@ class Evilution::Runner::SubjectPipeline
88
82
 
89
83
  def filter_by_target(subjects)
90
84
  matched = subjects.select(&target_matcher)
91
- raise Evilution::Error, "no method found matching '#{config.target}'" if matched.empty?
85
+ raise Evilution::Error, build_no_match_error if matched.empty?
92
86
 
93
87
  matched
94
88
  end
95
89
 
90
+ def resolve_target_files
91
+ return resolve_source_glob if source_glob_target?
92
+ return config.target_files unless config.target_files.empty?
93
+
94
+ @used_git_fallback = true
95
+ Evilution::Git::ChangedFiles.new.call
96
+ end
97
+
98
+ def build_no_match_error
99
+ base = "no subject matched '#{config.target}'"
100
+ return base unless @used_git_fallback
101
+
102
+ "#{base}; scanned git-changed files only. Pass file paths or " \
103
+ "--target source:<glob> to scan the full codebase."
104
+ end
105
+
96
106
  def target_matcher
97
107
  target = config.target
98
108
  if target.end_with?("*")
@@ -21,6 +21,7 @@ require_relative "parallel/pool"
21
21
  require_relative "session/store"
22
22
  require_relative "temp_dir_tracker"
23
23
  require_relative "rails_detector"
24
+ require_relative "parallel_db_warning"
24
25
  require_relative "runner/subject_pipeline"
25
26
  require_relative "runner/mutation_planner"
26
27
  require_relative "runner/isolation_resolver"
@@ -43,6 +44,7 @@ class Evilution::Runner
43
44
 
44
45
  def call
45
46
  install_signal_handlers
47
+ emit_parallel_db_warning
46
48
  start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
47
49
 
48
50
  subjects = subject_pipeline.call
@@ -150,6 +152,10 @@ class Evilution::Runner
150
152
  mutation_executor.call(mutations, baseline_result)
151
153
  end
152
154
 
155
+ def emit_parallel_db_warning
156
+ Evilution::ParallelDbWarning.warn_if_sqlite_parallel(config)
157
+ end
158
+
153
159
  def install_signal_handlers
154
160
  %w[INT TERM].each { |sig| install_signal_handler(sig) }
155
161
  end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "prism"
5
+ require_relative "../evilution"
6
+
7
+ # Content-hash-keyed LRU of Prism::ParseResult. Different source bytes always
8
+ # yield a different key, so the cache is valid for the lifetime of the process.
9
+ class Evilution::SourceAstCache
10
+ DEFAULT_MAX_ENTRIES = 50
11
+ private_constant :DEFAULT_MAX_ENTRIES
12
+
13
+ def initialize(max_entries: DEFAULT_MAX_ENTRIES)
14
+ @max_entries = max_entries
15
+ @entries = {}
16
+ end
17
+
18
+ def fetch(source)
19
+ return Prism.parse(source) if @max_entries <= 0
20
+
21
+ key = Digest::SHA256.digest(source)
22
+ if @entries.key?(key)
23
+ result = @entries.delete(key)
24
+ @entries[key] = result
25
+ return result
26
+ end
27
+
28
+ result = Prism.parse(source)
29
+ @entries[key] = result
30
+ evict_until_within_bounds
31
+ result
32
+ end
33
+
34
+ private
35
+
36
+ def evict_until_within_bounds
37
+ @entries.shift while @entries.length > @max_entries
38
+ end
39
+ end
@@ -0,0 +1,166 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+ require_relative "../evilution"
5
+
6
+ class Evilution::SpecAstCache
7
+ Block = Struct.new(:kind, :line, :end_line, :body_text)
8
+
9
+ BLOCK_METHODS = %i[
10
+ describe context fcontext xcontext
11
+ it fit xit specify
12
+ before after
13
+ ].freeze
14
+ private_constant :BLOCK_METHODS
15
+
16
+ DEFAULT_MAX_FILES = 50
17
+ DEFAULT_MAX_BLOCKS = 10_000
18
+ private_constant :DEFAULT_MAX_FILES, :DEFAULT_MAX_BLOCKS
19
+
20
+ def initialize(max_files: DEFAULT_MAX_FILES, max_blocks: DEFAULT_MAX_BLOCKS)
21
+ @max_files = max_files
22
+ @max_blocks = max_blocks
23
+ @entries = {}
24
+ @total_blocks = 0
25
+ end
26
+
27
+ def fetch(path)
28
+ if @entries.key?(path)
29
+ blocks = @entries.delete(path)
30
+ @entries[path] = blocks
31
+ return blocks
32
+ end
33
+
34
+ blocks = parse(path)
35
+ insert(path, blocks)
36
+ blocks
37
+ end
38
+
39
+ def cached?(path)
40
+ @entries.key?(path)
41
+ end
42
+
43
+ private
44
+
45
+ def insert(path, blocks)
46
+ @entries[path] = blocks
47
+ @total_blocks += blocks.length
48
+ evict_until_within_bounds
49
+ end
50
+
51
+ def evict_until_within_bounds
52
+ while @entries.length > @max_files || @total_blocks > @max_blocks
53
+ break if @entries.empty?
54
+
55
+ oldest_path = @entries.keys.first
56
+ evicted = @entries.delete(oldest_path)
57
+ @total_blocks -= evicted.length
58
+ end
59
+ end
60
+
61
+ def parse(path)
62
+ raise Evilution::ParseError.new("file not found: #{path}", file: path) unless File.exist?(path)
63
+
64
+ source = read_source(path)
65
+ result = Prism.parse(source)
66
+
67
+ if result.failure?
68
+ raise Evilution::ParseError.new(
69
+ "failed to parse #{path}: #{result.errors.map(&:message).join(", ")}",
70
+ file: path
71
+ )
72
+ end
73
+
74
+ comment_ranges = result.comments
75
+ .map { |c| c.location.start_offset...c.location.end_offset }
76
+ .sort_by(&:begin)
77
+ collector = BlockCollector.new(source, comment_ranges)
78
+ collector.visit(result.value)
79
+ collector.blocks
80
+ end
81
+
82
+ def read_source(path)
83
+ File.read(path)
84
+ rescue SystemCallError => e
85
+ raise Evilution::ParseError.new("cannot read #{path}: #{e.message}", file: path)
86
+ end
87
+
88
+ class BlockCollector < Prism::Visitor
89
+ attr_reader :blocks
90
+
91
+ def initialize(source, comment_ranges)
92
+ @source = source
93
+ @comment_ranges = comment_ranges
94
+ @blocks = []
95
+ super()
96
+ end
97
+
98
+ def visit_call_node(node)
99
+ @blocks << build_block(node) if block_method?(node)
100
+ super
101
+ end
102
+
103
+ private
104
+
105
+ def block_method?(node)
106
+ BLOCK_METHODS.include?(node.name) && node.block
107
+ end
108
+
109
+ def build_block(node)
110
+ location = node.location
111
+ Block.new(
112
+ node.name,
113
+ location.start_line,
114
+ location.end_line,
115
+ extract_body_text(node)
116
+ )
117
+ end
118
+
119
+ def extract_body_text(node)
120
+ block = node.block
121
+ return "" unless block
122
+
123
+ body = block.body
124
+ return "" unless body
125
+
126
+ start_off = body.location.start_offset
127
+ end_off = body.location.end_offset
128
+ slice = @source.byteslice(start_off, end_off - start_off) || ""
129
+ stripped = strip_comments(slice, start_off)
130
+ stripped.downcase
131
+ end
132
+
133
+ def strip_comments(slice, base_offset)
134
+ return slice if @comment_ranges.empty?
135
+
136
+ ranges = comment_ranges_within(base_offset, base_offset + slice.bytesize)
137
+ return slice if ranges.empty?
138
+
139
+ result = +""
140
+ cursor = base_offset
141
+ ranges.each do |range|
142
+ result << @source.byteslice(cursor, range.begin - cursor)
143
+ cursor = range.end
144
+ end
145
+ result << @source.byteslice(cursor, base_offset + slice.bytesize - cursor)
146
+ result
147
+ end
148
+
149
+ def comment_ranges_within(start_off, end_off)
150
+ lower = @comment_ranges.bsearch_index { |r| r.begin >= start_off }
151
+ return [] unless lower
152
+
153
+ result = []
154
+ idx = lower
155
+ while idx < @comment_ranges.length
156
+ range = @comment_ranges[idx]
157
+ break if range.begin >= end_off
158
+
159
+ result << range if range.end <= end_off
160
+ idx += 1
161
+ end
162
+ result
163
+ end
164
+ end
165
+ private_constant :BlockCollector
166
+ end
@@ -10,11 +10,12 @@ class Evilution::SpecResolver
10
10
  @request_dir = request_dir
11
11
  end
12
12
 
13
- def call(source_path)
13
+ def call(source_path, spec_pattern: nil)
14
14
  return nil if source_path.nil? || source_path.empty?
15
15
 
16
16
  normalized = normalize_path(source_path)
17
17
  candidates = candidate_test_paths(normalized)
18
+ candidates = filter_by_pattern(candidates, spec_pattern) if spec_pattern
18
19
  candidates.find { |path| File.exist?(path) }
19
20
  end
20
21
 
@@ -24,6 +25,10 @@ class Evilution::SpecResolver
24
25
 
25
26
  private
26
27
 
28
+ def filter_by_pattern(candidates, pattern)
29
+ candidates.select { |path| File.fnmatch?(pattern, path, File::FNM_PATHNAME | File::FNM_EXTGLOB) }
30
+ end
31
+
27
32
  def normalize_path(path)
28
33
  path = path.delete_prefix("./")
29
34
  path = path.delete_prefix("#{Dir.pwd}/") if path.start_with?("/")
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "spec_resolver"
4
+
5
+ class Evilution::SpecSelector
6
+ def initialize(spec_files: [], spec_mappings: {}, spec_pattern: nil, spec_resolver: Evilution::SpecResolver.new)
7
+ @spec_files = Array(spec_files)
8
+ @spec_mappings = spec_mappings || {}
9
+ @spec_pattern = spec_pattern
10
+ @spec_resolver = spec_resolver
11
+ end
12
+
13
+ def call(source_path)
14
+ return @spec_files unless @spec_files.empty?
15
+
16
+ mapped = mapping_for(source_path)
17
+ if mapped
18
+ existing = mapped.select { |path| File.exist?(path) }
19
+ return existing unless existing.empty?
20
+ end
21
+
22
+ resolved = @spec_resolver.call(source_path, spec_pattern: @spec_pattern)
23
+ resolved ? [resolved] : nil
24
+ end
25
+
26
+ private
27
+
28
+ def mapping_for(source_path)
29
+ @spec_mappings[normalize(source_path)]
30
+ end
31
+
32
+ def normalize(path)
33
+ return path if path.nil?
34
+
35
+ normalized = path.to_s
36
+ normalized = normalized.delete_prefix("#{Dir.pwd}/") if normalized.start_with?("/")
37
+ normalized.delete_prefix("./")
38
+ end
39
+ end
@@ -21,9 +21,15 @@ module Evilution::TempDirTracker
21
21
  end
22
22
 
23
23
  def self.cleanup_all
24
- @monitor.synchronize do
25
- @dirs.each { |d| FileUtils.rm_rf(d) }
26
- @dirs.clear
24
+ # Trap-safe: Signal.trap handlers forbid Monitor#synchronize, so both the
25
+ # snapshot and the per-dir tracking removal fall back to a lock-free path
26
+ # when ThreadError is raised. Successful removals drop the entry from
27
+ # @dirs; failures stay tracked so a later cleanup can retry.
28
+ snapshot_tracked_dirs.each do |d|
29
+ FileUtils.rm_rf(d)
30
+ remove_from_tracking(d)
31
+ rescue StandardError
32
+ nil
27
33
  end
28
34
  end
29
35
 
@@ -36,4 +42,18 @@ module Evilution::TempDirTracker
36
42
  @at_exit_registered = true
37
43
  end
38
44
  private_class_method :register_at_exit
45
+
46
+ def self.snapshot_tracked_dirs
47
+ @monitor.synchronize { @dirs.to_a }
48
+ rescue ThreadError
49
+ @dirs.to_a
50
+ end
51
+ private_class_method :snapshot_tracked_dirs
52
+
53
+ def self.remove_from_tracking(dir)
54
+ @monitor.synchronize { @dirs.delete(dir) }
55
+ rescue ThreadError
56
+ @dirs.delete(dir)
57
+ end
58
+ private_class_method :remove_from_tracking
39
59
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Evilution
4
- VERSION = "0.24.0"
4
+ VERSION = "0.26.0"
5
5
  end
data/script/memory_check CHANGED
@@ -7,8 +7,8 @@ require_relative "../lib/evilution/integration/rspec"
7
7
 
8
8
  FIXTURE = File.expand_path("../spec/support/fixtures/simple_class.rb", __dir__)
9
9
  FIXTURE_SPEC = File.expand_path("../spec/support/fixtures/simple_class_spec.rb", __dir__)
10
- COMPLEX_FIXTURE = File.expand_path("../lib/evilution/config.rb", __dir__)
11
- COMPLEX_FIXTURE_SPEC = File.expand_path("../spec/evilution/config_spec.rb", __dir__)
10
+ COMPLEX_FIXTURE = File.expand_path("../spec/support/fixtures/memory_check/target.rb", __dir__)
11
+ COMPLEX_FIXTURE_SPEC = File.expand_path("../spec/support/fixtures/memory_check/target_examples.rb", __dir__)
12
12
  ITERATIONS = Integer(ENV.fetch("MEMORY_CHECK_ITERATIONS", 50))
13
13
  MAX_GROWTH_KB = Integer(ENV.fetch("MEMORY_CHECK_MAX_GROWTH_KB", 10_240))
14
14
 
@@ -95,8 +95,10 @@ if mutations.size >= 2
95
95
  end
96
96
 
97
97
  # 5. RSpec integration per-mutation with complex fixture
98
- # Uses Config (227 LOC, 73 specs, 564 mutations) for realistic per-mutation load:
99
- # more ExampleGroup subclasses, deeper spec nesting, heavier metadata.
98
+ # Uses a frozen snapshot under spec/support/fixtures/memory_check/ for
99
+ # realistic per-mutation load: deep spec nesting, heavy metadata. Snapshot
100
+ # is intentional - decoupling the budget from project file growth so this
101
+ # check measures integration plumbing memory, not project size.
100
102
  complex_parser = Evilution::AST::Parser.new
101
103
  complex_registry = Evilution::Mutator::Registry.default
102
104
  complex_subjects = complex_parser.call(COMPLEX_FIXTURE)
@@ -105,7 +107,7 @@ complex_mutations = complex_subjects.flat_map { |s| complex_registry.mutations_f
105
107
  integration = Evilution::Integration::RSpec.new(test_files: [COMPLEX_FIXTURE_SPEC])
106
108
 
107
109
  # Budget is generous: per-mutation require() adds the file to $LOADED_FEATURES and
108
- # accumulates constant-redefinition warnings. Local runs land around 20-25 MB; CI
110
+ # accumulates constant-redefinition warnings. Local runs land around 20 MB; CI
109
111
  # varies up to ~30 MB depending on Ruby build and GC pressure, so we leave headroom.
110
112
  all_passed &= run_check("RSpec integration per-mutation (Config)", iterations: 20, max_growth_kb: 40_960) do
111
113
  mutation = complex_mutations.sample