evilution 0.5.0 → 0.7.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 +4 -4
- data/.beads/.migration-hint-ts +1 -1
- data/.beads/issues.jsonl +44 -4
- data/CHANGELOG.md +26 -0
- data/README.md +51 -4
- data/Rakefile +2 -0
- data/lib/evilution/baseline.rb +100 -0
- data/lib/evilution/cli.rb +48 -2
- data/lib/evilution/config.rb +36 -15
- data/lib/evilution/isolation/fork.rb +5 -2
- data/lib/evilution/isolation/in_process.rb +78 -0
- data/lib/evilution/mcp/mutate_tool.rb +115 -0
- data/lib/evilution/mcp/server.rb +19 -0
- data/lib/evilution/memory/leak_check.rb +91 -0
- data/lib/evilution/memory.rb +40 -0
- data/lib/evilution/mutation.rb +15 -1
- data/lib/evilution/mutator/operator/argument_removal.rb +42 -0
- data/lib/evilution/mutator/operator/method_call_removal.rb +22 -0
- data/lib/evilution/mutator/registry.rb +3 -1
- data/lib/evilution/reporter/cli.rb +34 -12
- data/lib/evilution/reporter/json.rb +6 -0
- data/lib/evilution/reporter/suggestion.rb +3 -1
- data/lib/evilution/result/mutation_result.rb +11 -3
- data/lib/evilution/result/summary.rb +20 -1
- data/lib/evilution/runner.rb +178 -29
- data/lib/evilution/subject.rb +4 -1
- data/lib/evilution/version.rb +1 -1
- data/lib/evilution.rb +5 -0
- data/lib/tasks/memory_check.rake +9 -0
- data/script/memory_check +94 -0
- metadata +32 -2
data/lib/evilution/config.rb
CHANGED
|
@@ -18,32 +18,21 @@ module Evilution
|
|
|
18
18
|
quiet: false,
|
|
19
19
|
jobs: 1,
|
|
20
20
|
fail_fast: nil,
|
|
21
|
+
baseline: true,
|
|
22
|
+
isolation: :auto,
|
|
21
23
|
line_ranges: {},
|
|
22
24
|
spec_files: []
|
|
23
25
|
}.freeze
|
|
24
26
|
|
|
25
27
|
attr_reader :target_files, :timeout, :format, :diff_base,
|
|
26
28
|
:target, :min_score, :integration, :coverage, :verbose, :quiet,
|
|
27
|
-
:jobs, :fail_fast, :line_ranges, :spec_files
|
|
29
|
+
:jobs, :fail_fast, :baseline, :isolation, :line_ranges, :spec_files
|
|
28
30
|
|
|
29
31
|
def initialize(**options)
|
|
30
32
|
file_options = options.delete(:skip_config_file) ? {} : load_config_file
|
|
31
33
|
merged = DEFAULTS.merge(file_options).merge(options)
|
|
32
34
|
warn_removed_options(merged, file_options)
|
|
33
|
-
|
|
34
|
-
@timeout = merged[:timeout]
|
|
35
|
-
@format = merged[:format].to_sym
|
|
36
|
-
@diff_base = merged[:diff_base]
|
|
37
|
-
@target = merged[:target]
|
|
38
|
-
@min_score = merged[:min_score].to_f
|
|
39
|
-
@integration = merged[:integration].to_sym
|
|
40
|
-
@coverage = merged[:coverage]
|
|
41
|
-
@verbose = merged[:verbose]
|
|
42
|
-
@quiet = merged[:quiet]
|
|
43
|
-
@jobs = validate_jobs(merged[:jobs])
|
|
44
|
-
@fail_fast = validate_fail_fast(merged[:fail_fast])
|
|
45
|
-
@line_ranges = merged[:line_ranges] || {}
|
|
46
|
-
@spec_files = Array(merged[:spec_files])
|
|
35
|
+
assign_attributes(merged)
|
|
47
36
|
freeze
|
|
48
37
|
end
|
|
49
38
|
|
|
@@ -71,6 +60,10 @@ module Evilution
|
|
|
71
60
|
!fail_fast.nil?
|
|
72
61
|
end
|
|
73
62
|
|
|
63
|
+
def baseline?
|
|
64
|
+
baseline
|
|
65
|
+
end
|
|
66
|
+
|
|
74
67
|
def self.file_options
|
|
75
68
|
CONFIG_FILES.each do |path|
|
|
76
69
|
next unless File.exist?(path)
|
|
@@ -128,6 +121,34 @@ module Evilution
|
|
|
128
121
|
raise ConfigError, "fail_fast must be a positive integer, got #{value.inspect}"
|
|
129
122
|
end
|
|
130
123
|
|
|
124
|
+
def assign_attributes(merged)
|
|
125
|
+
@target_files = Array(merged[:target_files])
|
|
126
|
+
@timeout = merged[:timeout]
|
|
127
|
+
@format = merged[:format].to_sym
|
|
128
|
+
@diff_base = merged[:diff_base]
|
|
129
|
+
@target = merged[:target]
|
|
130
|
+
@min_score = merged[:min_score].to_f
|
|
131
|
+
@integration = merged[:integration].to_sym
|
|
132
|
+
@coverage = merged[:coverage]
|
|
133
|
+
@verbose = merged[:verbose]
|
|
134
|
+
@quiet = merged[:quiet]
|
|
135
|
+
@jobs = validate_jobs(merged[:jobs])
|
|
136
|
+
@fail_fast = validate_fail_fast(merged[:fail_fast])
|
|
137
|
+
@baseline = merged[:baseline]
|
|
138
|
+
@isolation = validate_isolation(merged[:isolation])
|
|
139
|
+
@line_ranges = merged[:line_ranges] || {}
|
|
140
|
+
@spec_files = Array(merged[:spec_files])
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def validate_isolation(value)
|
|
144
|
+
raise ConfigError, "isolation must be auto, fork, or in_process, got nil" if value.nil?
|
|
145
|
+
|
|
146
|
+
value = value.to_sym
|
|
147
|
+
raise ConfigError, "isolation must be auto, fork, or in_process, got #{value.inspect}" unless %i[auto fork in_process].include?(value)
|
|
148
|
+
|
|
149
|
+
value
|
|
150
|
+
end
|
|
151
|
+
|
|
131
152
|
def validate_jobs(value)
|
|
132
153
|
raise ConfigError, "jobs must be a positive integer, got #{value.inspect}" if value.is_a?(Float)
|
|
133
154
|
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require "fileutils"
|
|
4
4
|
require "tmpdir"
|
|
5
|
+
require_relative "../memory"
|
|
5
6
|
|
|
6
7
|
module Evilution
|
|
7
8
|
module Isolation
|
|
@@ -51,7 +52,8 @@ module Evilution
|
|
|
51
52
|
end
|
|
52
53
|
|
|
53
54
|
def execute_in_child(mutation, test_command)
|
|
54
|
-
test_command.call(mutation)
|
|
55
|
+
result = test_command.call(mutation)
|
|
56
|
+
{ child_rss_kb: Memory.rss_kb }.merge(result)
|
|
55
57
|
rescue StandardError => e
|
|
56
58
|
{ passed: false, error: e.message }
|
|
57
59
|
end
|
|
@@ -98,7 +100,8 @@ module Evilution
|
|
|
98
100
|
mutation: mutation,
|
|
99
101
|
status: status,
|
|
100
102
|
duration: duration,
|
|
101
|
-
test_command: result[:test_command]
|
|
103
|
+
test_command: result[:test_command],
|
|
104
|
+
child_rss_kb: result[:child_rss_kb]
|
|
102
105
|
)
|
|
103
106
|
end
|
|
104
107
|
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "timeout"
|
|
4
|
+
require_relative "../memory"
|
|
5
|
+
require_relative "../result/mutation_result"
|
|
6
|
+
|
|
7
|
+
module Evilution
|
|
8
|
+
module Isolation
|
|
9
|
+
class InProcess
|
|
10
|
+
def call(mutation:, test_command:, timeout:)
|
|
11
|
+
start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
12
|
+
rss_before = Memory.rss_kb
|
|
13
|
+
result = execute_with_timeout(mutation, test_command, timeout)
|
|
14
|
+
rss_after = Memory.rss_kb
|
|
15
|
+
duration = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
|
|
16
|
+
delta = compute_memory_delta(rss_before, rss_after, result)
|
|
17
|
+
|
|
18
|
+
build_mutation_result(mutation, result, duration, rss_after, delta)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def execute_with_timeout(mutation, test_command, timeout)
|
|
24
|
+
result = Timeout.timeout(timeout) do
|
|
25
|
+
suppress_output { test_command.call(mutation) }
|
|
26
|
+
end
|
|
27
|
+
{ timeout: false }.merge(result)
|
|
28
|
+
rescue Timeout::Error
|
|
29
|
+
{ timeout: true }
|
|
30
|
+
rescue StandardError => e
|
|
31
|
+
{ timeout: false, passed: false, error: e.message }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def suppress_output
|
|
35
|
+
saved_stdout = $stdout
|
|
36
|
+
saved_stderr = $stderr
|
|
37
|
+
File.open(File::NULL, "w") do |null_out|
|
|
38
|
+
File.open(File::NULL, "w") do |null_err|
|
|
39
|
+
$stdout = null_out
|
|
40
|
+
$stderr = null_err
|
|
41
|
+
yield
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
ensure
|
|
45
|
+
$stdout = saved_stdout
|
|
46
|
+
$stderr = saved_stderr
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def compute_memory_delta(rss_before, rss_after, result)
|
|
50
|
+
return nil if result[:timeout]
|
|
51
|
+
return nil unless rss_before && rss_after
|
|
52
|
+
|
|
53
|
+
rss_after - rss_before
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def build_mutation_result(mutation, result, duration, rss_after, memory_delta_kb)
|
|
57
|
+
status = if result[:timeout]
|
|
58
|
+
:timeout
|
|
59
|
+
elsif result[:error]
|
|
60
|
+
:error
|
|
61
|
+
elsif result[:passed]
|
|
62
|
+
:survived
|
|
63
|
+
else
|
|
64
|
+
:killed
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
Result::MutationResult.new(
|
|
68
|
+
mutation: mutation,
|
|
69
|
+
status: status,
|
|
70
|
+
duration: duration,
|
|
71
|
+
test_command: result[:test_command],
|
|
72
|
+
child_rss_kb: rss_after,
|
|
73
|
+
memory_delta_kb: memory_delta_kb
|
|
74
|
+
)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "mcp"
|
|
5
|
+
require_relative "../config"
|
|
6
|
+
require_relative "../runner"
|
|
7
|
+
require_relative "../reporter/json"
|
|
8
|
+
|
|
9
|
+
module Evilution
|
|
10
|
+
module MCP
|
|
11
|
+
class MutateTool < ::MCP::Tool
|
|
12
|
+
tool_name "evilution-mutate"
|
|
13
|
+
description "Run mutation testing on Ruby source files"
|
|
14
|
+
input_schema(
|
|
15
|
+
properties: {
|
|
16
|
+
files: {
|
|
17
|
+
type: "array",
|
|
18
|
+
items: { type: "string" },
|
|
19
|
+
description: "Target files, supports line-range syntax (e.g. lib/foo.rb:15-30)"
|
|
20
|
+
},
|
|
21
|
+
target: {
|
|
22
|
+
type: "string",
|
|
23
|
+
description: "Only mutate the named method (e.g. Foo#bar)"
|
|
24
|
+
},
|
|
25
|
+
timeout: {
|
|
26
|
+
type: "integer",
|
|
27
|
+
description: "Per-mutation timeout in seconds (default: 30)"
|
|
28
|
+
},
|
|
29
|
+
jobs: {
|
|
30
|
+
type: "integer",
|
|
31
|
+
description: "Number of parallel workers (default: 1)"
|
|
32
|
+
},
|
|
33
|
+
fail_fast: {
|
|
34
|
+
type: "integer",
|
|
35
|
+
description: "Stop after N surviving mutants"
|
|
36
|
+
},
|
|
37
|
+
spec: {
|
|
38
|
+
type: "array",
|
|
39
|
+
items: { type: "string" },
|
|
40
|
+
description: "Spec files to run (overrides auto-detection)"
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
class << self
|
|
46
|
+
def call(server_context:, files: [], target: nil, timeout: nil, jobs: nil, fail_fast: nil, spec: nil) # rubocop:disable Lint/UnusedMethodArgument
|
|
47
|
+
parsed_files, line_ranges = parse_files(Array(files))
|
|
48
|
+
config_opts = build_config_opts(parsed_files, line_ranges, target, timeout, jobs, fail_fast, spec)
|
|
49
|
+
config = Config.new(**config_opts)
|
|
50
|
+
runner = Runner.new(config: config)
|
|
51
|
+
summary = runner.call
|
|
52
|
+
report = Reporter::JSON.new.call(summary)
|
|
53
|
+
|
|
54
|
+
::MCP::Tool::Response.new([{ type: "text", text: report }])
|
|
55
|
+
rescue Evilution::Error => e
|
|
56
|
+
error_payload = build_error_payload(e)
|
|
57
|
+
::MCP::Tool::Response.new([{ type: "text", text: ::JSON.generate(error_payload) }], error: true)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
def parse_files(raw_files)
|
|
63
|
+
files = []
|
|
64
|
+
ranges = {}
|
|
65
|
+
|
|
66
|
+
raw_files.each do |arg|
|
|
67
|
+
file, range_str = arg.split(":", 2)
|
|
68
|
+
files << file
|
|
69
|
+
next unless range_str
|
|
70
|
+
|
|
71
|
+
ranges[file] = parse_line_range(range_str)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
[files, ranges]
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def parse_line_range(str)
|
|
78
|
+
if str.include?("-")
|
|
79
|
+
start_str, end_str = str.split("-", 2)
|
|
80
|
+
start_line = Integer(start_str)
|
|
81
|
+
end_line = end_str.empty? ? Float::INFINITY : Integer(end_str)
|
|
82
|
+
start_line..end_line
|
|
83
|
+
else
|
|
84
|
+
line = Integer(str)
|
|
85
|
+
line..line
|
|
86
|
+
end
|
|
87
|
+
rescue ArgumentError, TypeError
|
|
88
|
+
raise ParseError, "invalid line range: #{str.inspect}"
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def build_config_opts(files, line_ranges, target, timeout, jobs, fail_fast, spec)
|
|
92
|
+
opts = { target_files: files, line_ranges: line_ranges, format: :json, quiet: true, skip_config_file: true }
|
|
93
|
+
opts[:target] = target if target
|
|
94
|
+
opts[:timeout] = timeout if timeout
|
|
95
|
+
opts[:jobs] = jobs if jobs
|
|
96
|
+
opts[:fail_fast] = fail_fast if fail_fast
|
|
97
|
+
opts[:spec_files] = spec if spec
|
|
98
|
+
opts
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def build_error_payload(error)
|
|
102
|
+
error_type = case error
|
|
103
|
+
when ConfigError then "config_error"
|
|
104
|
+
when ParseError then "parse_error"
|
|
105
|
+
else "runtime_error"
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
payload = { type: error_type, message: error.message }
|
|
109
|
+
payload[:file] = error.file if error.file
|
|
110
|
+
{ error: payload }
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "mcp"
|
|
4
|
+
require_relative "../version"
|
|
5
|
+
require_relative "mutate_tool"
|
|
6
|
+
|
|
7
|
+
module Evilution
|
|
8
|
+
module MCP
|
|
9
|
+
class Server
|
|
10
|
+
def self.build
|
|
11
|
+
::MCP::Server.new(
|
|
12
|
+
name: "evilution",
|
|
13
|
+
version: Evilution::VERSION,
|
|
14
|
+
tools: [MutateTool]
|
|
15
|
+
)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../memory"
|
|
4
|
+
|
|
5
|
+
module Evilution
|
|
6
|
+
module Memory
|
|
7
|
+
class LeakCheck
|
|
8
|
+
WARMUP_ITERATIONS = 5
|
|
9
|
+
DEFAULT_ITERATIONS = 50
|
|
10
|
+
DEFAULT_MAX_GROWTH_KB = 10_240 # 10 MB
|
|
11
|
+
|
|
12
|
+
attr_reader :samples
|
|
13
|
+
|
|
14
|
+
def initialize(iterations: DEFAULT_ITERATIONS, max_growth_kb: DEFAULT_MAX_GROWTH_KB)
|
|
15
|
+
@iterations = iterations
|
|
16
|
+
@max_growth_kb = max_growth_kb
|
|
17
|
+
@samples = []
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def run(&)
|
|
21
|
+
warmup(&)
|
|
22
|
+
measure(&)
|
|
23
|
+
result
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def rss_available?
|
|
27
|
+
!Evilution::Memory.rss_kb.nil?
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def growth_kb
|
|
31
|
+
return nil if samples.any?(&:nil?)
|
|
32
|
+
return 0 if samples.size < 2
|
|
33
|
+
|
|
34
|
+
samples.last - samples.first
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def passed?
|
|
38
|
+
kb = growth_kb
|
|
39
|
+
return false if kb.nil?
|
|
40
|
+
|
|
41
|
+
kb <= @max_growth_kb
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def warmup(&block)
|
|
47
|
+
WARMUP_ITERATIONS.times { block.call }
|
|
48
|
+
GC.start
|
|
49
|
+
GC.compact if GC.respond_to?(:compact)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def measure(&)
|
|
53
|
+
@samples << Evilution::Memory.rss_kb
|
|
54
|
+
|
|
55
|
+
@iterations.times do |i|
|
|
56
|
+
yield
|
|
57
|
+
|
|
58
|
+
next unless ((i + 1) % sample_interval).zero?
|
|
59
|
+
|
|
60
|
+
GC.start
|
|
61
|
+
@samples << Evilution::Memory.rss_kb
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
take_final_sample
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def take_final_sample
|
|
68
|
+
return if (@iterations % sample_interval).zero?
|
|
69
|
+
|
|
70
|
+
GC.start
|
|
71
|
+
@samples << Evilution::Memory.rss_kb
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def sample_interval
|
|
75
|
+
@sample_interval ||= [@iterations / 10, 1].max
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def result
|
|
79
|
+
{
|
|
80
|
+
passed: passed?,
|
|
81
|
+
growth_kb: growth_kb,
|
|
82
|
+
growth_mb: growth_kb ? growth_kb / 1024.0 : nil,
|
|
83
|
+
samples: samples,
|
|
84
|
+
iterations: @iterations,
|
|
85
|
+
max_growth_kb: @max_growth_kb,
|
|
86
|
+
rss_available: rss_available?
|
|
87
|
+
}
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Evilution
|
|
4
|
+
module Memory
|
|
5
|
+
PROC_STATUS_PATH = "/proc/%d/status"
|
|
6
|
+
RSS_PATTERN = /VmRSS:\s+(\d+)\s+kB/
|
|
7
|
+
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
def rss_kb
|
|
11
|
+
rss_kb_for(Process.pid)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def rss_mb
|
|
15
|
+
kb = rss_kb
|
|
16
|
+
return nil unless kb
|
|
17
|
+
|
|
18
|
+
kb / 1024.0
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def rss_kb_for(pid)
|
|
22
|
+
path = format(PROC_STATUS_PATH, pid)
|
|
23
|
+
content = File.read(path)
|
|
24
|
+
match = content.match(RSS_PATTERN)
|
|
25
|
+
return nil unless match
|
|
26
|
+
|
|
27
|
+
match[1].to_i
|
|
28
|
+
rescue Errno::ENOENT, Errno::EACCES, Errno::ESRCH
|
|
29
|
+
nil
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def delta
|
|
33
|
+
before = rss_kb
|
|
34
|
+
result = yield
|
|
35
|
+
after = rss_kb
|
|
36
|
+
delta_kb = before && after ? after - before : nil
|
|
37
|
+
[result, delta_kb]
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
data/lib/evilution/mutation.rb
CHANGED
|
@@ -16,10 +16,22 @@ module Evilution
|
|
|
16
16
|
@file_path = file_path
|
|
17
17
|
@line = line
|
|
18
18
|
@column = column
|
|
19
|
-
|
|
19
|
+
@diff = nil
|
|
20
20
|
end
|
|
21
21
|
|
|
22
22
|
def diff
|
|
23
|
+
@diff ||= compute_diff
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def strip_sources!
|
|
27
|
+
diff # ensure diff is cached before clearing sources
|
|
28
|
+
@original_source = nil
|
|
29
|
+
@mutated_source = nil
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def compute_diff
|
|
23
35
|
original_lines = original_source.lines
|
|
24
36
|
mutated_lines = mutated_source.lines
|
|
25
37
|
diffs = ::Diff::LCS.diff(original_lines, mutated_lines)
|
|
@@ -38,6 +50,8 @@ module Evilution
|
|
|
38
50
|
result.join("\n")
|
|
39
51
|
end
|
|
40
52
|
|
|
53
|
+
public
|
|
54
|
+
|
|
41
55
|
def to_s
|
|
42
56
|
"#{operator_name}: #{file_path}:#{line}"
|
|
43
57
|
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Evilution
|
|
4
|
+
module Mutator
|
|
5
|
+
module Operator
|
|
6
|
+
class ArgumentRemoval < Base
|
|
7
|
+
SKIP_TYPES = [
|
|
8
|
+
Prism::SplatNode,
|
|
9
|
+
Prism::KeywordHashNode,
|
|
10
|
+
Prism::BlockArgumentNode,
|
|
11
|
+
Prism::ForwardingArgumentsNode
|
|
12
|
+
].freeze
|
|
13
|
+
|
|
14
|
+
def visit_call_node(node)
|
|
15
|
+
args = node.arguments&.arguments
|
|
16
|
+
|
|
17
|
+
if args && args.length >= 2 && positional_only?(args)
|
|
18
|
+
args.each_index do |i|
|
|
19
|
+
remaining = args.each_with_index.filter_map { |a, j| a.slice if j != i }
|
|
20
|
+
replacement = remaining.join(", ")
|
|
21
|
+
|
|
22
|
+
add_mutation(
|
|
23
|
+
offset: node.arguments.location.start_offset,
|
|
24
|
+
length: node.arguments.location.length,
|
|
25
|
+
replacement:,
|
|
26
|
+
node:
|
|
27
|
+
)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
super
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def positional_only?(args)
|
|
37
|
+
args.none? { |arg| SKIP_TYPES.any? { |type| arg.is_a?(type) } }
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Evilution
|
|
4
|
+
module Mutator
|
|
5
|
+
module Operator
|
|
6
|
+
class MethodCallRemoval < Base
|
|
7
|
+
def visit_call_node(node)
|
|
8
|
+
if node.receiver
|
|
9
|
+
add_mutation(
|
|
10
|
+
offset: node.location.start_offset,
|
|
11
|
+
length: node.location.length,
|
|
12
|
+
replacement: node.receiver.slice,
|
|
13
|
+
node: node
|
|
14
|
+
)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
super
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -23,7 +23,9 @@ module Evilution
|
|
|
23
23
|
Operator::MethodBodyReplacement,
|
|
24
24
|
Operator::NegationInsertion,
|
|
25
25
|
Operator::ReturnValueRemoval,
|
|
26
|
-
Operator::CollectionReplacement
|
|
26
|
+
Operator::CollectionReplacement,
|
|
27
|
+
Operator::MethodCallRemoval,
|
|
28
|
+
Operator::ArgumentRemoval
|
|
27
29
|
].each { |op| registry.register(op) }
|
|
28
30
|
registry
|
|
29
31
|
end
|
|
@@ -13,15 +13,10 @@ module Evilution
|
|
|
13
13
|
lines << mutations_line(summary)
|
|
14
14
|
lines << score_line(summary)
|
|
15
15
|
lines << duration_line(summary)
|
|
16
|
-
|
|
17
|
-
if
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
summary.survived_results.each do |result|
|
|
21
|
-
lines << format_survived(result)
|
|
22
|
-
end
|
|
23
|
-
end
|
|
24
|
-
|
|
16
|
+
peak = summary.peak_memory_mb
|
|
17
|
+
lines << peak_memory_line(peak) if peak
|
|
18
|
+
append_survived(lines, summary)
|
|
19
|
+
append_neutral(lines, summary)
|
|
25
20
|
lines << ""
|
|
26
21
|
lines << "[TRUNCATED] Stopped early due to --fail-fast" if summary.truncated?
|
|
27
22
|
lines << result_line(summary)
|
|
@@ -31,17 +26,35 @@ module Evilution
|
|
|
31
26
|
|
|
32
27
|
private
|
|
33
28
|
|
|
29
|
+
def append_survived(lines, summary)
|
|
30
|
+
return unless summary.survived_results.any?
|
|
31
|
+
|
|
32
|
+
lines << ""
|
|
33
|
+
lines << "Survived mutations:"
|
|
34
|
+
summary.survived_results.each { |result| lines << format_survived(result) }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def append_neutral(lines, summary)
|
|
38
|
+
return unless summary.neutral_results.any?
|
|
39
|
+
|
|
40
|
+
lines << ""
|
|
41
|
+
lines << "Neutral mutations (test already failing):"
|
|
42
|
+
summary.neutral_results.each { |result| lines << format_neutral(result) }
|
|
43
|
+
end
|
|
44
|
+
|
|
34
45
|
def header
|
|
35
46
|
"Evilution v#{Evilution::VERSION} — Mutation Testing Results"
|
|
36
47
|
end
|
|
37
48
|
|
|
38
49
|
def mutations_line(summary)
|
|
39
|
-
"Mutations: #{summary.total} total, #{summary.killed} killed, " \
|
|
40
|
-
|
|
50
|
+
parts = "Mutations: #{summary.total} total, #{summary.killed} killed, " \
|
|
51
|
+
"#{summary.survived} survived, #{summary.timed_out} timed out"
|
|
52
|
+
parts += ", #{summary.neutral} neutral" if summary.neutral.positive?
|
|
53
|
+
parts
|
|
41
54
|
end
|
|
42
55
|
|
|
43
56
|
def score_line(summary)
|
|
44
|
-
denominator = summary.total - summary.errors
|
|
57
|
+
denominator = summary.total - summary.errors - summary.neutral
|
|
45
58
|
score_pct = format_pct(summary.score)
|
|
46
59
|
"Score: #{score_pct} (#{summary.killed}/#{denominator})"
|
|
47
60
|
end
|
|
@@ -57,6 +70,11 @@ module Evilution
|
|
|
57
70
|
" #{mutation.operator_name}: #{location}\n#{diff_lines}"
|
|
58
71
|
end
|
|
59
72
|
|
|
73
|
+
def format_neutral(result)
|
|
74
|
+
mutation = result.mutation
|
|
75
|
+
" #{mutation.operator_name}: #{mutation.file_path}:#{mutation.line}"
|
|
76
|
+
end
|
|
77
|
+
|
|
60
78
|
def result_line(summary)
|
|
61
79
|
min_score = 0.8
|
|
62
80
|
pass_fail = summary.success?(min_score: min_score) ? "PASS" : "FAIL"
|
|
@@ -65,6 +83,10 @@ module Evilution
|
|
|
65
83
|
"Result: #{pass_fail} (score #{score_pct} #{pass_fail == "PASS" ? ">=" : "<"} #{threshold_pct})"
|
|
66
84
|
end
|
|
67
85
|
|
|
86
|
+
def peak_memory_line(peak_mb)
|
|
87
|
+
format("Peak memory: %<mb>.1f MB", mb: peak_mb)
|
|
88
|
+
end
|
|
89
|
+
|
|
68
90
|
def format_pct(value)
|
|
69
91
|
format("%.2f%%", value * 100)
|
|
70
92
|
end
|
|
@@ -24,6 +24,7 @@ module Evilution
|
|
|
24
24
|
summary: build_summary(summary),
|
|
25
25
|
survived: summary.survived_results.map { |r| build_mutation_detail(r) },
|
|
26
26
|
killed: summary.killed_results.map { |r| build_mutation_detail(r) },
|
|
27
|
+
neutral: summary.neutral_results.map { |r| build_mutation_detail(r) },
|
|
27
28
|
timed_out: summary.results.select(&:timeout?).map { |r| build_mutation_detail(r) },
|
|
28
29
|
errors: summary.results.select(&:error?).map { |r| build_mutation_detail(r) }
|
|
29
30
|
}
|
|
@@ -36,10 +37,13 @@ module Evilution
|
|
|
36
37
|
survived: summary.survived,
|
|
37
38
|
timed_out: summary.timed_out,
|
|
38
39
|
errors: summary.errors,
|
|
40
|
+
neutral: summary.neutral,
|
|
39
41
|
score: summary.score.round(4),
|
|
40
42
|
duration: summary.duration.round(4)
|
|
41
43
|
}
|
|
42
44
|
data[:truncated] = true if summary.truncated?
|
|
45
|
+
peak = summary.peak_memory_mb
|
|
46
|
+
data[:peak_memory_mb] = peak.round(1) if peak
|
|
43
47
|
data
|
|
44
48
|
end
|
|
45
49
|
|
|
@@ -55,6 +59,8 @@ module Evilution
|
|
|
55
59
|
}
|
|
56
60
|
detail[:suggestion] = @suggestion.suggestion_for(mutation) if result.status == :survived
|
|
57
61
|
detail[:test_command] = result.test_command if result.test_command
|
|
62
|
+
detail[:child_rss_kb] = result.child_rss_kb if result.child_rss_kb
|
|
63
|
+
detail[:memory_delta_kb] = result.memory_delta_kb if result.memory_delta_kb
|
|
58
64
|
detail
|
|
59
65
|
end
|
|
60
66
|
end
|
|
@@ -21,7 +21,9 @@ module Evilution
|
|
|
21
21
|
"method_body_replacement" => "Add a test that checks the method's return value or side effects",
|
|
22
22
|
"negation_insertion" => "Add a test where the predicate result matters (not just truthiness)",
|
|
23
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"
|
|
24
|
+
"collection_replacement" => "Add a test that checks the return value of the collection operation, not just side effects",
|
|
25
|
+
"method_call_removal" => "Add a test that depends on the return value or side effect of this method call",
|
|
26
|
+
"argument_removal" => "Add a test that verifies the correct arguments are passed to this method call"
|
|
25
27
|
}.freeze
|
|
26
28
|
|
|
27
29
|
DEFAULT_SUGGESTION = "Add a more specific test that detects this mutation"
|