evilution 0.12.0 → 0.14.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 (85) hide show
  1. checksums.yaml +4 -4
  2. data/.beads/.migration-hint-ts +1 -1
  3. data/.beads/issues.jsonl +127 -0
  4. data/CHANGELOG.md +29 -0
  5. data/lib/evilution/ast/parser.rb +69 -68
  6. data/lib/evilution/ast/source_surgeon.rb +7 -9
  7. data/lib/evilution/ast.rb +4 -0
  8. data/lib/evilution/baseline.rb +73 -75
  9. data/lib/evilution/cache.rb +75 -77
  10. data/lib/evilution/cli.rb +408 -173
  11. data/lib/evilution/config.rb +141 -136
  12. data/lib/evilution/equivalent/detector.rb +25 -27
  13. data/lib/evilution/equivalent/heuristic/alias_swap.rb +29 -33
  14. data/lib/evilution/equivalent/heuristic/dead_code.rb +41 -45
  15. data/lib/evilution/equivalent/heuristic/method_body_nil.rb +11 -15
  16. data/lib/evilution/equivalent/heuristic/noop_source.rb +5 -9
  17. data/lib/evilution/equivalent/heuristic.rb +6 -0
  18. data/lib/evilution/equivalent.rb +4 -0
  19. data/lib/evilution/git/changed_files.rb +35 -37
  20. data/lib/evilution/git.rb +4 -0
  21. data/lib/evilution/integration/base.rb +5 -7
  22. data/lib/evilution/integration/rspec.rb +114 -116
  23. data/lib/evilution/integration.rb +4 -0
  24. data/lib/evilution/isolation/fork.rb +98 -100
  25. data/lib/evilution/isolation/in_process.rb +59 -61
  26. data/lib/evilution/isolation.rb +4 -0
  27. data/lib/evilution/mcp/mutate_tool.rb +172 -143
  28. data/lib/evilution/mcp/server.rb +12 -11
  29. data/lib/evilution/mcp/session_diff_tool.rb +89 -0
  30. data/lib/evilution/mcp/session_list_tool.rb +46 -0
  31. data/lib/evilution/mcp/session_show_tool.rb +53 -0
  32. data/lib/evilution/mcp.rb +4 -0
  33. data/lib/evilution/memory/leak_check.rb +80 -84
  34. data/lib/evilution/memory.rb +34 -36
  35. data/lib/evilution/mutation.rb +40 -42
  36. data/lib/evilution/mutator/base.rb +46 -48
  37. data/lib/evilution/mutator/operator/argument_nil_substitution.rb +32 -36
  38. data/lib/evilution/mutator/operator/argument_removal.rb +32 -36
  39. data/lib/evilution/mutator/operator/arithmetic_replacement.rb +26 -30
  40. data/lib/evilution/mutator/operator/array_literal.rb +18 -22
  41. data/lib/evilution/mutator/operator/block_removal.rb +16 -20
  42. data/lib/evilution/mutator/operator/boolean_literal_replacement.rb +38 -42
  43. data/lib/evilution/mutator/operator/boolean_operator_replacement.rb +41 -45
  44. data/lib/evilution/mutator/operator/collection_replacement.rb +32 -36
  45. data/lib/evilution/mutator/operator/comparison_replacement.rb +24 -28
  46. data/lib/evilution/mutator/operator/compound_assignment.rb +119 -0
  47. data/lib/evilution/mutator/operator/conditional_branch.rb +25 -29
  48. data/lib/evilution/mutator/operator/conditional_flip.rb +26 -30
  49. data/lib/evilution/mutator/operator/conditional_negation.rb +25 -29
  50. data/lib/evilution/mutator/operator/float_literal.rb +22 -26
  51. data/lib/evilution/mutator/operator/hash_literal.rb +18 -22
  52. data/lib/evilution/mutator/operator/integer_literal.rb +18 -44
  53. data/lib/evilution/mutator/operator/method_body_replacement.rb +12 -16
  54. data/lib/evilution/mutator/operator/method_call_removal.rb +12 -16
  55. data/lib/evilution/mutator/operator/negation_insertion.rb +12 -16
  56. data/lib/evilution/mutator/operator/nil_replacement.rb +13 -17
  57. data/lib/evilution/mutator/operator/range_replacement.rb +12 -16
  58. data/lib/evilution/mutator/operator/receiver_replacement.rb +16 -20
  59. data/lib/evilution/mutator/operator/regexp_mutation.rb +15 -19
  60. data/lib/evilution/mutator/operator/return_value_removal.rb +12 -16
  61. data/lib/evilution/mutator/operator/send_mutation.rb +36 -40
  62. data/lib/evilution/mutator/operator/statement_deletion.rb +13 -17
  63. data/lib/evilution/mutator/operator/string_literal.rb +18 -22
  64. data/lib/evilution/mutator/operator/symbol_literal.rb +17 -21
  65. data/lib/evilution/mutator/operator.rb +6 -0
  66. data/lib/evilution/mutator/registry.rb +54 -55
  67. data/lib/evilution/mutator.rb +4 -0
  68. data/lib/evilution/parallel/pool.rb +56 -58
  69. data/lib/evilution/parallel.rb +4 -0
  70. data/lib/evilution/reporter/cli.rb +99 -101
  71. data/lib/evilution/reporter/html.rb +242 -244
  72. data/lib/evilution/reporter/json.rb +57 -59
  73. data/lib/evilution/reporter/suggestion.rb +326 -313
  74. data/lib/evilution/reporter.rb +4 -0
  75. data/lib/evilution/result/mutation_result.rb +43 -46
  76. data/lib/evilution/result/summary.rb +80 -81
  77. data/lib/evilution/result.rb +4 -0
  78. data/lib/evilution/runner.rb +334 -323
  79. data/lib/evilution/session/store.rb +147 -0
  80. data/lib/evilution/session.rb +4 -0
  81. data/lib/evilution/spec_resolver.rb +49 -47
  82. data/lib/evilution/subject.rb +14 -16
  83. data/lib/evilution/version.rb +1 -1
  84. data/lib/evilution.rb +14 -0
  85. metadata +20 -2
