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.
Files changed (90) hide show
  1. checksums.yaml +4 -4
  2. data/.beads/.migration-hint-ts +1 -1
  3. data/.beads/issues.jsonl +17 -17
  4. data/CHANGELOG.md +39 -0
  5. data/lib/evilution/ast/inheritance_scanner.rb +70 -0
  6. data/lib/evilution/ast/parser.rb +73 -68
  7. data/lib/evilution/ast/source_surgeon.rb +7 -9
  8. data/lib/evilution/ast.rb +4 -0
  9. data/lib/evilution/baseline.rb +73 -75
  10. data/lib/evilution/cache.rb +75 -77
  11. data/lib/evilution/cli.rb +412 -173
  12. data/lib/evilution/config.rb +141 -136
  13. data/lib/evilution/equivalent/detector.rb +29 -27
  14. data/lib/evilution/equivalent/heuristic/alias_swap.rb +32 -33
  15. data/lib/evilution/equivalent/heuristic/arithmetic_identity.rb +30 -0
  16. data/lib/evilution/equivalent/heuristic/comment_marking.rb +21 -0
  17. data/lib/evilution/equivalent/heuristic/dead_code.rb +41 -45
  18. data/lib/evilution/equivalent/heuristic/method_body_nil.rb +11 -15
  19. data/lib/evilution/equivalent/heuristic/noop_source.rb +5 -9
  20. data/lib/evilution/equivalent/heuristic.rb +6 -0
  21. data/lib/evilution/equivalent.rb +4 -0
  22. data/lib/evilution/git/changed_files.rb +35 -37
  23. data/lib/evilution/git.rb +4 -0
  24. data/lib/evilution/integration/base.rb +5 -7
  25. data/lib/evilution/integration/rspec.rb +114 -116
  26. data/lib/evilution/integration.rb +4 -0
  27. data/lib/evilution/isolation/fork.rb +98 -100
  28. data/lib/evilution/isolation/in_process.rb +59 -61
  29. data/lib/evilution/isolation.rb +4 -0
  30. data/lib/evilution/mcp/mutate_tool.rb +172 -143
  31. data/lib/evilution/mcp/server.rb +12 -11
  32. data/lib/evilution/mcp/session_diff_tool.rb +89 -0
  33. data/lib/evilution/mcp/session_list_tool.rb +46 -0
  34. data/lib/evilution/mcp/session_show_tool.rb +53 -0
  35. data/lib/evilution/mcp.rb +4 -0
  36. data/lib/evilution/memory/leak_check.rb +80 -84
  37. data/lib/evilution/memory.rb +34 -36
  38. data/lib/evilution/mutation.rb +40 -42
  39. data/lib/evilution/mutator/base.rb +62 -48
  40. data/lib/evilution/mutator/operator/argument_nil_substitution.rb +32 -36
  41. data/lib/evilution/mutator/operator/argument_removal.rb +32 -36
  42. data/lib/evilution/mutator/operator/arithmetic_replacement.rb +26 -30
  43. data/lib/evilution/mutator/operator/array_literal.rb +18 -22
  44. data/lib/evilution/mutator/operator/block_removal.rb +16 -20
  45. data/lib/evilution/mutator/operator/boolean_literal_replacement.rb +38 -42
  46. data/lib/evilution/mutator/operator/boolean_operator_replacement.rb +41 -45
  47. data/lib/evilution/mutator/operator/collection_replacement.rb +32 -36
  48. data/lib/evilution/mutator/operator/comparison_replacement.rb +24 -28
  49. data/lib/evilution/mutator/operator/compound_assignment.rb +114 -118
  50. data/lib/evilution/mutator/operator/conditional_branch.rb +25 -29
  51. data/lib/evilution/mutator/operator/conditional_flip.rb +26 -30
  52. data/lib/evilution/mutator/operator/conditional_negation.rb +25 -29
  53. data/lib/evilution/mutator/operator/float_literal.rb +22 -26
  54. data/lib/evilution/mutator/operator/hash_literal.rb +18 -22
  55. data/lib/evilution/mutator/operator/integer_literal.rb +2 -0
  56. data/lib/evilution/mutator/operator/method_body_replacement.rb +12 -16
  57. data/lib/evilution/mutator/operator/method_call_removal.rb +12 -16
  58. data/lib/evilution/mutator/operator/mixin_removal.rb +80 -0
  59. data/lib/evilution/mutator/operator/negation_insertion.rb +12 -16
  60. data/lib/evilution/mutator/operator/nil_replacement.rb +13 -17
  61. data/lib/evilution/mutator/operator/range_replacement.rb +12 -16
  62. data/lib/evilution/mutator/operator/receiver_replacement.rb +16 -20
  63. data/lib/evilution/mutator/operator/regexp_mutation.rb +15 -19
  64. data/lib/evilution/mutator/operator/return_value_removal.rb +12 -16
  65. data/lib/evilution/mutator/operator/send_mutation.rb +36 -40
  66. data/lib/evilution/mutator/operator/statement_deletion.rb +13 -17
  67. data/lib/evilution/mutator/operator/string_literal.rb +18 -22
  68. data/lib/evilution/mutator/operator/superclass_removal.rb +65 -0
  69. data/lib/evilution/mutator/operator/symbol_literal.rb +17 -21
  70. data/lib/evilution/mutator/operator.rb +6 -0
  71. data/lib/evilution/mutator/registry.rb +56 -56
  72. data/lib/evilution/mutator.rb +4 -0
  73. data/lib/evilution/parallel/pool.rb +56 -58
  74. data/lib/evilution/parallel.rb +4 -0
  75. data/lib/evilution/reporter/cli.rb +99 -101
  76. data/lib/evilution/reporter/html.rb +242 -244
  77. data/lib/evilution/reporter/json.rb +57 -59
  78. data/lib/evilution/reporter/suggestion.rb +354 -328
  79. data/lib/evilution/reporter.rb +4 -0
  80. data/lib/evilution/result/mutation_result.rb +43 -46
  81. data/lib/evilution/result/summary.rb +80 -81
  82. data/lib/evilution/result.rb +4 -0
  83. data/lib/evilution/runner.rb +401 -316
  84. data/lib/evilution/session/store.rb +147 -0
  85. data/lib/evilution/session.rb +4 -0
  86. data/lib/evilution/spec_resolver.rb +49 -47
  87. data/lib/evilution/subject.rb +14 -16
  88. data/lib/evilution/version.rb +1 -1
  89. data/lib/evilution.rb +16 -0
  90. 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
