evilution 0.8.0 → 0.10.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.
@@ -9,13 +9,14 @@ require_relative "isolation/in_process"
9
9
  require_relative "integration/rspec"
10
10
  require_relative "reporter/json"
11
11
  require_relative "reporter/cli"
12
+ require_relative "reporter/html"
12
13
  require_relative "reporter/suggestion"
13
- require_relative "diff/parser"
14
- require_relative "diff/file_filter"
14
+ require_relative "equivalent/detector"
15
15
  require_relative "git/changed_files"
16
16
  require_relative "result/mutation_result"
17
17
  require_relative "result/summary"
18
18
  require_relative "baseline"
19
+ require_relative "cache"
19
20
  require_relative "parallel/pool"
20
21
 
21
22
  module Evilution
@@ -27,6 +28,7 @@ module Evilution
27
28
  @parser = AST::Parser.new
28
29
  @registry = Mutator::Registry.default
29
30
  @isolator = build_isolator
31
+ @cache = config.incremental? ? Cache.new : nil
30
32
  end
31
33
 
32
34
  def call
@@ -35,13 +37,18 @@ module Evilution
35
37
  subjects = parse_subjects
36
38
  subjects = filter_by_target(subjects) if config.target?
37
39
  subjects = filter_by_line_ranges(subjects) if config.line_ranges?
38
- subjects = filter_by_diff(subjects) if config.diff?
39
40
  log_memory("after parse_subjects", "#{subjects.length} subjects")
40
41
 
41
42
  baseline_result = run_baseline(subjects)
42
43
 
43
44
  mutations = generate_mutations(subjects)
45
+ equivalent_mutations, mutations = filter_equivalent(mutations)
46
+ release_subject_nodes(subjects)
44
47
  results, truncated = run_mutations(mutations, baseline_result)
48
+ results += equivalent_mutations.map do |m|
49
+ m.strip_sources!
50
+ equivalent_result(m)
51
+ end
45
52
  log_memory("after run_mutations", "#{results.length} results")
46
53
 
47
54
  duration = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
@@ -54,7 +61,7 @@ module Evilution
54
61
 
55
62
  private
56
63
 
57
- attr_reader :parser, :registry, :isolator
64
+ attr_reader :parser, :registry, :isolator, :cache
58
65
 
59
66
  def parse_subjects
60
67
  files = resolve_target_files
@@ -68,7 +75,11 @@ module Evilution
68
75
  end
69
76
 
70
77
  def filter_by_target(subjects)
71
- matched = subjects.select { |s| s.name == config.target }
78
+ matched = if config.target.include?("#")
79
+ subjects.select { |s| s.name == config.target }
80
+ else
81
+ subjects.select { |s| s.name.start_with?("#{config.target}#") }
82
+ end
72
83
  raise Error, "no method found matching '#{config.target}'" if matched.empty?
73
84
 
74
85
  matched
@@ -85,20 +96,24 @@ module Evilution
85
96
  end
86
97
  end
87
98
 
88
- def filter_by_diff(subjects)
89
- diff_parser = Diff::Parser.new
90
- changed_ranges = diff_parser.parse(config.diff_base)
91
- Diff::FileFilter.new.filter(subjects, changed_ranges)
92
- end
93
-
94
99
  def generate_mutations(subjects)
95
100
  subjects.flat_map do |subject|
96
- mutations = registry.mutations_for(subject)
97
- subject.release_node!
98
- mutations
101
+ registry.mutations_for(subject)
99
102
  end
100
103
  end
101
104
 
105
+ def filter_equivalent(mutations)
106
+ Equivalent::Detector.new.call(mutations)
107
+ end
108
+
109
+ def release_subject_nodes(subjects)
110
+ subjects.each(&:release_node!)
111
+ end
112
+
113
+ def equivalent_result(mutation)
114
+ Result::MutationResult.new(mutation: mutation, status: :equivalent, duration: 0.0)
115
+ end
116
+
102
117
  def run_baseline(subjects)
103
118
  return nil unless config.baseline? && subjects.any?
104
119
 
@@ -125,12 +140,10 @@ module Evilution
125
140
  truncated = false
