evilution 0.6.0 → 0.8.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.
@@ -2,8 +2,10 @@
2
2
 
3
3
  require_relative "config"
4
4
  require_relative "ast/parser"
5
+ require_relative "memory"
5
6
  require_relative "mutator/registry"
6
7
  require_relative "isolation/fork"
8
+ require_relative "isolation/in_process"
7
9
  require_relative "integration/rspec"
8
10
  require_relative "reporter/json"
9
11
  require_relative "reporter/cli"
@@ -13,6 +15,7 @@ require_relative "diff/file_filter"
13
15
  require_relative "git/changed_files"
14
16
  require_relative "result/mutation_result"
15
17
  require_relative "result/summary"
18
+ require_relative "baseline"
16
19
  require_relative "parallel/pool"
17
20
 
18
21
  module Evilution
@@ -23,7 +26,7 @@ module Evilution
23
26
  @config = config
24
27
  @parser = AST::Parser.new
25
28
  @registry = Mutator::Registry.default
26
- @isolator = Isolation::Fork.new
29
+ @isolator = build_isolator
27
30
  end
28
31
 
29
32
  def call
@@ -33,8 +36,14 @@ module Evilution
33
36
  subjects = filter_by_target(subjects) if config.target?
34
37
  subjects = filter_by_line_ranges(subjects) if config.line_ranges?
35
38
  subjects = filter_by_diff(subjects) if config.diff?
39
+ log_memory("after parse_subjects", "#{subjects.length} subjects")
40
+
41
+ baseline_result = run_baseline(subjects)
42
+
36
43
  mutations = generate_mutations(subjects)
37
- results, truncated = run_mutations(mutations)
44
+ results, truncated = run_mutations(mutations, baseline_result)
45
+ log_memory("after run_mutations", "#{results.length} results")
46
+
38
47
  duration = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
39
48
 
40
49
  summary = Result::Summary.new(results: results, duration: duration, truncated: truncated)
@@ -83,19 +92,34 @@ module Evilution
83
92
  end
84
93
 
85
94
  def generate_mutations(subjects)
86
- subjects.flat_map { |subject| registry.mutations_for(subject) }
95
+ subjects.flat_map do |subject|
96
+ mutations = registry.mutations_for(subject)
97
+ subject.release_node!
98
+ mutations
99
+ end
100
+ end
101
+
102
+ def run_baseline(subjects)
103
+ return nil unless config.baseline? && subjects.any?
104
+
105
+ log_baseline_start
106
+ baseline = Baseline.new(timeout: config.timeout)
107
+ result = baseline.call(subjects)
108
+ log_baseline_complete(result)
109
+ result
87
110
  end
88
111
 
89
- def run_mutations(mutations)
112
+ def run_mutations(mutations, baseline_result = nil)
90
113
  if config.jobs > 1
91
- run_mutations_parallel(mutations)
114
+ run_mutations_parallel(mutations, baseline_result)
92
115
  else
93
- run_mutations_sequential(mutations)
116
+ run_mutations_sequential(mutations, baseline_result)
94
117
  end
95
118
  end
96
119
 
97
- def run_mutations_sequential(mutations)
120
+ def run_mutations_sequential(mutations, baseline_result = nil)
98
121
  integration = build_integration
122
+ spec_resolver = baseline_result&.failed? ? SpecResolver.new : nil
99
123
  results = []
100
124
  survived_count = 0
101
125
  truncated = false
@@ -107,11 +131,14 @@ module Evilution
107
131
  test_command: test_command,
108
132
  timeout: config.timeout
109
133
  )
134
+ mutation.strip_sources!
135
+ result = neutralize_if_baseline_failed(result, baseline_result, spec_resolver)
110
136
  results << result
111
137
  survived_count += 1 if result.survived?
112
- log_progress(index + 1, mutations.length, result.status)
138
+ log_progress(index + 1, result.status)
139
+ log_mutation_diagnostics(result)
113
140
 
114
- if config.fail_fast? && survived_count >= config.fail_fast && index < mutations.length - 1
141
+ if config.fail_fast? && survived_count >= config.fail_fast
115
142
  truncated = true
116
143
  break
117
144
  end
@@ -120,37 +147,105 @@ module Evilution
120
147
  [results, truncated]
121
148
  end
122
149
 
123
- def run_mutations_parallel(mutations)
150
+ def run_mutations_parallel(mutations, baseline_result = nil)
124
151
  integration = build_integration
