evilution 0.22.7 → 0.23.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/interactions.jsonl +3 -0
- data/CHANGELOG.md +12 -0
- data/README.md +36 -7
- data/lib/evilution/cli/command.rb +37 -0
- data/lib/evilution/cli/commands/environment_show.rb +20 -0
- data/lib/evilution/cli/commands/init.rb +24 -0
- data/lib/evilution/cli/commands/mcp.rb +19 -0
- data/lib/evilution/cli/commands/run.rb +68 -0
- data/lib/evilution/cli/commands/session_diff.rb +30 -0
- data/lib/evilution/cli/commands/session_gc.rb +46 -0
- data/lib/evilution/cli/commands/session_list.rb +51 -0
- data/lib/evilution/cli/commands/session_show.rb +27 -0
- data/lib/evilution/cli/commands/subjects.rb +50 -0
- data/lib/evilution/cli/commands/tests_list.rb +43 -0
- data/lib/evilution/cli/commands/util_mutation.rb +66 -0
- data/lib/evilution/cli/commands/version.rb +17 -0
- data/lib/evilution/cli/commands.rb +4 -0
- data/lib/evilution/cli/dispatcher.rb +23 -0
- data/lib/evilution/cli/parsed_args.rb +12 -0
- data/lib/evilution/cli/parser.rb +257 -0
- data/lib/evilution/cli/printers/environment.rb +53 -0
- data/lib/evilution/cli/printers/session_detail.rb +76 -0
- data/lib/evilution/cli/printers/session_diff.rb +57 -0
- data/lib/evilution/cli/printers/session_list.rb +48 -0
- data/lib/evilution/cli/printers/subjects.rb +35 -0
- data/lib/evilution/cli/printers/tests_list.rb +45 -0
- data/lib/evilution/cli/printers/util_mutation.rb +35 -0
- data/lib/evilution/cli/printers.rb +4 -0
- data/lib/evilution/cli/result.rb +9 -0
- data/lib/evilution/cli.rb +30 -850
- data/lib/evilution/config.rb +18 -3
- data/lib/evilution/integration/base.rb +14 -0
- data/lib/evilution/integration/minitest.rb +6 -1
- data/lib/evilution/integration/rspec.rb +10 -2
- data/lib/evilution/isolation/fork.rb +10 -9
- data/lib/evilution/isolation/in_process.rb +10 -9
- data/lib/evilution/mcp/info_tool.rb +261 -0
- data/lib/evilution/mcp/mutate_tool.rb +112 -19
- data/lib/evilution/mcp/server.rb +3 -4
- data/lib/evilution/mcp/session_diff_tool.rb +5 -1
- data/lib/evilution/mcp/session_list_tool.rb +5 -1
- data/lib/evilution/mcp/session_show_tool.rb +5 -1
- data/lib/evilution/mcp/session_tool.rb +157 -0
- data/lib/evilution/reporter/html.rb +41 -0
- data/lib/evilution/runner.rb +3 -1
- data/lib/evilution/version.rb +1 -1
- metadata +30 -2
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optparse"
|
|
4
|
+
require_relative "../version"
|
|
5
|
+
require_relative "parsed_args"
|
|
6
|
+
|
|
7
|
+
class Evilution::CLI::Parser
|
|
8
|
+
SIMPLE_COMMANDS = {
|
|
9
|
+
"version" => :version,
|
|
10
|
+
"init" => :init,
|
|
11
|
+
"mcp" => :mcp,
|
|
12
|
+
"subjects" => :subjects
|
|
13
|
+
}.freeze
|
|
14
|
+
|
|
15
|
+
SESSION_SUBCOMMANDS = {
|
|
16
|
+
"list" => :session_list,
|
|
17
|
+
"show" => :session_show,
|
|
18
|
+
"diff" => :session_diff,
|
|
19
|
+
"gc" => :session_gc
|
|
20
|
+
}.freeze
|
|
21
|
+
|
|
22
|
+
TESTS_SUBCOMMANDS = { "list" => :tests_list }.freeze
|
|
23
|
+
ENVIRONMENT_SUBCOMMANDS = { "show" => :environment_show }.freeze
|
|
24
|
+
UTIL_SUBCOMMANDS = { "mutation" => :util_mutation }.freeze
|
|
25
|
+
|
|
26
|
+
def initialize(argv, stdin: $stdin)
|
|
27
|
+
@argv = argv.dup
|
|
28
|
+
@stdin = stdin
|
|
29
|
+
@options = {}
|
|
30
|
+
@files = []
|
|
31
|
+
@line_ranges = {}
|
|
32
|
+
@command = :run
|
|
33
|
+
@parse_error = nil
|
|
34
|
+
@stdin_error = nil
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def parse
|
|
38
|
+
extract_command
|
|
39
|
+
return build_parsed_args if @command == :parse_error
|
|
40
|
+
|
|
41
|
+
preprocess_flags
|
|
42
|
+
remaining = build_option_parser.parse!(@argv)
|
|
43
|
+
@files, @line_ranges = parse_file_args(remaining)
|
|
44
|
+
read_stdin_files if @options.delete(:stdin) && %i[run subjects].include?(@command)
|
|
45
|
+
build_parsed_args
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def read_stdin_files
|
|
51
|
+
if @files.any?
|
|
52
|
+
@stdin_error = "--stdin cannot be combined with positional file arguments"
|
|
53
|
+
return
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
lines = []
|
|
57
|
+
@stdin.each_line do |line|
|
|
58
|
+
line = line.strip
|
|
59
|
+
lines << line unless line.empty?
|
|
60
|
+
end
|
|
61
|
+
stdin_files, stdin_ranges = parse_file_args(lines)
|
|
62
|
+
@files = stdin_files
|
|
63
|
+
@line_ranges = @line_ranges.merge(stdin_ranges)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def build_parsed_args
|
|
67
|
+
Evilution::CLI::ParsedArgs.new(
|
|
68
|
+
command: @command,
|
|
69
|
+
options: @options,
|
|
70
|
+
files: @files,
|
|
71
|
+
line_ranges: @line_ranges,
|
|
72
|
+
stdin_error: @stdin_error,
|
|
73
|
+
parse_error: @parse_error
|
|
74
|
+
)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def extract_command
|
|
78
|
+
first = @argv.first
|
|
79
|
+
if SIMPLE_COMMANDS.key?(first)
|
|
80
|
+
@command = SIMPLE_COMMANDS[first]
|
|
81
|
+
@argv.shift
|
|
82
|
+
elsif first == "run"
|
|
83
|
+
@argv.shift
|
|
84
|
+
elsif first == "session"
|
|
85
|
+
@argv.shift
|
|
86
|
+
extract_subcommand(SESSION_SUBCOMMANDS, "session", "list, show, diff, gc")
|
|
87
|
+
elsif first == "tests"
|
|
88
|
+
@argv.shift
|
|
89
|
+
extract_subcommand(TESTS_SUBCOMMANDS, "tests", "list")
|
|
90
|
+
elsif first == "environment"
|
|
91
|
+
@argv.shift
|
|
92
|
+
extract_subcommand(ENVIRONMENT_SUBCOMMANDS, "environment", "show")
|
|
93
|
+
elsif first == "util"
|
|
94
|
+
@argv.shift
|
|
95
|
+
extract_subcommand(UTIL_SUBCOMMANDS, "util", "mutation")
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def extract_subcommand(table, family, available)
|
|
100
|
+
sub = @argv.first
|
|
101
|
+
if table.key?(sub)
|
|
102
|
+
@command = table[sub]
|
|
103
|
+
@argv.shift
|
|
104
|
+
elsif sub.nil?
|
|
105
|
+
@command = :parse_error
|
|
106
|
+
@parse_error = "Missing #{family} subcommand. Available subcommands: #{available}"
|
|
107
|
+
else
|
|
108
|
+
@command = :parse_error
|
|
109
|
+
@parse_error = "Unknown #{family} subcommand: #{sub}. Available subcommands: #{available}"
|
|
110
|
+
@argv.shift
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def preprocess_flags
|
|
115
|
+
result = []
|
|
116
|
+
i = 0
|
|
117
|
+
while i < @argv.length
|
|
118
|
+
arg = @argv[i]
|
|
119
|
+
if arg == "--fail-fast"
|
|
120
|
+
next_arg = @argv[i + 1]
|
|
121
|
+
|
|
122
|
+
if next_arg && next_arg.match?(/\A-?\d+\z/)
|
|
123
|
+
@options[:fail_fast] = next_arg
|
|
124
|
+
i += 2
|
|
125
|
+
else
|
|
126
|
+
result << arg
|
|
127
|
+
i += 1
|
|
128
|
+
end
|
|
129
|
+
elsif arg.start_with?("--fail-fast=")
|
|
130
|
+
@options[:fail_fast] = arg.delete_prefix("--fail-fast=")
|
|
131
|
+
i += 1
|
|
132
|
+
else
|
|
133
|
+
result << arg
|
|
134
|
+
i += 1
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
@argv = result
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def build_option_parser
|
|
141
|
+
OptionParser.new do |opts|
|
|
142
|
+
opts.banner = "Usage: evilution [command] [options] [files...]"
|
|
143
|
+
opts.version = Evilution::VERSION
|
|
144
|
+
add_separators(opts)
|
|
145
|
+
add_options(opts)
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def add_separators(opts)
|
|
150
|
+
opts.separator ""
|
|
151
|
+
opts.separator "Line-range targeting: lib/foo.rb:15-30, lib/foo.rb:15, lib/foo.rb:15-"
|
|
152
|
+
opts.separator ""
|
|
153
|
+
opts.separator "Commands: run (default), init, session {list,show,diff,gc}, subjects, tests {list},"
|
|
154
|
+
opts.separator " util {mutation}, environment {show}, mcp, version"
|
|
155
|
+
opts.separator ""
|
|
156
|
+
opts.separator "Options:"
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def add_options(opts)
|
|
160
|
+
add_core_options(opts)
|
|
161
|
+
add_filter_options(opts)
|
|
162
|
+
add_flag_options(opts)
|
|
163
|
+
add_session_options(opts)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def add_core_options(opts)
|
|
167
|
+
opts.on("-j", "--jobs N", Integer, "Number of parallel workers (default: 1)") { |n| @options[:jobs] = n }
|
|
168
|
+
opts.on("-t", "--timeout N", Integer, "Per-mutation timeout in seconds") { |n| @options[:timeout] = n }
|
|
169
|
+
opts.on("-f", "--format FORMAT", "Output format: text, json, html") { |f| @options[:format] = f.to_sym }
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def add_filter_options(opts)
|
|
173
|
+
opts.on("--min-score FLOAT", Float, "Minimum mutation score to pass") { |s| @options[:min_score] = s }
|
|
174
|
+
opts.on("--spec FILES", Array, "Spec files to run (comma-separated)") { |f| @options[:spec_files] = f }
|
|
175
|
+
opts.on("--spec-dir DIR", "Include all specs in DIR") { |d| expand_spec_dir(d) }
|
|
176
|
+
opts.on("--target EXPR",
|
|
177
|
+
"Filter: method (Foo#bar), type (Foo#/Foo.), namespace (Foo*),",
|
|
178
|
+
"class (Foo), glob (source:**/*.rb), hierarchy (descendants:Foo)") do |m|
|
|
179
|
+
@options[:target] = m
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def add_flag_options(opts)
|
|
184
|
+
opts.on("--fail-fast", "Stop after N surviving mutants " \
|
|
185
|
+
"(default: disabled; if provided without N, uses 1; use --fail-fast=N)") { @options[:fail_fast] ||= 1 }
|
|
186
|
+
opts.on("--no-baseline", "Skip baseline test suite check") { @options[:baseline] = false }
|
|
187
|
+
opts.on("--incremental", "Cache killed/timeout results; skip re-running them on unchanged files") { @options[:incremental] = true }
|
|
188
|
+
opts.on("--integration NAME", "Test integration: rspec, minitest (default: rspec)") { |i| @options[:integration] = i }
|
|
189
|
+
opts.on("--isolation STRATEGY", "Isolation: auto, fork, in_process (default: auto)") { |s| @options[:isolation] = s }
|
|
190
|
+
opts.on("--preload FILE", "Preload FILE in the parent process before forking " \
|
|
191
|
+
"(default: auto-detect spec/rails_helper.rb for Rails projects)") { |f| @options[:preload] = f }
|
|
192
|
+
opts.on("--no-preload", "Disable parent-process preload even for Rails projects") { @options[:preload] = false }
|
|
193
|
+
opts.on("--stdin", "Read target file paths from stdin (one per line)") { @options[:stdin] = true }
|
|
194
|
+
opts.on("--suggest-tests", "Generate concrete test code in suggestions (RSpec or Minitest)") { @options[:suggest_tests] = true }
|
|
195
|
+
opts.on("--no-progress", "Disable progress bar") { @options[:progress] = false }
|
|
196
|
+
add_extra_flag_options(opts)
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def add_extra_flag_options(opts)
|
|
200
|
+
opts.on("--skip-heredoc-literals", "Skip all string literal mutations inside heredocs") { @options[:skip_heredoc_literals] = true }
|
|
201
|
+
opts.on("--related-specs-heuristic", "Append related request/integration/feature/system specs for includes() mutations") do
|
|
202
|
+
@options[:related_specs_heuristic] = true
|
|
203
|
+
end
|
|
204
|
+
opts.on("--show-disabled", "Report mutations skipped by # evilution:disable") { @options[:show_disabled] = true }
|
|
205
|
+
opts.on("--baseline-session PATH", "Compare against a baseline session in HTML report") { |p| @options[:baseline_session] = p }
|
|
206
|
+
opts.on("--save-session", "Save session results to .evilution/results/") { @options[:save_session] = true }
|
|
207
|
+
opts.on("-e", "--eval CODE", "Evaluate code snippet (for util mutation)") { |c| @options[:eval] = c }
|
|
208
|
+
opts.on("-v", "--verbose", "Verbose output") { @options[:verbose] = true }
|
|
209
|
+
opts.on("-q", "--quiet", "Suppress output") { @options[:quiet] = true }
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def add_session_options(opts)
|
|
213
|
+
opts.on("--results-dir DIR", "Session results directory") { |d| @options[:results_dir] = d }
|
|
214
|
+
opts.on("--limit N", Integer, "Show only the N most recent sessions") { |n| @options[:limit] = n }
|
|
215
|
+
opts.on("--since DATE", "Show sessions since DATE (YYYY-MM-DD)") { |d| @options[:since] = d }
|
|
216
|
+
opts.on("--older-than DURATION", "Delete sessions older than DURATION (e.g., 30d, 24h, 1w)") do |d|
|
|
217
|
+
@options[:older_than] = d
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def parse_file_args(raw_args)
|
|
222
|
+
files = []
|
|
223
|
+
ranges = {}
|
|
224
|
+
|
|
225
|
+
raw_args.each do |arg|
|
|
226
|
+
file, range_str = arg.split(":", 2)
|
|
227
|
+
files << file
|
|
228
|
+
next unless range_str
|
|
229
|
+
|
|
230
|
+
ranges[file] = parse_line_range(range_str)
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
[files, ranges]
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def parse_line_range(str)
|
|
237
|
+
if str.include?("-")
|
|
238
|
+
start_str, end_str = str.split("-", 2)
|
|
239
|
+
start_line = Integer(start_str)
|
|
240
|
+
end_line = end_str.empty? ? Float::INFINITY : Integer(end_str)
|
|
241
|
+
start_line..end_line
|
|
242
|
+
else
|
|
243
|
+
line = Integer(str)
|
|
244
|
+
line..line
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def expand_spec_dir(dir)
|
|
249
|
+
unless File.directory?(dir)
|
|
250
|
+
warn("Error: #{dir} is not a directory")
|
|
251
|
+
return
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
specs = Dir.glob(File.join(dir, "**/*_spec.rb"))
|
|
255
|
+
@options[:spec_files] = Array(@options[:spec_files]) + specs
|
|
256
|
+
end
|
|
257
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../printers"
|
|
4
|
+
|
|
5
|
+
class Evilution::CLI::Printers::Environment
|
|
6
|
+
def initialize(config, config_file:)
|
|
7
|
+
@config = config
|
|
8
|
+
@config_file = config_file
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def render(io)
|
|
12
|
+
lines = header_lines
|
|
13
|
+
lines.concat(settings_lines)
|
|
14
|
+
io.puts(lines.join("\n"))
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
|
|
19
|
+
def header_lines
|
|
20
|
+
[
|
|
21
|
+
"Evilution Environment",
|
|
22
|
+
("=" * 30),
|
|
23
|
+
"",
|
|
24
|
+
"evilution: #{Evilution::VERSION}",
|
|
25
|
+
"ruby: #{RUBY_VERSION}",
|
|
26
|
+
"config_file: #{@config_file || "(none)"}",
|
|
27
|
+
"",
|
|
28
|
+
"Settings:"
|
|
29
|
+
]
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def settings_lines
|
|
33
|
+
[
|
|
34
|
+
" timeout: #{@config.timeout}",
|
|
35
|
+
" format: #{@config.format}",
|
|
36
|
+
" integration: #{@config.integration}",
|
|
37
|
+
" jobs: #{@config.jobs}",
|
|
38
|
+
" isolation: #{@config.isolation}",
|
|
39
|
+
" baseline: #{@config.baseline}",
|
|
40
|
+
" incremental: #{@config.incremental}",
|
|
41
|
+
" verbose: #{@config.verbose}",
|
|
42
|
+
" quiet: #{@config.quiet}",
|
|
43
|
+
" progress: #{@config.progress}",
|
|
44
|
+
" fail_fast: #{@config.fail_fast || "(disabled)"}",
|
|
45
|
+
" min_score: #{@config.min_score}",
|
|
46
|
+
" suggest_tests: #{@config.suggest_tests}",
|
|
47
|
+
" save_session: #{@config.save_session}",
|
|
48
|
+
" target: #{@config.target || "(all files)"}",
|
|
49
|
+
" skip_heredoc_literals: #{@config.skip_heredoc_literals}",
|
|
50
|
+
" ignore_patterns: #{@config.ignore_patterns.empty? ? "(none)" : @config.ignore_patterns.inspect}"
|
|
51
|
+
]
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require_relative "../printers"
|
|
5
|
+
|
|
6
|
+
class Evilution::CLI::Printers::SessionDetail
|
|
7
|
+
def initialize(data, format:)
|
|
8
|
+
@data = data
|
|
9
|
+
@format = format
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def render(io)
|
|
13
|
+
@format == :json ? render_json(io) : render_text(io)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
private
|
|
17
|
+
|
|
18
|
+
def render_json(io)
|
|
19
|
+
io.puts(JSON.pretty_generate(@data))
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def render_text(io)
|
|
23
|
+
print_header(io, @data)
|
|
24
|
+
print_summary(io, @data["summary"])
|
|
25
|
+
print_survived(io, @data["survived"] || [])
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def print_header(io, data)
|
|
29
|
+
io.puts("Session: #{data["timestamp"]}")
|
|
30
|
+
io.puts("Version: #{data["version"]}")
|
|
31
|
+
print_git_context(io, data["git"])
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def print_git_context(io, git)
|
|
35
|
+
return unless git.is_a?(Hash)
|
|
36
|
+
|
|
37
|
+
branch = git["branch"]
|
|
38
|
+
sha = git["sha"]
|
|
39
|
+
return if branch.to_s.empty? && sha.to_s.empty?
|
|
40
|
+
|
|
41
|
+
io.puts("Git: #{branch} (#{sha})")
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def print_summary(io, summary)
|
|
45
|
+
io.puts("")
|
|
46
|
+
io.puts(
|
|
47
|
+
format(
|
|
48
|
+
"Score: %<score>.2f%% Total: %<total>d Killed: %<killed>d Survived: %<surv>d " \
|
|
49
|
+
"Timed out: %<to>d Errors: %<err>d Duration: %<dur>.1fs",
|
|
50
|
+
score: summary["score"] * 100, total: summary["total"], killed: summary["killed"],
|
|
51
|
+
surv: summary["survived"], to: summary["timed_out"], err: summary["errors"],
|
|
52
|
+
dur: summary["duration"]
|
|
53
|
+
)
|
|
54
|
+
)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def print_survived(io, survived)
|
|
58
|
+
io.puts("")
|
|
59
|
+
if survived.empty?
|
|
60
|
+
io.puts("No survived mutations")
|
|
61
|
+
else
|
|
62
|
+
io.puts("Survived mutations (#{survived.length}):")
|
|
63
|
+
survived.each_with_index { |m, i| print_mutation_detail(io, m, i + 1) }
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def print_mutation_detail(io, mutation, index)
|
|
68
|
+
io.puts("")
|
|
69
|
+
io.puts(" #{index}. #{mutation["operator"]} — #{mutation["file"]}:#{mutation["line"]}")
|
|
70
|
+
io.puts(" Subject: #{mutation["subject"]}")
|
|
71
|
+
return unless mutation["diff"]
|
|
72
|
+
|
|
73
|
+
io.puts(" Diff:")
|
|
74
|
+
mutation["diff"].each_line { |line| io.puts(" #{line.chomp}") }
|
|
75
|
+
end
|
|
76
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require_relative "../printers"
|
|
5
|
+
|
|
6
|
+
class Evilution::CLI::Printers::SessionDiff
|
|
7
|
+
def initialize(result, format:)
|
|
8
|
+
@result = result
|
|
9
|
+
@format = format
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def render(io)
|
|
13
|
+
@format == :json ? render_json(io) : render_text(io)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
private
|
|
17
|
+
|
|
18
|
+
def render_json(io)
|
|
19
|
+
io.puts(JSON.pretty_generate(@result.to_h))
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def render_text(io)
|
|
23
|
+
print_summary(io, @result.summary)
|
|
24
|
+
print_section(io, "Fixed (survived \u2192 killed)", @result.fixed, "\e[32m")
|
|
25
|
+
print_section(io, "New survivors (killed \u2192 survived)", @result.new_survivors, "\e[31m")
|
|
26
|
+
print_section(io, "Persistent survivors", @result.persistent, "\e[33m")
|
|
27
|
+
|
|
28
|
+
return unless @result.fixed.empty? && @result.new_survivors.empty? && @result.persistent.empty?
|
|
29
|
+
|
|
30
|
+
io.puts("")
|
|
31
|
+
io.puts("No mutation changes between sessions")
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def print_summary(io, summary)
|
|
35
|
+
delta_str = format("%+.2f%%", summary.score_delta * 100)
|
|
36
|
+
io.puts("Session Diff")
|
|
37
|
+
io.puts("=" * 40)
|
|
38
|
+
io.puts(format("Base score: %<score>6.2f%% (%<killed>d/%<total>d killed)",
|
|
39
|
+
score: summary.base_score * 100, killed: summary.base_killed,
|
|
40
|
+
total: summary.base_total))
|
|
41
|
+
io.puts(format("Head score: %<score>6.2f%% (%<killed>d/%<total>d killed)",
|
|
42
|
+
score: summary.head_score * 100, killed: summary.head_killed,
|
|
43
|
+
total: summary.head_total))
|
|
44
|
+
io.puts("Delta: #{delta_str}")
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def print_section(io, title, mutations, color)
|
|
48
|
+
return if mutations.empty?
|
|
49
|
+
|
|
50
|
+
reset = "\e[0m"
|
|
51
|
+
io.puts("")
|
|
52
|
+
io.puts("#{color}#{title} (#{mutations.length}):#{reset}")
|
|
53
|
+
mutations.each do |m|
|
|
54
|
+
io.puts(" #{m["operator"]} \u2014 #{m["file"]}:#{m["line"]} #{m["subject"]}")
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require_relative "../printers"
|
|
5
|
+
|
|
6
|
+
class Evilution::CLI::Printers::SessionList
|
|
7
|
+
def initialize(sessions, format:)
|
|
8
|
+
@sessions = sessions
|
|
9
|
+
@format = format
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def render(io)
|
|
13
|
+
@format == :json ? render_json(io) : render_text(io)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
private
|
|
17
|
+
|
|
18
|
+
def render_json(io)
|
|
19
|
+
io.puts(JSON.pretty_generate(@sessions.map { |s| session_to_hash(s) }))
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def render_text(io)
|
|
23
|
+
header = "Timestamp Total Killed Surv. Score Duration"
|
|
24
|
+
io.puts(header)
|
|
25
|
+
io.puts("-" * header.length)
|
|
26
|
+
@sessions.each { |s| io.puts(format_row(s)) }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def format_row(session)
|
|
30
|
+
format(
|
|
31
|
+
"%-30<ts>s %6<total>d %6<killed>d %6<surv>d %7.2<score>f%% %7.1<dur>fs",
|
|
32
|
+
ts: session[:timestamp], total: session[:total], killed: session[:killed],
|
|
33
|
+
surv: session[:survived], score: session[:score] * 100, dur: session[:duration]
|
|
34
|
+
)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def session_to_hash(session)
|
|
38
|
+
{
|
|
39
|
+
"timestamp" => session[:timestamp],
|
|
40
|
+
"total" => session[:total],
|
|
41
|
+
"killed" => session[:killed],
|
|
42
|
+
"survived" => session[:survived],
|
|
43
|
+
"score" => session[:score],
|
|
44
|
+
"duration" => session[:duration],
|
|
45
|
+
"file" => session[:file]
|
|
46
|
+
}
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../printers"
|
|
4
|
+
|
|
5
|
+
class Evilution::CLI::Printers::Subjects
|
|
6
|
+
def initialize(entries, total_mutations:)
|
|
7
|
+
@entries = entries
|
|
8
|
+
@total_mutations = total_mutations
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def render(io)
|
|
12
|
+
@entries.each { |entry| io.puts(format_entry(entry)) }
|
|
13
|
+
io.puts("")
|
|
14
|
+
io.puts(summary_line)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
|
|
19
|
+
def format_entry(entry)
|
|
20
|
+
" #{entry[:name]} #{entry[:file_path]}:#{entry[:line_number]} (#{mutation_label(entry[:mutation_count])})"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def mutation_label(count)
|
|
24
|
+
pluralize(count, "mutation", "mutations")
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def summary_line
|
|
28
|
+
"#{pluralize(@entries.length, "subject", "subjects")}, " \
|
|
29
|
+
"#{pluralize(@total_mutations, "mutation", "mutations")}"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def pluralize(count, singular, plural)
|
|
33
|
+
"#{count} #{count == 1 ? singular : plural}"
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../printers"
|
|
4
|
+
|
|
5
|
+
class Evilution::CLI::Printers::TestsList
|
|
6
|
+
def initialize(mode:, specs: nil, entries: nil)
|
|
7
|
+
@mode = mode
|
|
8
|
+
@specs = specs
|
|
9
|
+
@entries = entries
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def render(io)
|
|
13
|
+
case @mode
|
|
14
|
+
when :explicit then render_explicit(io)
|
|
15
|
+
when :resolved then render_resolved(io)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def render_explicit(io)
|
|
22
|
+
@specs.each { |f| io.puts(" #{f}") }
|
|
23
|
+
io.puts("")
|
|
24
|
+
io.puts(@specs.length == 1 ? "1 spec file" : "#{@specs.length} spec files")
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def render_resolved(io)
|
|
28
|
+
unique_specs = []
|
|
29
|
+
@entries.each do |entry|
|
|
30
|
+
source = entry[:source]
|
|
31
|
+
spec = entry[:spec]
|
|
32
|
+
if spec
|
|
33
|
+
unique_specs << spec
|
|
34
|
+
io.puts(" #{spec} (#{source})")
|
|
35
|
+
else
|
|
36
|
+
io.puts(" #{source} (no spec found)")
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
unique_specs.uniq!
|
|
41
|
+
io.puts("")
|
|
42
|
+
spec_label = unique_specs.length == 1 ? "1 spec file" : "#{unique_specs.length} spec files"
|
|
43
|
+
io.puts("#{@entries.length} source files, #{spec_label}")
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require_relative "../printers"
|
|
5
|
+
|
|
6
|
+
class Evilution::CLI::Printers::UtilMutation
|
|
7
|
+
def initialize(mutations, format:)
|
|
8
|
+
@mutations = mutations
|
|
9
|
+
@format = format
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def render(io)
|
|
13
|
+
@format == :json ? render_json(io) : render_text(io)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
private
|
|
17
|
+
|
|
18
|
+
def render_text(io)
|
|
19
|
+
@mutations.each_with_index do |m, i|
|
|
20
|
+
io.puts("#{i + 1}. #{m.operator_name} — #{m.subject.name} (line #{m.line})")
|
|
21
|
+
m.diff.each_line { |line| io.puts(" #{line.chomp}") }
|
|
22
|
+
io.puts("")
|
|
23
|
+
end
|
|
24
|
+
label = @mutations.length == 1 ? "1 mutation" : "#{@mutations.length} mutations"
|
|
25
|
+
io.puts(label)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def render_json(io)
|
|
29
|
+
data = @mutations.map do |m|
|
|
30
|
+
{ operator: m.operator_name, subject: m.subject.name,
|
|
31
|
+
file: m.file_path, line: m.line, diff: m.diff }
|
|
32
|
+
end
|
|
33
|
+
io.puts(JSON.pretty_generate(data))
|
|
34
|
+
end
|
|
35
|
+
end
|