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,98 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Evilution
|
|
4
|
+
module Parallel
|
|
5
|
+
class Pool
|
|
6
|
+
def initialize(jobs:)
|
|
7
|
+
@jobs = jobs
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
# Executes mutations in parallel across N worker processes.
|
|
11
|
+
#
|
|
12
|
+
# @param mutations [Array] Array of mutation objects to run
|
|
13
|
+
# @param test_command_builder [#call] Callable that receives a mutation and returns a test command callable
|
|
14
|
+
# @param timeout [Numeric] Per-mutation timeout in seconds
|
|
15
|
+
# @return [Array<Result::MutationResult>]
|
|
16
|
+
def call(mutations:, test_command_builder:, timeout:)
|
|
17
|
+
return [] if mutations.empty?
|
|
18
|
+
|
|
19
|
+
worker_count = [@jobs, mutations.size].min
|
|
20
|
+
chunks = partition(mutations, worker_count)
|
|
21
|
+
|
|
22
|
+
pipes = worker_count.times.map { IO.pipe }
|
|
23
|
+
|
|
24
|
+
pids = chunks.each_with_index.map do |chunk, index|
|
|
25
|
+
_, write_io = pipes[index]
|
|
26
|
+
|
|
27
|
+
pid = Process.fork do
|
|
28
|
+
# Close all read ends in the child; close sibling write ends too
|
|
29
|
+
pipes.each_with_index do |(r, w), i|
|
|
30
|
+
if i == index
|
|
31
|
+
r.close
|
|
32
|
+
else
|
|
33
|
+
r.close
|
|
34
|
+
w.close
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
results = run_chunk(chunk, test_command_builder, timeout)
|
|
39
|
+
Marshal.dump(results, write_io)
|
|
40
|
+
write_io.close
|
|
41
|
+
exit!(0)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
write_io.close
|
|
45
|
+
pid
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
results = collect_results(pipes.map(&:first), pids)
|
|
49
|
+
|
|
50
|
+
results
|
|
51
|
+
ensure
|
|
52
|
+
# Ensure all pipes are closed even if something goes wrong
|
|
53
|
+
pipes&.each do |read_io, write_io|
|
|
54
|
+
read_io.close unless read_io.closed?
|
|
55
|
+
write_io.close unless write_io.closed?
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
attr_reader :jobs
|
|
62
|
+
|
|
63
|
+
# Distributes mutations across N chunks using round-robin.
|
|
64
|
+
# File isolation is handled by Integration::RSpec via temp directories
|
|
65
|
+
# and $LOAD_PATH, so same-file mutations can safely run in parallel.
|
|
66
|
+
def partition(mutations, n)
|
|
67
|
+
chunks = Array.new(n) { [] }
|
|
68
|
+
mutations.each_with_index { |m, i| chunks[i % n] << m }
|
|
69
|
+
chunks
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Runs a chunk of mutations sequentially inside a worker process.
|
|
73
|
+
def run_chunk(mutations, test_command_builder, timeout)
|
|
74
|
+
worker = Worker.new
|
|
75
|
+
worker.call(mutations: mutations, test_command_builder: test_command_builder, timeout: timeout)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Reads results from all worker pipes and waits for workers to finish.
|
|
79
|
+
def collect_results(read_ios, pids)
|
|
80
|
+
results = []
|
|
81
|
+
|
|
82
|
+
read_ios.each_with_index do |read_io, _index|
|
|
83
|
+
data = read_io.read
|
|
84
|
+
read_io.close
|
|
85
|
+
|
|
86
|
+
unless data.empty?
|
|
87
|
+
chunk_results = Marshal.load(data) # rubocop:disable Security/MarshalLoad
|
|
88
|
+
results.concat(chunk_results)
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
pids.each { |pid| Process.wait(pid) rescue nil } # rubocop:disable Style/RescueModifier
|
|
93
|
+
|
|
94
|
+
results
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Evilution
|
|
4
|
+
module Parallel
|
|
5
|
+
class Worker
|
|
6
|
+
def initialize(isolator: Isolation::Fork.new)
|
|
7
|
+
@isolator = isolator
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
# Runs a batch of mutations sequentially using fork isolation.
|
|
11
|
+
#
|
|
12
|
+
# @param mutations [Array<Mutation>] Mutations to execute
|
|
13
|
+
# @param test_command_builder [#call] Receives a mutation, returns a test command callable
|
|
14
|
+
# @param timeout [Numeric] Per-mutation timeout in seconds
|
|
15
|
+
# @return [Array<Result::MutationResult>]
|
|
16
|
+
def call(mutations:, test_command_builder:, timeout:)
|
|
17
|
+
mutations.map do |mutation|
|
|
18
|
+
test_command = test_command_builder.call(mutation)
|
|
19
|
+
@isolator.call(mutation: mutation, test_command: test_command, timeout: timeout)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Evilution
|
|
4
|
+
module Reporter
|
|
5
|
+
class CLI
|
|
6
|
+
SEPARATOR = "=" * 44
|
|
7
|
+
|
|
8
|
+
def call(summary)
|
|
9
|
+
lines = []
|
|
10
|
+
lines << header
|
|
11
|
+
lines << SEPARATOR
|
|
12
|
+
lines << ""
|
|
13
|
+
lines << mutations_line(summary)
|
|
14
|
+
lines << score_line(summary)
|
|
15
|
+
lines << duration_line(summary)
|
|
16
|
+
|
|
17
|
+
if summary.survived_results.any?
|
|
18
|
+
lines << ""
|
|
19
|
+
lines << "Survived mutations:"
|
|
20
|
+
summary.survived_results.each do |result|
|
|
21
|
+
lines << format_survived(result)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
lines << ""
|
|
26
|
+
lines << result_line(summary)
|
|
27
|
+
|
|
28
|
+
lines.join("\n")
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def header
|
|
34
|
+
"Evilution v#{Evilution::VERSION} — Mutation Testing Results"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def mutations_line(summary)
|
|
38
|
+
"Mutations: #{summary.total} total, #{summary.killed} killed, " \
|
|
39
|
+
"#{summary.survived} survived, #{summary.timed_out} timed out"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def score_line(summary)
|
|
43
|
+
denominator = summary.total - summary.errors
|
|
44
|
+
score_pct = format_pct(summary.score)
|
|
45
|
+
"Score: #{score_pct} (#{summary.killed}/#{denominator})"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def duration_line(summary)
|
|
49
|
+
"Duration: #{format("%.2f", summary.duration)}s"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def format_survived(result)
|
|
53
|
+
mutation = result.mutation
|
|
54
|
+
location = "#{mutation.file_path}:#{mutation.line}"
|
|
55
|
+
diff_lines = mutation.diff.split("\n").map { |l| " #{l}" }.join("\n")
|
|
56
|
+
" #{mutation.operator_name}: #{location}\n#{diff_lines}"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def result_line(summary)
|
|
60
|
+
min_score = 0.8
|
|
61
|
+
pass_fail = summary.success?(min_score: min_score) ? "PASS" : "FAIL"
|
|
62
|
+
score_pct = format_pct(summary.score)
|
|
63
|
+
threshold_pct = format_pct(min_score)
|
|
64
|
+
"Result: #{pass_fail} (score #{score_pct} #{pass_fail == "PASS" ? ">=" : "<"} #{threshold_pct})"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def format_pct(value)
|
|
68
|
+
format("%.2f%%", value * 100)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "time"
|
|
5
|
+
require_relative "suggestion"
|
|
6
|
+
|
|
7
|
+
module Evilution
|
|
8
|
+
module Reporter
|
|
9
|
+
class JSON
|
|
10
|
+
def initialize
|
|
11
|
+
@suggestion = Suggestion.new
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def call(summary)
|
|
15
|
+
::JSON.generate(build_report(summary))
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
def build_report(summary)
|
|
21
|
+
{
|
|
22
|
+
version: Evilution::VERSION,
|
|
23
|
+
timestamp: Time.now.iso8601,
|
|
24
|
+
summary: build_summary(summary),
|
|
25
|
+
survived: summary.survived_results.map { |r| build_mutation_detail(r) },
|
|
26
|
+
killed: summary.killed_results.map { |r| build_mutation_detail(r) },
|
|
27
|
+
timed_out: summary.results.select(&:timeout?).map { |r| build_mutation_detail(r) },
|
|
28
|
+
errors: summary.results.select(&:error?).map { |r| build_mutation_detail(r) }
|
|
29
|
+
}
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def build_summary(summary)
|
|
33
|
+
{
|
|
34
|
+
total: summary.total,
|
|
35
|
+
killed: summary.killed,
|
|
36
|
+
survived: summary.survived,
|
|
37
|
+
timed_out: summary.timed_out,
|
|
38
|
+
errors: summary.errors,
|
|
39
|
+
score: summary.score.round(4),
|
|
40
|
+
duration: summary.duration.round(4)
|
|
41
|
+
}
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def build_mutation_detail(result)
|
|
45
|
+
mutation = result.mutation
|
|
46
|
+
detail = {
|
|
47
|
+
operator: mutation.operator_name,
|
|
48
|
+
file: mutation.file_path,
|
|
49
|
+
line: mutation.line,
|
|
50
|
+
status: result.status.to_s,
|
|
51
|
+
duration: result.duration.round(4),
|
|
52
|
+
diff: mutation.diff
|
|
53
|
+
}
|
|
54
|
+
detail[:suggestion] = @suggestion.suggestion_for(mutation) if result.status == :survived
|
|
55
|
+
detail
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Evilution
|
|
4
|
+
module Reporter
|
|
5
|
+
class Suggestion
|
|
6
|
+
TEMPLATES = {
|
|
7
|
+
"comparison_replacement" => "Add a test for the boundary condition where the comparison operand equals the threshold exactly",
|
|
8
|
+
"arithmetic_replacement" => "Add a test that verifies the arithmetic result, not just truthiness of the outcome",
|
|
9
|
+
"boolean_operator_replacement" => "Add a test where only one of the boolean conditions is true to distinguish && from ||",
|
|
10
|
+
"boolean_literal_replacement" => "Add a test that exercises the false/true branch explicitly",
|
|
11
|
+
"nil_replacement" => "Add a test that asserts the return value is not nil",
|
|
12
|
+
"integer_literal" => "Add a test that checks the exact numeric value, not just > 0 or truthy",
|
|
13
|
+
"float_literal" => "Add a test that checks the exact floating-point value returned",
|
|
14
|
+
"string_literal" => "Add a test that asserts the string content, not just its presence",
|
|
15
|
+
"array_literal" => "Add a test that verifies the array contents or length",
|
|
16
|
+
"hash_literal" => "Add a test that verifies the hash keys and values",
|
|
17
|
+
"symbol_literal" => "Add a test that checks the exact symbol returned",
|
|
18
|
+
"conditional_negation" => "Add tests for both the true and false branches of this conditional",
|
|
19
|
+
"conditional_branch" => "Add a test that exercises the removed branch of this conditional",
|
|
20
|
+
"statement_deletion" => "Add a test that depends on the side effect of this statement",
|
|
21
|
+
"method_body_replacement" => "Add a test that checks the method's return value or side effects",
|
|
22
|
+
"negation_insertion" => "Add a test where the predicate result matters (not just truthiness)",
|
|
23
|
+
"return_value_removal" => "Add a test that uses the return value of this method",
|
|
24
|
+
"collection_replacement" => "Add a test that checks the return value of the collection operation, not just side effects"
|
|
25
|
+
}.freeze
|
|
26
|
+
|
|
27
|
+
DEFAULT_SUGGESTION = "Add a more specific test that detects this mutation"
|
|
28
|
+
|
|
29
|
+
# Generate suggestions for survived mutations.
|
|
30
|
+
#
|
|
31
|
+
# @param summary [Result::Summary]
|
|
32
|
+
# @return [Array<Hash>] Array of { mutation:, suggestion: }
|
|
33
|
+
def call(summary)
|
|
34
|
+
summary.survived_results.map do |result|
|
|
35
|
+
{
|
|
36
|
+
mutation: result.mutation,
|
|
37
|
+
suggestion: suggestion_for(result.mutation)
|
|
38
|
+
}
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Generate a suggestion for a single mutation.
|
|
43
|
+
#
|
|
44
|
+
# @param mutation [Mutation]
|
|
45
|
+
# @return [String]
|
|
46
|
+
def suggestion_for(mutation)
|
|
47
|
+
TEMPLATES.fetch(mutation.operator_name, DEFAULT_SUGGESTION)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Evilution
|
|
4
|
+
module Result
|
|
5
|
+
class MutationResult
|
|
6
|
+
STATUSES = %i[killed survived timeout error].freeze
|
|
7
|
+
|
|
8
|
+
attr_reader :mutation, :status, :duration, :killing_test
|
|
9
|
+
|
|
10
|
+
def initialize(mutation:, status:, duration: 0.0, killing_test: nil)
|
|
11
|
+
raise ArgumentError, "invalid status: #{status}" unless STATUSES.include?(status)
|
|
12
|
+
|
|
13
|
+
@mutation = mutation
|
|
14
|
+
@status = status
|
|
15
|
+
@duration = duration
|
|
16
|
+
@killing_test = killing_test
|
|
17
|
+
freeze
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def killed?
|
|
21
|
+
status == :killed
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def survived?
|
|
25
|
+
status == :survived
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def timeout?
|
|
29
|
+
status == :timeout
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def error?
|
|
33
|
+
status == :error
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Evilution
|
|
4
|
+
module Result
|
|
5
|
+
class Summary
|
|
6
|
+
attr_reader :results, :duration
|
|
7
|
+
|
|
8
|
+
def initialize(results:, duration: 0.0)
|
|
9
|
+
@results = results
|
|
10
|
+
@duration = duration
|
|
11
|
+
freeze
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def total
|
|
15
|
+
results.length
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def killed
|
|
19
|
+
results.count(&:killed?)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def survived
|
|
23
|
+
results.count(&:survived?)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def timed_out
|
|
27
|
+
results.count(&:timeout?)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def errors
|
|
31
|
+
results.count(&:error?)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def score
|
|
35
|
+
denominator = total - errors
|
|
36
|
+
return 0.0 if denominator.zero?
|
|
37
|
+
|
|
38
|
+
killed.to_f / denominator
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def success?(min_score: 1.0)
|
|
42
|
+
score >= min_score
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def survived_results
|
|
46
|
+
results.select(&:survived?)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def killed_results
|
|
50
|
+
results.select(&:killed?)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "config"
|
|
4
|
+
require_relative "ast/parser"
|
|
5
|
+
require_relative "mutator/registry"
|
|
6
|
+
require_relative "isolation/fork"
|
|
7
|
+
require_relative "parallel/pool"
|
|
8
|
+
require_relative "integration/rspec"
|
|
9
|
+
require_relative "reporter/json"
|
|
10
|
+
require_relative "reporter/cli"
|
|
11
|
+
require_relative "reporter/suggestion"
|
|
12
|
+
require_relative "coverage/collector"
|
|
13
|
+
require_relative "coverage/test_map"
|
|
14
|
+
require_relative "diff/parser"
|
|
15
|
+
require_relative "diff/file_filter"
|
|
16
|
+
require_relative "result/mutation_result"
|
|
17
|
+
require_relative "result/summary"
|
|
18
|
+
|
|
19
|
+
module Evilution
|
|
20
|
+
class Runner
|
|
21
|
+
attr_reader :config
|
|
22
|
+
|
|
23
|
+
def initialize(config: Config.new)
|
|
24
|
+
@config = config
|
|
25
|
+
@parser = AST::Parser.new
|
|
26
|
+
@registry = Mutator::Registry.default
|
|
27
|
+
@isolator = Isolation::Fork.new
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def call
|
|
31
|
+
start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
32
|
+
|
|
33
|
+
subjects = parse_subjects
|
|
34
|
+
subjects = filter_by_diff(subjects) if config.diff?
|
|
35
|
+
mutations = generate_mutations(subjects)
|
|
36
|
+
test_map = collect_coverage if config.coverage && config.integration == :rspec
|
|
37
|
+
mutations, skipped = filter_by_coverage(mutations, test_map) if test_map
|
|
38
|
+
results = run_mutations(mutations)
|
|
39
|
+
results.concat(skipped) if skipped
|
|
40
|
+
duration = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
|
|
41
|
+
|
|
42
|
+
summary = Result::Summary.new(results: results, duration: duration)
|
|
43
|
+
output_report(summary)
|
|
44
|
+
|
|
45
|
+
summary
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
attr_reader :parser, :registry, :isolator
|
|
51
|
+
|
|
52
|
+
def parse_subjects
|
|
53
|
+
config.target_files.flat_map { |file| parser.call(file) }
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def filter_by_diff(subjects)
|
|
57
|
+
diff_parser = Diff::Parser.new
|
|
58
|
+
changed_ranges = diff_parser.parse(config.diff_base)
|
|
59
|
+
Diff::FileFilter.new.filter(subjects, changed_ranges)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def generate_mutations(subjects)
|
|
63
|
+
subjects.flat_map { |subject| registry.mutations_for(subject) }
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def collect_coverage
|
|
67
|
+
test_files = Dir.glob("spec/**/*_spec.rb")
|
|
68
|
+
return nil if test_files.empty?
|
|
69
|
+
|
|
70
|
+
data = Coverage::Collector.new.call(test_files: test_files)
|
|
71
|
+
Coverage::TestMap.new(data)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def filter_by_coverage(mutations, test_map)
|
|
75
|
+
covered, uncovered = mutations.partition do |m|
|
|
76
|
+
test_map.covered?(File.expand_path(m.file_path), m.line)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
skipped = uncovered.map do |m|
|
|
80
|
+
Result::MutationResult.new(mutation: m, status: :survived, duration: 0.0)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
[covered, skipped]
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def run_mutations(mutations)
|
|
87
|
+
integration = build_integration
|
|
88
|
+
|
|
89
|
+
if config.jobs > 1 && mutations.size > 1
|
|
90
|
+
run_parallel(mutations, integration)
|
|
91
|
+
else
|
|
92
|
+
run_sequential(mutations, integration)
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def run_sequential(mutations, integration)
|
|
97
|
+
mutations.map do |mutation|
|
|
98
|
+
test_command = ->(m) { integration.call(m) }
|
|
99
|
+
isolator.call(
|
|
100
|
+
mutation: mutation,
|
|
101
|
+
test_command: test_command,
|
|
102
|
+
timeout: config.timeout
|
|
103
|
+
)
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def run_parallel(mutations, integration)
|
|
108
|
+
pool = Parallel::Pool.new(jobs: config.jobs)
|
|
109
|
+
test_command_builder = ->(_mutation) { ->(m) { integration.call(m) } }
|
|
110
|
+
pool.call(mutations: mutations, test_command_builder: test_command_builder, timeout: config.timeout)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def build_integration
|
|
114
|
+
case config.integration
|
|
115
|
+
when :rspec
|
|
116
|
+
Integration::RSpec.new
|
|
117
|
+
else
|
|
118
|
+
raise Error, "unknown integration: #{config.integration}"
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def output_report(summary)
|
|
123
|
+
reporter = build_reporter
|
|
124
|
+
return unless reporter
|
|
125
|
+
|
|
126
|
+
output = reporter.call(summary)
|
|
127
|
+
$stdout.puts(output) unless config.quiet
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def build_reporter
|
|
131
|
+
case config.format
|
|
132
|
+
when :json
|
|
133
|
+
Reporter::JSON.new
|
|
134
|
+
when :text
|
|
135
|
+
Reporter::CLI.new
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Evilution
|
|
4
|
+
class Subject
|
|
5
|
+
attr_reader :name, :file_path, :line_number, :source, :node
|
|
6
|
+
|
|
7
|
+
def initialize(name:, file_path:, line_number:, source:, node:)
|
|
8
|
+
@name = name
|
|
9
|
+
@file_path = file_path
|
|
10
|
+
@line_number = line_number
|
|
11
|
+
@source = source
|
|
12
|
+
@node = node
|
|
13
|
+
freeze
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def to_s
|
|
17
|
+
"#{name} (#{file_path}:#{line_number})"
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
data/lib/evilution.rb
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "evilution/version"
|
|
4
|
+
require_relative "evilution/config"
|
|
5
|
+
require_relative "evilution/subject"
|
|
6
|
+
require_relative "evilution/mutation"
|
|
7
|
+
require_relative "evilution/ast/source_surgeon"
|
|
8
|
+
require_relative "evilution/ast/parser"
|
|
9
|
+
require_relative "evilution/mutator/base"
|
|
10
|
+
require_relative "evilution/mutator/operator/comparison_replacement"
|
|
11
|
+
require_relative "evilution/mutator/operator/boolean_literal_replacement"
|
|
12
|
+
require_relative "evilution/mutator/operator/integer_literal"
|
|
13
|
+
require_relative "evilution/mutator/operator/float_literal"
|
|
14
|
+
require_relative "evilution/mutator/operator/nil_replacement"
|
|
15
|
+
require_relative "evilution/mutator/operator/boolean_operator_replacement"
|
|
16
|
+
require_relative "evilution/mutator/operator/arithmetic_replacement"
|
|
17
|
+
require_relative "evilution/mutator/operator/string_literal"
|
|
18
|
+
require_relative "evilution/mutator/operator/array_literal"
|
|
19
|
+
require_relative "evilution/mutator/operator/hash_literal"
|
|
20
|
+
require_relative "evilution/mutator/operator/conditional_branch"
|
|
21
|
+
require_relative "evilution/mutator/operator/symbol_literal"
|
|
22
|
+
require_relative "evilution/mutator/operator/conditional_negation"
|
|
23
|
+
require_relative "evilution/mutator/operator/negation_insertion"
|
|
24
|
+
require_relative "evilution/mutator/operator/statement_deletion"
|
|
25
|
+
require_relative "evilution/mutator/operator/method_body_replacement"
|
|
26
|
+
require_relative "evilution/mutator/operator/return_value_removal"
|
|
27
|
+
require_relative "evilution/mutator/operator/collection_replacement"
|
|
28
|
+
require_relative "evilution/mutator/registry"
|
|
29
|
+
require_relative "evilution/isolation/fork"
|
|
30
|
+
require_relative "evilution/parallel/worker"
|
|
31
|
+
require_relative "evilution/parallel/pool"
|
|
32
|
+
require_relative "evilution/diff/parser"
|
|
33
|
+
require_relative "evilution/diff/file_filter"
|
|
34
|
+
require_relative "evilution/integration/base"
|
|
35
|
+
require_relative "evilution/integration/rspec"
|
|
36
|
+
require_relative "evilution/result/mutation_result"
|
|
37
|
+
require_relative "evilution/result/summary"
|
|
38
|
+
require_relative "evilution/reporter/json"
|
|
39
|
+
require_relative "evilution/reporter/cli"
|
|
40
|
+
require_relative "evilution/reporter/suggestion"
|
|
41
|
+
require_relative "evilution/coverage/collector"
|
|
42
|
+
require_relative "evilution/coverage/test_map"
|
|
43
|
+
require_relative "evilution/cli"
|
|
44
|
+
require_relative "evilution/runner"
|
|
45
|
+
|
|
46
|
+
module Evilution
|
|
47
|
+
class Error < StandardError; end
|
|
48
|
+
class ConfigError < Error; end
|
|
49
|
+
class ParseError < Error; end
|
|
50
|
+
class IsolationError < Error; end
|
|
51
|
+
end
|
data/sig/evilution.rbs
ADDED