evilution 0.17.0 → 0.19.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 (38) hide show
  1. checksums.yaml +4 -4
  2. data/.beads/.migration-hint-ts +1 -1
  3. data/.beads/issues.jsonl +103 -33
  4. data/CHANGELOG.md +50 -0
  5. data/README.md +144 -50
  6. data/lib/evilution/ast/sorbet_sig_detector.rb +52 -0
  7. data/lib/evilution/baseline.rb +9 -1
  8. data/lib/evilution/cli.rb +398 -23
  9. data/lib/evilution/config.rb +10 -2
  10. data/lib/evilution/disable_comment.rb +90 -0
  11. data/lib/evilution/integration/rspec.rb +74 -5
  12. data/lib/evilution/isolation/fork.rb +10 -6
  13. data/lib/evilution/isolation/in_process.rb +14 -10
  14. data/lib/evilution/mcp/session_diff_tool.rb +5 -35
  15. data/lib/evilution/mutator/operator/collection_return.rb +33 -0
  16. data/lib/evilution/mutator/operator/defined_check.rb +16 -0
  17. data/lib/evilution/mutator/operator/keyword_argument.rb +91 -0
  18. data/lib/evilution/mutator/operator/multiple_assignment.rb +47 -0
  19. data/lib/evilution/mutator/operator/regex_capture.rb +43 -0
  20. data/lib/evilution/mutator/operator/scalar_return.rb +37 -0
  21. data/lib/evilution/mutator/operator/splat_operator.rb +46 -0
  22. data/lib/evilution/mutator/operator/yield_statement.rb +51 -0
  23. data/lib/evilution/mutator/registry.rb +9 -1
  24. data/lib/evilution/parallel/pool.rb +7 -53
  25. data/lib/evilution/parallel/work_queue.rb +265 -0
  26. data/lib/evilution/reporter/cli.rb +21 -1
  27. data/lib/evilution/reporter/html.rb +69 -3
  28. data/lib/evilution/reporter/json.rb +23 -2
  29. data/lib/evilution/reporter/suggestion.rb +29 -1
  30. data/lib/evilution/result/mutation_result.rb +5 -2
  31. data/lib/evilution/result/summary.rb +19 -2
  32. data/lib/evilution/runner.rb +123 -12
  33. data/lib/evilution/session/diff.rb +85 -0
  34. data/lib/evilution/spec_resolver.rb +13 -1
  35. data/lib/evilution/version.rb +1 -1
  36. data/lib/evilution.rb +11 -0
  37. data/script/memory_check +22 -0
  38. metadata +14 -2
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,52 @@ 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 == :run
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
- $stdout.puts(Evilution::VERSION)
29
- 0
30
- when :init
31
- run_init
32
- when :mcp
33
- run_mcp
34
- when :session_list
35
- run_session_list
36
- when :session_show
37
- run_session_show
38
- when :session_gc
39
- run_session_gc
40
- when :session_error
41
- warn("Error: #{@session_error}")
42
- 2
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 expand_spec_dir(dir)
57
+ unless File.directory?(dir)
58
+ warn("Error: #{dir} is not a directory")
59
+ return
60
+ end
61
+
62
+ specs = Dir.glob(File.join(dir, "**/*_spec.rb"))
63
+ @options[:spec_files] = Array(@options[:spec_files]) + specs
64
+ end
65
+
66
+ def run_subcommand_error(message)
67
+ warn("Error: #{message}")
68
+ 2
69
+ end
70
+
50
71
  def extract_command(argv)
51
72
  case argv.first
52
73
  when "version"
@@ -61,6 +82,18 @@ class Evilution::CLI
61
82
  when "session"
62
83
  argv.shift
63
84
  extract_session_subcommand(argv)
85
+ when "subjects"
86
+ @command = :subjects
87
+ argv.shift
88
+ when "tests"
89
+ argv.shift
90
+ extract_tests_subcommand(argv)
91
+ when "environment"
92
+ argv.shift
93
+ extract_environment_subcommand(argv)
94
+ when "util"
95
+ argv.shift
96
+ extract_util_subcommand(argv)
64
97
  when "run"
65
98
  argv.shift
66
99
  end
@@ -76,15 +109,66 @@ class Evilution::CLI
76
109
  when "show"