125
152
  pool = Parallel::Pool.new(size: config.jobs)
126
- results = []
127
- survived_count = 0
128
- truncated = false
129
- completed = 0
153
+ worker_isolator = Isolation::InProcess.new
154
+ spec_resolver = baseline_result&.failed? ? SpecResolver.new : nil
155
+ state = { results: [], survived_count: 0, truncated: false, completed: 0 }
130
156
 
131
157
  mutations.each_slice(config.jobs) do |batch|
132
- break if truncated
158
+ break if state[:truncated]
133
159
 
134
- batch_results = pool.map(batch) do |mutation|
160
+ compact_results = pool.map(batch) do |mutation|
135
161
  test_command = ->(m) { integration.call(m) }
136
- isolator.call(mutation: mutation, test_command: test_command, timeout: config.timeout)
162
+ result = worker_isolator.call(mutation: mutation, test_command: test_command, timeout: config.timeout)
163
+ compact_result(result)
137
164
  end
138
165
 
139
- batch_results.each do |result|
140
- results << result
141
- survived_count += 1 if result.survived?
142
- completed += 1
143
- log_progress(completed, mutations.length, result.status)
144
- end
166
+ batch.each(&:strip_sources!)
167
+ batch_results = rebuild_results(batch, compact_results)
168
+ process_batch(batch_results, baseline_result, spec_resolver, state)
169
+ end
170
+
171
+ [state[:results], state[:truncated]]
172
+ end
145
173
 
146
- truncated = true if should_truncate?(survived_count, completed, mutations.length)
174
+ def process_batch(batch_results, baseline_result, spec_resolver, state)
175
+ batch_results.each do |result|
176
+ result = neutralize_if_baseline_failed(result, baseline_result, spec_resolver)
177
+ state[:results] << result
178
+ state[:survived_count] += 1 if result.survived?
179
+ state[:completed] += 1
180
+ log_progress(state[:completed], result.status)
181
+ log_mutation_diagnostics(result)
147
182
  end
148
183
 
149
- [results, truncated]
184
+ log_memory("after batch", "#{state[:completed]} complete")
185
+ state[:truncated] = true if should_truncate?(state[:survived_count])
186
+ end
187
+
188
+ def neutralize_if_baseline_failed(result, baseline_result, spec_resolver)
189
+ return result unless result.survived? && baseline_result && baseline_result.failed?
190
+
191
+ if config.spec_files.any?
192
+ neutralize = true
193
+ else
194
+ spec_file = spec_resolver.call(result.mutation.file_path) || "spec"
195
+ neutralize = baseline_result.failed_spec_files.include?(spec_file)
196
+ end
197
+ return result unless neutralize
198
+
199
+ Result::MutationResult.new(
200
+ mutation: result.mutation,
201
+ status: :neutral,
202
+ duration: result.duration,
203
+ test_command: result.test_command,
204
+ child_rss_kb: result.child_rss_kb,
205
+ memory_delta_kb: result.memory_delta_kb
206
+ )
207
+ end
208
+
209
+ def compact_result(result)
210
+ {
211
+ status: result.status,
212
+ duration: result.duration,
213
+ killing_test: result.killing_test,
214
+ test_command: result.test_command,
215
+ child_rss_kb: result.child_rss_kb,
216
+ memory_delta_kb: result.memory_delta_kb
217
+ }
150
218
  end
151
219
 
152
- def should_truncate?(survived_count, completed, total)
153
- config.fail_fast? && survived_count >= config.fail_fast && completed < total
220
+ def rebuild_results(batch, compact_results)
221
+ batch.zip(compact_results).map do |mutation, data|
222
+ Result::MutationResult.new(
223
+ mutation: mutation,
224
+ status: data[:status],
225
+ duration: data[:duration],
226
+ killing_test: data[:killing_test],
227
+ test_command: data[:test_command],
228
+ child_rss_kb: data[:child_rss_kb],
229
+ memory_delta_kb: data[:memory_delta_kb]
230
+ )
231
+ end
232
+ end
233
+
234
+ def should_truncate?(survived_count)
235
+ config.fail_fast? && survived_count >= config.fail_fast
236
+ end
237
+
238
+ def build_isolator
239
+ case resolve_isolation
240
+ when :fork then Isolation::Fork.new
241
+ when :in_process then Isolation::InProcess.new
242
+ end
243
+ end
244
+
245
+ def resolve_isolation
246
+ return :fork if config.isolation == :fork
247
+
248
+ :in_process
154
249
  end
