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.
Files changed (52) hide show
  1. checksums.yaml +4 -4
  2. data/.beads/.migration-hint-ts +1 -1
  3. data/.beads/issues.jsonl +47 -46
  4. data/CHANGELOG.md +48 -0
  5. data/README.md +143 -50
  6. data/docs/ast_pattern_syntax.md +210 -0
  7. data/lib/evilution/ast/pattern/filter.rb +25 -0
  8. data/lib/evilution/ast/pattern/matcher.rb +107 -0
  9. data/lib/evilution/ast/pattern/parser.rb +185 -0
  10. data/lib/evilution/ast/pattern.rb +4 -0
  11. data/lib/evilution/ast/sorbet_sig_detector.rb +52 -0
  12. data/lib/evilution/cli.rb +400 -24
  13. data/lib/evilution/config.rb +43 -2
  14. data/lib/evilution/disable_comment.rb +90 -0
  15. data/lib/evilution/hooks/loader.rb +35 -0
  16. data/lib/evilution/hooks/registry.rb +60 -0
  17. data/lib/evilution/hooks.rb +58 -0
  18. data/lib/evilution/integration/base.rb +4 -0
  19. data/lib/evilution/integration/rspec.rb +6 -2
  20. data/lib/evilution/isolation/fork.rb +5 -0
  21. data/lib/evilution/mcp/session_diff_tool.rb +5 -35
  22. data/lib/evilution/mutator/base.rb +4 -1
  23. data/lib/evilution/mutator/operator/collection_return.rb +33 -0
  24. data/lib/evilution/mutator/operator/defined_check.rb +16 -0
  25. data/lib/evilution/mutator/operator/index_assignment_removal.rb +18 -0
  26. data/lib/evilution/mutator/operator/index_to_dig.rb +58 -0
  27. data/lib/evilution/mutator/operator/index_to_fetch.rb +30 -0
  28. data/lib/evilution/mutator/operator/keyword_argument.rb +91 -0
  29. data/lib/evilution/mutator/operator/mixin_removal.rb +2 -1
  30. data/lib/evilution/mutator/operator/multiple_assignment.rb +47 -0
  31. data/lib/evilution/mutator/operator/pattern_matching_alternative.rb +46 -0
  32. data/lib/evilution/mutator/operator/pattern_matching_array.rb +97 -0
  33. data/lib/evilution/mutator/operator/pattern_matching_guard.rb +44 -0
  34. data/lib/evilution/mutator/operator/regex_capture.rb +43 -0
  35. data/lib/evilution/mutator/operator/scalar_return.rb +37 -0
  36. data/lib/evilution/mutator/operator/splat_operator.rb +46 -0
  37. data/lib/evilution/mutator/operator/superclass_removal.rb +2 -1
  38. data/lib/evilution/mutator/operator/yield_statement.rb +51 -0
  39. data/lib/evilution/mutator/registry.rb +17 -3
  40. data/lib/evilution/parallel/pool.rb +7 -51
  41. data/lib/evilution/parallel/work_queue.rb +224 -0
  42. data/lib/evilution/reporter/cli.rb +22 -1
  43. data/lib/evilution/reporter/html.rb +76 -3
  44. data/lib/evilution/reporter/json.rb +23 -2
  45. data/lib/evilution/reporter/suggestion.rb +115 -1
  46. data/lib/evilution/result/summary.rb +20 -2
  47. data/lib/evilution/runner.rb +133 -13
  48. data/lib/evilution/session/diff.rb +85 -0
  49. data/lib/evilution/session/store.rb +5 -2
  50. data/lib/evilution/version.rb +1 -1
  51. data/lib/evilution.rb +23 -0
  52. 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 == :run
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
- $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
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}, mcp, version"
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
- runner = Evilution::Runner.new(config: config)
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
 
@@ -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