- module Evilution
10
- class CLI
11
- def initialize(argv, stdin: $stdin)
12
- @options = {}
13
- @command = :run
14
- @stdin = stdin
15
- argv = argv.dup
16
- argv = extract_command(argv)
17
- argv = preprocess_flags(argv)
18
- raw_args = build_option_parser.parse!(argv)
19
- @files, @line_ranges = parse_file_args(raw_args)
20
- read_stdin_files if @options.delete(:stdin) && @command == :run
21
- end
22
-
23
- def call
24
- case @command
25
- when :version
26
- $stdout.puts(VERSION)
27
- 0
28
- when :init
29
- run_init
30
- when :mcp
31
- run_mcp
32
- when :run
33
- run_mutations
34
- end
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
- private
38
-
39
- def extract_command(argv)
40
- case argv.first
41
- when "version"
42
- @command = :version
43
- argv.shift
44
- when "init"
45
- @command = :init
46
- argv.shift
47
- when "mcp"
48
- @command = :mcp
49
- argv.shift
50
- when "run"
51
- argv.shift
52
- end
53
- argv
54
- end
55
-
56
- def preprocess_flags(argv)
57
- result = []
58
- i = 0
59
- while i < argv.length
60
- arg = argv[i]
61
- if arg == "--fail-fast"
62
- next_arg = argv[i + 1]
63
-
64
- if next_arg && next_arg.match?(/\A-?\d+\z/)
65
- @options[:fail_fast] = next_arg
66
- i += 2
67
- else
68
- result << arg
69
- i += 1
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
- def build_option_parser
83
- OptionParser.new do |opts|
84
- opts.banner = "Usage: evilution [command] [options] [files...]"
85
- opts.version = VERSION
86
- add_separators(opts)
87
- add_options(opts)
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
- def add_separators(opts)
92
- opts.separator ""
93
- opts.separator "Line-range targeting: lib/foo.rb:15-30, lib/foo.rb:15, lib/foo.rb:15-"
94
- opts.separator ""
95
- opts.separator "Commands: run (default), init, mcp, version"
96
- opts.separator ""
97
- opts.separator "Options:"
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
- def add_options(opts)
101
- add_core_options(opts)
102
- add_filter_options(opts)
103
- add_flag_options(opts)
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
- def add_core_options(opts)
107
- opts.on("-j", "--jobs N", Integer, "Number of parallel workers (default: 1)") { |n| @options[:jobs] = n }
108
- opts.on("-t", "--timeout N", Integer, "Per-mutation timeout in seconds") { |n| @options[:timeout] = n }
109
- opts.on("-f", "--format FORMAT", "Output format: text, json, html") { |f| @options[:format] = f.to_sym }
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
- def add_filter_options(opts)
113
- opts.on("--min-score FLOAT", Float, "Minimum mutation score to pass") { |s| @options[:min_score] = s }
114
- opts.on("--spec FILES", Array, "Spec files to run (comma-separated)") { |f| @options[:spec_files] = f }
115
- opts.on("--target METHOD", "Only mutate the named method (e.g. Foo::Bar#calculate)") { |m| @options[:target] = m }
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
- def add_flag_options(opts)
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
- def run_init
131
- path = ".evilution.yml"
132
- if File.exist?(path)
133
- warn("#{path} already exists")
134
- return 1
135
- end
224
+ [files, ranges]
225
+ end
136
226
 