77
110
  @command = :session_show
78
111
  argv.shift
112
+ when "diff"
113
+ @command = :session_diff
114
+ argv.shift
79
115
  when "gc"
80
116
  @command = :session_gc
81
117
  argv.shift
82
118
  when nil
83
119
  @command = :session_error
84
- @session_error = "Missing session subcommand. Available subcommands: list, show, gc"
120
+ @session_error = "Missing session subcommand. Available subcommands: list, show, diff, gc"
85
121
  else
86
122
  @command = :session_error
87
- @session_error = "Unknown session subcommand: #{subcommand}. Available subcommands: list, show, gc"
123
+ @session_error = "Unknown session subcommand: #{subcommand}. Available subcommands: list, show, diff, gc"
124
+ argv.shift
125
+ end
126
+ end
127
+
128
+ def extract_environment_subcommand(argv)
129
+ subcommand = argv.first
130
+ case subcommand
131
+ when "show"
132
+ @command = :environment_show
133
+ argv.shift
134
+ when nil
135
+ @command = :environment_error
136
+ @environment_error = "Missing environment subcommand. Available subcommands: show"
137
+ else
138
+ @command = :environment_error
139
+ @environment_error = "Unknown environment subcommand: #{subcommand}. Available subcommands: show"
140
+ argv.shift
141
+ end
142
+ end
143
+
144
+ def extract_tests_subcommand(argv)
145
+ subcommand = argv.first
146
+ case subcommand
147
+ when "list"
148
+ @command = :tests_list
149
+ argv.shift
150
+ when nil
151
+ @command = :tests_error
152
+ @tests_error = "Missing tests subcommand. Available subcommands: list"
153
+ else
154
+ @command = :tests_error
155
+ @tests_error = "Unknown tests subcommand: #{subcommand}. Available subcommands: list"
156
+ argv.shift
157
+ end
158
+ end
159
+
160
+ def extract_util_subcommand(argv)
161
+ subcommand = argv.first
162
+ case subcommand
163
+ when "mutation"
164
+ @command = :util_mutation
165
+ argv.shift
166
+ when nil
167
+ @command = :util_error
168
+ @util_error = "Missing util subcommand. Available subcommands: mutation"
169
+ else
170
+ @command = :util_error
171
+ @util_error = "Unknown util subcommand: #{subcommand}. Available subcommands: mutation"
88
172
  argv.shift
89
173
  end
90
174
  end
@@ -128,7 +212,8 @@ class Evilution::CLI
128
212
  opts.separator ""
129
213
  opts.separator "Line-range targeting: lib/foo.rb:15-30, lib/foo.rb:15, lib/foo.rb:15-"
130
214
  opts.separator ""
131
- opts.separator "Commands: run (default), init, session {list,show,gc}, mcp, version"
215
+ opts.separator "Commands: run (default), init, session {list,show,diff,gc}, subjects, tests {list},"
216
+ opts.separator " util {mutation}, environment {show}, mcp, version"
132
217
  opts.separator ""
133
218
  opts.separator "Options:"
134
219
  end
@@ -149,6 +234,7 @@ class Evilution::CLI
149
234
  def add_filter_options(opts)
150
235
  opts.on("--min-score FLOAT", Float, "Minimum mutation score to pass") { |s| @options[:min_score] = s }
151
236
  opts.on("--spec FILES", Array, "Spec files to run (comma-separated)") { |f| @options[:spec_files] = f }
237
+ opts.on("--spec-dir DIR", "Include all specs in DIR") { |d| expand_spec_dir(d) }
152
238
  opts.on("--target EXPR",
153
239
  "Filter: method (Foo#bar), type (Foo#/Foo.), namespace (Foo*),",
154
240
  "class (Foo), glob (source:**/*.rb), hierarchy (descendants:Foo)") do |m|
@@ -165,7 +251,14 @@ class Evilution::CLI
165
251
  opts.on("--stdin", "Read target file paths from stdin (one per line)") { @options[:stdin] = true }
166
252
  opts.on("--suggest-tests", "Generate concrete RSpec test code in suggestions") { @options[:suggest_tests] = true }
167
253
  opts.on("--no-progress", "Disable progress bar") { @options[:progress] = false }
