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.
Files changed (48) hide show
  1. checksums.yaml +4 -4
  2. data/.beads/interactions.jsonl +3 -0
  3. data/CHANGELOG.md +12 -0
  4. data/README.md +36 -7
  5. data/lib/evilution/cli/command.rb +37 -0
  6. data/lib/evilution/cli/commands/environment_show.rb +20 -0
  7. data/lib/evilution/cli/commands/init.rb +24 -0
  8. data/lib/evilution/cli/commands/mcp.rb +19 -0
  9. data/lib/evilution/cli/commands/run.rb +68 -0
  10. data/lib/evilution/cli/commands/session_diff.rb +30 -0
  11. data/lib/evilution/cli/commands/session_gc.rb +46 -0
  12. data/lib/evilution/cli/commands/session_list.rb +51 -0
  13. data/lib/evilution/cli/commands/session_show.rb +27 -0
  14. data/lib/evilution/cli/commands/subjects.rb +50 -0
  15. data/lib/evilution/cli/commands/tests_list.rb +43 -0
  16. data/lib/evilution/cli/commands/util_mutation.rb +66 -0
  17. data/lib/evilution/cli/commands/version.rb +17 -0
  18. data/lib/evilution/cli/commands.rb +4 -0
  19. data/lib/evilution/cli/dispatcher.rb +23 -0
  20. data/lib/evilution/cli/parsed_args.rb +12 -0
  21. data/lib/evilution/cli/parser.rb +257 -0
  22. data/lib/evilution/cli/printers/environment.rb +53 -0
  23. data/lib/evilution/cli/printers/session_detail.rb +76 -0
  24. data/lib/evilution/cli/printers/session_diff.rb +57 -0
  25. data/lib/evilution/cli/printers/session_list.rb +48 -0
  26. data/lib/evilution/cli/printers/subjects.rb +35 -0
  27. data/lib/evilution/cli/printers/tests_list.rb +45 -0
  28. data/lib/evilution/cli/printers/util_mutation.rb +35 -0
  29. data/lib/evilution/cli/printers.rb +4 -0
  30. data/lib/evilution/cli/result.rb +9 -0
  31. data/lib/evilution/cli.rb +30 -850
  32. data/lib/evilution/config.rb +18 -3
  33. data/lib/evilution/integration/base.rb +14 -0
  34. data/lib/evilution/integration/minitest.rb +6 -1
  35. data/lib/evilution/integration/rspec.rb +10 -2
  36. data/lib/evilution/isolation/fork.rb +10 -9
  37. data/lib/evilution/isolation/in_process.rb +10 -9
  38. data/lib/evilution/mcp/info_tool.rb +261 -0
  39. data/lib/evilution/mcp/mutate_tool.rb +112 -19
  40. data/lib/evilution/mcp/server.rb +3 -4
  41. data/lib/evilution/mcp/session_diff_tool.rb +5 -1
  42. data/lib/evilution/mcp/session_list_tool.rb +5 -1
  43. data/lib/evilution/mcp/session_show_tool.rb +5 -1
  44. data/lib/evilution/mcp/session_tool.rb +157 -0
  45. data/lib/evilution/reporter/html.rb +41 -0
  46. data/lib/evilution/runner.rb +3 -1
  47. data/lib/evilution/version.rb +1 -1
  48. 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
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Evilution::CLI::Printers
4
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Evilution::CLI
4
+ Result = Struct.new(:exit_code, :error, :error_rendered, keyword_init: true) do
5
+ def initialize(exit_code:, error: nil, error_rendered: false)
6
+ super
7
+ end
8
+ end
9
+ end