evilution 0.13.0 → 0.15.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 +17 -17
- data/CHANGELOG.md +39 -0
- data/lib/evilution/ast/inheritance_scanner.rb +70 -0
- data/lib/evilution/ast/parser.rb +73 -68
- data/lib/evilution/ast/source_surgeon.rb +7 -9
- data/lib/evilution/ast.rb +4 -0
- data/lib/evilution/baseline.rb +73 -75
- data/lib/evilution/cache.rb +75 -77
- data/lib/evilution/cli.rb +412 -173
- data/lib/evilution/config.rb +141 -136
- data/lib/evilution/equivalent/detector.rb +29 -27
- data/lib/evilution/equivalent/heuristic/alias_swap.rb +32 -33
- data/lib/evilution/equivalent/heuristic/arithmetic_identity.rb +30 -0
- data/lib/evilution/equivalent/heuristic/comment_marking.rb +21 -0
- data/lib/evilution/equivalent/heuristic/dead_code.rb +41 -45
- data/lib/evilution/equivalent/heuristic/method_body_nil.rb +11 -15
- data/lib/evilution/equivalent/heuristic/noop_source.rb +5 -9
- data/lib/evilution/equivalent/heuristic.rb +6 -0
- data/lib/evilution/equivalent.rb +4 -0
- data/lib/evilution/git/changed_files.rb +35 -37
- data/lib/evilution/git.rb +4 -0
- data/lib/evilution/integration/base.rb +5 -7
- data/lib/evilution/integration/rspec.rb +114 -116
- data/lib/evilution/integration.rb +4 -0
- data/lib/evilution/isolation/fork.rb +98 -100
- data/lib/evilution/isolation/in_process.rb +59 -61
- data/lib/evilution/isolation.rb +4 -0
- data/lib/evilution/mcp/mutate_tool.rb +172 -143
- data/lib/evilution/mcp/server.rb +12 -11
- data/lib/evilution/mcp/session_diff_tool.rb +89 -0
- data/lib/evilution/mcp/session_list_tool.rb +46 -0
- data/lib/evilution/mcp/session_show_tool.rb +53 -0
- data/lib/evilution/mcp.rb +4 -0
- data/lib/evilution/memory/leak_check.rb +80 -84
- data/lib/evilution/memory.rb +34 -36
- data/lib/evilution/mutation.rb +40 -42
- data/lib/evilution/mutator/base.rb +62 -48
- data/lib/evilution/mutator/operator/argument_nil_substitution.rb +32 -36
- data/lib/evilution/mutator/operator/argument_removal.rb +32 -36
- data/lib/evilution/mutator/operator/arithmetic_replacement.rb +26 -30
- data/lib/evilution/mutator/operator/array_literal.rb +18 -22
- data/lib/evilution/mutator/operator/block_removal.rb +16 -20
- data/lib/evilution/mutator/operator/boolean_literal_replacement.rb +38 -42
- data/lib/evilution/mutator/operator/boolean_operator_replacement.rb +41 -45
- data/lib/evilution/mutator/operator/collection_replacement.rb +32 -36
- data/lib/evilution/mutator/operator/comparison_replacement.rb +24 -28
- data/lib/evilution/mutator/operator/compound_assignment.rb +114 -118
- data/lib/evilution/mutator/operator/conditional_branch.rb +25 -29
- data/lib/evilution/mutator/operator/conditional_flip.rb +26 -30
- data/lib/evilution/mutator/operator/conditional_negation.rb +25 -29
- data/lib/evilution/mutator/operator/float_literal.rb +22 -26
- data/lib/evilution/mutator/operator/hash_literal.rb +18 -22
- data/lib/evilution/mutator/operator/integer_literal.rb +2 -0
- data/lib/evilution/mutator/operator/method_body_replacement.rb +12 -16
- data/lib/evilution/mutator/operator/method_call_removal.rb +12 -16
- data/lib/evilution/mutator/operator/mixin_removal.rb +80 -0
- data/lib/evilution/mutator/operator/negation_insertion.rb +12 -16
- data/lib/evilution/mutator/operator/nil_replacement.rb +13 -17
- data/lib/evilution/mutator/operator/range_replacement.rb +12 -16
- data/lib/evilution/mutator/operator/receiver_replacement.rb +16 -20
- data/lib/evilution/mutator/operator/regexp_mutation.rb +15 -19
- data/lib/evilution/mutator/operator/return_value_removal.rb +12 -16
- data/lib/evilution/mutator/operator/send_mutation.rb +36 -40
- data/lib/evilution/mutator/operator/statement_deletion.rb +13 -17
- data/lib/evilution/mutator/operator/string_literal.rb +18 -22
- data/lib/evilution/mutator/operator/superclass_removal.rb +65 -0
- data/lib/evilution/mutator/operator/symbol_literal.rb +17 -21
- data/lib/evilution/mutator/operator.rb +6 -0
- data/lib/evilution/mutator/registry.rb +56 -56
- data/lib/evilution/mutator.rb +4 -0
- data/lib/evilution/parallel/pool.rb +56 -58
- data/lib/evilution/parallel.rb +4 -0
- data/lib/evilution/reporter/cli.rb +99 -101
- data/lib/evilution/reporter/html.rb +242 -244
- data/lib/evilution/reporter/json.rb +57 -59
- data/lib/evilution/reporter/suggestion.rb +354 -328
- data/lib/evilution/reporter.rb +4 -0
- data/lib/evilution/result/mutation_result.rb +43 -46
- data/lib/evilution/result/summary.rb +80 -81
- data/lib/evilution/result.rb +4 -0
- data/lib/evilution/runner.rb +401 -316
- data/lib/evilution/session/store.rb +147 -0
- data/lib/evilution/session.rb +4 -0
- data/lib/evilution/spec_resolver.rb +49 -47
- data/lib/evilution/subject.rb +14 -16
- data/lib/evilution/version.rb +1 -1
- data/lib/evilution.rb +16 -0
- metadata +24 -2
data/lib/evilution/cli.rb
CHANGED
|
@@ -6,222 +6,461 @@ require_relative "version"
|
|
|
6
6
|
require_relative "config"
|
|
7
7
|
require_relative "runner"
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
9
|
+
class Evilution::CLI
|
|
10
|
+
def initialize(argv, stdin: $stdin)
|
|
11
|
+
@options = {}
|
|
12
|
+
@command = :run
|
|
13
|
+
@stdin = stdin
|
|
14
|
+
argv = argv.dup
|
|
15
|
+
argv = extract_command(argv)
|
|
16
|
+
argv = preprocess_flags(argv)
|
|
17
|
+
raw_args = build_option_parser.parse!(argv)
|
|
18
|
+
@files, @line_ranges = parse_file_args(raw_args)
|
|
19
|
+
read_stdin_files if @options.delete(:stdin) && @command == :run
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def call
|
|
23
|
+
case @command
|
|
24
|
+
when :version
|
|
25
|
+
$stdout.puts(Evilution::VERSION)
|
|
26
|
+
0
|
|
27
|
+
when :init
|
|
28
|
+
run_init
|
|
29
|
+
when :mcp
|
|
30
|
+
run_mcp
|
|
31
|
+
when :session_list
|
|
32
|
+
run_session_list
|
|
33
|
+
when :session_show
|
|
34
|
+
run_session_show
|
|
35
|
+
when :session_gc
|
|
36
|
+
run_session_gc
|
|
37
|
+
when :session_error
|
|
38
|
+
warn("Error: #{@session_error}")
|
|
39
|
+
2
|
|
40
|
+
when :run
|
|
41
|
+
run_mutations
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def extract_command(argv)
|
|
48
|
+
case argv.first
|
|
49
|
+
when "version"
|
|
50
|
+
@command = :version
|
|
51
|
+
argv.shift
|
|
52
|
+
when "init"
|
|
53
|
+
@command = :init
|
|
54
|
+
argv.shift
|
|
55
|
+
when "mcp"
|
|
56
|
+
@command = :mcp
|
|
57
|
+
argv.shift
|
|
58
|
+
when "session"
|
|
59
|
+
argv.shift
|
|
60
|
+
extract_session_subcommand(argv)
|
|
61
|
+
when "run"
|
|
62
|
+
argv.shift
|
|
35
63
|
end
|
|
64
|
+
argv
|
|
65
|
+
end
|
|
36
66
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
end
|
|
71
|
-
elsif arg.start_with?("--fail-fast=")
|
|
72
|
-
@options[:fail_fast] = arg.delete_prefix("--fail-fast=")
|
|
73
|
-
i += 1
|
|
67
|
+
def extract_session_subcommand(argv)
|
|
68
|
+
subcommand = argv.first
|
|
69
|
+
case subcommand
|
|
70
|
+
when "list"
|
|
71
|
+
@command = :session_list
|
|
72
|
+
argv.shift
|
|
73
|
+
when "show"
|
|
74
|
+
@command = :session_show
|
|
75
|
+
argv.shift
|
|
76
|
+
when "gc"
|
|
77
|
+
@command = :session_gc
|
|
78
|
+
argv.shift
|
|
79
|
+
when nil
|
|
80
|
+
@command = :session_error
|
|
81
|
+
@session_error = "Missing session subcommand. Available subcommands: list, show, gc"
|
|
82
|
+
else
|
|
83
|
+
@command = :session_error
|
|
84
|
+
@session_error = "Unknown session subcommand: #{subcommand}. Available subcommands: list, show, gc"
|
|
85
|
+
argv.shift
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def preprocess_flags(argv)
|
|
90
|
+
result = []
|
|
91
|
+
i = 0
|
|
92
|
+
while i < argv.length
|
|
93
|
+
arg = argv[i]
|
|
94
|
+
if arg == "--fail-fast"
|
|
95
|
+
next_arg = argv[i + 1]
|
|
96
|
+
|
|
97
|
+
if next_arg && next_arg.match?(/\A-?\d+\z/)
|
|
98
|
+
@options[:fail_fast] = next_arg
|
|
99
|
+
i += 2
|
|
74
100
|
else
|
|
75
101
|
result << arg
|
|
76
102
|
i += 1
|
|
77
103
|
end
|
|
104
|
+
elsif arg.start_with?("--fail-fast=")
|
|
105
|
+
@options[:fail_fast] = arg.delete_prefix("--fail-fast=")
|
|
106
|
+
i += 1
|
|
107
|
+
else
|
|
108
|
+
result << arg
|
|
109
|
+
i += 1
|
|
78
110
|
end
|
|
79
|
-
result
|
|
80
111
|
end
|
|
112
|
+
result
|
|
113
|
+
end
|
|
81
114
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
end
|
|
115
|
+
def build_option_parser
|
|
116
|
+
OptionParser.new do |opts|
|
|
117
|
+
opts.banner = "Usage: evilution [command] [options] [files...]"
|
|
118
|
+
opts.version = Evilution::VERSION
|
|
119
|
+
add_separators(opts)
|
|
120
|
+
add_options(opts)
|
|
89
121
|
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def add_separators(opts)
|
|
125
|
+
opts.separator ""
|
|
126
|
+
opts.separator "Line-range targeting: lib/foo.rb:15-30, lib/foo.rb:15, lib/foo.rb:15-"
|
|
127
|
+
opts.separator ""
|
|
128
|
+
opts.separator "Commands: run (default), init, session {list,show,gc}, mcp, version"
|
|
129
|
+
opts.separator ""
|
|
130
|
+
opts.separator "Options:"
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def add_options(opts)
|
|
134
|
+
add_core_options(opts)
|
|
135
|
+
add_filter_options(opts)
|
|
136
|
+
add_flag_options(opts)
|
|
137
|
+
add_session_options(opts)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def add_core_options(opts)
|
|
141
|
+
opts.on("-j", "--jobs N", Integer, "Number of parallel workers (default: 1)") { |n| @options[:jobs] = n }
|
|
142
|
+
opts.on("-t", "--timeout N", Integer, "Per-mutation timeout in seconds") { |n| @options[:timeout] = n }
|
|
143
|
+
opts.on("-f", "--format FORMAT", "Output format: text, json, html") { |f| @options[:format] = f.to_sym }
|
|
144
|
+
end
|
|
90
145
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
146
|
+
def add_filter_options(opts)
|
|
147
|
+
opts.on("--min-score FLOAT", Float, "Minimum mutation score to pass") { |s| @options[:min_score] = s }
|
|
148
|
+
opts.on("--spec FILES", Array, "Spec files to run (comma-separated)") { |f| @options[:spec_files] = f }
|
|
149
|
+
opts.on("--target EXPR",
|
|
150
|
+
"Filter: method (Foo#bar), type (Foo#/Foo.), namespace (Foo*),",
|
|
151
|
+
"class (Foo), glob (source:**/*.rb), hierarchy (descendants:Foo)") do |m|
|
|
152
|
+
@options[:target] = m
|
|
98
153
|
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def add_flag_options(opts)
|
|
157
|
+
opts.on("--fail-fast", "Stop after N surviving mutants " \
|
|
158
|
+
"(default: disabled; if provided without N, uses 1; use --fail-fast=N)") { @options[:fail_fast] ||= 1 }
|
|
159
|
+
opts.on("--no-baseline", "Skip baseline test suite check") { @options[:baseline] = false }
|
|
160
|
+
opts.on("--incremental", "Cache killed/timeout results; skip re-running them on unchanged files") { @options[:incremental] = true }
|
|
161
|
+
opts.on("--isolation STRATEGY", "Isolation: auto, fork, in_process (default: auto)") { |s| @options[:isolation] = s }
|
|
162
|
+
opts.on("--stdin", "Read target file paths from stdin (one per line)") { @options[:stdin] = true }
|
|
163
|
+
opts.on("--suggest-tests", "Generate concrete RSpec test code in suggestions") { @options[:suggest_tests] = true }
|
|
164
|
+
opts.on("--save-session", "Save session results to .evilution/results/") { @options[:save_session] = true }
|
|
165
|
+
opts.on("-v", "--verbose", "Verbose output") { @options[:verbose] = true }
|
|
166
|
+
opts.on("-q", "--quiet", "Suppress output") { @options[:quiet] = true }
|
|
167
|
+
end
|
|
99
168
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
169
|
+
def add_session_options(opts)
|
|
170
|
+
opts.on("--results-dir DIR", "Session results directory") { |d| @options[:results_dir] = d }
|
|
171
|
+
opts.on("--limit N", Integer, "Show only the N most recent sessions") { |n| @options[:limit] = n }
|
|
172
|
+
opts.on("--since DATE", "Show sessions since DATE (YYYY-MM-DD)") { |d| @options[:since] = d }
|
|
173
|
+
opts.on("--older-than DURATION", "Delete sessions older than DURATION (e.g., 30d, 24h, 1w)") do |d|
|
|
174
|
+
@options[:older_than] = d
|
|
104
175
|
end
|
|
176
|
+
end
|
|
105
177
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
178
|
+
def run_init
|
|
179
|
+
path = ".evilution.yml"
|
|
180
|
+
if File.exist?(path)
|
|
181
|
+
warn("#{path} already exists")
|
|
182
|
+
return 1
|
|
110
183
|
end
|
|
111
184
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
185
|
+
File.write(path, Evilution::Config.default_template)
|
|
186
|
+
$stdout.puts("Created #{path}")
|
|
187
|
+
0
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def run_mcp
|
|
191
|
+
require_relative "mcp/server"
|
|
192
|
+
server = Evilution::MCP::Server.build
|
|
193
|
+
transport = ::MCP::Server::Transports::StdioTransport.new(server)
|
|
194
|
+
transport.open
|
|
195
|
+
0
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def read_stdin_files
|
|
199
|
+
@stdin_error = "--stdin cannot be combined with positional file arguments" unless @files.empty?
|
|
200
|
+
return if @stdin_error
|
|
201
|
+
|
|
202
|
+
lines = []
|
|
203
|
+
@stdin.each_line do |line|
|
|
204
|
+
line = line.strip
|
|
205
|
+
lines << line unless line.empty?
|
|
116
206
|
end
|
|
207
|
+
stdin_files, stdin_ranges = parse_file_args(lines)
|
|
208
|
+
@files = stdin_files
|
|
209
|
+
@line_ranges = @line_ranges.merge(stdin_ranges)
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def parse_file_args(raw_args)
|
|
213
|
+
files = []
|
|
214
|
+
ranges = {}
|
|
215
|
+
|
|
216
|
+
raw_args.each do |arg|
|
|
217
|
+
file, range_str = arg.split(":", 2)
|
|
218
|
+
files << file
|
|
219
|
+
next unless range_str
|
|
117
220
|
|
|
118
|
-
|
|
119
|
-
opts.on("--fail-fast", "Stop after N surviving mutants " \
|
|
120
|
-
"(default: disabled; if provided without N, uses 1; use --fail-fast=N)") { @options[:fail_fast] ||= 1 }
|
|
121
|
-
opts.on("--no-baseline", "Skip baseline test suite check") { @options[:baseline] = false }
|
|
122
|
-
opts.on("--incremental", "Cache killed/timeout results; skip re-running them on unchanged files") { @options[:incremental] = true }
|
|
123
|
-
opts.on("--isolation STRATEGY", "Isolation: auto, fork, in_process (default: auto)") { |s| @options[:isolation] = s }
|
|
124
|
-
opts.on("--stdin", "Read target file paths from stdin (one per line)") { @options[:stdin] = true }
|
|
125
|
-
opts.on("--suggest-tests", "Generate concrete RSpec test code in suggestions") { @options[:suggest_tests] = true }
|
|
126
|
-
opts.on("-v", "--verbose", "Verbose output") { @options[:verbose] = true }
|
|
127
|
-
opts.on("-q", "--quiet", "Suppress output") { @options[:quiet] = true }
|
|
221
|
+
ranges[file] = parse_line_range(range_str)
|
|
128
222
|
end
|
|
129
223
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
if File.exist?(path)
|
|
133
|
-
warn("#{path} already exists")
|
|
134
|
-
return 1
|
|
135
|
-
end
|
|
224
|
+
[files, ranges]
|
|
225
|
+
end
|
|
136
226
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
227
|
+
def parse_line_range(str)
|
|
228
|
+
if str.include?("-")
|
|
229
|
+
start_str, end_str = str.split("-", 2)
|
|
230
|
+
start_line = Integer(start_str)
|
|
231
|
+
end_line = end_str.empty? ? Float::INFINITY : Integer(end_str)
|
|
232
|
+
start_line..end_line
|
|
233
|
+
else
|
|
234
|
+
line = Integer(str)
|
|
235
|
+
line..line
|
|
140
236
|
end
|
|
237
|
+
end
|
|
141
238
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
239
|
+
def run_session_list
|
|
240
|
+
require_relative "session/store"
|
|
241
|
+
|
|
242
|
+
store_opts = {}
|
|
243
|
+
store_opts[:results_dir] = @options[:results_dir] if @options[:results_dir]
|
|
244
|
+
store = Evilution::Session::Store.new(**store_opts)
|
|
245
|
+
sessions = store.list
|
|
246
|
+
sessions = filter_sessions(sessions)
|
|
247
|
+
|
|
248
|
+
if sessions.empty?
|
|
249
|
+
$stdout.puts("No sessions found")
|
|
250
|
+
return 0
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
if @options[:format] == :json
|
|
254
|
+
$stdout.puts(JSON.pretty_generate(sessions.map { |s| session_to_hash(s) }))
|
|
255
|
+
else
|
|
256
|
+
print_session_table(sessions)
|
|
148
257
|
end
|
|
149
258
|
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
259
|
+
0
|
|
260
|
+
rescue Evilution::ConfigError => e
|
|
261
|
+
warn("Error: #{e.message}")
|
|
262
|
+
2
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def filter_sessions(sessions)
|
|
266
|
+
if @options[:since]
|
|
267
|
+
cutoff = parse_date(@options[:since])
|
|
268
|
+
sessions = sessions.select do |s|
|
|
269
|
+
ts = s[:timestamp]
|
|
270
|
+
next false unless ts.is_a?(String)
|
|
153
271
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
lines << line unless line.empty?
|
|
272
|
+
Time.parse(ts) >= cutoff
|
|
273
|
+
rescue ArgumentError
|
|
274
|
+
false
|
|
158
275
|
end
|
|
159
|
-
stdin_files, stdin_ranges = parse_file_args(lines)
|
|
160
|
-
@files = stdin_files
|
|
161
|
-
@line_ranges = @line_ranges.merge(stdin_ranges)
|
|
162
276
|
end
|
|
277
|
+
sessions = sessions.first(@options[:limit]) if @options[:limit]
|
|
278
|
+
sessions
|
|
279
|
+
end
|
|
163
280
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
281
|
+
def parse_date(value)
|
|
282
|
+
Time.parse(value)
|
|
283
|
+
rescue ArgumentError
|
|
284
|
+
raise Evilution::ConfigError, "invalid --since date: #{value.inspect}. Use YYYY-MM-DD format"
|
|
285
|
+
end
|
|
167
286
|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
files << file
|
|
171
|
-
next unless range_str
|
|
287
|
+
def run_session_show
|
|
288
|
+
require_relative "session/store"
|
|
172
289
|
|
|
173
|
-
|
|
174
|
-
|
|
290
|
+
path = @files.first
|
|
291
|
+
raise Evilution::ConfigError, "session file path required" unless path
|
|
175
292
|
|
|
176
|
-
|
|
177
|
-
|
|
293
|
+
store = Evilution::Session::Store.new
|
|
294
|
+
data = store.load(path)
|
|
178
295
|
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
end_line = end_str.empty? ? Float::INFINITY : Integer(end_str)
|
|
184
|
-
start_line..end_line
|
|
185
|
-
else
|
|
186
|
-
line = Integer(str)
|
|
187
|
-
line..line
|
|
188
|
-
end
|
|
296
|
+
if @options[:format] == :json
|
|
297
|
+
$stdout.puts(JSON.pretty_generate(data))
|
|
298
|
+
else
|
|
299
|
+
print_session_detail(data)
|
|
189
300
|
end
|
|
190
301
|
|
|
191
|
-
|
|
192
|
-
|
|
302
|
+
0
|
|
303
|
+
rescue Evilution::Error => e
|
|
304
|
+
warn("Error: #{e.message}")
|
|
305
|
+
2
|
|
306
|
+
rescue ::JSON::ParserError => e
|
|
307
|
+
warn("Error: invalid session file: #{e.message}")
|
|
308
|
+
2
|
|
309
|
+
end
|
|
193
310
|
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
311
|
+
def run_session_gc
|
|
312
|
+
require_relative "session/store"
|
|
313
|
+
|
|
314
|
+
raise Evilution::ConfigError, "--older-than is required for session gc" unless @options[:older_than]
|
|
315
|
+
|
|
316
|
+
cutoff = parse_duration(@options[:older_than])
|
|
317
|
+
store_opts = {}
|
|
318
|
+
store_opts[:results_dir] = @options[:results_dir] if @options[:results_dir]
|
|
319
|
+
store = Evilution::Session::Store.new(**store_opts)
|
|
320
|
+
deleted = store.gc(older_than: cutoff)
|
|
321
|
+
|
|
322
|
+
if deleted.empty?
|
|
323
|
+
$stdout.puts("No sessions to delete")
|
|
324
|
+
else
|
|
325
|
+
$stdout.puts("Deleted #{deleted.length} session#{"s" unless deleted.length == 1}")
|
|
206
326
|
end
|
|
207
327
|
|
|
208
|
-
|
|
209
|
-
|
|
328
|
+
0
|
|
329
|
+
rescue Evilution::ConfigError => e
|
|
330
|
+
warn("Error: #{e.message}")
|
|
331
|
+
2
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
def parse_duration(value)
|
|
335
|
+
match = value.match(/\A(\d+)([dhw])\z/)
|
|
336
|
+
raise Evilution::ConfigError, "invalid --older-than format: #{value.inspect}. Use Nd, Nh, or Nw (e.g., 30d)" unless match
|
|
337
|
+
|
|
338
|
+
amount = match[1].to_i
|
|
339
|
+
seconds = case match[2]
|
|
340
|
+
when "h" then amount * 3600
|
|
341
|
+
when "d" then amount * 86_400
|
|
342
|
+
when "w" then amount * 604_800
|
|
343
|
+
end
|
|
344
|
+
Time.now - seconds
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
def print_session_detail(data)
|
|
348
|
+
print_session_header(data)
|
|
349
|
+
print_session_summary(data["summary"])
|
|
350
|
+
print_survived_section(data["survived"] || [])
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
def print_session_header(data)
|
|
354
|
+
$stdout.puts("Session: #{data["timestamp"]}")
|
|
355
|
+
$stdout.puts("Version: #{data["version"]}")
|
|
356
|
+
print_git_context(data["git"])
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
def print_git_context(git)
|
|
360
|
+
return unless git.is_a?(Hash)
|
|
361
|
+
|
|
362
|
+
branch = git["branch"]
|
|
363
|
+
sha = git["sha"]
|
|
364
|
+
return if branch.to_s.empty? && sha.to_s.empty?
|
|
365
|
+
|
|
366
|
+
$stdout.puts("Git: #{branch} (#{sha})")
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
def print_session_summary(summary)
|
|
370
|
+
$stdout.puts("")
|
|
371
|
+
$stdout.puts(
|
|
372
|
+
format(
|
|
373
|
+
"Score: %<score>.2f%% Total: %<total>d Killed: %<killed>d Survived: %<surv>d " \
|
|
374
|
+
"Timed out: %<to>d Errors: %<err>d Duration: %<dur>.1fs",
|
|
375
|
+
score: summary["score"] * 100, total: summary["total"], killed: summary["killed"],
|
|
376
|
+
surv: summary["survived"], to: summary["timed_out"], err: summary["errors"],
|
|
377
|
+
dur: summary["duration"]
|
|
378
|
+
)
|
|
379
|
+
)
|
|
380
|
+
end
|
|
210
381
|
|
|
211
|
-
|
|
212
|
-
|
|
382
|
+
def print_survived_section(survived)
|
|
383
|
+
$stdout.puts("")
|
|
384
|
+
if survived.empty?
|
|
385
|
+
$stdout.puts("No survived mutations")
|
|
386
|
+
else
|
|
387
|
+
$stdout.puts("Survived mutations (#{survived.length}):")
|
|
388
|
+
survived.each_with_index { |m, i| print_mutation_detail(m, i + 1) }
|
|
213
389
|
end
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
def print_mutation_detail(mutation, index)
|
|
393
|
+
$stdout.puts("")
|
|
394
|
+
$stdout.puts(" #{index}. #{mutation["operator"]} — #{mutation["file"]}:#{mutation["line"]}")
|
|
395
|
+
$stdout.puts(" Subject: #{mutation["subject"]}")
|
|
396
|
+
return unless mutation["diff"]
|
|
214
397
|
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
398
|
+
$stdout.puts(" Diff:")
|
|
399
|
+
mutation["diff"].each_line { |line| $stdout.puts(" #{line}") }
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
def session_to_hash(session)
|
|
403
|
+
{
|
|
404
|
+
"timestamp" => session[:timestamp],
|
|
405
|
+
"total" => session[:total],
|
|
406
|
+
"killed" => session[:killed],
|
|
407
|
+
"survived" => session[:survived],
|
|
408
|
+
"score" => session[:score],
|
|
409
|
+
"duration" => session[:duration],
|
|
410
|
+
"file" => session[:file]
|
|
411
|
+
}
|
|
412
|
+
end
|
|
221
413
|
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
414
|
+
def print_session_table(sessions)
|
|
415
|
+
header = "Timestamp Total Killed Surv. Score Duration"
|
|
416
|
+
$stdout.puts(header)
|
|
417
|
+
$stdout.puts("-" * header.length)
|
|
418
|
+
sessions.each { |s| print_session_row(s) }
|
|
419
|
+
end
|
|
420
|
+
|
|
421
|
+
def print_session_row(session)
|
|
422
|
+
$stdout.puts(
|
|
423
|
+
format(
|
|
424
|
+
"%-30<ts>s %6<total>d %6<killed>d %6<surv>d %7.2<score>f%% %7.1<dur>fs",
|
|
425
|
+
ts: session[:timestamp], total: session[:total], killed: session[:killed],
|
|
426
|
+
surv: session[:survived], score: session[:score] * 100, dur: session[:duration]
|
|
427
|
+
)
|
|
428
|
+
)
|
|
429
|
+
end
|
|
430
|
+
|
|
431
|
+
def run_mutations
|
|
432
|
+
raise Evilution::ConfigError, @stdin_error if @stdin_error
|
|
433
|
+
|
|
434
|
+
file_options = Evilution::Config.file_options
|
|
435
|
+
config = Evilution::Config.new(**@options, target_files: @files, line_ranges: @line_ranges)
|
|
436
|
+
runner = Evilution::Runner.new(config: config)
|
|
437
|
+
summary = runner.call
|
|
438
|
+
summary.success?(min_score: config.min_score) ? 0 : 1
|
|
439
|
+
rescue Evilution::Error => e
|
|
440
|
+
if json_format?(config, file_options)
|
|
441
|
+
$stdout.puts(JSON.generate(error_payload(e)))
|
|
442
|
+
else
|
|
443
|
+
warn("Error: #{e.message}")
|
|
225
444
|
end
|
|
445
|
+
2
|
|
446
|
+
end
|
|
447
|
+
|
|
448
|
+
def json_format?(config, file_options)
|
|
449
|
+
return config.json? if config
|
|
450
|
+
|
|
451
|
+
format = @options[:format] || (file_options && file_options[:format])
|
|
452
|
+
format && format.to_sym == :json
|
|
453
|
+
end
|
|
454
|
+
|
|
455
|
+
def error_payload(error)
|
|
456
|
+
error_type = case error
|
|
457
|
+
when Evilution::ConfigError then "config_error"
|
|
458
|
+
when Evilution::ParseError then "parse_error"
|
|
459
|
+
else "runtime_error"
|
|
460
|
+
end
|
|
461
|
+
|
|
462
|
+
payload = { type: error_type, message: error.message }
|
|
463
|
+
payload[:file] = error.file if error.file
|
|
464
|
+
{ error: payload }
|
|
226
465
|
end
|
|
227
466
|
end
|