evilution 0.17.0 → 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 +28 -28
- data/CHANGELOG.md +31 -0
- data/README.md +143 -50
- data/lib/evilution/ast/sorbet_sig_detector.rb +52 -0
- data/lib/evilution/cli.rb +387 -23
- data/lib/evilution/config.rb +10 -2
- data/lib/evilution/disable_comment.rb +90 -0
- data/lib/evilution/mcp/session_diff_tool.rb +5 -35
- 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/keyword_argument.rb +91 -0
- data/lib/evilution/mutator/operator/multiple_assignment.rb +47 -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/yield_statement.rb +51 -0
- data/lib/evilution/mutator/registry.rb +9 -1
- data/lib/evilution/parallel/pool.rb +6 -52
- data/lib/evilution/parallel/work_queue.rb +224 -0
- data/lib/evilution/reporter/cli.rb +21 -1
- data/lib/evilution/reporter/html.rb +69 -3
- data/lib/evilution/reporter/json.rb +22 -2
- data/lib/evilution/reporter/suggestion.rb +29 -1
- data/lib/evilution/result/summary.rb +19 -2
- data/lib/evilution/runner.rb +116 -8
- data/lib/evilution/session/diff.rb +85 -0
- data/lib/evilution/version.rb +1 -1
- data/lib/evilution.rb +11 -0
- metadata +14 -2
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
class Evilution::AST::SorbetSigDetector
|
|
6
|
+
def call(source)
|
|
7
|
+
return [] if source.empty?
|
|
8
|
+
|
|
9
|
+
result = Prism.parse(source)
|
|
10
|
+
return [] if result.failure?
|
|
11
|
+
|
|
12
|
+
ranges = []
|
|
13
|
+
collect_sig_ranges(result.value, ranges, :byte)
|
|
14
|
+
ranges
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def line_ranges(source)
|
|
18
|
+
return [] if source.empty?
|
|
19
|
+
|
|
20
|
+
result = Prism.parse(source)
|
|
21
|
+
return [] if result.failure?
|
|
22
|
+
|
|
23
|
+
ranges = []
|
|
24
|
+
collect_sig_ranges(result.value, ranges, :line)
|
|
25
|
+
ranges
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def collect_sig_ranges(node, ranges, mode)
|
|
31
|
+
if sig_block?(node)
|
|
32
|
+
loc = node.location
|
|
33
|
+
ranges << if mode == :byte
|
|
34
|
+
(loc.start_offset...loc.end_offset)
|
|
35
|
+
else
|
|
36
|
+
(loc.start_line..loc.end_line)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
node.child_nodes.each do |child|
|
|
41
|
+
collect_sig_ranges(child, ranges, mode) if child
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def sig_block?(node)
|
|
46
|
+
node.is_a?(Prism::CallNode) &&
|
|
47
|
+
node.name == :sig &&
|
|
48
|
+
node.receiver.nil? &&
|
|
49
|
+
node.arguments.nil? &&
|
|
50
|
+
!node.block.nil?
|
|
51
|
+
end
|
|
52
|
+
end
|
data/lib/evilution/cli.rb
CHANGED
|
@@ -2,12 +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"
|
|
7
8
|
require_relative "hooks"
|
|
8
9
|
require_relative "hooks/registry"
|
|
9
10
|
require_relative "hooks/loader"
|
|
10
11
|
require_relative "runner"
|
|
12
|
+
require_relative "spec_resolver"
|
|
13
|
+
require_relative "git/changed_files"
|
|
11
14
|
|
|
12
15
|
class Evilution::CLI
|
|
13
16
|
def initialize(argv, stdin: $stdin)
|
|
@@ -19,34 +22,42 @@ class Evilution::CLI
|
|
|
19
22
|
argv = preprocess_flags(argv)
|
|
20
23
|
raw_args = build_option_parser.parse!(argv)
|
|
21
24
|
@files, @line_ranges = parse_file_args(raw_args)
|
|
22
|
-
read_stdin_files if @options.delete(:stdin) && @command
|
|
25
|
+
read_stdin_files if @options.delete(:stdin) && %i[run subjects].include?(@command)
|
|
23
26
|
end
|
|
24
27
|
|
|
25
|
-
def call
|
|
28
|
+
def call # rubocop:disable Metrics/CyclomaticComplexity
|
|
26
29
|
case @command
|
|
27
|
-
when :version
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
when :
|
|
31
|
-
|
|
32
|
-
when :
|
|
33
|
-
|
|
34
|
-
when :
|
|
35
|
-
|
|
36
|
-
when :
|
|
37
|
-
|
|
38
|
-
when :
|
|
39
|
-
|
|
40
|
-
when :
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
when :run
|
|
44
|
-
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
|
|
45
46
|
end
|
|
46
47
|
end
|
|
47
48
|
|
|
48
49
|
private
|
|
49
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
|
+
|
|
50
61
|
def extract_command(argv)
|
|
51
62
|
case argv.first
|
|
52
63
|
when "version"
|
|
@@ -61,6 +72,18 @@ class Evilution::CLI
|
|
|
61
72
|
when "session"
|
|
62
73
|
argv.shift
|
|
63
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)
|
|
64
87
|
when "run"
|
|
65
88
|
argv.shift
|
|
66
89
|
end
|
|
@@ -76,15 +99,66 @@ class Evilution::CLI
|
|
|
76
99
|
when "show"
|
|
77
100
|
@command = :session_show
|
|
78
101
|
argv.shift
|
|
102
|
+
when "diff"
|
|
103
|
+
@command = :session_diff
|
|
104
|
+
argv.shift
|
|
79
105
|
when "gc"
|
|
80
106
|
@command = :session_gc
|
|
81
107
|
argv.shift
|
|
82
108
|
when nil
|
|
83
109
|
@command = :session_error
|
|
84
|
-
@session_error = "Missing session subcommand. Available subcommands: list, show, gc"
|
|
110
|
+
@session_error = "Missing session subcommand. Available subcommands: list, show, diff, gc"
|
|
85
111
|
else
|
|
86
112
|
@command = :session_error
|
|
87
|
-
@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"
|
|
88
162
|
argv.shift
|
|
89
163
|
end
|
|
90
164
|
end
|
|
@@ -128,7 +202,8 @@ class Evilution::CLI
|
|
|
128
202
|
opts.separator ""
|
|
129
203
|
opts.separator "Line-range targeting: lib/foo.rb:15-30, lib/foo.rb:15, lib/foo.rb:15-"
|
|
130
204
|
opts.separator ""
|
|
131
|
-
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"
|
|
132
207
|
opts.separator ""
|
|
133
208
|
opts.separator "Options:"
|
|
134
209
|
end
|
|
@@ -165,7 +240,14 @@ class Evilution::CLI
|
|
|
165
240
|
opts.on("--stdin", "Read target file paths from stdin (one per line)") { @options[:stdin] = true }
|
|
166
241
|
opts.on("--suggest-tests", "Generate concrete RSpec test code in suggestions") { @options[:suggest_tests] = true }
|
|
167
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 }
|
|
168
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 }
|
|
169
251
|
opts.on("-v", "--verbose", "Verbose output") { @options[:verbose] = true }
|
|
170
252
|
opts.on("-q", "--quiet", "Suppress output") { @options[:quiet] = true }
|
|
171
253
|
end
|
|
@@ -191,6 +273,151 @@ class Evilution::CLI
|
|
|
191
273
|
0
|
|
192
274
|
end
|
|
193
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
|
+
|
|
194
421
|
def run_mcp
|
|
195
422
|
require_relative "mcp/server"
|
|
196
423
|
server = Evilution::MCP::Server.build
|
|
@@ -240,6 +467,79 @@ class Evilution::CLI
|
|
|
240
467
|
end
|
|
241
468
|
end
|
|
242
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
|
+
|
|
243
543
|
def run_session_list
|
|
244
544
|
require_relative "session/store"
|
|
245
545
|
|
|
@@ -312,6 +612,70 @@ class Evilution::CLI
|
|
|
312
612
|
2
|
|
313
613
|
end
|
|
314
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
|
+
|
|
315
679
|
def run_session_gc
|
|
316
680
|
require_relative "session/store"
|
|
317
681
|
|
data/lib/evilution/config.rb
CHANGED
|
@@ -23,14 +23,16 @@ class Evilution::Config
|
|
|
23
23
|
save_session: false,
|
|
24
24
|
line_ranges: {},
|
|
25
25
|
spec_files: [],
|
|
26
|
-
ignore_patterns: []
|
|
26
|
+
ignore_patterns: [],
|
|
27
|
+
show_disabled: false,
|
|
28
|
+
baseline_session: nil
|
|
27
29
|
}.freeze
|
|
28
30
|
|
|
29
31
|
attr_reader :target_files, :timeout, :format,
|
|
30
32
|
:target, :min_score, :integration, :verbose, :quiet,
|
|
31
33
|
:jobs, :fail_fast, :baseline, :isolation, :incremental, :suggest_tests,
|
|
32
34
|
:progress, :save_session, :line_ranges, :spec_files, :hooks,
|
|
33
|
-
:ignore_patterns
|
|
35
|
+
:ignore_patterns, :show_disabled, :baseline_session
|
|
34
36
|
|
|
35
37
|
def initialize(**options)
|
|
36
38
|
file_options = options.delete(:skip_config_file) ? {} : load_config_file
|
|
@@ -83,6 +85,10 @@ class Evilution::Config
|
|
|
83
85
|
save_session
|
|
84
86
|
end
|
|
85
87
|
|
|
88
|
+
def show_disabled?
|
|
89
|
+
show_disabled
|
|
90
|
+
end
|
|
91
|
+
|
|
86
92
|
def self.file_options
|
|
87
93
|
CONFIG_FILES.each do |path|
|
|
88
94
|
next unless File.exist?(path)
|
|
@@ -171,6 +177,8 @@ class Evilution::Config
|
|
|
171
177
|
@line_ranges = merged[:line_ranges] || {}
|
|
172
178
|
@spec_files = Array(merged[:spec_files])
|
|
173
179
|
@ignore_patterns = validate_ignore_patterns(merged[:ignore_patterns])
|
|
180
|
+
@show_disabled = merged[:show_disabled]
|
|
181
|
+
@baseline_session = merged[:baseline_session]
|
|
174
182
|
@hooks = validate_hooks(merged[:hooks])
|
|
175
183
|
end
|
|
176
184
|
|
|
@@ -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
|