@@ -2,85 +2,86 @@
2
2
 
3
3
  require "prism"
4
4
 
5
- module Evilution
6
- module AST
7
- class Parser
8
- def call(file_path)
9
- raise ParseError.new("file not found: #{file_path}", file: file_path) unless File.exist?(file_path)
10
-
11
- begin
12
- source = File.read(file_path)
13
- rescue SystemCallError => e
14
- raise ParseError.new("cannot read #{file_path}: #{e.message}", file: file_path)
15
- end
16
- result = Prism.parse(source)
17
-
18
- raise ParseError.new("failed to parse #{file_path}: #{result.errors.map(&:message).join(", ")}", file: file_path) if result.failure?
19
-
20
- extract_subjects(result.value, source, file_path)
5
+ module Evilution::AST
6
+ class Parser
7
+ def call(file_path)
8
+ raise Evilution::ParseError.new("file not found: #{file_path}", file: file_path) unless File.exist?(file_path)
9
+
10
+ begin
11
+ source = File.read(file_path)
12
+ rescue SystemCallError => e
13
+ raise Evilution::ParseError.new("cannot read #{file_path}: #{e.message}", file: file_path)
21
14
  end
15
+ result = Prism.parse(source)
22
16
 
23
- private
24
-
25
- def extract_subjects(tree, source, file_path)
26
- finder = SubjectFinder.new(source, file_path)
27
- finder.visit(tree)
28
- finder.subjects
17
+ if result.failure?
18
+ raise Evilution::ParseError.new("failed to parse #{file_path}: #{result.errors.map(&:message).join(", ")}",
19
+ file: file_path)
29
20
  end