126
141
 
127
142
  mutations.each_with_index do |mutation, index|
128
- test_command = ->(m) { integration.call(m) }
129
- result = isolator.call(
130
- mutation: mutation,
131
- test_command: test_command,
132
- timeout: config.timeout
133
- )
143
+ result = execute_or_fetch(mutation) do
144
+ test_command = ->(m) { integration.call(m) }
145
+ isolator.call(mutation: mutation, test_command: test_command, timeout: config.timeout)
146
+ end
134
147
  mutation.strip_sources!
135
148
  result = neutralize_if_baseline_failed(result, baseline_result, spec_resolver)
136
149
  results << result
@@ -157,20 +170,34 @@ module Evilution
157
170
  mutations.each_slice(config.jobs) do |batch|
158
171
  break if state[:truncated]
159
172
 
160
- compact_results = pool.map(batch) do |mutation|
161
- test_command = ->(m) { integration.call(m) }
162
- result = worker_isolator.call(mutation: mutation, test_command: test_command, timeout: config.timeout)
163
- compact_result(result)
164
- end
165
-
166
- batch.each(&:strip_sources!)
167
- batch_results = rebuild_results(batch, compact_results)
173
+ batch_results = run_parallel_batch(batch, pool, worker_isolator, integration)
168
174
  process_batch(batch_results, baseline_result, spec_resolver, state)
169
175
  end
170
176
 
171
177
  [state[:results], state[:truncated]]
172
178
  end
173
179
 
180
+ def run_parallel_batch(batch, pool, worker_isolator, integration)
181
+ uncached_indices, cached_results = partition_cached(batch)
182
+ worker_results = run_uncached_workers(batch, uncached_indices, pool, worker_isolator, integration)
183
+ compact_results = merge_parallel_results(batch, uncached_indices, cached_results, worker_results)
184
+ batch.each(&:strip_sources!)
185
+ batch_results = rebuild_results(batch, compact_results)
186
+ batch_results.each { |r| store_cached_result(r.mutation, r) }
187
+ batch_results
188
+ end
189
+
190
+ def run_uncached_workers(batch, uncached_indices, pool, worker_isolator, integration)
191
+ return [] if uncached_indices.empty?
192
+
193
+ uncached = uncached_indices.map { |i| batch[i] }
194
+ pool.map(uncached) do |mutation|
195
+ test_command = ->(m) { integration.call(m) }
196
+ result = worker_isolator.call(mutation: mutation, test_command: test_command, timeout: config.timeout)
197
+ compact_result(result)
198
+ end
199
+ end
200
+
174
201
  def process_batch(batch_results, baseline_result, spec_resolver, state)
175
202
  batch_results.each do |result|
176
203
  result = neutralize_if_baseline_failed(result, baseline_result, spec_resolver)
@@ -263,7 +290,15 @@ module Evilution
263
290
  return unless reporter
264
291
 
265
292
  output = reporter.call(summary)
266
- $stdout.puts(output) unless config.quiet
293
+ return if config.quiet
294
+
295
+ if config.html?
296
+ path = "evilution-report.html"
297
+ File.write(path, output)
298
+ warn "HTML report written to #{path}"
299
+ else
300
+ $stdout.puts(output)
301
+ end
267
302
  end
268
303
 
269
304
  def log_baseline_start
@@ -332,7 +367,67 @@ module Evilution
332
367
  Reporter::JSON.new
333
368
  when :text
334
369
  Reporter::CLI.new
370
+ when :html
371
+ Reporter::HTML.new
372
+ end
373
+ end
374
+
375
+ def partition_cached(batch)
376
+ uncached_indices = []
377
+ cached_results = {}
378
+
379
+ batch.each_with_index do |mutation, i|
380
+ cached = fetch_cached_result(mutation)
381
+ if cached
382
+ cached_results[i] = compact_result(cached)
383
+ else
384
+ uncached_indices << i
385
+ end
335
386
  end