254
+ add_extra_flag_options(opts)
255
+ end
256
+
257
+ def add_extra_flag_options(opts)
258
+ opts.on("--show-disabled", "Report mutations skipped by # evilution:disable") { @options[:show_disabled] = true }
259
+ opts.on("--baseline-session PATH", "Compare against a baseline session in HTML report") { |p| @options[:baseline_session] = p }
168
260
  opts.on("--save-session", "Save session results to .evilution/results/") { @options[:save_session] = true }
261
+ opts.on("-e", "--eval CODE", "Evaluate code snippet (for util mutation)") { |c| @options[:eval] = c }
169
262
  opts.on("-v", "--verbose", "Verbose output") { @options[:verbose] = true }
170
263
  opts.on("-q", "--quiet", "Suppress output") { @options[:quiet] = true }
171
264
  end
@@ -191,6 +284,151 @@ class Evilution::CLI
191
284
  0
192
285
  end
193
286
 
287
+ def run_subjects
288
+ raise Evilution::ConfigError, @stdin_error if @stdin_error
289
+
290
+ config = Evilution::Config.new(target_files: @files, line_ranges: @line_ranges, **@options)
291
+ runner = Evilution::Runner.new(config: config)
292
+ subjects = runner.parse_and_filter_subjects
293
+
294
+ if subjects.empty?
295
+ $stdout.puts("No subjects found")
296
+ return 0
297
+ end
298
+
299
+ registry = Evilution::Mutator::Registry.default
300
+ filter = build_subject_filter(config)
301
+ total_mutations = 0
302
+
303
+ subjects.each do |subj|
304
+ count = registry.mutations_for(subj, filter: filter).length
305
+ total_mutations += count
306
+ label = count == 1 ? "1 mutation" : "#{count} mutations"
307
+ $stdout.puts(" #{subj.name} #{subj.file_path}:#{subj.line_number} (#{label})")
308
+ ensure
309
+ subj.release_node!
310
+ end
311
+
312
+ $stdout.puts("")
313
+ $stdout.puts("#{subjects.length} subjects, #{total_mutations} mutations")
314
+ 0
315
+ rescue Evilution::Error => e
316
+ warn("Error: #{e.message}")
317
+ 2
318
+ end
319
+
320
+ def build_subject_filter(config)
321
+ return nil if config.ignore_patterns.empty?
322
+
323
+ require_relative "ast/pattern/filter"
324
+ Evilution::AST::Pattern::Filter.new(config.ignore_patterns)
325
+ end
326
+
327
+ def run_tests_list
328
+ config = Evilution::Config.new(target_files: @files, line_ranges: @line_ranges, **@options)
329
+
330
+ if config.spec_files.any?
331
+ print_explicit_spec_files(config.spec_files)
332
+ return 0
333
+ end
334
+
335
+ source_files = resolve_source_files(config)
336
+ if source_files.empty?
337
+ $stdout.puts("No source files found")
338
+ return 0
339
+ end
340
+
341
+ resolver = Evilution::SpecResolver.new
342
+ print_resolved_specs(source_files, resolver)
343
+ 0
344
+ rescue Evilution::Error => e
345
+ warn("Error: #{e.message}")
346
+ 2
347
+ end
348
+
349
+ def resolve_source_files(config)
350
+ return config.target_files unless config.target_files.empty?
351
+
352
+ Evilution::Git::ChangedFiles.new.call
353
+ rescue Evilution::Error
354
+ []
355
+ end
356
+
357
+ def print_explicit_spec_files(spec_files)
358
+ spec_files.each { |f| $stdout.puts(" #{f}") }
359
+ label = spec_files.length == 1 ? "1 spec file" : "#{spec_files.length} spec files"
360
+ $stdout.puts("")
361
+ $stdout.puts(label)
362
+ end
363
+
364
+ def print_resolved_specs(source_files, resolver)
365
+ unique_specs = []
366
+ source_files.each do |source|
367
+ spec = resolver.call(source)
368
+ if spec
369
+ unique_specs << spec
370
+ $stdout.puts(" #{spec} (#{source})")
371
+ else
372
+ $stdout.puts(" #{source} (no spec found)")
373
+ end
374
+ end
375
+
376
+ unique_specs.uniq!
377
+ $stdout.puts("")
378
+ spec_label = unique_specs.length == 1 ? "1 spec file" : "#{unique_specs.length} spec files"
379
+ $stdout.puts("#{source_files.length} source files, #{spec_label}")
380
+ end
381
+
382
+ def run_environment_show
383
+ config = Evilution::Config.new(**@options)
384
+ $stdout.puts(format_environment(config))
385
+ 0
386
+ rescue Evilution::ConfigError => e
387
+ warn("Error: #{e.message}")
388
+ 2
389
+ end
390
+
391
+ def format_environment(config)
392
+ config_file = Evilution::Config::CONFIG_FILES.find { |path| File.exist?(path) }
393
+ lines = environment_header(config_file)
394
+ lines.concat(environment_settings(config))
395
+ lines.join("\n")
396
+ end
397
+
398
+ def environment_header(config_file)
399
+ [
400
+ "Evilution Environment",
401
+ ("=" * 30),
402
+ "",
403
+ "evilution: #{Evilution::VERSION}",
404
+ "ruby: #{RUBY_VERSION}",
405
+ "config_file: #{config_file || "(none)"}",
406
+ "",
407
+ "Settings:"
408
+ ]
409
+ end
410
+
411
+ def environment_settings(config)
412
+ [
413
+ " timeout: #{config.timeout}",
414
+ " format: #{config.format}",
415
+ " integration: #{config.integration}",
416
+ " jobs: #{config.jobs}",
417
+ " isolation: #{config.isolation}",
418
+ " baseline: #{config.baseline}",
419
+ " incremental: #{config.incremental}",
420
+ " verbose: #{config.verbose}",
421
+ " quiet: #{config.quiet}",
422
+ " progress: #{config.progress}",
423
+ " fail_fast: #{config.fail_fast || "(disabled)"}",
424
+ " min_score: #{config.min_score}",
425
+ " suggest_tests: #{config.suggest_tests}",
426
+ " save_session: #{config.save_session}",
427
+ " target: #{config.target || "(all files)"}",
428
+ " ignore_patterns: #{config.ignore_patterns.empty? ? "(none)" : config.ignore_patterns.inspect}"
429
+ ]
430
+ end
431
+
194
432
  def run_mcp