21
+
22
+ extract_subjects(result.value, source, file_path)
30
23
  end
31
24
 
32
- class SubjectFinder < Prism::Visitor
33
- attr_reader :subjects
25
+ private
34
26
 
35
- def initialize(source, file_path)
36
- @source = source
37
- @file_path = file_path
38
- @subjects = []
39
- @context = []
40
- end
27
+ def extract_subjects(tree, source, file_path)
28
+ finder = SubjectFinder.new(source, file_path)
29
+ finder.visit(tree)
30
+ finder.subjects
31
+ end
32
+ end
41
33
 
42
- def visit_module_node(node)
43
- @context.push(constant_name(node.constant_path))
44
- super
45
- @context.pop
46
- end
34
+ class SubjectFinder < Prism::Visitor
35
+ attr_reader :subjects
47
36
 
48
- def visit_class_node(node)
49
- @context.push(constant_name(node.constant_path))
50
- super
51
- @context.pop
52
- end
37
+ def initialize(source, file_path)
38
+ @source = source
39
+ @file_path = file_path
40
+ @subjects = []
41
+ @context = []
42
+ end
53
43
 
54
- def visit_def_node(node)
55
- scope = @context.join("::")
56
- name = if scope.empty?
57
- "##{node.name}"
58
- else
59
- "#{scope}##{node.name}"
60
- end
61
-
62
- loc = node.location
63
- method_source = @source[loc.start_offset...loc.end_offset]
64
-
65
- @subjects << Subject.new(
66
- name: name,
67
- file_path: @file_path,
68
- line_number: loc.start_line,
69
- source: method_source,
70
- node: node
71
- )
72
-
73
- super
74
- end
44
+ def visit_module_node(node)
45
+ @context.push(constant_name(node.constant_path))
46
+ super
47
+ @context.pop
48
+ end
49
+
50
+ def visit_class_node(node)
51
+ @context.push(constant_name(node.constant_path))
52
+ super
53
+ @context.pop
54
+ end
55
+
56
+ def visit_def_node(node)
57
+ scope = @context.join("::")
58
+ name = if scope.empty?
59
+ "##{node.name}"
60
+ else
61
+ "#{scope}##{node.name}"
62
+ end
63
+
64
+ loc = node.location
65
+ method_source = @source[loc.start_offset...loc.end_offset]
66
+
67
+ @subjects << Evilution::Subject.new(
68
+ name: name,
69
+ file_path: @file_path,
70
+ line_number: loc.start_line,
71
+ source: method_source,
72
+ node: node
73
+ )
74
+
75
+ super
76
+ end
75
77
 
76
- private
78
+ private
77
79
 
78
- def constant_name(node)
79
- if node.respond_to?(:full_name)
80
- node.full_name
81
- else
82
- node.name.to_s
83
- end
80
+ def constant_name(node)
81
+ if node.respond_to?(:full_name)
82
+ node.full_name
83
+ else
84
+ node.name.to_s
84
85
  end
85
86
  end
86
87
  end
@@ -1,13 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module Evilution
4
- module AST
5
- module SourceSurgeon
6
- def self.apply(source, offset:, length:, replacement:)
7
- result = source.dup
8
- result[offset, length] = replacement
9
- result
10
- end
11
- end
3
+ require_relative "../ast"
4
+
5
+ module Evilution::AST::SourceSurgeon
6
+ def self.apply(source, offset:, length:, replacement:)
7
+ result = source.dup
8
+ result[offset, length] = replacement
9
+ result
12
10
  end
13
11
  end
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Evilution::AST
4
+ end
@@ -2,99 +2,97 @@
2
2
 
3
3
  require_relative "spec_resolver"
4
4
 
