evilution 0.9.0 → 0.11.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 (41) hide show
  1. checksums.yaml +4 -4
  2. data/.beads/.migration-hint-ts +1 -1
  3. data/.beads/issues.jsonl +15 -14
  4. data/CHANGELOG.md +34 -0
  5. data/README.md +12 -4
  6. data/lib/evilution/cli.rb +1 -10
  7. data/lib/evilution/config.rb +4 -24
  8. data/lib/evilution/equivalent/detector.rb +42 -0
  9. data/lib/evilution/equivalent/heuristic/alias_swap.rb +37 -0
  10. data/lib/evilution/equivalent/heuristic/dead_code.rb +51 -0
  11. data/lib/evilution/equivalent/heuristic/method_body_nil.rb +23 -0
  12. data/lib/evilution/equivalent/heuristic/noop_source.rb +13 -0
  13. data/lib/evilution/mcp/mutate_tool.rb +37 -6
  14. data/lib/evilution/mutator/operator/argument_nil_substitution.rb +42 -0
  15. data/lib/evilution/mutator/operator/arithmetic_replacement.rb +3 -1
  16. data/lib/evilution/mutator/operator/array_literal.rb +7 -0
  17. data/lib/evilution/mutator/operator/boolean_literal_replacement.rb +15 -0
  18. data/lib/evilution/mutator/operator/collection_replacement.rb +9 -1
  19. data/lib/evilution/mutator/operator/comparison_replacement.rb +4 -4
  20. data/lib/evilution/mutator/operator/float_literal.rb +7 -0
  21. data/lib/evilution/mutator/operator/hash_literal.rb +7 -0
  22. data/lib/evilution/mutator/operator/integer_literal.rb +7 -0
  23. data/lib/evilution/mutator/operator/nil_replacement.rb +10 -6
  24. data/lib/evilution/mutator/operator/regexp_mutation.rb +11 -6
  25. data/lib/evilution/mutator/operator/send_mutation.rb +49 -0
  26. data/lib/evilution/mutator/operator/string_literal.rb +7 -0
  27. data/lib/evilution/mutator/operator/symbol_literal.rb +7 -0
  28. data/lib/evilution/mutator/registry.rb +3 -1
  29. data/lib/evilution/reporter/cli.rb +11 -1
  30. data/lib/evilution/reporter/html.rb +252 -0
  31. data/lib/evilution/reporter/json.rb +5 -1
  32. data/lib/evilution/result/mutation_result.rb +5 -1
  33. data/lib/evilution/result/summary.rb +9 -1
  34. data/lib/evilution/runner.rb +32 -13
  35. data/lib/evilution/version.rb +1 -1
  36. data/lib/evilution.rb +4 -4
  37. metadata +10 -6
  38. data/lib/evilution/coverage/collector.rb +0 -47
  39. data/lib/evilution/coverage/test_map.rb +0 -25
  40. data/lib/evilution/diff/file_filter.rb +0 -29
  41. data/lib/evilution/diff/parser.rb +0 -47
@@ -40,8 +40,12 @@ module Evilution
40
40
  results.count(&:neutral?)
41
41
  end
42
42
 
43
+ def equivalent
44
+ results.count(&:equivalent?)
45
+ end
46
+
43
47
  def score
44
- denominator = total - errors - neutral
48
+ denominator = total - errors - neutral - equivalent
45
49
  return 0.0 if denominator.zero?
46
50
 
47
51
  killed.to_f / denominator
@@ -63,6 +67,10 @@ module Evilution
63
67
  results.select(&:neutral?)
64
68
  end
65
69
 
70
+ def equivalent_results
71
+ results.select(&:equivalent?)
72
+ end
73
+
66
74
  def peak_memory_mb
67
75
  max_rss = nil
68
76
  results.each do |result|
@@ -9,9 +9,9 @@ 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"
@@ -37,13 +37,18 @@ module Evilution
37
37
  subjects = parse_subjects
38
38
  subjects = filter_by_target(subjects) if config.target?
39
39
  subjects = filter_by_line_ranges(subjects) if config.line_ranges?
40
- subjects = filter_by_diff(subjects) if config.diff?
41
40
  log_memory("after parse_subjects", "#{subjects.length} subjects")
42
41
 
43
42
  baseline_result = run_baseline(subjects)
44
43
 
45
44
  mutations = generate_mutations(subjects)
45
+ equivalent_mutations, mutations = filter_equivalent(mutations)
46
+ release_subject_nodes(subjects)
46
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
47
52
  log_memory("after run_mutations", "#{results.length} results")
48
53
 
49
54
  duration = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