387
+
388
+ [uncached_indices, cached_results]
389
+ end
390
+
391
+ def merge_parallel_results(batch, uncached_indices, cached_results, worker_results)
392
+ result_map = cached_results.dup
393
+ uncached_indices.each_with_index { |batch_idx, worker_idx| result_map[batch_idx] = worker_results[worker_idx] }
394
+ batch.each_index.map { |i| result_map[i] }
395
+ end
396
+
397
+ def execute_or_fetch(mutation)
398
+ cached = fetch_cached_result(mutation)
399
+ return cached if cached
400
+
401
+ result = yield
402
+ store_cached_result(mutation, result)
403
+ result
404
+ end
405
+
406
+ def fetch_cached_result(mutation)
407
+ return nil unless cache
408
+
409
+ data = cache.fetch(mutation)
410
+ return nil unless data
411
+ return nil unless %i[killed timeout].include?(data[:status])
412
+
413
+ Result::MutationResult.new(
414
+ mutation: mutation,
415
+ status: data[:status],
416
+ duration: data[:duration],
417
+ killing_test: data[:killing_test],
418
+ test_command: data[:test_command]
419
+ )
420
+ end
421
+
422
+ def store_cached_result(mutation, result)
423
+ return unless cache
424
+ return unless result.killed? || result.timeout?
425
+
426
+ cache.store(mutation,
427
+ status: result.status,
428
+ duration: result.duration,
429
+ killing_test: result.killing_test,
430
+ test_command: result.test_command)
336
431
  end
337
432
  end
338
433
  end
@@ -28,13 +28,35 @@ module Evilution
28
28
  base = source_path.sub(/\.rb\z/, "_spec.rb")
29
29
  prefix = STRIPPABLE_PREFIXES.find { |p| source_path.start_with?(p) }
30
30
 
31
- if prefix
32
- stripped = "spec/#{base.delete_prefix(prefix)}"
33
- kept = "spec/#{base}"
34
- [stripped, kept]
35
- else
36
- ["spec/#{base}"]
31
+ candidates = if prefix
32
+ stripped = base.delete_prefix(prefix)
33
+ ["spec/#{stripped}", "spec/#{base}"]
34
+ else
35
+ ["spec/#{base}"]
36
+ end
37
+
38
+ fallbacks = candidates.flat_map { |c| parent_fallback_candidates(c) }.uniq
39
+ candidates + fallbacks
40
+ end
41
+
42
+ def parent_fallback_candidates(spec_path)
43
+ parts = spec_path.split("/")
44
+ # parts: ["spec", "foo", "bar_spec.rb"] — need at least 3 parts for fallback
45
+ return [] if parts.length < 3
46
+
47
+ candidates = []
48
+ # Remove filename, then progressively remove directories
49
+ dir_parts = parts[1..-2] # ["models", "game"]
50
+ (dir_parts.length - 1).downto(0) do |i|
51
+ file = "#{dir_parts[i]}_spec.rb"
52
+ if i.zero?
53
+ candidates << "spec/#{file}"
54
+ else
55
+ parent = dir_parts[0...i].join("/")
56
+ candidates << "spec/#{parent}/#{file}"
57
+ end
37
58
  end
59
+ candidates
38
60
  end
39
61
  end
40
62
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Evilution
4
- VERSION = "0.8.0"
4
+ VERSION = "0.10.0"
5
5
  end
data/lib/evilution.rb CHANGED
@@ -32,12 +32,14 @@ require_relative "evilution/mutator/operator/block_removal"
32
32
  require_relative "evilution/mutator/operator/conditional_flip"
33
33
  require_relative "evilution/mutator/operator/range_replacement"
34
34
  require_relative "evilution/mutator/operator/regexp_mutation"
35
+ require_relative "evilution/mutator/operator/receiver_replacement"
36
+ require_relative "evilution/mutator/operator/send_mutation"
37
+ require_relative "evilution/mutator/operator/argument_nil_substitution"
35
38
  require_relative "evilution/mutator/registry"
39
+ require_relative "evilution/equivalent/detector"
36
40
  require_relative "evilution/isolation/fork"
37
41
  require_relative "evilution/isolation/in_process"
38
42
  require_relative "evilution/parallel/pool"