5
- module Evilution
6
- class Baseline
7
- Result = Struct.new(:failed_spec_files, :duration) do
8
- def initialize(**)
9
- super
10
- freeze
11
- end
12
-
13
- def failed?
14
- !failed_spec_files.empty?
15
- end
5
+ class Evilution::Baseline
6
+ Result = Struct.new(:failed_spec_files, :duration) do
7
+ def initialize(**)
8
+ super
9
+ freeze
16
10
  end
17
11
 
18
- def initialize(spec_resolver: SpecResolver.new, timeout: 30)
19
- @spec_resolver = spec_resolver
20
- @timeout = timeout
12
+ def failed?
13
+ !failed_spec_files.empty?
21
14
  end
15
+ end
22
16
 
23
- def call(subjects)
24
- start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
25
- spec_files = resolve_unique_spec_files(subjects)
26
- failed = Set.new
17
+ def initialize(spec_resolver: Evilution::SpecResolver.new, timeout: 30)
18
+ @spec_resolver = spec_resolver
19
+ @timeout = timeout
20
+ end
27
21
 
28
- spec_files.each do |spec_file|
29
- failed.add(spec_file) unless run_spec_file(spec_file)
30
- end
22
+ def call(subjects)
23
+ start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
24
+ spec_files = resolve_unique_spec_files(subjects)
25
+ failed = Set.new
31
26
 
32
- duration = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
33
- Result.new(failed_spec_files: failed, duration: duration)
27
+ spec_files.each do |spec_file|
28
+ failed.add(spec_file) unless run_spec_file(spec_file)
34
29
  end
35
30
 
36
- def run_spec_file(spec_file)
37
- read_io, write_io = IO.pipe
38
- pid = fork_spec_runner(spec_file, read_io, write_io)
39
- write_io.close
40
- read_result(read_io, pid)
41
- rescue StandardError
42
- false
43
- ensure
44
- read_io&.close
45
- write_io&.close
46
- end
31
+ duration = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
32
+ Result.new(failed_spec_files: failed, duration: duration)
33
+ end
34
+
35
+ def run_spec_file(spec_file)
36
+ read_io, write_io = IO.pipe
37
+ pid = fork_spec_runner(spec_file, read_io, write_io)
38
+ write_io.close
39
+ read_result(read_io, pid)
40
+ rescue StandardError
41
+ false
42
+ ensure
43
+ read_io&.close
44
+ write_io&.close
45
+ end
47
46
 
48
- def fork_spec_runner(spec_file, read_io, write_io)
49
- Process.fork do
50
- read_io.close
51
- $stdout.reopen(File::NULL, "w")
52
- $stderr.reopen(File::NULL, "w")
53
-
54
- require "rspec/core"
55
- ::RSpec.reset
56
- status = ::RSpec::Core::Runner.run(
57
- ["--format", "progress", "--no-color", "--order", "defined", spec_file]
58
- )
59
- Marshal.dump({ passed: status.zero? }, write_io)
60
- write_io.close
61
- exit!(status.zero? ? 0 : 1)
62
- end
47
+ def fork_spec_runner(spec_file, read_io, write_io)
48
+ Process.fork do
49
+ read_io.close
50
+ $stdout.reopen(File::NULL, "w")
51
+ $stderr.reopen(File::NULL, "w")
52
+
53
+ require "rspec/core"
54
+ RSpec.reset
55
+ status = RSpec::Core::Runner.run(
56
+ ["--format", "progress", "--no-color", "--order", "defined", spec_file]
57
+ )
58
+ Marshal.dump({ passed: status.zero? }, write_io)
59
+ write_io.close
60
+ exit!(status.zero? ? 0 : 1)
63
61
  end
62
+ end
64
63
 
65
- GRACE_PERIOD = 0.5
64
+ GRACE_PERIOD = 0.5
66
65
 