155
250
 
156
251
  def build_integration
@@ -171,10 +266,64 @@ module Evilution
171
266
  $stdout.puts(output) unless config.quiet
172
267
  end
173
268
 
174
- def log_progress(current, total, status)
269
+ def log_baseline_start
270
+ return if config.quiet || !config.text? || !$stderr.tty?
271
+
272
+ $stderr.write("Running baseline test suite...\n")
273
+ end
274
+
275
+ def log_baseline_complete(result)
175
276
  return if config.quiet || !config.text? || !$stderr.tty?
176
277
 
177
- $stderr.write("mutation #{current}/#{total} #{status}\n")
278
+ count = result.failed_spec_files.size
279
+ $stderr.write("Baseline complete: #{count} failing spec file#{"s" unless count == 1}\n")
280
+ end
281
+
282
+ def log_progress(current, status)
283
+ return if config.quiet || !config.text? || !$stderr.tty?
284
+
285
+ $stderr.write("mutation #{current} #{status}\n")
286
+ end
287
+
288
+ def log_memory(phase, context = nil)
289
+ return unless config.verbose && !config.quiet
290
+
291
+ rss = Memory.rss_mb
292
+ return unless rss
293
+
294
+ gc = gc_stats_string
295
+ msg = format("[memory] %<phase>s: %<rss>.1f MB", phase: phase, rss: rss)
296
+ context = [context, gc].compact.join(", ")
297
+ msg += " (#{context})" unless context.empty?
298
+ $stderr.write("#{msg}\n")
299
+ end
300
+
301
+ def log_mutation_diagnostics(result)
302
+ return unless config.verbose && !config.quiet
303
+
304
+ parts = []
305
+ parts << format("child_rss: %<mb>.1f MB", mb: result.child_rss_kb / 1024.0) if result.child_rss_kb
306
+
307
+ if result.memory_delta_kb
308
+ sign = result.memory_delta_kb.negative? ? "" : "+"
309
+ parts << format("delta: %<sign>s%<mb>.1f MB", sign: sign, mb: result.memory_delta_kb / 1024.0)
310
+ end
311
+
312
+ parts << gc_stats_string
313
+
314
+ return if parts.empty?
315
+
316
+ $stderr.write("[verbose] #{result.mutation}: #{parts.join(", ")}\n")
317
+ end
318
+
319
+ def gc_stats_string
320
+ stats = GC.stat
321
+ format(
322
+ "heap_live_slots: %<live>d, allocated: %<alloc>d, freed: %<freed>d",
323
+ live: stats[:heap_live_slots],
324
+ alloc: stats[:total_allocated_objects],
325
+ freed: stats[:total_freed_objects]
326
+ )
178
327
  end
179
328
 
180
329
  def build_reporter
@@ -10,7 +10,10 @@ module Evilution
10
10
  @line_number = line_number
11
11
  @source = source
12
12
  @node = node
13
- freeze
13
+ end
14
+
15
+ def release_node!
16
+ @node = nil
14
17
  end
15
18
 
16
19
  def to_s
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Evilution
4
- VERSION = "0.6.0"
4
+ VERSION = "0.8.0"
5
5
  end
data/lib/evilution.rb CHANGED
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "evilution/version"
4
+ require_relative "evilution/memory"
4
5
  require_relative "evilution/config"
5
6
  require_relative "evilution/subject"
6
7
  require_relative "evilution/mutation"
@@ -26,8 +27,14 @@ require_relative "evilution/mutator/operator/method_body_replacement"
26
27
  require_relative "evilution/mutator/operator/return_value_removal"
27
28
  require_relative "evilution/mutator/operator/collection_replacement"
28
29
  require_relative "evilution/mutator/operator/method_call_removal"
30
+ require_relative "evilution/mutator/operator/argument_removal"
31
+ require_relative "evilution/mutator/operator/block_removal"
32
+ require_relative "evilution/mutator/operator/conditional_flip"
33
+ require_relative "evilution/mutator/operator/range_replacement"
34
+ require_relative "evilution/mutator/operator/regexp_mutation"
29
35
  require_relative "evilution/mutator/registry"
30
36
  require_relative "evilution/isolation/fork"