39
- require_relative "evilution/diff/parser"
40
- require_relative "evilution/diff/file_filter"
41
43
  require_relative "evilution/git/changed_files"
42
44
  require_relative "evilution/integration/base"
43
45
  require_relative "evilution/integration/rspec"
@@ -45,11 +47,11 @@ require_relative "evilution/result/mutation_result"
45
47
  require_relative "evilution/result/summary"
46
48
  require_relative "evilution/reporter/json"
47
49
  require_relative "evilution/reporter/cli"
50
+ require_relative "evilution/reporter/html"
48
51
  require_relative "evilution/reporter/suggestion"
49
- require_relative "evilution/coverage/collector"
50
- require_relative "evilution/coverage/test_map"
51
52
  require_relative "evilution/spec_resolver"
52
53
  require_relative "evilution/baseline"
54
+ require_relative "evilution/cache"
53
55
  require_relative "evilution/cli"
54
56
  require_relative "evilution/runner"
55
57
 
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.8.0
4
+ version: 0.10.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-19 00:00:00.000000000 Z
11
+ date: 2026-03-20 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: diff-lcs
@@ -81,12 +81,14 @@ files:
81
81
  - lib/evilution/ast/parser.rb
82
82
  - lib/evilution/ast/source_surgeon.rb
83
83
  - lib/evilution/baseline.rb
84
+ - lib/evilution/cache.rb
84
85
  - lib/evilution/cli.rb
85
86
  - lib/evilution/config.rb
86
- - lib/evilution/coverage/collector.rb
87
- - lib/evilution/coverage/test_map.rb
88
- - lib/evilution/diff/file_filter.rb
89
- - lib/evilution/diff/parser.rb
87
+ - lib/evilution/equivalent/detector.rb
88
+ - lib/evilution/equivalent/heuristic/alias_swap.rb
89
+ - lib/evilution/equivalent/heuristic/dead_code.rb
90
+ - lib/evilution/equivalent/heuristic/method_body_nil.rb
91
+ - lib/evilution/equivalent/heuristic/noop_source.rb
90
92
  - lib/evilution/git/changed_files.rb
91
93
  - lib/evilution/integration/base.rb
92
94
  - lib/evilution/integration/rspec.rb
@@ -98,6 +100,7 @@ files:
98
100
  - lib/evilution/memory/leak_check.rb
99
101
  - lib/evilution/mutation.rb
100
102
  - lib/evilution/mutator/base.rb
103
+ - lib/evilution/mutator/operator/argument_nil_substitution.rb
101
104
  - lib/evilution/mutator/operator/argument_removal.rb
102
105
  - lib/evilution/mutator/operator/arithmetic_replacement.rb
103
106
  - lib/evilution/mutator/operator/array_literal.rb
@@ -117,14 +120,17 @@ files:
117
120
  - lib/evilution/mutator/operator/negation_insertion.rb
118
121
  - lib/evilution/mutator/operator/nil_replacement.rb
119
122
  - lib/evilution/mutator/operator/range_replacement.rb
123
+ - lib/evilution/mutator/operator/receiver_replacement.rb
120
124
  - lib/evilution/mutator/operator/regexp_mutation.rb
121
125
  - lib/evilution/mutator/operator/return_value_removal.rb
126
+ - lib/evilution/mutator/operator/send_mutation.rb
122
127
  - lib/evilution/mutator/operator/statement_deletion.rb
123
128
  - lib/evilution/mutator/operator/string_literal.rb
124
129
  - lib/evilution/mutator/operator/symbol_literal.rb
125
130
  - lib/evilution/mutator/registry.rb
126
131
  - lib/evilution/parallel/pool.rb
127
132
  - lib/evilution/reporter/cli.rb
133
+ - lib/evilution/reporter/html.rb
128
134
  - lib/evilution/reporter/json.rb
129
135
  - lib/evilution/reporter/suggestion.rb
130
136
  - lib/evilution/result/mutation_result.rb