67
- def read_result(read_io, pid)
68
- if read_io.wait_readable(@timeout)
69
- data = read_io.read
70
- Process.wait(pid)
71
- return false if data.empty?
66
+ def read_result(read_io, pid)
67
+ if read_io.wait_readable(@timeout)
68
+ data = read_io.read
69
+ Process.wait(pid)
70
+ return false if data.empty?
72
71
 
73
- result = Marshal.load(data) # rubocop:disable Security/MarshalLoad
74
- result[:passed]
75
- else
76
- terminate_child(pid)
77
- false
78
- end
72
+ result = Marshal.load(data) # rubocop:disable Security/MarshalLoad
73
+ result[:passed]
74
+ else
75
+ terminate_child(pid)
76
+ false
79
77
  end
78
+ end
80
79
 
81
- def terminate_child(pid)
82
- Process.kill("TERM", pid) rescue nil # rubocop:disable Style/RescueModifier
83
- _, status = Process.waitpid2(pid, Process::WNOHANG)
84
- return if status
80
+ def terminate_child(pid)
81
+ Process.kill("TERM", pid) rescue nil # rubocop:disable Style/RescueModifier
82
+ _, status = Process.waitpid2(pid, Process::WNOHANG)
83
+ return if status
85
84
 
86
- sleep(GRACE_PERIOD)
87
- _, status = Process.waitpid2(pid, Process::WNOHANG)
88
- return if status
85
+ sleep(GRACE_PERIOD)
86
+ _, status = Process.waitpid2(pid, Process::WNOHANG)
87
+ return if status
89
88
 
90
- Process.kill("KILL", pid) rescue nil # rubocop:disable Style/RescueModifier
91
- Process.wait(pid) rescue nil # rubocop:disable Style/RescueModifier
92
- end
89
+ Process.kill("KILL", pid) rescue nil # rubocop:disable Style/RescueModifier
90
+ Process.wait(pid) rescue nil # rubocop:disable Style/RescueModifier
91
+ end
93
92
 
94
- private
93
+ private
95
94
 
96
- def resolve_unique_spec_files(subjects)
97
- subjects.map { |s| @spec_resolver.call(s.file_path) || "spec" }.uniq
98
- end
95
+ def resolve_unique_spec_files(subjects)
96
+ subjects.map { |s| @spec_resolver.call(s.file_path) || "spec" }.uniq
99
97
  end
100
98
  end
@@ -4,82 +4,80 @@ require "digest"
4
4
  require "json"
5
5
  require "fileutils"
6
6
 