37
+ require_relative "evilution/isolation/in_process"
31
38
  require_relative "evilution/parallel/pool"
32
39
  require_relative "evilution/diff/parser"
33
40
  require_relative "evilution/diff/file_filter"
@@ -42,6 +49,7 @@ require_relative "evilution/reporter/suggestion"
42
49
  require_relative "evilution/coverage/collector"
43
50
  require_relative "evilution/coverage/test_map"
44
51
  require_relative "evilution/spec_resolver"
52
+ require_relative "evilution/baseline"
45
53
  require_relative "evilution/cli"
46
54
  require_relative "evilution/runner"
47
55
 
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ namespace :memory do
4
+ desc "Run memory leak checks against fixture workload"
5
+ task :check do
6
+ script = File.expand_path("../../script/memory_check", __dir__)
7
+ system("ruby", script) || abort("Memory check failed!")
8
+ end
9
+ end
@@ -0,0 +1,94 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "../lib/evilution"
5
+ require_relative "../lib/evilution/memory/leak_check"
6
+
7
+ FIXTURE = File.expand_path("../spec/support/fixtures/simple_class.rb", __dir__)
8
+ ITERATIONS = Integer(ENV.fetch("MEMORY_CHECK_ITERATIONS", 50))
9
+ MAX_GROWTH_KB = Integer(ENV.fetch("MEMORY_CHECK_MAX_GROWTH_KB", 10_240))
10
+
11
+ def setup_workload
12
+ parser = Evilution::AST::Parser.new
13
+ registry = Evilution::Mutator::Registry.default
14
+ subjects = parser.call(FIXTURE)
15
+ mutations = subjects.flat_map { |s| registry.mutations_for(s) }
16
+ abort("No mutations found in fixture") if mutations.empty?
17
+ mutations
18
+ end
19
+
20
+ def run_check(name, iterations: ITERATIONS, max_growth_kb: MAX_GROWTH_KB, &)
21
+ check = Evilution::Memory::LeakCheck.new(iterations: iterations, max_growth_kb: max_growth_kb)
22
+ result = check.run(&)
23
+ report(name, result)
24
+ result[:passed]
25
+ end
26
+
27
+ def report(name, result)
28
+ status = result[:passed] ? "PASS" : "FAIL"
29
+ growth = result[:growth_mb] ? format("%.1f MB", result[:growth_mb]) : "N/A"
30
+ max = format("%.1f MB", result[:max_growth_kb] / 1024.0)
31
+ samples = result[:samples].map { |s| s ? format("%.1f", s / 1024.0) : "N/A" }.join(" -> ")
32
+
33
+ puts "[#{status}] #{name}"
34
+ puts " Growth: #{growth} (max: #{max})"
35
+ puts " Samples (MB): #{samples}"
36
+ puts
37
+ end
38
+
39
+ abort("RSS measurement unavailable (requires /proc filesystem)") unless Evilution::Memory.rss_kb
40
+
41
+ mutations = setup_workload
42
+ stub_test_command = ->(_m) { { passed: false } }
43
+ all_passed = true
44
+
45
+ # 1. InProcess isolation
46
+ all_passed &= run_check("InProcess isolation") do
47
+ mutation = mutations.sample
48
+ isolator = Evilution::Isolation::InProcess.new
49
+ isolator.call(mutation: mutation, test_command: stub_test_command, timeout: 5)
50
+ end
51
+
52
+ # 2. Fork isolation
53
+ all_passed &= run_check("Fork isolation") do
54
+ mutation = mutations.sample
55
+ isolator = Evilution::Isolation::Fork.new
56
+ isolator.call(mutation: mutation, test_command: stub_test_command, timeout: 5)
57
+ end
58
+
59
+ # 3. Mutation generation + stripping
60
+ parser = Evilution::AST::Parser.new
61
+ registry = Evilution::Mutator::Registry.default
62
+
63
+ all_passed &= run_check("Mutation generation + stripping") do
64
+ subjects = parser.call(FIXTURE)
65
+ new_mutations = subjects.flat_map { |s| registry.mutations_for(s) }
66
+ subjects.each(&:release_node!)
67
+ new_mutations.each(&:strip_sources!)
68
+ end
69
+
70
+ # 4. Parallel pool with compact serialization
71
+ if mutations.size >= 2
72
+ all_passed &= run_check("Parallel pool (compact)", iterations: 20) do
73
+ pool = Evilution::Parallel::Pool.new(size: 2)
74
+ batch = mutations.first(2)
75
+ worker_isolator = Evilution::Isolation::InProcess.new
76
+
77
+ compact_results = pool.map(batch) do |mutation|
78
+ result = worker_isolator.call(mutation: mutation, test_command: stub_test_command, timeout: 5)
79
+ { status: result.status, duration: result.duration,
80
+ child_rss_kb: result.child_rss_kb, memory_delta_kb: result.memory_delta_kb }
81
+ end
82
+
83
+ batch.each(&:strip_sources!)
84
+ batch.zip(compact_results).map do |mutation, data|
85
+ Evilution::Result::MutationResult.new(
86
+ mutation: mutation, status: data[:status], duration: data[:duration],
87
+ child_rss_kb: data[:child_rss_kb], memory_delta_kb: data[:memory_delta_kb]
88
+ )
89
+ end
90
+ end
91
+ end
92
+
93
+ puts all_passed ? "All memory checks passed." : "Some memory checks failed!"
94
+ exit(all_passed ? 0 : 1)
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: evilution
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.0
4
+ version: 0.8.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Denis Kiselev
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-03-17 00:00:00.000000000 Z
11
+ date: 2026-03-19 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: diff-lcs
@@ -80,6 +80,7 @@ files:
80
80
  - lib/evilution.rb