@@ -1,47 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "coverage"
4
- require "stringio"
5
-
6
- module Evilution
7
- module Coverage
8
- class Collector
9
- def call(test_files:)
10
- read_io, write_io = IO.pipe
11
-
12
- pid = ::Process.fork do
13
- read_io.close
14
- result = collect_coverage(test_files)
15
- Marshal.dump(result, write_io)
16
- write_io.close
17
- exit!(0)
18
- end
19
-
20
- write_io.close
21
- data = read_io.read
22
- read_io.close
23
- ::Process.wait(pid)
24
-
25
- Marshal.load(data) # rubocop:disable Security/MarshalLoad
26
- ensure
27
- read_io&.close
28
- write_io&.close
29
- end
30
-
31
- private
32
-
33
- def collect_coverage(test_files)
34
- ::Coverage.start
35
- return ::Coverage.result if test_files.empty?
36
-
37
- require "rspec/core"
38
- ::RSpec.reset
39
- ::RSpec::Core::Runner.run(
40
- ["--format", "progress", "--no-color", "--order", "defined", *test_files],
41
- StringIO.new, StringIO.new
42
- )
43
- ::Coverage.result
44
- end
45
- end
46
- end
47
- end
@@ -1,25 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Evilution
4
- module Coverage
5
- class TestMap
6
- def initialize(coverage_data)
7
- @coverage_data = coverage_data
8
- end
9
-
10
- # Returns true if the given source line was executed during tests.
11
- # file_path should be an absolute path matching coverage data keys.
12
- # line is 1-based (editor line numbers).
13
- def covered?(file_path, line)
14
- line_data = @coverage_data[file_path]
15
- return false unless line_data
16
-
17
- index = line - 1
18
- return false if index.negative? || index >= line_data.length
19
-
20
- count = line_data[index]
21
- !count.nil? && count.positive?
22
- end
23
- end
24
- end
25
- end
@@ -1,29 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Evilution
4
- module Diff
5
- class FileFilter
6
- # Filters subjects to only those whose methods overlap with changed lines.
7
- #
8
- # @param subjects [Array<Subject>] All extracted subjects
9
- # @param changed_ranges [Array<Hash>] Output from Diff::Parser#parse
10
- # @return [Array<Subject>] Subjects overlapping with changes
11
- def filter(subjects, changed_ranges)
12
- lookup = build_lookup(changed_ranges)
13
-
14
- subjects.select do |subject|
15
- ranges = lookup[subject.file_path]
16
- next false unless ranges
17
-
18
- ranges.any? { |range| range.cover?(subject.line_number) }
19
- end
20
- end
21
-
22
- private
23
-
24
- def build_lookup(changed_ranges)
25
- changed_ranges.to_h { [_1[:file], _1[:lines]] }
26
- end
27
- end
28
- end
29
- end
@@ -1,47 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Evilution
4
- module Diff
5
- class Parser
6
- # Parses git diff output to extract changed file paths and line ranges.
7
- #
8
- # @param diff_base [String] Git ref to diff against (e.g., "HEAD~1", "main")
9
- # @return [Array<Hash>] Array of { file: String, lines: Array<Range> }
10
- def parse(diff_base)
11
- output = run_git_diff(diff_base)
12
- parse_diff_output(output)
13
- end
14
-
15
- private
16
-
17
- def run_git_diff(diff_base)
18
- `git diff --unified=0 #{diff_base}..HEAD -- '*.rb' 2>/dev/null`
19
- end
20
-
21
- def parse_diff_output(output)
22
- result = {}
23
- current_file = nil
24
-
25
- output.each_line do |line|
26
- case line
27
- when %r{^diff --git a/.+ b/(.+)$}
28
- current_file = Regexp.last_match(1)
29
- when /^@@\s+-\d+(?:,\d+)?\s+\+(\d+)(?:,(\d+))?\s+@@/
30
- next unless current_file
31
-
32
- start_line = Regexp.last_match(1).to_i
33
- count = (Regexp.last_match(2) || "1").to_i
34
-
35
- next if count.zero? # Pure deletion, no new lines
36
-
37
- end_line = start_line + count - 1
38
- result[current_file] ||= []
39
- result[current_file] << (start_line..end_line)
40
- end
41
- end
42
-
43
- result.map { |file, lines| { file: file, lines: lines } }
44
- end
45
- end
46
- end
47
- end