evilution 0.16.1 → 0.18.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 +47 -46
- data/CHANGELOG.md +48 -0
- data/README.md +143 -50
- data/docs/ast_pattern_syntax.md +210 -0
- data/lib/evilution/ast/pattern/filter.rb +25 -0
- data/lib/evilution/ast/pattern/matcher.rb +107 -0
- data/lib/evilution/ast/pattern/parser.rb +185 -0
- data/lib/evilution/ast/pattern.rb +4 -0
- data/lib/evilution/ast/sorbet_sig_detector.rb +52 -0
- data/lib/evilution/cli.rb +400 -24
- data/lib/evilution/config.rb +43 -2
- data/lib/evilution/disable_comment.rb +90 -0
- data/lib/evilution/hooks/loader.rb +35 -0
- data/lib/evilution/hooks/registry.rb +60 -0
- data/lib/evilution/hooks.rb +58 -0
- data/lib/evilution/integration/base.rb +4 -0
- data/lib/evilution/integration/rspec.rb +6 -2
- data/lib/evilution/isolation/fork.rb +5 -0
- data/lib/evilution/mcp/session_diff_tool.rb +5 -35
- data/lib/evilution/mutator/base.rb +4 -1
- data/lib/evilution/mutator/operator/collection_return.rb +33 -0
- data/lib/evilution/mutator/operator/defined_check.rb +16 -0
- data/lib/evilution/mutator/operator/index_assignment_removal.rb +18 -0
- data/lib/evilution/mutator/operator/index_to_dig.rb +58 -0
- data/lib/evilution/mutator/operator/index_to_fetch.rb +30 -0
- data/lib/evilution/mutator/operator/keyword_argument.rb +91 -0
- data/lib/evilution/mutator/operator/mixin_removal.rb +2 -1
- data/lib/evilution/mutator/operator/multiple_assignment.rb +47 -0
- data/lib/evilution/mutator/operator/pattern_matching_alternative.rb +46 -0
- data/lib/evilution/mutator/operator/pattern_matching_array.rb +97 -0
- data/lib/evilution/mutator/operator/pattern_matching_guard.rb +44 -0
- data/lib/evilution/mutator/operator/regex_capture.rb +43 -0
- data/lib/evilution/mutator/operator/scalar_return.rb +37 -0
- data/lib/evilution/mutator/operator/splat_operator.rb +46 -0
- data/lib/evilution/mutator/operator/superclass_removal.rb +2 -1
- data/lib/evilution/mutator/operator/yield_statement.rb +51 -0
- data/lib/evilution/mutator/registry.rb +17 -3
- data/lib/evilution/parallel/pool.rb +7 -51
- data/lib/evilution/parallel/work_queue.rb +224 -0
- data/lib/evilution/reporter/cli.rb +22 -1
- data/lib/evilution/reporter/html.rb +76 -3
- data/lib/evilution/reporter/json.rb +23 -2
- data/lib/evilution/reporter/suggestion.rb +115 -1
- data/lib/evilution/result/summary.rb +20 -2
- data/lib/evilution/runner.rb +133 -13
- data/lib/evilution/session/diff.rb +85 -0
- data/lib/evilution/session/store.rb +5 -2
- data/lib/evilution/version.rb +1 -1
- data/lib/evilution.rb +23 -0
- metadata +28 -2
data/lib/evilution/cli.rb
CHANGED
|
@@ -2,9 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
require "json"
|
|
4
4
|
require "optparse"
|
|
5
|
+
require "tempfile"
|
|
5
6
|
require_relative "version"
|
|
6
7
|
require_relative "config"
|
|
8
|
+
require_relative "hooks"
|
|
9
|
+
require_relative "hooks/registry"
|
|
10
|
+
require_relative "hooks/loader"
|
|
7
11
|
require_relative "runner"
|
|
12
|
+
require_relative "spec_resolver"
|
|
13
|
+
require_relative "git/changed_files"
|
|
8
14
|
|
|
9
15
|
class Evilution::CLI
|
|
10
16
|
def initialize(argv, stdin: $stdin)
|
|
@@ -16,34 +22,42 @@ class Evilution::CLI
|
|
|
16
22
|
argv = preprocess_flags(argv)
|
|
17
23
|
raw_args = build_option_parser.parse!(argv)
|
|
18
24
|
@files, @line_ranges = parse_file_args(raw_args)
|
|
19
|
-
read_stdin_files if @options.delete(:stdin) && @command
|
|
25
|
+
read_stdin_files if @options.delete(:stdin) && %i[run subjects].include?(@command)
|
|
20
26
|
end
|
|
21
27
|
|
|
22
|
-
def call
|
|
28
|
+
def call # rubocop:disable Metrics/CyclomaticComplexity
|
|
23
29
|
case @command
|
|
24
|
-
when :version
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
when :
|
|
28
|
-
|
|
29
|
-
when :
|
|
30
|
-
|
|
31
|
-
when :
|
|
32
|
-
|
|
33
|
-
when :
|
|
34
|
-
|
|
35
|
-
when :
|
|
36
|
-
|
|
37
|
-
when :
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
when :run
|
|
41
|
-
run_mutations
|
|
30
|
+
when :version then run_version
|
|
31
|
+
when :init then run_init
|
|
32
|
+
when :mcp then run_mcp
|
|
33
|
+
when :session_list then run_session_list
|
|
34
|
+
when :session_show then run_session_show
|
|
35
|
+
when :session_diff then run_session_diff
|
|
36
|
+
when :session_gc then run_session_gc
|
|
37
|
+
when :session_error then run_subcommand_error(@session_error)
|
|
38
|
+
when :subjects then run_subjects
|
|
39
|
+
when :tests_list then run_tests_list
|
|
40
|
+
when :tests_error then run_subcommand_error(@tests_error)
|
|
41
|
+
when :environment_show then run_environment_show
|
|
42
|
+
when :environment_error then run_subcommand_error(@environment_error)
|
|
43
|
+
when :util_mutation then run_util_mutation
|
|
44
|
+
when :util_error then run_subcommand_error(@util_error)
|
|
45
|
+
when :run then run_mutations
|
|
42
46
|
end
|
|
43
47
|
end
|
|
44
48
|
|
|
45
49
|
private
|
|
46
50
|
|
|
51
|
+
def run_version
|
|
52
|
+
$stdout.puts(Evilution::VERSION)
|
|
53
|
+
0
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def run_subcommand_error(message)
|
|
57
|
+
warn("Error: #{message}")
|
|
58
|
+
2
|
|
59
|
+
end
|
|
60
|
+
|
|
47
61
|
def extract_command(argv)
|
|
48
62
|
case argv.first
|
|
49
63
|
when "version"
|
|
@@ -58,6 +72,18 @@ class Evilution::CLI
|
|
|
58
72
|
when "session"
|
|
59
73
|
argv.shift
|
|
60
74
|
extract_session_subcommand(argv)
|
|
75
|
+
when "subjects"
|
|
76
|
+
@command = :subjects
|
|
77
|
+
argv.shift
|
|
78
|
+
when "tests"
|
|
79
|
+
argv.shift
|
|
80
|
+
extract_tests_subcommand(argv)
|
|
81
|
+
when "environment"
|
|
82
|
+
argv.shift
|
|
83
|
+
extract_environment_subcommand(argv)
|
|
84
|
+
when "util"
|
|
85
|
+
argv.shift
|
|
86
|
+
extract_util_subcommand(argv)
|
|
61
87
|
when "run"
|
|
62
88
|
argv.shift
|
|
63
89
|
end
|
|
@@ -73,15 +99,66 @@ class Evilution::CLI
|
|
|
73
99
|
when "show"
|
|
74
100
|
@command = :session_show
|
|
75
101
|
argv.shift
|
|
102
|
+
when "diff"
|
|
103
|
+
@command = :session_diff
|
|
104
|
+
argv.shift
|
|
76
105
|
when "gc"
|
|
77
106
|
@command = :session_gc
|
|
78
107
|
argv.shift
|
|
79
108
|
when nil
|
|
80
109
|
@command = :session_error
|
|
81
|
-
@session_error = "Missing session subcommand. Available subcommands: list, show, gc"
|
|
110
|
+
@session_error = "Missing session subcommand. Available subcommands: list, show, diff, gc"
|
|
82
111
|
else
|
|
83
112
|
@command = :session_error
|
|
84
|
-
@session_error = "Unknown session subcommand: #{subcommand}. Available subcommands: list, show, gc"
|
|
113
|
+
@session_error = "Unknown session subcommand: #{subcommand}. Available subcommands: list, show, diff, gc"
|
|
114
|
+
argv.shift
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def extract_environment_subcommand(argv)
|
|
119
|
+
subcommand = argv.first
|
|
120
|
+
case subcommand
|
|
121
|
+
when "show"
|
|
122
|
+
@command = :environment_show
|
|
123
|
+
argv.shift
|
|
124
|
+
when nil
|
|
125
|
+
@command = :environment_error
|
|
126
|
+
@environment_error = "Missing environment subcommand. Available subcommands: show"
|
|
127
|
+
else
|
|
128
|
+
@command = :environment_error
|
|
129
|
+
@environment_error = "Unknown environment subcommand: #{subcommand}. Available subcommands: show"
|
|
130
|
+
argv.shift
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def extract_tests_subcommand(argv)
|
|
135
|
+
subcommand = argv.first
|
|
136
|
+
case subcommand
|
|
137
|
+
when "list"
|
|
138
|
+
@command = :tests_list
|
|
139
|
+
argv.shift
|
|
140
|
+
when nil
|
|
141
|
+
@command = :tests_error
|
|
142
|
+
@tests_error = "Missing tests subcommand. Available subcommands: list"
|
|
143
|
+
else
|
|
144
|
+
@command = :tests_error
|
|
145
|
+
@tests_error = "Unknown tests subcommand: #{subcommand}. Available subcommands: list"
|
|
146
|
+
argv.shift
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def extract_util_subcommand(argv)
|
|
151
|
+
subcommand = argv.first
|
|
152
|
+
case subcommand
|
|
153
|
+
when "mutation"
|
|
154
|
+
@command = :util_mutation
|
|
155
|
+
argv.shift
|
|
156
|
+
when nil
|
|
157
|
+
@command = :util_error
|
|
158
|
+
@util_error = "Missing util subcommand. Available subcommands: mutation"
|
|
159
|
+
else
|
|
160
|
+
@command = :util_error
|
|
161
|
+
@util_error = "Unknown util subcommand: #{subcommand}. Available subcommands: mutation"
|
|
85
162
|
argv.shift
|
|
86
163
|
end
|
|
87
164
|
end
|
|
@@ -125,7 +202,8 @@ class Evilution::CLI
|
|
|
125
202
|
opts.separator ""
|
|
126
203
|
opts.separator "Line-range targeting: lib/foo.rb:15-30, lib/foo.rb:15, lib/foo.rb:15-"
|
|
127
204
|
opts.separator ""
|
|
128
|
-
opts.separator "Commands: run (default), init, session {list,show,gc},
|
|
205
|
+
opts.separator "Commands: run (default), init, session {list,show,diff,gc}, subjects, tests {list},"
|
|
206
|
+
opts.separator " util {mutation}, environment {show}, mcp, version"
|
|
129
207
|
opts.separator ""
|
|
130
208
|
opts.separator "Options:"
|
|
131
209
|
end
|
|
@@ -162,7 +240,14 @@ class Evilution::CLI
|
|
|
162
240
|
opts.on("--stdin", "Read target file paths from stdin (one per line)") { @options[:stdin] = true }
|
|
163
241
|
opts.on("--suggest-tests", "Generate concrete RSpec test code in suggestions") { @options[:suggest_tests] = true }
|
|
164
242
|
opts.on("--no-progress", "Disable progress bar") { @options[:progress] = false }
|
|
243
|
+
add_extra_flag_options(opts)
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def add_extra_flag_options(opts)
|
|
247
|
+
opts.on("--show-disabled", "Report mutations skipped by # evilution:disable") { @options[:show_disabled] = true }
|
|
248
|
+
opts.on("--baseline-session PATH", "Compare against a baseline session in HTML report") { |p| @options[:baseline_session] = p }
|
|
165
249
|
opts.on("--save-session", "Save session results to .evilution/results/") { @options[:save_session] = true }
|
|
250
|
+
opts.on("-e", "--eval CODE", "Evaluate code snippet (for util mutation)") { |c| @options[:eval] = c }
|
|
166
251
|
opts.on("-v", "--verbose", "Verbose output") { @options[:verbose] = true }
|
|
167
252
|
opts.on("-q", "--quiet", "Suppress output") { @options[:quiet] = true }
|
|
168
253
|
end
|
|
@@ -188,6 +273,151 @@ class Evilution::CLI
|
|
|
188
273
|
0
|
|
189
274
|
end
|
|
190
275
|
|
|
276
|
+
def run_subjects
|
|
277
|
+
raise Evilution::ConfigError, @stdin_error if @stdin_error
|
|
278
|
+
|
|
279
|
+
config = Evilution::Config.new(target_files: @files, line_ranges: @line_ranges, **@options)
|
|
280
|
+
runner = Evilution::Runner.new(config: config)
|
|
281
|
+
subjects = runner.parse_and_filter_subjects
|
|
282
|
+
|
|
283
|
+
if subjects.empty?
|
|
284
|
+
$stdout.puts("No subjects found")
|
|
285
|
+
return 0
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
registry = Evilution::Mutator::Registry.default
|
|
289
|
+
filter = build_subject_filter(config)
|
|
290
|
+
total_mutations = 0
|
|
291
|
+
|
|
292
|
+
subjects.each do |subj|
|
|
293
|
+
count = registry.mutations_for(subj, filter: filter).length
|
|
294
|
+
total_mutations += count
|
|
295
|
+
label = count == 1 ? "1 mutation" : "#{count} mutations"
|
|
296
|
+
$stdout.puts(" #{subj.name} #{subj.file_path}:#{subj.line_number} (#{label})")
|
|
297
|
+
ensure
|
|
298
|
+
subj.release_node!
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
$stdout.puts("")
|
|
302
|
+
$stdout.puts("#{subjects.length} subjects, #{total_mutations} mutations")
|
|
303
|
+
0
|
|
304
|
+
rescue Evilution::Error => e
|
|
305
|
+
warn("Error: #{e.message}")
|
|
306
|
+
2
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
def build_subject_filter(config)
|
|
310
|
+
return nil if config.ignore_patterns.empty?
|
|
311
|
+
|
|
312
|
+
require_relative "ast/pattern/filter"
|
|
313
|
+
Evilution::AST::Pattern::Filter.new(config.ignore_patterns)
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
def run_tests_list
|
|
317
|
+
config = Evilution::Config.new(target_files: @files, line_ranges: @line_ranges, **@options)
|
|
318
|
+
|
|
319
|
+
if config.spec_files.any?
|
|
320
|
+
print_explicit_spec_files(config.spec_files)
|
|
321
|
+
return 0
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
source_files = resolve_source_files(config)
|
|
325
|
+
if source_files.empty?
|
|
326
|
+
$stdout.puts("No source files found")
|
|
327
|
+
return 0
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
resolver = Evilution::SpecResolver.new
|
|
331
|
+
print_resolved_specs(source_files, resolver)
|
|
332
|
+
0
|
|
333
|
+
rescue Evilution::Error => e
|
|
334
|
+
warn("Error: #{e.message}")
|
|
335
|
+
2
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
def resolve_source_files(config)
|
|
339
|
+
return config.target_files unless config.target_files.empty?
|
|
340
|
+
|
|
341
|
+
Evilution::Git::ChangedFiles.new.call
|
|
342
|
+
rescue Evilution::Error
|
|
343
|
+
[]
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
def print_explicit_spec_files(spec_files)
|
|
347
|
+
spec_files.each { |f| $stdout.puts(" #{f}") }
|
|
348
|
+
label = spec_files.length == 1 ? "1 spec file" : "#{spec_files.length} spec files"
|
|
349
|
+
$stdout.puts("")
|
|
350
|
+
$stdout.puts(label)
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
def print_resolved_specs(source_files, resolver)
|
|
354
|
+
unique_specs = []
|
|
355
|
+
source_files.each do |source|
|
|
356
|
+
spec = resolver.call(source)
|
|
357
|
+
if spec
|
|
358
|
+
unique_specs << spec
|
|
359
|
+
$stdout.puts(" #{spec} (#{source})")
|
|
360
|
+
else
|
|
361
|
+
$stdout.puts(" #{source} (no spec found)")
|
|
362
|
+
end
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
unique_specs.uniq!
|
|
366
|
+
$stdout.puts("")
|
|
367
|
+
spec_label = unique_specs.length == 1 ? "1 spec file" : "#{unique_specs.length} spec files"
|
|
368
|
+
$stdout.puts("#{source_files.length} source files, #{spec_label}")
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
def run_environment_show
|
|
372
|
+
config = Evilution::Config.new(**@options)
|
|
373
|
+
$stdout.puts(format_environment(config))
|
|
374
|
+
0
|
|
375
|
+
rescue Evilution::ConfigError => e
|
|
376
|
+
warn("Error: #{e.message}")
|
|
377
|
+
2
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
def format_environment(config)
|
|
381
|
+
config_file = Evilution::Config::CONFIG_FILES.find { |path| File.exist?(path) }
|
|
382
|
+
lines = environment_header(config_file)
|
|
383
|
+
lines.concat(environment_settings(config))
|
|
384
|
+
lines.join("\n")
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
def environment_header(config_file)
|
|
388
|
+
[
|
|
389
|
+
"Evilution Environment",
|
|
390
|
+
("=" * 30),
|
|
391
|
+
"",
|
|
392
|
+
"evilution: #{Evilution::VERSION}",
|
|
393
|
+
"ruby: #{RUBY_VERSION}",
|
|
394
|
+
"config_file: #{config_file || "(none)"}",
|
|
395
|
+
"",
|
|
396
|
+
"Settings:"
|
|
397
|
+
]
|
|
398
|
+
end
|
|
399
|
+
|
|
400
|
+
def environment_settings(config)
|
|
401
|
+
[
|
|
402
|
+
" timeout: #{config.timeout}",
|
|
403
|
+
" format: #{config.format}",
|
|
404
|
+
" integration: #{config.integration}",
|
|
405
|
+
" jobs: #{config.jobs}",
|
|
406
|
+
" isolation: #{config.isolation}",
|
|
407
|
+
" baseline: #{config.baseline}",
|
|
408
|
+
" incremental: #{config.incremental}",
|
|
409
|
+
" verbose: #{config.verbose}",
|
|
410
|
+
" quiet: #{config.quiet}",
|
|
411
|
+
" progress: #{config.progress}",
|
|
412
|
+
" fail_fast: #{config.fail_fast || "(disabled)"}",
|
|
413
|
+
" min_score: #{config.min_score}",
|
|
414
|
+
" suggest_tests: #{config.suggest_tests}",
|
|
415
|
+
" save_session: #{config.save_session}",
|
|
416
|
+
" target: #{config.target || "(all files)"}",
|
|
417
|
+
" ignore_patterns: #{config.ignore_patterns.empty? ? "(none)" : config.ignore_patterns.inspect}"
|
|
418
|
+
]
|
|
419
|
+
end
|
|
420
|
+
|
|
191
421
|
def run_mcp
|
|
192
422
|
require_relative "mcp/server"
|
|
193
423
|
server = Evilution::MCP::Server.build
|
|
@@ -237,6 +467,79 @@ class Evilution::CLI
|
|
|
237
467
|
end
|
|
238
468
|
end
|
|
239
469
|
|
|
470
|
+
def run_util_mutation
|
|
471
|
+
source, file_path = resolve_util_mutation_source
|
|
472
|
+
subjects = parse_source_to_subjects(source, file_path)
|
|
473
|
+
registry = Evilution::Mutator::Registry.default
|
|
474
|
+
mutations = subjects.flat_map { |s| registry.mutations_for(s) }
|
|
475
|
+
|
|
476
|
+
if mutations.empty?
|
|
477
|
+
$stdout.puts("No mutations generated")
|
|
478
|
+
return 0
|
|
479
|
+
end
|
|
480
|
+
|
|
481
|
+
if @options[:format] == :json
|
|
482
|
+
print_util_mutations_json(mutations)
|
|
483
|
+
else
|
|
484
|
+
print_util_mutations_text(mutations)
|
|
485
|
+
end
|
|
486
|
+
|
|
487
|
+
0
|
|
488
|
+
rescue Evilution::Error => e
|
|
489
|
+
warn("Error: #{e.message}")
|
|
490
|
+
2
|
|
491
|
+
ensure
|
|
492
|
+
@util_tmpfile&.close!
|
|
493
|
+
end
|
|
494
|
+
|
|
495
|
+
def resolve_util_mutation_source
|
|
496
|
+
if @options[:eval]
|
|
497
|
+
tmpfile = Tempfile.new(["evilution_eval", ".rb"])
|
|
498
|
+
tmpfile.write(@options[:eval])
|
|
499
|
+
tmpfile.flush
|
|
500
|
+
@util_tmpfile = tmpfile
|
|
501
|
+
[@options[:eval], tmpfile.path]
|
|
502
|
+
elsif @files.first
|
|
503
|
+
path = @files.first
|
|
504
|
+
raise Evilution::Error, "file not found: #{path}" unless File.exist?(path)
|
|
505
|
+
|
|
506
|
+
begin
|
|
507
|
+
[File.read(path), path]
|
|
508
|
+
rescue SystemCallError => e
|
|
509
|
+
raise Evilution::Error, e.message
|
|
510
|
+
end
|
|
511
|
+
else
|
|
512
|
+
raise Evilution::Error, "source required: use -e 'code' or provide a file path"
|
|
513
|
+
end
|
|
514
|
+
end
|
|
515
|
+
|
|
516
|
+
def parse_source_to_subjects(source, file_label)
|
|
517
|
+
result = Prism.parse(source)
|
|
518
|
+
raise Evilution::Error, "failed to parse source: #{result.errors.map(&:message).join(", ")}" if result.failure?
|
|
519
|
+
|
|
520
|
+
finder = Evilution::AST::SubjectFinder.new(source, file_label)
|
|
521
|
+
finder.visit(result.value)
|
|
522
|
+
finder.subjects
|
|
523
|
+
end
|
|
524
|
+
|
|
525
|
+
def print_util_mutations_text(mutations)
|
|
526
|
+
mutations.each_with_index do |m, i|
|
|
527
|
+
$stdout.puts("#{i + 1}. #{m.operator_name} — #{m.subject.name} (line #{m.line})")
|
|
528
|
+
m.diff.each_line { |line| $stdout.puts(" #{line}") }
|
|
529
|
+
$stdout.puts("")
|
|
530
|
+
end
|
|
531
|
+
label = mutations.length == 1 ? "1 mutation" : "#{mutations.length} mutations"
|
|
532
|
+
$stdout.puts(label)
|
|
533
|
+
end
|
|
534
|
+
|
|
535
|
+
def print_util_mutations_json(mutations)
|
|
536
|
+
data = mutations.map do |m|
|
|
537
|
+
{ operator: m.operator_name, subject: m.subject.name,
|
|
538
|
+
file: m.file_path, line: m.line, diff: m.diff }
|
|
539
|
+
end
|
|
540
|
+
$stdout.puts(JSON.pretty_generate(data))
|
|
541
|
+
end
|
|
542
|
+
|
|
240
543
|
def run_session_list
|
|
241
544
|
require_relative "session/store"
|
|
242
545
|
|
|
@@ -309,6 +612,70 @@ class Evilution::CLI
|
|
|
309
612
|
2
|
|
310
613
|
end
|
|
311
614
|
|
|
615
|
+
def run_session_diff
|
|
616
|
+
require_relative "session/store"
|
|
617
|
+
require_relative "session/diff"
|
|
618
|
+
|
|
619
|
+
raise Evilution::ConfigError, "two session file paths required" unless @files.length == 2
|
|
620
|
+
|
|
621
|
+
store = Evilution::Session::Store.new
|
|
622
|
+
base_data = store.load(@files[0])
|
|
623
|
+
head_data = store.load(@files[1])
|
|
624
|
+
|
|
625
|
+
diff = Evilution::Session::Diff.new
|
|
626
|
+
result = diff.call(base_data, head_data)
|
|
627
|
+
|
|
628
|
+
if @options[:format] == :json
|
|
629
|
+
$stdout.puts(JSON.pretty_generate(result.to_h))
|
|
630
|
+
else
|
|
631
|
+
print_session_diff(result)
|
|
632
|
+
end
|
|
633
|
+
|
|
634
|
+
0
|
|
635
|
+
rescue Evilution::Error, SystemCallError => e
|
|
636
|
+
warn("Error: #{e.message}")
|
|
637
|
+
2
|
|
638
|
+
rescue ::JSON::ParserError => e
|
|
639
|
+
warn("Error: invalid session file: #{e.message}")
|
|
640
|
+
2
|
|
641
|
+
end
|
|
642
|
+
|
|
643
|
+
def print_session_diff(result)
|
|
644
|
+
print_diff_summary(result.summary)
|
|
645
|
+
print_diff_section("Fixed (survived \u2192 killed)", result.fixed, "\e[32m")
|
|
646
|
+
print_diff_section("New survivors (killed \u2192 survived)", result.new_survivors, "\e[31m")
|
|
647
|
+
print_diff_section("Persistent survivors", result.persistent, "\e[33m")
|
|
648
|
+
|
|
649
|
+
return unless result.fixed.empty? && result.new_survivors.empty? && result.persistent.empty?
|
|
650
|
+
|
|
651
|
+
$stdout.puts("")
|
|
652
|
+
$stdout.puts("No mutation changes between sessions")
|
|
653
|
+
end
|
|
654
|
+
|
|
655
|
+
def print_diff_summary(summary)
|
|
656
|
+
delta_str = format("%+.2f%%", summary.score_delta * 100)
|
|
657
|
+
$stdout.puts("Session Diff")
|
|
658
|
+
$stdout.puts("=" * 40)
|
|
659
|
+
$stdout.puts(format("Base score: %<score>6.2f%% (%<killed>d/%<total>d killed)",
|
|
660
|
+
score: summary.base_score * 100, killed: summary.base_killed,
|
|
661
|
+
total: summary.base_total))
|
|
662
|
+
$stdout.puts(format("Head score: %<score>6.2f%% (%<killed>d/%<total>d killed)",
|
|
663
|
+
score: summary.head_score * 100, killed: summary.head_killed,
|
|
664
|
+
total: summary.head_total))
|
|
665
|
+
$stdout.puts("Delta: #{delta_str}")
|
|
666
|
+
end
|
|
667
|
+
|
|
668
|
+
def print_diff_section(title, mutations, color)
|
|
669
|
+
return if mutations.empty?
|
|
670
|
+
|
|
671
|
+
reset = "\e[0m"
|
|
672
|
+
$stdout.puts("")
|
|
673
|
+
$stdout.puts("#{color}#{title} (#{mutations.length}):#{reset}")
|
|
674
|
+
mutations.each do |m|
|
|
675
|
+
$stdout.puts(" #{m["operator"]} — #{m["file"]}:#{m["line"]} #{m["subject"]}")
|
|
676
|
+
end
|
|
677
|
+
end
|
|
678
|
+
|
|
312
679
|
def run_session_gc
|
|
313
680
|
require_relative "session/store"
|
|
314
681
|
|
|
@@ -434,7 +801,8 @@ class Evilution::CLI
|
|
|
434
801
|
|
|
435
802
|
file_options = Evilution::Config.file_options
|
|
436
803
|
config = Evilution::Config.new(**@options, target_files: @files, line_ranges: @line_ranges)
|
|
437
|
-
|
|
804
|
+
hooks = build_hooks(config)
|
|
805
|
+
runner = Evilution::Runner.new(config: config, hooks: hooks)
|
|
438
806
|
summary = runner.call
|
|
439
807
|
summary.success?(min_score: config.min_score) ? 0 : 1
|
|
440
808
|
rescue Evilution::Error => e
|
|
@@ -446,6 +814,14 @@ class Evilution::CLI
|
|
|
446
814
|
2
|
|
447
815
|
end
|
|
448
816
|
|
|
817
|
+
def build_hooks(config)
|
|
818
|
+
return nil if config.hooks.empty?
|
|
819
|
+
|
|
820
|
+
registry = Evilution::Hooks::Registry.new
|
|
821
|
+
Evilution::Hooks::Loader.call(registry, config.hooks)
|
|
822
|
+
registry
|
|
823
|
+
end
|
|
824
|
+
|
|
449
825
|
def json_format?(config, file_options)
|
|
450
826
|
return config.json? if config
|
|
451
827
|
|
data/lib/evilution/config.rb
CHANGED
|
@@ -22,13 +22,17 @@ class Evilution::Config
|
|
|
22
22
|
progress: true,
|
|
23
23
|
save_session: false,
|
|
24
24
|
line_ranges: {},
|
|
25
|
-
spec_files: []
|
|
25
|
+
spec_files: [],
|
|
26
|
+
ignore_patterns: [],
|
|
27
|
+
show_disabled: false,
|
|
28
|
+
baseline_session: nil
|
|
26
29
|
}.freeze
|
|
27
30
|
|
|
28
31
|
attr_reader :target_files, :timeout, :format,
|
|
29
32
|
:target, :min_score, :integration, :verbose, :quiet,
|
|
30
33
|
:jobs, :fail_fast, :baseline, :isolation, :incremental, :suggest_tests,
|
|
31
|
-
:progress, :save_session, :line_ranges, :spec_files
|
|
34
|
+
:progress, :save_session, :line_ranges, :spec_files, :hooks,
|
|
35
|
+
:ignore_patterns, :show_disabled, :baseline_session
|
|
32
36
|
|
|
33
37
|
def initialize(**options)
|
|
34
38
|
file_options = options.delete(:skip_config_file) ? {} : load_config_file
|
|
@@ -81,6 +85,10 @@ class Evilution::Config
|
|
|
81
85
|
save_session
|
|
82
86
|
end
|
|
83
87
|
|
|
88
|
+
def show_disabled?
|
|
89
|
+
show_disabled
|
|
90
|
+
end
|
|
91
|
+
|
|
84
92
|
def self.file_options
|
|
85
93
|
CONFIG_FILES.each do |path|
|
|
86
94
|
next unless File.exist?(path)
|
|
@@ -122,6 +130,17 @@ class Evilution::Config
|
|
|
122
130
|
|
|
123
131
|
# Generate concrete RSpec test code in suggestions (default: false)
|
|
124
132
|
# suggest_tests: false
|
|
133
|
+
|
|
134
|
+
# Hooks: Ruby files returning a Proc, keyed by lifecycle event
|
|
135
|
+
# hooks:
|
|
136
|
+
# worker_process_start: config/evilution_hooks/worker_start.rb
|
|
137
|
+
# mutation_insert_pre: config/evilution_hooks/mutation_pre.rb
|
|
138
|
+
|
|
139
|
+
# AST patterns to skip during mutation generation (default: [])
|
|
140
|
+
# See docs/ast_pattern_syntax.md for pattern syntax
|
|
141
|
+
# ignore_patterns:
|
|
142
|
+
# - "call{name=info, receiver=call{name=logger}}"
|
|
143
|
+
# - "call{name=debug|warn}"
|
|
125
144
|
YAML
|
|
126
145
|
end
|
|
127
146
|
|
|
@@ -157,6 +176,10 @@ class Evilution::Config
|
|
|
157
176
|
@save_session = merged[:save_session]
|
|
158
177
|
@line_ranges = merged[:line_ranges] || {}
|
|
159
178
|
@spec_files = Array(merged[:spec_files])
|
|
179
|
+
@ignore_patterns = validate_ignore_patterns(merged[:ignore_patterns])
|
|
180
|
+
@show_disabled = merged[:show_disabled]
|
|
181
|
+
@baseline_session = merged[:baseline_session]
|
|
182
|
+
@hooks = validate_hooks(merged[:hooks])
|
|
160
183
|
end
|
|
161
184
|
|
|
162
185
|
def validate_isolation(value)
|
|
@@ -180,6 +203,24 @@ class Evilution::Config
|
|
|
180
203
|
raise Evilution::ConfigError, "jobs must be a positive integer, got #{value.inspect}"
|
|
181
204
|
end
|
|
182
205
|
|
|
206
|
+
def validate_ignore_patterns(value)
|
|
207
|
+
patterns = Array(value)
|
|
208
|
+
patterns.each do |pattern|
|
|
209
|
+
unless pattern.is_a?(String)
|
|
210
|
+
raise Evilution::ConfigError,
|
|
211
|
+
"ignore_patterns must be an array of strings, got #{pattern.class} (#{pattern.inspect})"
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
patterns
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def validate_hooks(value)
|
|
218
|
+
return {} if value.nil?
|
|
219
|
+
raise Evilution::ConfigError, "hooks must be a mapping of event names to file paths, got #{value.class}" unless value.is_a?(Hash)
|
|
220
|
+
|
|
221
|
+
value
|
|
222
|
+
end
|
|
223
|
+
|
|
183
224
|
def load_config_file
|
|
184
225
|
self.class.file_options
|
|
185
226
|
end
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
class Evilution::DisableComment
|
|
6
|
+
DISABLE_MARKER = /\A#\s*evilution:disable\s*\z/
|
|
7
|
+
ENABLE_MARKER = /\A#\s*evilution:enable\s*\z/
|
|
8
|
+
|
|
9
|
+
def call(source)
|
|
10
|
+
return [] if source.empty?
|
|
11
|
+
|
|
12
|
+
result = Prism.parse(source)
|
|
13
|
+
return [] if result.failure?
|
|
14
|
+
|
|
15
|
+
method_ranges = collect_def_ranges(result.value)
|
|
16
|
+
comments = classify_comments(result, source)
|
|
17
|
+
scan_comments(comments, method_ranges, source.lines.length)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
def classify_comments(parse_result, source)
|
|
23
|
+
parse_result.comments.filter_map do |comment|
|
|
24
|
+
loc = comment.location
|
|
25
|
+
text = source[loc.start_offset...loc.end_offset]
|
|
26
|
+
|
|
27
|
+
if text.match?(DISABLE_MARKER)
|
|
28
|
+
line = source.lines[loc.start_line - 1]
|
|
29
|
+
standalone = line.strip == text.strip
|
|
30
|
+
{ type: :disable, line: loc.start_line, standalone: standalone }
|
|
31
|
+
elsif text.match?(ENABLE_MARKER)
|
|
32
|
+
{ type: :enable, line: loc.start_line }
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def scan_comments(comments, method_ranges, total_lines)
|
|
38
|
+
disabled = []
|
|
39
|
+
range_start = nil
|
|
40
|
+
|
|
41
|
+
comments.each do |comment|
|
|
42
|
+
if comment[:type] == :enable && range_start
|
|
43
|
+
disabled << (range_start..comment[:line])
|
|
44
|
+
range_start = nil
|
|
45
|
+
elsif comment[:type] == :disable && range_start.nil?
|
|
46
|
+
range_start = process_disable(comment, method_ranges, disabled)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
disabled << (range_start..total_lines) if range_start
|
|
51
|
+
|
|
52
|
+
disabled
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def process_disable(comment, method_ranges, disabled)
|
|
56
|
+
unless comment[:standalone]
|
|
57
|
+
disabled << (comment[:line]..comment[:line])
|
|
58
|
+
return nil
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
method_range = find_method_range(method_ranges, comment[:line] + 1)
|
|
62
|
+
if method_range
|
|
63
|
+
disabled << (comment[:line]..method_range.last)
|
|
64
|
+
nil
|
|
65
|
+
else
|
|
66
|
+
comment[:line]
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def collect_def_ranges(node)
|
|
71
|
+
ranges = []
|
|
72
|
+
walk_def_nodes(node, ranges)
|
|
73
|
+
ranges
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def walk_def_nodes(node, ranges)
|
|
77
|
+
if node.is_a?(Prism::DefNode)
|
|
78
|
+
loc = node.location
|
|
79
|
+
ranges << (loc.start_line..loc.end_line)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
node.child_nodes.each do |child|
|
|
83
|
+
walk_def_nodes(child, ranges) if child
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def find_method_range(method_ranges, def_line)
|
|
88
|
+
method_ranges.find { |range| range.first == def_line }
|
|
89
|
+
end
|
|
90
|
+
end
|