195
433
  require_relative "mcp/server"
196
434
  server = Evilution::MCP::Server.build
@@ -240,6 +478,79 @@ class Evilution::CLI
240
478
  end
241
479
  end
242
480
 
481
+ def run_util_mutation
482
+ source, file_path = resolve_util_mutation_source
483
+ subjects = parse_source_to_subjects(source, file_path)
484
+ registry = Evilution::Mutator::Registry.default
485
+ mutations = subjects.flat_map { |s| registry.mutations_for(s) }
486
+
487
+ if mutations.empty?
488
+ $stdout.puts("No mutations generated")
489
+ return 0
490
+ end
491
+
492
+ if @options[:format] == :json
493
+ print_util_mutations_json(mutations)
494
+ else
495
+ print_util_mutations_text(mutations)
496
+ end
497
+
498
+ 0
499
+ rescue Evilution::Error => e
500
+ warn("Error: #{e.message}")
501
+ 2
502
+ ensure
503
+ @util_tmpfile&.close!
504
+ end
505
+
506
+ def resolve_util_mutation_source
507
+ if @options[:eval]
508
+ tmpfile = Tempfile.new(["evilution_eval", ".rb"])
509
+ tmpfile.write(@options[:eval])
510
+ tmpfile.flush
511
+ @util_tmpfile = tmpfile
512
+ [@options[:eval], tmpfile.path]
513
+ elsif @files.first
514
+ path = @files.first
515
+ raise Evilution::Error, "file not found: #{path}" unless File.exist?(path)
516
+
517
+ begin
518
+ [File.read(path), path]
519
+ rescue SystemCallError => e
520
+ raise Evilution::Error, e.message
521
+ end
522
+ else
523
+ raise Evilution::Error, "source required: use -e 'code' or provide a file path"
524
+ end
525
+ end
526
+
527
+ def parse_source_to_subjects(source, file_label)
528
+ result = Prism.parse(source)
529
+ raise Evilution::Error, "failed to parse source: #{result.errors.map(&:message).join(", ")}" if result.failure?
530
+
531
+ finder = Evilution::AST::SubjectFinder.new(source, file_label)
532
+ finder.visit(result.value)
533
+ finder.subjects
534
+ end
535
+
536
+ def print_util_mutations_text(mutations)
537
+ mutations.each_with_index do |m, i|
538
+ $stdout.puts("#{i + 1}. #{m.operator_name} — #{m.subject.name} (line #{m.line})")
539
+ m.diff.each_line { |line| $stdout.puts(" #{line}") }
540
+ $stdout.puts("")
541
+ end
542
+ label = mutations.length == 1 ? "1 mutation" : "#{mutations.length} mutations"
543
+ $stdout.puts(label)
544
+ end
545
+
546
+ def print_util_mutations_json(mutations)
547
+ data = mutations.map do |m|
548
+ { operator: m.operator_name, subject: m.subject.name,
549
+ file: m.file_path, line: m.line, diff: m.diff }
550
+ end
551
+ $stdout.puts(JSON.pretty_generate(data))
552
+ end
553
+
243
554
  def run_session_list