7
- module Evilution
8
- class Cache
9
- DEFAULT_DIR = "tmp/evilution_cache"
10
-
11
- def initialize(cache_dir: DEFAULT_DIR)
12
- @cache_dir = cache_dir
13
- end
14
-
15
- def fetch(mutation)
16
- return nil if mutation.original_source.nil?
17
-
18
- file_key = file_key(mutation)
19
- entry_key = entry_key(mutation)
20
- data = read_file(file_key)
21
- return nil unless data
22
-
23
- entry = data[entry_key]
24
- return nil unless entry.is_a?(Hash) && entry["status"].is_a?(String)
25
-
26
- { status: entry["status"].to_sym, duration: entry["duration"],
27
- killing_test: entry["killing_test"], test_command: entry["test_command"] }
28
- end
29
-
30
- def store(mutation, result_data)
31
- file_key = file_key(mutation)
32
- entry_key = entry_key(mutation)
33
- data = read_file(file_key) || {}
34
-
35
- data[entry_key] = {
36
- "status" => result_data[:status].to_s,
37
- "duration" => result_data[:duration],
38
- "killing_test" => result_data[:killing_test],
39
- "test_command" => result_data[:test_command]
40
- }
41
-
42
- write_file(file_key, data)
43
- end
44
-
45
- def clear
46
- FileUtils.rm_rf(@cache_dir)
47
- end
48
-
49
- private
50
-
51
- def file_key(mutation)
52
- content_hash = Digest::SHA256.hexdigest(mutation.original_source)
53
- "#{safe_filename(mutation.file_path)}_#{content_hash[0, 16]}"
54
- end
55
-
56
- def entry_key(mutation)
57
- "#{mutation.operator_name}:#{mutation.line}:#{mutation.column}"
58
- end
59
-
60
- def safe_filename(path)
61
- path.gsub(%r{[/\\]}, "_").gsub(/[^a-zA-Z0-9._-]/, "")
62
- end
63
-
64
- def read_file(file_key)
65
- path = cache_path(file_key)
66
- return nil unless File.exist?(path)
67
-
68
- JSON.parse(File.read(path))
69
- rescue JSON::ParserError
70
- nil
71
- end
72
-
73
- def write_file(file_key, data)
74
- FileUtils.mkdir_p(@cache_dir)
75
- path = cache_path(file_key)
76
- tmp = "#{path}.#{Process.pid}.tmp"
77
- File.write(tmp, JSON.generate(data))
78
- File.rename(tmp, path)
79
- end
80
-
81
- def cache_path(file_key)
82
- File.join(@cache_dir, "#{file_key}.json")
83
- end
7
+ class Evilution::Cache
8
+ DEFAULT_DIR = "tmp/evilution_cache"
9
+
10
+ def initialize(cache_dir: DEFAULT_DIR)
11
+ @cache_dir = cache_dir
12
+ end
13
+
14
+ def fetch(mutation)
15
+ return nil if mutation.original_source.nil?
16
+
17
+ file_key = file_key(mutation)
18
+ entry_key = entry_key(mutation)
19
+ data = read_file(file_key)
20
+ return nil unless data
21
+
22
+ entry = data[entry_key]
23
+ return nil unless entry.is_a?(Hash) && entry["status"].is_a?(String)
24
+
25
+ { status: entry["status"].to_sym, duration: entry["duration"],
26
+ killing_test: entry["killing_test"], test_command: entry["test_command"] }
27
+ end
28
+
29
+ def store(mutation, result_data)
30
+ file_key = file_key(mutation)
31
+ entry_key = entry_key(mutation)
32
+ data = read_file(file_key) || {}
33
+
34
+ data[entry_key] = {
35
+ "status" => result_data[:status].to_s,
36
+ "duration" => result_data[:duration],
37
+ "killing_test" => result_data[:killing_test],
38
+ "test_command" => result_data[:test_command]
39
+ }
40
+
41
+ write_file(file_key, data)
42
+ end
43
+
44
+ def clear
45
+ FileUtils.rm_rf(@cache_dir)
46
+ end
47
+
48
+ private
49
+
50
+ def file_key(mutation)
51
+ content_hash = Digest::SHA256.hexdigest(mutation.original_source)
52
+ "#{safe_filename(mutation.file_path)}_#{content_hash[0, 16]}"
53
+ end
54
+
55
+ def entry_key(mutation)
56
+ "#{mutation.operator_name}:#{mutation.line}:#{mutation.column}"
57
+ end
58
+
59
+ def safe_filename(path)
60
+ path.gsub(%r{[/\\]}, "_").gsub(/[^a-zA-Z0-9._-]/, "")
61
+ end
62
+
63
+ def read_file(file_key)
64
+ path = cache_path(file_key)
65
+ return nil unless File.exist?(path)
66
+
67
+ JSON.parse(File.read(path))
68
+ rescue JSON::ParserError
69
+ nil
70
+ end
71
+
72
+ def write_file(file_key, data)
73
+ FileUtils.mkdir_p(@cache_dir)
74
+ path = cache_path(file_key)
75
+ tmp = "#{path}.#{Process.pid}.tmp"
76
+ File.write(tmp, JSON.generate(data))
77
+ File.rename(tmp, path)
78
+ end
79
+
80
+ def cache_path(file_key)
81
+ File.join(@cache_dir, "#{file_key}.json")
84
82
  end
85
83
  end