evilution 0.1.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 (63) hide show
  1. checksums.yaml +7 -0
  2. data/.beads/.gitignore +51 -0
  3. data/.beads/.migration-hint-ts +1 -0
  4. data/.beads/README.md +81 -0
  5. data/.beads/config.yaml +67 -0
  6. data/.beads/interactions.jsonl +0 -0
  7. data/.beads/issues.jsonl +68 -0
  8. data/.beads/metadata.json +4 -0
  9. data/.claude/prompts/architect.md +98 -0
  10. data/.claude/prompts/devops.md +106 -0
  11. data/.claude/prompts/tests.md +160 -0
  12. data/CHANGELOG.md +19 -0
  13. data/CODE_OF_CONDUCT.md +10 -0
  14. data/LICENSE.txt +21 -0
  15. data/README.md +190 -0
  16. data/Rakefile +12 -0
  17. data/claude-swarm.yml +28 -0
  18. data/exe/evilution +6 -0
  19. data/lib/evilution/ast/parser.rb +83 -0
  20. data/lib/evilution/ast/source_surgeon.rb +13 -0
  21. data/lib/evilution/cli.rb +78 -0
  22. data/lib/evilution/config.rb +98 -0
  23. data/lib/evilution/coverage/collector.rb +47 -0
  24. data/lib/evilution/coverage/test_map.rb +25 -0
  25. data/lib/evilution/diff/file_filter.rb +29 -0
  26. data/lib/evilution/diff/parser.rb +47 -0
  27. data/lib/evilution/integration/base.rb +11 -0
  28. data/lib/evilution/integration/rspec.rb +184 -0
  29. data/lib/evilution/isolation/fork.rb +70 -0
  30. data/lib/evilution/mutation.rb +45 -0
  31. data/lib/evilution/mutator/base.rb +54 -0
  32. data/lib/evilution/mutator/operator/arithmetic_replacement.rb +37 -0
  33. data/lib/evilution/mutator/operator/array_literal.rb +22 -0
  34. data/lib/evilution/mutator/operator/boolean_literal_replacement.rb +31 -0
  35. data/lib/evilution/mutator/operator/boolean_operator_replacement.rb +50 -0
  36. data/lib/evilution/mutator/operator/collection_replacement.rb +37 -0
  37. data/lib/evilution/mutator/operator/comparison_replacement.rb +37 -0
  38. data/lib/evilution/mutator/operator/conditional_branch.rb +36 -0
  39. data/lib/evilution/mutator/operator/conditional_negation.rb +36 -0
  40. data/lib/evilution/mutator/operator/float_literal.rb +26 -0
  41. data/lib/evilution/mutator/operator/hash_literal.rb +22 -0
  42. data/lib/evilution/mutator/operator/integer_literal.rb +45 -0
  43. data/lib/evilution/mutator/operator/method_body_replacement.rb +22 -0
  44. data/lib/evilution/mutator/operator/negation_insertion.rb +22 -0
  45. data/lib/evilution/mutator/operator/nil_replacement.rb +20 -0
  46. data/lib/evilution/mutator/operator/return_value_removal.rb +22 -0
  47. data/lib/evilution/mutator/operator/statement_deletion.rb +24 -0
  48. data/lib/evilution/mutator/operator/string_literal.rb +22 -0
  49. data/lib/evilution/mutator/operator/symbol_literal.rb +20 -0
  50. data/lib/evilution/mutator/registry.rb +55 -0
  51. data/lib/evilution/parallel/pool.rb +98 -0
  52. data/lib/evilution/parallel/worker.rb +24 -0
  53. data/lib/evilution/reporter/cli.rb +72 -0
  54. data/lib/evilution/reporter/json.rb +59 -0
  55. data/lib/evilution/reporter/suggestion.rb +51 -0
  56. data/lib/evilution/result/mutation_result.rb +37 -0
  57. data/lib/evilution/result/summary.rb +54 -0
  58. data/lib/evilution/runner.rb +139 -0
  59. data/lib/evilution/subject.rb +20 -0
  60. data/lib/evilution/version.rb +5 -0
  61. data/lib/evilution.rb +51 -0
  62. data/sig/evilution.rbs +4 -0
  63. metadata +130 -0
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+ require_relative "version"
5
+ require_relative "config"
6
+ require_relative "runner"
7
+
8
+ module Evilution
9
+ class CLI
10
+ def initialize(argv)
11
+ @options = {}
12
+ @command = :run
13
+ argv = argv.dup
14
+
15
+ case argv.first
16
+ when "version"
17
+ @command = :version
18
+ argv.shift
19
+ when "init"
20
+ @command = :init
21
+ argv.shift
22
+ when "run"
23
+ argv.shift
24
+ end
25
+
26
+ parser = OptionParser.new do |opts|
27
+ opts.banner = "Usage: evilution [command] [options] [files...]"
28
+
29
+ opts.on("-j", "--jobs N", Integer, "Number of parallel workers") { |n| @options[:jobs] = n }
30
+ opts.on("-t", "--timeout N", Integer, "Per-mutation timeout in seconds") { |n| @options[:timeout] = n }
31
+ opts.on("-f", "--format FORMAT", "Output format: text, json") { |f| @options[:format] = f.to_sym }
32
+ opts.on("--diff BASE", "Only mutate code changed since BASE") { |b| @options[:diff_base] = b }
33
+ opts.on("--min-score FLOAT", Float, "Minimum mutation score to pass") { |s| @options[:min_score] = s }
34
+ opts.on("--no-coverage", "Disable coverage-based filtering of uncovered mutations") { @options[:coverage] = false }
35
+ opts.on("-v", "--verbose", "Verbose output") { @options[:verbose] = true }
36
+ opts.on("-q", "--quiet", "Suppress output") { @options[:quiet] = true }
37
+ end
38
+
39
+ @files = parser.parse!(argv)
40
+ end
41
+
42
+ def call
43
+ case @command
44
+ when :version
45
+ $stdout.puts(VERSION)
46
+ 0
47
+ when :init
48
+ run_init
49
+ when :run
50
+ run_mutations
51
+ end
52
+ end
53
+
54
+ private
55
+
56
+ def run_init
57
+ path = ".evilution.yml"
58
+ if File.exist?(path)
59
+ warn("#{path} already exists")
60
+ return 1
61
+ end
62
+
63
+ File.write(path, Config.default_template)
64
+ $stdout.puts("Created #{path}")
65
+ 0
66
+ end
67
+
68
+ def run_mutations
69
+ config = Config.new(**@options, target_files: @files)
70
+ runner = Runner.new(config: config)
71
+ summary = runner.call
72
+ summary.success?(min_score: config.min_score) ? 0 : 1
73
+ rescue Error => e
74
+ warn("Error: #{e.message}")
75
+ 2
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module Evilution
6
+ class Config
7
+ CONFIG_FILES = %w[.evilution.yml config/evilution.yml].freeze
8
+
9
+ DEFAULTS = {
10
+ jobs: nil,
11
+ timeout: 10,
12
+ format: :text,
13
+ diff_base: nil,
14
+ target: nil,
15
+ min_score: 0.0,
16
+ integration: :rspec,
17
+ coverage: true,
18
+ verbose: false,
19
+ quiet: false
20
+ }.freeze
21
+
22
+ attr_reader :target_files, :jobs, :timeout, :format, :diff_base,
23
+ :target, :min_score, :integration, :coverage, :verbose, :quiet
24
+
25
+ def initialize(**options)
26
+ file_options = options.delete(:skip_config_file) ? {} : load_config_file
27
+ merged = DEFAULTS.merge(file_options).merge(options)
28
+ @target_files = Array(merged[:target_files])
29
+ @jobs = merged[:jobs] || default_jobs
30
+ @timeout = merged[:timeout]
31
+ @format = merged[:format].to_sym
32
+ @diff_base = merged[:diff_base]
33
+ @target = merged[:target]
34
+ @min_score = merged[:min_score].to_f
35
+ @integration = merged[:integration].to_sym
36
+ @coverage = merged[:coverage]
37
+ @verbose = merged[:verbose]
38
+ @quiet = merged[:quiet]
39
+ freeze
40
+ end
41
+
42
+ def json?
43
+ format == :json
44
+ end
45
+
46
+ def text?
47
+ format == :text
48
+ end
49
+
50
+ def diff?
51
+ !diff_base.nil?
52
+ end
53
+
54
+ # Generates a default config file template.
55
+ def self.default_template
56
+ <<~YAML
57
+ # Evilution configuration
58
+ # See: https://github.com/marinazzio/evilution
59
+
60
+ # Number of parallel workers (default: number of CPU cores)
61
+ # jobs: 4
62
+
63
+ # Per-mutation timeout in seconds (default: 10)
64
+ # timeout: 10
65
+
66
+ # Output format: text or json (default: text)
67
+ # format: text
68
+
69
+ # Minimum mutation score to pass (0.0 to 1.0, default: 0.0)
70
+ # min_score: 0.0
71
+
72
+ # Test integration: rspec (default: rspec)
73
+ # integration: rspec
74
+
75
+ # Skip mutations on uncovered lines (default: true)
76
+ # coverage: true
77
+ YAML
78
+ end
79
+
80
+ private
81
+
82
+ def load_config_file
83
+ CONFIG_FILES.each do |path|
84
+ next unless File.exist?(path)
85
+
86
+ data = YAML.safe_load_file(path, symbolize_names: true)
87
+ return data.is_a?(Hash) ? data : {}
88
+ end
89
+
90
+ {}
91
+ end
92
+
93
+ def default_jobs
94
+ require "etc"
95
+ Etc.nprocessors
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,47 @@
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
@@ -0,0 +1,25 @@
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
@@ -0,0 +1,29 @@
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
@@ -0,0 +1,47 @@
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
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Evilution
4
+ module Integration
5
+ class Base
6
+ def call(mutation)
7
+ raise NotImplementedError, "#{self.class}#call must be implemented"
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,184 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "stringio"
5
+ require "tmpdir"
6
+ require_relative "base"
7
+
8
+ module Evilution
9
+ module Integration
10
+ class RSpec < Base
11
+ def initialize(test_files: nil)
12
+ @test_files = test_files
13
+ @rspec_loaded = false
14
+ super()
15
+ end
16
+
17
+ def call(mutation)
18
+ @original_content = nil
19
+ @temp_dir = nil
20
+ @lock_file = nil
21
+ ensure_rspec_loaded
22
+ apply_mutation(mutation)
23
+ run_rspec(mutation)
24
+ ensure
25
+ restore_original(mutation)
26
+ end
27
+
28
+ private
29
+
30
+ attr_reader :test_files
31
+
32
+ def ensure_rspec_loaded
33
+ return if @rspec_loaded
34
+
35
+ require "rspec/core"
36
+ @rspec_loaded = true
37
+ rescue LoadError => e
38
+ raise Evilution::Error, "rspec-core is required but not available: #{e.message}"
39
+ end
40
+
41
+ def apply_mutation(mutation)
42
+ subpath = resolve_require_subpath(mutation.file_path)
43
+
44
+ if subpath
45
+ @temp_dir = Dir.mktmpdir("evilution")
46
+ dest = File.join(@temp_dir, subpath)
47
+ FileUtils.mkdir_p(File.dirname(dest))
48
+ File.write(dest, mutation.mutated_source)
49
+ $LOAD_PATH.unshift(@temp_dir)
50
+ else
51
+ # Fallback: direct write when file isn't under any $LOAD_PATH entry.
52
+ # Acquire an exclusive lock to prevent concurrent workers from corrupting the file.
53
+ lock_path = File.join(Dir.tmpdir, "evilution-#{File.expand_path(mutation.file_path).hash.abs}.lock")
54
+ @lock_file = File.open(lock_path, File::CREAT | File::RDWR) # rubocop:disable Style/FileOpen
55
+ @lock_file.flock(File::LOCK_EX)
56
+ @original_content = File.read(mutation.file_path)
57
+ File.write(mutation.file_path, mutation.mutated_source)
58
+ end
59
+ end
60
+
61
+ def restore_original(mutation)
62
+ if @temp_dir
63
+ $LOAD_PATH.delete(@temp_dir)
64
+ $LOADED_FEATURES.reject! { |f| f.start_with?(@temp_dir) }
65
+ FileUtils.rm_rf(@temp_dir)
66
+ @temp_dir = nil
67
+ elsif @original_content
68
+ File.write(mutation.file_path, @original_content)
69
+ @lock_file&.flock(File::LOCK_UN)
70
+ @lock_file&.close
71
+ @lock_file = nil
72
+ end
73
+ end
74
+
75
+ def resolve_require_subpath(file_path)
76
+ absolute = File.expand_path(file_path)
77
+
78
+ $LOAD_PATH.each do |entry|
79
+ dir = File.expand_path(entry)
80
+ prefix = dir.end_with?("/") ? dir : "#{dir}/"
81
+ next unless absolute.start_with?(prefix)
82
+
83
+ return absolute.delete_prefix(prefix)
84
+ end
85
+
86
+ nil
87
+ end
88
+
89
+ def run_rspec(mutation)
90
+ # When used via the Runner with Isolation::Fork, each mutation is executed
91
+ # in its own forked child process, so RSpec state (loaded example groups,
92
+ # world, configuration) cannot accumulate across mutation runs — the child
93
+ # process exits after each run.
94
+ #
95
+ # This integration can also be invoked directly (e.g. in specs or alternative
96
+ # runners) without fork isolation. RSpec.reset is called here as
97
+ # defense-in-depth to clear RSpec state between mutation runs in those cases.
98
+ ::RSpec.reset
99
+
100
+ out = StringIO.new
101
+ err = StringIO.new
102
+ args = build_args(mutation)
103
+
104
+ status = ::RSpec::Core::Runner.run(args, out, err)
105
+
106
+ { passed: status.zero? }
107
+ rescue StandardError => e
108
+ { passed: false, error: e.message }
109
+ end
110
+
111
+ def build_args(mutation)
112
+ files = test_files || detect_test_files(mutation)
113
+ ["--format", "progress", "--no-color", "--order", "defined", *files]
114
+ end
115
+
116
+ def detect_test_files(mutation)
117
+ # Convention: lib/foo/bar.rb -> spec/foo/bar_spec.rb
118
+ candidates = spec_file_candidates(mutation.file_path)
119
+ found = candidates.select { |f| File.exist?(f) }
120
+ return found unless found.empty?
121
+
122
+ # Fallback: find spec/ directory relative to the mutation's project root
123
+ fallback = fallback_spec_dir(mutation.file_path)
124
+ return [fallback] if fallback
125
+
126
+ []
127
+ end
128
+
129
+ def fallback_spec_dir(source_path)
130
+ expanded = File.expand_path(source_path)
131
+
132
+ # Derive spec/ from mutation's project, not CWD
133
+ if expanded.include?("/lib/")
134
+ project_root = expanded.split(%r{/lib/}, 2).first
135
+ spec_dir = File.join(project_root, "spec")
136
+ return spec_dir if Dir.exist?(spec_dir)
137
+ end
138
+
139
+ # Walk up from the file's directory looking for spec/
140
+ dir = File.dirname(expanded)
141
+ loop do
142
+ spec_dir = File.join(dir, "spec")
143
+ return spec_dir if Dir.exist?(spec_dir)
144
+
145
+ parent = File.dirname(dir)
146
+ break if parent == dir
147
+
148
+ dir = parent
149
+ end
150
+
151
+ nil
152
+ end
153
+
154
+ def spec_file_candidates(source_path)
155
+ candidates = []
156
+
157
+ if source_path.start_with?("lib/")
158
+ # lib/foo/bar.rb -> spec/foo/bar_spec.rb
159
+ relative = source_path.sub(%r{^lib/}, "")
160
+ spec_name = relative.sub(/\.rb$/, "_spec.rb")
161
+ candidates << File.join("spec", spec_name)
162
+ candidates << File.join("spec", "unit", spec_name)
163
+ elsif source_path.include?("/lib/")
164
+ # /absolute/path/lib/foo/bar.rb -> /absolute/path/spec/foo/bar_spec.rb
165
+ prefix, relative = source_path.split(%r{/lib/}, 2)
166
+ spec_name = relative.sub(/\.rb$/, "_spec.rb")
167
+ candidates << File.join(prefix, "spec", spec_name)
168
+ candidates << File.join(prefix, "spec", "unit", spec_name)
169
+ end
170
+
171
+ # Same directory: foo/bar.rb -> foo/bar_spec.rb
172
+ sibling_spec = source_path.sub(/\.rb$/, "_spec.rb")
173
+ candidates << sibling_spec
174
+
175
+ # Subdirectory spec/ variant: foo/bar.rb -> foo/spec/bar_spec.rb
176
+ dir = File.dirname(source_path)
177
+ base = File.basename(source_path, ".rb")
178
+ candidates << File.join(dir, "spec", "#{base}_spec.rb")
179
+
180
+ candidates.uniq
181
+ end
182
+ end
183
+ end
184
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Evilution
4
+ module Isolation
5
+ class Fork
6
+ def call(mutation:, test_command:, timeout:)
7
+ start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
8
+ read_io, write_io = IO.pipe
9
+
10
+ pid = ::Process.fork do
11
+ read_io.close
12
+ result = execute_in_child(mutation, test_command)
13
+ Marshal.dump(result, write_io)
14
+ write_io.close
15
+ exit!(result[:passed] ? 0 : 1)
16
+ end
17
+
18
+ write_io.close
19
+ result = wait_for_result(pid, read_io, timeout)
20
+ duration = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
21
+
22
+ build_mutation_result(mutation, result, duration)
23
+ ensure
24
+ read_io&.close
25
+ write_io&.close
26
+ end
27
+
28
+ private
29
+
30
+ def execute_in_child(mutation, test_command)
31
+ test_command.call(mutation)
32
+ rescue StandardError => e
33
+ { passed: false, error: e.message }
34
+ end
35
+
36
+ def wait_for_result(pid, read_io, timeout)
37
+ if read_io.wait_readable(timeout)
38
+ data = read_io.read
39
+ ::Process.wait(pid)
40
+ return { timeout: false }.merge(Marshal.load(data)) unless data.empty? # rubocop:disable Security/MarshalLoad
41
+
42
+ ::Process.wait(pid) rescue nil # rubocop:disable Style/RescueModifier
43
+ { timeout: false, passed: false, error: "empty result from child" }
44
+ else
45
+ ::Process.kill("KILL", pid) rescue nil # rubocop:disable Style/RescueModifier
46
+ ::Process.wait(pid) rescue nil # rubocop:disable Style/RescueModifier
47
+ { timeout: true }
48
+ end
49
+ end
50
+
51
+ def build_mutation_result(mutation, result, duration)
52
+ status = if result[:timeout]
53
+ :timeout
54
+ elsif result[:error]
55
+ :error
56
+ elsif result[:passed]
57
+ :survived
58
+ else
59
+ :killed
60
+ end
61
+
62
+ Result::MutationResult.new(
63
+ mutation: mutation,
64
+ status: status,
65
+ duration: duration
66
+ )
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "diff/lcs"
4
+ require "diff/lcs/hunk"
5
+
6
+ module Evilution
7
+ class Mutation
8
+ attr_reader :subject, :operator_name, :original_source,
9
+ :mutated_source, :file_path, :line, :column
10
+
11
+ def initialize(subject:, operator_name:, original_source:, mutated_source:, file_path:, line:, column: 0)
12
+ @subject = subject
13
+ @operator_name = operator_name
14
+ @original_source = original_source
15
+ @mutated_source = mutated_source
16
+ @file_path = file_path
17
+ @line = line
18
+ @column = column
19
+ freeze
20
+ end
21
+
22
+ def diff
23
+ original_lines = original_source.lines
24
+ mutated_lines = mutated_source.lines
25
+ diffs = ::Diff::LCS.diff(original_lines, mutated_lines)
26
+
27
+ return "" if diffs.empty?
28
+
29
+ result = []
30
+ diffs.flatten(1).each do |change|
31
+ case change.action
32
+ when "-"
33
+ result << "- #{change.element.chomp}"
34
+ when "+"
35
+ result << "+ #{change.element.chomp}"
36
+ end
37
+ end
38
+ result.join("\n")
39
+ end
40
+
41
+ def to_s
42
+ "#{operator_name}: #{file_path}:#{line}"
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ module Evilution
6
+ module Mutator
7
+ class Base < Prism::Visitor
8
+ attr_reader :mutations
9
+
10
+ def initialize
11
+ @mutations = []
12
+ @subject = nil
13
+ @file_source = nil
14
+ end
15
+
16
+ def call(subject)
17
+ @subject = subject
18
+ @file_source = File.read(subject.file_path)
19
+ @mutations = []
20
+ visit(subject.node)
21
+ @mutations
22
+ end
23
+
24
+ private
25
+
26
+ def add_mutation(offset:, length:, replacement:, node:)
27
+ mutated_source = AST::SourceSurgeon.apply(
28
+ @file_source,
29
+ offset: offset,
30
+ length: length,
31
+ replacement: replacement
32
+ )
33
+
34
+ @mutations << Mutation.new(
35
+ subject: @subject,
36
+ operator_name: self.class.operator_name,
37
+ original_source: @file_source,
38
+ mutated_source: mutated_source,
39
+ file_path: @subject.file_path,
40
+ line: node.location.start_line,
41
+ column: node.location.start_column
42
+ )
43
+ end
44
+
45
+ def self.operator_name
46
+ class_name = name || "anonymous"
47
+ class_name.split("::").last
48
+ .gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
49
+ .gsub(/([a-z\d])([A-Z])/, '\1_\2')
50
+ .downcase
51
+ end
52
+ end
53
+ end
54
+ end