244
555
  require_relative "session/store"
245
556
 
@@ -312,6 +623,70 @@ class Evilution::CLI
312
623
  2
313
624
  end
314
625
 
626
+ def run_session_diff
627
+ require_relative "session/store"
628
+ require_relative "session/diff"
629
+
630
+ raise Evilution::ConfigError, "two session file paths required" unless @files.length == 2
631
+
632
+ store = Evilution::Session::Store.new
633
+ base_data = store.load(@files[0])
634
+ head_data = store.load(@files[1])
635
+
636
+ diff = Evilution::Session::Diff.new
637
+ result = diff.call(base_data, head_data)
638
+
639
+ if @options[:format] == :json
640
+ $stdout.puts(JSON.pretty_generate(result.to_h))
641
+ else
642
+ print_session_diff(result)
643
+ end
644
+
645
+ 0
646
+ rescue Evilution::Error, SystemCallError => e
647
+ warn("Error: #{e.message}")
648
+ 2
649
+ rescue ::JSON::ParserError => e
650
+ warn("Error: invalid session file: #{e.message}")
651
+ 2
652
+ end
653
+
654
+ def print_session_diff(result)
655
+ print_diff_summary(result.summary)
656
+ print_diff_section("Fixed (survived \u2192 killed)", result.fixed, "\e[32m")
657
+ print_diff_section("New survivors (killed \u2192 survived)", result.new_survivors, "\e[31m")
658
+ print_diff_section("Persistent survivors", result.persistent, "\e[33m")
659
+
660
+ return unless result.fixed.empty? && result.new_survivors.empty? && result.persistent.empty?
661
+
662
+ $stdout.puts("")
663
+ $stdout.puts("No mutation changes between sessions")
664
+ end
665
+
666
+ def print_diff_summary(summary)
667
+ delta_str = format("%+.2f%%", summary.score_delta * 100)
668
+ $stdout.puts("Session Diff")
669
+ $stdout.puts("=" * 40)
670
+ $stdout.puts(format("Base score: %<score>6.2f%% (%<killed>d/%<total>d killed)",
671
+ score: summary.base_score * 100, killed: summary.base_killed,
672
+ total: summary.base_total))
673
+ $stdout.puts(format("Head score: %<score>6.2f%% (%<killed>d/%<total>d killed)",
674
+ score: summary.head_score * 100, killed: summary.head_killed,
675
+ total: summary.head_total))
676
+ $stdout.puts("Delta: #{delta_str}")
677
+ end
678
+
679
+ def print_diff_section(title, mutations, color)
680
+ return if mutations.empty?
681
+
682
+ reset = "\e[0m"
683
+ $stdout.puts("")
684
+ $stdout.puts("#{color}#{title} (#{mutations.length}):#{reset}")
685
+ mutations.each do |m|
686
+ $stdout.puts(" #{m["operator"]} — #{m["file"]}:#{m["line"]} #{m["subject"]}")
687
+ end
688
+ end
689
+
315
690
  def run_session_gc
316
691
  require_relative "session/store"
317
692
 
@@ -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