@@ -91,20 +96,24 @@ module Evilution
91
96
  end
92
97
  end
93
98
 
94
- def filter_by_diff(subjects)
95
- diff_parser = Diff::Parser.new
96
- changed_ranges = diff_parser.parse(config.diff_base)
97
- Diff::FileFilter.new.filter(subjects, changed_ranges)
98
- end
99
-
100
99
  def generate_mutations(subjects)
101
100
  subjects.flat_map do |subject|
102
- mutations = registry.mutations_for(subject)
103
- subject.release_node!
104
- mutations
101
+ registry.mutations_for(subject)
105
102
  end
106
103
  end
107
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
+
108
117
  def run_baseline(subjects)
109
118
  return nil unless config.baseline? && subjects.any?
110
119
 
@@ -281,7 +290,15 @@ module Evilution
281
290
  return unless reporter
282
291
 
283
292
  output = reporter.call(summary)
284
- $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
285
302
  end
286
303
 
287
304
  def log_baseline_start
@@ -350,6 +367,8 @@ module Evilution
350
367
  Reporter::JSON.new
351
368
  when :text
352
369
  Reporter::CLI.new
370
+ when :html
371
+ Reporter::HTML.new
353
372
  end
354
373
  end
355
374
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Evilution
4
- VERSION = "0.9.0"
4
+ VERSION = "0.11.0"
5
5
  end
data/lib/evilution.rb CHANGED
@@ -33,12 +33,13 @@ 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
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"
36
38
  require_relative "evilution/mutator/registry"
39
+ require_relative "evilution/equivalent/detector"
37
40
  require_relative "evilution/isolation/fork"
38
41
  require_relative "evilution/isolation/in_process"
39
42
  require_relative "evilution/parallel/pool"
40
- require_relative "evilution/diff/parser"
41
- require_relative "evilution/diff/file_filter"
42
43
  require_relative "evilution/git/changed_files"
43
44
  require_relative "evilution/integration/base"
44
45
  require_relative "evilution/integration/rspec"
@@ -46,9 +47,8 @@ require_relative "evilution/result/mutation_result"
46
47
  require_relative "evilution/result/summary"
47
48
  require_relative "evilution/reporter/json"
48
49
  require_relative "evilution/reporter/cli"
50
+ require_relative "evilution/reporter/html"
49
51
  require_relative "evilution/reporter/suggestion"
50
- require_relative "evilution/coverage/collector"
51
- require_relative "evilution/coverage/test_map"
52
52
  require_relative "evilution/spec_resolver"
53
53
  require_relative "evilution/baseline"
54
54
  require_relative "evilution/cache"
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.9.0
4
+ version: 0.11.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-21 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: diff-lcs
@@ -84,10 +84,11 @@ files:
84
84
  - lib/evilution/cache.rb
85
85
  - lib/evilution/cli.rb
86
86
  - lib/evilution/config.rb
87
- - lib/evilution/coverage/collector.rb
88
- - lib/evilution/coverage/test_map.rb
89
- - lib/evilution/diff/file_filter.rb
90
- - 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
91
92
  - lib/evilution/git/changed_files.rb
92
93
  - lib/evilution/integration/base.rb
93
94
  - lib/evilution/integration/rspec.rb
@@ -99,6 +100,7 @@ files:
99
100
  - lib/evilution/memory/leak_check.rb
100
101
  - lib/evilution/mutation.rb
101
102
  - lib/evilution/mutator/base.rb
103
+ - lib/evilution/mutator/operator/argument_nil_substitution.rb
102
104
  - lib/evilution/mutator/operator/argument_removal.rb
103
105
  - lib/evilution/mutator/operator/arithmetic_replacement.rb
104
106
  - lib/evilution/mutator/operator/array_literal.rb
@@ -121,12 +123,14 @@ files:
121
123
  - lib/evilution/mutator/operator/receiver_replacement.rb
122
124
  - lib/evilution/mutator/operator/regexp_mutation.rb
123
125
  - lib/evilution/mutator/operator/return_value_removal.rb
126
+ - lib/evilution/mutator/operator/send_mutation.rb
124
127
  - lib/evilution/mutator/operator/statement_deletion.rb
125
128
  - lib/evilution/mutator/operator/string_literal.rb
126
129
  - lib/evilution/mutator/operator/symbol_literal.rb
127
130
  - lib/evilution/mutator/registry.rb
128
131
  - lib/evilution/parallel/pool.rb
129
132
  - lib/evilution/reporter/cli.rb
133
+ - lib/evilution/reporter/html.rb
130
134
  - lib/evilution/reporter/json.rb
131
135
  - lib/evilution/reporter/suggestion.rb
132
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