137
- File.write(path, Config.default_template)
138
- $stdout.puts("Created #{path}")
139
- 0
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
- def run_mcp
143
- require_relative "mcp/server"
144
- server = MCP::Server.build
145
- transport = ::MCP::Server::Transports::StdioTransport.new(server)
146
- transport.open
147
- 0
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
- def read_stdin_files
151
- @stdin_error = "--stdin cannot be combined with positional file arguments" unless @files.empty?
152
- return if @stdin_error
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
- lines = []
155
- @stdin.each_line do |line|
156
- line = line.strip
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
- def parse_file_args(raw_args)
165
- files = []
166
- ranges = {}
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
- raw_args.each do |arg|
169
- file, range_str = arg.split(":", 2)
170
- files << file
171
- next unless range_str
287
+ def run_session_show
288
+ require_relative "session/store"
172
289
 
173
- ranges[file] = parse_line_range(range_str)
174
- end
290
+ path = @files.first
291
+ raise Evilution::ConfigError, "session file path required" unless path
175
292
 
176
- [files, ranges]
177
- end
293
+ store = Evilution::Session::Store.new
294
+ data = store.load(path)
178
295
 
179
- def parse_line_range(str)
180
- if str.include?("-")
181
- start_str, end_str = str.split("-", 2)
182
- start_line = Integer(start_str)
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
- def run_mutations
192
- raise ConfigError, @stdin_error if @stdin_error
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
- file_options = Config.file_options
195
- config = Config.new(**@options, target_files: @files, line_ranges: @line_ranges)
196
- runner = Runner.new(config: config)
197
- summary = runner.call
198
- summary.success?(min_score: config.min_score) ? 0 : 1
199
- rescue Error => e
200
- if json_format?(config, file_options)
201
- $stdout.puts(JSON.generate(error_payload(e)))
202
- else
203
- warn("Error: #{e.message}")
204
- end
205
- 2
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
- def json_format?(config, file_options)
209
- return config.json? if config
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
- format = @options[:format] || (file_options && file_options[:format])
212
- format && format.to_sym == :json
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
- def error_payload(error)
216
- error_type = case error
217
- when ConfigError then "config_error"
218
- when ParseError then "parse_error"
219
- else "runtime_error"
220
- end
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
- payload = { type: error_type, message: error.message }
223
- payload[:file] = error.file if error.file
224
- { error: payload }
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