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.
- checksums.yaml +7 -0
- data/.beads/.gitignore +51 -0
- data/.beads/.migration-hint-ts +1 -0
- data/.beads/README.md +81 -0
- data/.beads/config.yaml +67 -0
- data/.beads/interactions.jsonl +0 -0
- data/.beads/issues.jsonl +68 -0
- data/.beads/metadata.json +4 -0
- data/.claude/prompts/architect.md +98 -0
- data/.claude/prompts/devops.md +106 -0
- data/.claude/prompts/tests.md +160 -0
- data/CHANGELOG.md +19 -0
- data/CODE_OF_CONDUCT.md +10 -0
- data/LICENSE.txt +21 -0
- data/README.md +190 -0
- data/Rakefile +12 -0
- data/claude-swarm.yml +28 -0
- data/exe/evilution +6 -0
- data/lib/evilution/ast/parser.rb +83 -0
- data/lib/evilution/ast/source_surgeon.rb +13 -0
- data/lib/evilution/cli.rb +78 -0
- data/lib/evilution/config.rb +98 -0
- data/lib/evilution/coverage/collector.rb +47 -0
- data/lib/evilution/coverage/test_map.rb +25 -0
- data/lib/evilution/diff/file_filter.rb +29 -0
- data/lib/evilution/diff/parser.rb +47 -0
- data/lib/evilution/integration/base.rb +11 -0
- data/lib/evilution/integration/rspec.rb +184 -0
- data/lib/evilution/isolation/fork.rb +70 -0
- data/lib/evilution/mutation.rb +45 -0
- data/lib/evilution/mutator/base.rb +54 -0
- data/lib/evilution/mutator/operator/arithmetic_replacement.rb +37 -0
- data/lib/evilution/mutator/operator/array_literal.rb +22 -0
- data/lib/evilution/mutator/operator/boolean_literal_replacement.rb +31 -0
- data/lib/evilution/mutator/operator/boolean_operator_replacement.rb +50 -0
- data/lib/evilution/mutator/operator/collection_replacement.rb +37 -0
- data/lib/evilution/mutator/operator/comparison_replacement.rb +37 -0
- data/lib/evilution/mutator/operator/conditional_branch.rb +36 -0
- data/lib/evilution/mutator/operator/conditional_negation.rb +36 -0
- data/lib/evilution/mutator/operator/float_literal.rb +26 -0
- data/lib/evilution/mutator/operator/hash_literal.rb +22 -0
- data/lib/evilution/mutator/operator/integer_literal.rb +45 -0
- data/lib/evilution/mutator/operator/method_body_replacement.rb +22 -0
- data/lib/evilution/mutator/operator/negation_insertion.rb +22 -0
- data/lib/evilution/mutator/operator/nil_replacement.rb +20 -0
- data/lib/evilution/mutator/operator/return_value_removal.rb +22 -0
- data/lib/evilution/mutator/operator/statement_deletion.rb +24 -0
- data/lib/evilution/mutator/operator/string_literal.rb +22 -0
- data/lib/evilution/mutator/operator/symbol_literal.rb +20 -0
- data/lib/evilution/mutator/registry.rb +55 -0
- data/lib/evilution/parallel/pool.rb +98 -0
- data/lib/evilution/parallel/worker.rb +24 -0
- data/lib/evilution/reporter/cli.rb +72 -0
- data/lib/evilution/reporter/json.rb +59 -0
- data/lib/evilution/reporter/suggestion.rb +51 -0
- data/lib/evilution/result/mutation_result.rb +37 -0
- data/lib/evilution/result/summary.rb +54 -0
- data/lib/evilution/runner.rb +139 -0
- data/lib/evilution/subject.rb +20 -0
- data/lib/evilution/version.rb +5 -0
- data/lib/evilution.rb +51 -0
- data/sig/evilution.rbs +4 -0
- 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,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
|