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.
@@ -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 == :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 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}, 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"
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
 
@@ -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