81
81
  - lib/evilution/ast/parser.rb
82
82
  - lib/evilution/ast/source_surgeon.rb
83
+ - lib/evilution/baseline.rb
83
84
  - lib/evilution/cli.rb
84
85
  - lib/evilution/config.rb
85
86
  - lib/evilution/coverage/collector.rb
@@ -90,17 +91,23 @@ files:
90
91
  - lib/evilution/integration/base.rb
91
92
  - lib/evilution/integration/rspec.rb
92
93
  - lib/evilution/isolation/fork.rb
94
+ - lib/evilution/isolation/in_process.rb
93
95
  - lib/evilution/mcp/mutate_tool.rb
94
96
  - lib/evilution/mcp/server.rb
97
+ - lib/evilution/memory.rb
98
+ - lib/evilution/memory/leak_check.rb
95
99
  - lib/evilution/mutation.rb
96
100
  - lib/evilution/mutator/base.rb
101
+ - lib/evilution/mutator/operator/argument_removal.rb
97
102
  - lib/evilution/mutator/operator/arithmetic_replacement.rb
98
103
  - lib/evilution/mutator/operator/array_literal.rb
104
+ - lib/evilution/mutator/operator/block_removal.rb
99
105
  - lib/evilution/mutator/operator/boolean_literal_replacement.rb
100
106
  - lib/evilution/mutator/operator/boolean_operator_replacement.rb
101
107
  - lib/evilution/mutator/operator/collection_replacement.rb
102
108
  - lib/evilution/mutator/operator/comparison_replacement.rb
103
109
  - lib/evilution/mutator/operator/conditional_branch.rb
110
+ - lib/evilution/mutator/operator/conditional_flip.rb
104
111
  - lib/evilution/mutator/operator/conditional_negation.rb
105
112
  - lib/evilution/mutator/operator/float_literal.rb
106
113
  - lib/evilution/mutator/operator/hash_literal.rb
@@ -109,6 +116,8 @@ files:
109
116
  - lib/evilution/mutator/operator/method_call_removal.rb
110
117
  - lib/evilution/mutator/operator/negation_insertion.rb
111
118
  - lib/evilution/mutator/operator/nil_replacement.rb
119
+ - lib/evilution/mutator/operator/range_replacement.rb
120
+ - lib/evilution/mutator/operator/regexp_mutation.rb
112
121
  - lib/evilution/mutator/operator/return_value_removal.rb
113
122
  - lib/evilution/mutator/operator/statement_deletion.rb
114
123
  - lib/evilution/mutator/operator/string_literal.rb
@@ -124,6 +133,8 @@ files:
124
133
  - lib/evilution/spec_resolver.rb
125
134
  - lib/evilution/subject.rb
126
135
  - lib/evilution/version.rb
136
+ - lib/tasks/memory_check.rake
137
+ - script/memory_check
127
138
  - sig/evilution.rbs
128
139
  homepage: https://github.com/marinazzio/evilution
129
140
  licenses: