evilution 0.24.0 → 0.26.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 (88) hide show
  1. checksums.yaml +4 -4
  2. data/.beads/interactions.jsonl +210 -0
  3. data/.claude/prompts/architect.md +14 -1
  4. data/.claude/skills/create-issue/SKILL.md +55 -0
  5. data/CHANGELOG.md +51 -0
  6. data/README.md +80 -4
  7. data/exe/evil +6 -0
  8. data/lib/evilution/ast/constant_names.rb +34 -0
  9. data/lib/evilution/ast/source_surgeon.rb +15 -1
  10. data/lib/evilution/cli/commands/compare.rb +68 -0
  11. data/lib/evilution/cli/parser/command_extractor.rb +2 -1
  12. data/lib/evilution/cli/parser/options_builder.rb +21 -1
  13. data/lib/evilution/cli/printers/compare.rb +159 -0
  14. data/lib/evilution/cli.rb +1 -0
  15. data/lib/evilution/compare/categorizer.rb +109 -0
  16. data/lib/evilution/compare/detector.rb +21 -0
  17. data/lib/evilution/compare/fingerprint.rb +83 -0
  18. data/lib/evilution/compare/invalid_input.rb +12 -0
  19. data/lib/evilution/compare/normalizer.rb +106 -0
  20. data/lib/evilution/compare/record.rb +16 -0
  21. data/lib/evilution/compare.rb +6 -0
  22. data/lib/evilution/config.rb +165 -3
  23. data/lib/evilution/example_filter.rb +143 -0
  24. data/lib/evilution/integration/base.rb +4 -155
  25. data/lib/evilution/integration/crash_detector.rb +5 -2
  26. data/lib/evilution/integration/loading/concern_state_cleaner.rb +49 -0
  27. data/lib/evilution/integration/loading/constant_pinner.rb +24 -0
  28. data/lib/evilution/integration/loading/mutation_applier.rb +52 -0
  29. data/lib/evilution/integration/loading/redefinition_recovery.rb +54 -0
  30. data/lib/evilution/integration/loading/source_evaluator.rb +15 -0
  31. data/lib/evilution/integration/loading/syntax_validator.rb +19 -0
  32. data/lib/evilution/integration/loading.rb +6 -0
  33. data/lib/evilution/integration/minitest.rb +10 -5
  34. data/lib/evilution/integration/minitest_crash_detector.rb +5 -2
  35. data/lib/evilution/integration/rspec.rb +82 -7
  36. data/lib/evilution/isolation/fork.rb +25 -0
  37. data/lib/evilution/load_path/subpath_resolver.rb +25 -0
  38. data/lib/evilution/load_path.rb +4 -0
  39. data/lib/evilution/mcp/info_tool.rb +77 -5
  40. data/lib/evilution/mcp/mutate_tool/config_builder.rb +20 -0
  41. data/lib/evilution/mcp/mutate_tool/error_payload.rb +17 -0
  42. data/lib/evilution/mcp/mutate_tool/option_parser.rb +54 -0
  43. data/lib/evilution/mcp/mutate_tool/progress_streamer.rb +37 -0
  44. data/lib/evilution/mcp/mutate_tool/report_trimmer.rb +31 -0
  45. data/lib/evilution/mcp/mutate_tool/survived_enricher.rb +52 -0
  46. data/lib/evilution/mcp/mutate_tool.rb +34 -186
  47. data/lib/evilution/mutation.rb +43 -3
  48. data/lib/evilution/mutator/base.rb +39 -1
  49. data/lib/evilution/mutator/operator/argument_nil_substitution.rb +5 -1
  50. data/lib/evilution/mutator/operator/argument_removal.rb +5 -1
  51. data/lib/evilution/parallel/work_queue.rb +149 -31
  52. data/lib/evilution/parallel_db_warning.rb +68 -0
  53. data/lib/evilution/reporter/cli.rb +37 -11
  54. data/lib/evilution/reporter/html/assets/style.css +17 -0
  55. data/lib/evilution/reporter/html/sections/file_section.rb +15 -0
  56. data/lib/evilution/reporter/html/sections/neutral_details.rb +25 -0
  57. data/lib/evilution/reporter/html/sections/unparseable_details.rb +25 -0
  58. data/lib/evilution/reporter/html/sections/unresolved_details.rb +25 -0
  59. data/lib/evilution/reporter/html/templates/file_section.html.erb +3 -0
  60. data/lib/evilution/reporter/html/templates/neutral_details.html.erb +14 -0
  61. data/lib/evilution/reporter/html/templates/summary_cards.html.erb +3 -0
  62. data/lib/evilution/reporter/html/templates/unparseable_details.html.erb +11 -0
  63. data/lib/evilution/reporter/html/templates/unresolved_details.html.erb +11 -0
  64. data/lib/evilution/reporter/json.rb +8 -2
  65. data/lib/evilution/reporter/suggestion/diff_helpers.rb +28 -0
  66. data/lib/evilution/reporter/suggestion/registry.rb +64 -0
  67. data/lib/evilution/reporter/suggestion/templates/generic.rb +55 -0
  68. data/lib/evilution/reporter/suggestion/templates/minitest.rb +659 -0
  69. data/lib/evilution/reporter/suggestion/templates/rspec.rb +613 -0
  70. data/lib/evilution/reporter/suggestion.rb +8 -1327
  71. data/lib/evilution/result/mutation_result.rb +5 -1
  72. data/lib/evilution/result/summary.rb +13 -1
  73. data/lib/evilution/runner/baseline_runner.rb +23 -2
  74. data/lib/evilution/runner/isolation_resolver.rb +12 -1
  75. data/lib/evilution/runner/mutation_executor.rb +83 -13
  76. data/lib/evilution/runner/subject_pipeline.rb +18 -8
  77. data/lib/evilution/runner.rb +6 -0
  78. data/lib/evilution/source_ast_cache.rb +39 -0
  79. data/lib/evilution/spec_ast_cache.rb +166 -0
  80. data/lib/evilution/spec_resolver.rb +6 -1
  81. data/lib/evilution/spec_selector.rb +39 -0
  82. data/lib/evilution/temp_dir_tracker.rb +23 -3
  83. data/lib/evilution/version.rb +1 -1
  84. data/script/memory_check +7 -5
  85. metadata +46 -5
  86. data/lib/evilution/mcp/session_diff_tool.rb +0 -63
  87. data/lib/evilution/mcp/session_list_tool.rb +0 -50
  88. data/lib/evilution/mcp/session_show_tool.rb +0 -57
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require_relative "../compare"
5
+
6
+ module Evilution::Compare::Fingerprint
7
+ module_function
8
+
9
+ def extract_from_evilution_diff(diff)
10
+ minus = []
11
+ plus = []
12
+ diff.to_s.each_line do |line|
13
+ line = line.chomp
14
+ if line.start_with?("- ")
15
+ minus << line[2..]
16
+ elsif line.start_with?("+ ")
17
+ plus << line[2..]
18
+ end
19
+ end
20
+ { minus: minus, plus: plus }
21
+ end
22
+
23
+ def extract_from_mutant_diff(diff)
24
+ minus = []
25
+ plus = []
26
+ diff.to_s.each_line do |line|
27
+ line = line.chomp
28
+ next if line.start_with?("---", "+++", "@@")
29
+
30
+ if line.start_with?("-")
31
+ minus << line[1..]
32
+ elsif line.start_with?("+")
33
+ plus << line[1..]
34
+ end
35
+ end
36
+ { minus: minus, plus: plus }
37
+ end
38
+
39
+ # v1 limitation: only " and ' literals are preserved. Regex literals (/.../),
40
+ # heredocs, %w[], %q{} forms are treated as ordinary code — whitespace runs
41
+ # inside them collapse. A mutation touching whitespace inside a regex may
42
+ # false-match across tools.
43
+ # rubocop:disable Metrics/PerceivedComplexity, Style/MultipleComparison
44
+ def normalize_line(line)
45
+ out = +""
46
+ i = 0
47
+ in_literal = nil
48
+ last_was_space = false
49
+ chars = line.chars
50
+ while i < chars.length
51
+ ch = chars[i]
52
+ if in_literal
53
+ out << ch
54
+ if ch == "\\" && i + 1 < chars.length
55
+ out << chars[i + 1]
56
+ i += 2
57
+ next
58
+ end
59
+ in_literal = nil if ch == in_literal
60
+ elsif ch == '"' || ch == "'"
61
+ in_literal = ch
62
+ out << ch
63
+ last_was_space = false
64
+ elsif ch == " " || ch == "\t"
65
+ out << " " unless last_was_space || out.empty?
66
+ last_was_space = true
67
+ else
68
+ out << ch
69
+ last_was_space = false
70
+ end
71
+ i += 1
72
+ end
73
+ out.rstrip
74
+ end
75
+ # rubocop:enable Metrics/PerceivedComplexity, Style/MultipleComparison
76
+
77
+ def compute(file_path:, line:, body:)
78
+ minus = body[:minus].map { |l| normalize_line(l) }
79
+ plus = body[:plus].map { |l| normalize_line(l) }
80
+ payload = [file_path, line.to_s, minus.join("\n"), plus.join("\n")].join("\x00")
81
+ Digest::SHA256.hexdigest(payload)
82
+ end
83
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../compare"
4
+
5
+ class Evilution::Compare::InvalidInput < StandardError
6
+ attr_reader :index
7
+
8
+ def initialize(message, index: nil)
9
+ super(message)
10
+ @index = index
11
+ end
12
+ end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../compare"
4
+ require_relative "record"
5
+ require_relative "fingerprint"
6
+
7
+ class Evilution::Compare::Normalizer
8
+ EVILUTION_BUCKETS = %w[killed survived timed_out errors neutral equivalent unresolved unparseable].freeze
9
+ EVILUTION_STATUS_MAP = {
10
+ "killed" => :killed,
11
+ "survived" => :survived,
12
+ "timeout" => :timeout,
13
+ "error" => :error,
14
+ "neutral" => :neutral,
15
+ "equivalent" => :equivalent,
16
+ "unresolved" => :unresolved,
17
+ "unparseable" => :unparseable
18
+ }.freeze
19
+
20
+ def from_evilution(json)
21
+ records = []
22
+ EVILUTION_BUCKETS.each do |bucket|
23
+ Array(json[bucket]).each do |entry|
24
+ records << build_evilution_record(entry, index: records.size)
25
+ end
26
+ end
27
+ records
28
+ end
29
+
30
+ def from_mutant(json)
31
+ records = []
32
+ Array(json["subject_results"]).each do |subject|
33
+ source_path = subject["source_path"] or
34
+ raise Evilution::Compare::InvalidInput.new("missing 'source_path' on subject", index: records.size)
35
+ Array(subject["coverage_results"]).each do |cov|
36
+ records << build_mutant_record(cov, source_path: source_path, index: records.size)
37
+ end
38
+ end
39
+ records
40
+ end
41
+
42
+ private
43
+
44
+ def build_evilution_record(entry, index:)
45
+ file_path = entry["file"] or raise Evilution::Compare::InvalidInput.new("missing 'file' in record", index: index)
46
+ line = entry["line"] or raise Evilution::Compare::InvalidInput.new("missing 'line' in record", index: index)
47
+ diff = entry["diff"].to_s
48
+ status = EVILUTION_STATUS_MAP[entry["status"]] ||
49
+ raise(Evilution::Compare::InvalidInput.new("unknown status #{entry["status"].inspect}", index: index))
50
+ body = Evilution::Compare::Fingerprint.extract_from_evilution_diff(diff)
51
+ Evilution::Compare::Record.new(
52
+ source: :evilution,
53
+ file_path: file_path,
54
+ line: line,
55
+ status: status,
56
+ fingerprint: Evilution::Compare::Fingerprint.compute(file_path: file_path, line: line, body: body),
57
+ operator: entry["operator"],
58
+ diff_body: diff,
59
+ raw: entry
60
+ )
61
+ end
62
+
63
+ def build_mutant_record(cov, source_path:, index:)
64
+ mr = cov["mutation_result"] or raise Evilution::Compare::InvalidInput.new("missing mutation_result", index: index)
65
+ cr = cov["criteria_result"] or raise Evilution::Compare::InvalidInput.new("missing criteria_result", index: index)
66
+ ident = mr["mutation_identification"].to_s
67
+ line = parse_mutant_line(ident, index)
68
+ diff = mr["mutation_diff"].to_s
69
+ status = derive_mutant_status(mr, cr, index)
70
+ body = Evilution::Compare::Fingerprint.extract_from_mutant_diff(diff)
71
+ Evilution::Compare::Record.new(
72
+ source: :mutant,
73
+ file_path: source_path,
74
+ line: line,
75
+ status: status,
76
+ fingerprint: Evilution::Compare::Fingerprint.compute(file_path: source_path, line: line, body: body),
77
+ operator: nil,
78
+ diff_body: diff,
79
+ raw: { "mutation_result" => mr, "criteria_result" => cr, "source_path" => source_path }
80
+ )
81
+ end
82
+
83
+ # mutant_identification format: <type>:<subject>:<path>:<line>:<sha1[0..4]>.
84
+ # Line is always the second-to-last colon-separated field. Works with paths
85
+ # containing colons (e.g. Windows drive letters) because we index from the
86
+ # right, but a malformed path-less identification will raise InvalidInput.
87
+ def parse_mutant_line(ident, index)
88
+ parts = ident.split(":")
89
+ raise Evilution::Compare::InvalidInput.new("cannot parse line from #{ident.inspect}", index: index) if parts.length < 5
90
+
91
+ Integer(parts[-2])
92
+ rescue ArgumentError
93
+ raise Evilution::Compare::InvalidInput.new("non-integer line in #{ident.inspect}", index: index)
94
+ end
95
+
96
+ def derive_mutant_status(mr, cr, index)
97
+ type = mr["mutation_type"]
98
+ return :neutral if %w[neutral noop].include?(type)
99
+ return :timeout if cr["timeout"]
100
+ return :error if cr["process_abort"]
101
+ return :killed if cr["test_result"]
102
+ return :survived if type == "evil"
103
+
104
+ raise Evilution::Compare::InvalidInput.new("unknown mutant result shape: type=#{type.inspect} cr=#{cr.inspect}", index: index)
105
+ end
106
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../compare"
4
+
5
+ module Evilution::Compare
6
+ Record = Data.define(
7
+ :source,
8
+ :file_path,
9
+ :line,
10
+ :status,
11
+ :fingerprint,
12
+ :operator,
13
+ :diff_body,
14
+ :raw
15
+ )
16
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Evilution::Compare
4
+ end
5
+
6
+ require_relative "compare/invalid_input"
@@ -1,6 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "yaml"
4
+ require_relative "spec_resolver"
5
+ require_relative "spec_selector"
4
6
 
5
7
  class Evilution::Config
6
8
  CONFIG_FILES = %w[.evilution.yml config/evilution.yml].freeze
@@ -29,20 +31,31 @@ class Evilution::Config
29
31
  skip_heredoc_literals: false,
30
32
  related_specs_heuristic: false,
31
33
  fallback_to_full_suite: false,
32
- preload: nil
34
+ preload: nil,
35
+ spec_mappings: {},
36
+ spec_pattern: nil,
37
+ example_targeting: true,
38
+ example_targeting_fallback: :full_file,
39
+ example_targeting_cache: { max_files: 50, max_blocks: 10_000 }
33
40
  }.freeze
34
41
 
42
+ EXAMPLE_TARGETING_FALLBACKS = %i[full_file unresolved].freeze
43
+ private_constant :EXAMPLE_TARGETING_FALLBACKS
44
+
35
45
  attr_reader :target_files, :timeout, :format,
36
46
  :target, :min_score, :integration, :verbose, :quiet,
37
47
  :jobs, :fail_fast, :baseline, :isolation, :incremental, :suggest_tests,
38
48
  :progress, :save_session, :line_ranges, :spec_files, :hooks,
39
49
  :ignore_patterns, :show_disabled, :baseline_session,
40
50
  :skip_heredoc_literals, :related_specs_heuristic,
41
- :fallback_to_full_suite, :preload
51
+ :fallback_to_full_suite, :preload, :spec_mappings, :spec_pattern,
52
+ :example_targeting, :example_targeting_fallback, :example_targeting_cache,
53
+ :spec_selector
42
54
 
43
55
  def initialize(**options)
44
56
  file_options = options.delete(:skip_config_file) ? {} : load_config_file
45
- merged = DEFAULTS.merge(file_options).merge(options)
57
+ env_options = load_env_options
58
+ merged = DEFAULTS.merge(file_options).merge(env_options).merge(options)
46
59
  assign_attributes(merged)
47
60
  freeze
48
61
  end
@@ -103,6 +116,10 @@ class Evilution::Config
103
116
  related_specs_heuristic
104
117
  end
105
118
 
119
+ def example_targeting?
120
+ example_targeting
121
+ end
122
+
106
123
  def fallback_to_full_suite?
107
124
  fallback_to_full_suite
108
125
  end
@@ -177,6 +194,23 @@ class Evilution::Config
177
194
  # worker_process_start: config/evilution_hooks/worker_start.rb
178
195
  # mutation_insert_pre: config/evilution_hooks/mutation_pre.rb
179
196
 
197
+ # Per-mutation example targeting (default: true). When enabled, Evilution
198
+ # parses resolved spec files and restricts each mutation run to examples
199
+ # whose bodies reference the mutated method/class token. Set to false
200
+ # to run every example in the resolved spec files. You can also disable
201
+ # without editing the file by exporting EV_DISABLE_EXAMPLE_TARGETING=1.
202
+ # example_targeting: true
203
+
204
+ # Behavior when targeting finds no matching example (default: full_file).
205
+ # full_file - run every example in the resolved spec files
206
+ # unresolved - mark the mutation :unresolved and skip
207
+ # example_targeting_fallback: full_file
208
+
209
+ # LRU cache bounds for the spec AST parser that powers example targeting.
210
+ # example_targeting_cache:
211
+ # max_files: 50
212
+ # max_blocks: 10000
213
+
180
214
  # AST patterns to skip during mutation generation (default: [])
181
215
  # See docs/ast_pattern_syntax.md for pattern syntax
182
216
  # ignore_patterns:
@@ -225,6 +259,88 @@ class Evilution::Config
225
259
  @fallback_to_full_suite = merged[:fallback_to_full_suite]
226
260
  @hooks = validate_hooks(merged[:hooks])
227
261
  @preload = validate_preload(merged[:preload])
262
+ @spec_mappings = validate_spec_mappings(merged[:spec_mappings])
263
+ @spec_pattern = validate_spec_pattern(merged[:spec_pattern])
264
+ assign_example_targeting(merged)
265
+ @spec_selector = build_spec_selector
266
+ end
267
+
268
+ def assign_example_targeting(merged)
269
+ @example_targeting = merged[:example_targeting] ? true : false
270
+ @example_targeting_fallback = validate_example_targeting_fallback(merged[:example_targeting_fallback])
271
+ @example_targeting_cache = validate_example_targeting_cache(merged[:example_targeting_cache])
272
+ end
273
+
274
+ def build_spec_selector
275
+ Evilution::SpecSelector.new(
276
+ spec_files: @spec_files,
277
+ spec_mappings: @spec_mappings,
278
+ spec_pattern: @spec_pattern,
279
+ spec_resolver: build_spec_resolver
280
+ )
281
+ end
282
+
283
+ def build_spec_resolver
284
+ case @integration
285
+ when :minitest
286
+ Evilution::SpecResolver.new(test_dir: "test", test_suffix: "_test.rb", request_dir: "integration")
287
+ else
288
+ Evilution::SpecResolver.new
289
+ end
290
+ end
291
+
292
+ def validate_spec_mappings(value)
293
+ return {} if value.nil?
294
+
295
+ raise Evilution::ConfigError, "spec_mappings must be a Hash, got #{value.class}" unless value.is_a?(Hash)
296
+
297
+ normalized = value.each_with_object({}) do |(source, specs), acc|
298
+ key = normalize_spec_mappings_key(source)
299
+ acc[key] = normalize_spec_mappings_value(key, specs)
300
+ end
301
+
302
+ warn_missing_spec_mappings(normalized)
303
+ normalized
304
+ end
305
+
306
+ def normalize_spec_mappings_key(source)
307
+ key = source.to_s
308
+ key = key.delete_prefix("#{Dir.pwd}/") if key.start_with?("/")
309
+ key.delete_prefix("./")
310
+ end
311
+
312
+ def normalize_spec_mappings_value(source, specs)
313
+ case specs
314
+ when String then [specs]
315
+ when Array
316
+ specs.each do |entry|
317
+ unless entry.is_a?(String)
318
+ raise Evilution::ConfigError,
319
+ "spec_mappings[#{source.inspect}] entries must be string paths, got #{entry.class}"
320
+ end
321
+ end
322
+ specs
323
+ else
324
+ raise Evilution::ConfigError,
325
+ "spec_mappings[#{source.inspect}] must be a string or array of strings, got #{specs.class}"
326
+ end
327
+ end
328
+
329
+ def warn_missing_spec_mappings(mappings)
330
+ mappings.each do |source, specs|
331
+ specs.each do |spec_path|
332
+ next if File.exist?(spec_path)
333
+
334
+ warn "[evilution] spec_mappings[#{source.inspect}]: #{spec_path} not found, skipping"
335
+ end
336
+ end
337
+ end
338
+
339
+ def validate_spec_pattern(value)
340
+ return nil if value.nil?
341
+ return value if value.is_a?(String)
342
+
343
+ raise Evilution::ConfigError, "spec_pattern must be nil or a String glob, got #{value.class}"
228
344
  end
229
345
 
230
346
  def validate_preload(value)
@@ -279,6 +395,52 @@ class Evilution::Config
279
395
  patterns
280
396
  end
281
397
 
398
+ def validate_example_targeting_fallback(value)
399
+ unless value.is_a?(String) || value.is_a?(Symbol)
400
+ raise Evilution::ConfigError,
401
+ "example_targeting_fallback must be full_file or unresolved, got #{value.inspect}"
402
+ end
403
+
404
+ sym = value.to_sym
405
+ unless EXAMPLE_TARGETING_FALLBACKS.include?(sym)
406
+ raise Evilution::ConfigError,
407
+ "example_targeting_fallback must be full_file or unresolved, got #{sym.inspect}"
408
+ end
409
+
410
+ sym
411
+ end
412
+
413
+ def validate_example_targeting_cache(value)
414
+ raise Evilution::ConfigError, "example_targeting_cache must be a Hash, got #{value.class}" unless value.is_a?(Hash)
415
+
416
+ normalized = value.each_with_object({}) do |(k, v), acc|
417
+ unless k.is_a?(String) || k.is_a?(Symbol)
418
+ raise Evilution::ConfigError,
419
+ "example_targeting_cache keys must be Strings or Symbols, got #{k.inspect}"
420
+ end
421
+ acc[k.to_sym] = v
422
+ end
423
+ merged = DEFAULTS[:example_targeting_cache].merge(normalized)
424
+ validate_positive_int!(merged, :max_files)
425
+ validate_positive_int!(merged, :max_blocks)
426
+ merged
427
+ end
428
+
429
+ def validate_positive_int!(cache, key)
430
+ v = cache[key]
431
+ return if v.is_a?(Integer) && v >= 1
432
+
433
+ raise Evilution::ConfigError,
434
+ "example_targeting_cache.#{key} must be a positive integer, got #{v.inspect}"
435
+ end
436
+
437
+ def load_env_options
438
+ opts = {}
439
+ val = ENV.fetch("EV_DISABLE_EXAMPLE_TARGETING", nil)
440
+ opts[:example_targeting] = false if val && !val.empty? && val != "0"
441
+ opts
442
+ end
443
+
282
444
  def validate_hooks(value)
283
445
  return {} if value.nil?
284
446
  raise Evilution::ConfigError, "hooks must be a mapping of event names to file paths, got #{value.class}" unless value.is_a?(Hash)
@@ -0,0 +1,143 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+ require_relative "../evilution"
5
+ require_relative "spec_ast_cache"
6
+
7
+ class Evilution::ExampleFilter
8
+ VALID_FALLBACKS = %i[full_file unresolved].freeze
9
+ private_constant :VALID_FALLBACKS
10
+
11
+ def initialize(cache:, fallback: :full_file, source_cache: nil)
12
+ raise ArgumentError, "invalid fallback: #{fallback.inspect}" unless VALID_FALLBACKS.include?(fallback)
13
+
14
+ @cache = cache
15
+ @fallback = fallback
16
+ @source_cache = source_cache
17
+ end
18
+
19
+ def call(mutation, spec_paths)
20
+ return fallback_result(spec_paths) if spec_paths.nil? || spec_paths.empty?
21
+
22
+ token = extract_token(mutation)
23
+ return fallback_result(spec_paths) unless token
24
+
25
+ locations = scan_specs(token, spec_paths)
26
+ return fallback_result(spec_paths) if locations.empty?
27
+
28
+ locations.sort
29
+ end
30
+
31
+ private
32
+
33
+ def fallback_result(spec_paths)
34
+ case @fallback
35
+ when :full_file then spec_paths
36
+ when :unresolved then nil
37
+ end
38
+ end
39
+
40
+ def extract_token(mutation)
41
+ result = if @source_cache.nil?
42
+ Prism.parse(mutation.original_source)
43
+ else
44
+ @source_cache.fetch(mutation.original_source)
45
+ end
46
+ return nil if result.failure?
47
+
48
+ finder = EnclosingNodeFinder.new(mutation.line)
49
+ finder.visit(result.value)
50
+ finder.token
51
+ end
52
+
53
+ def scan_specs(token, spec_paths)
54
+ pattern = /(?<!\w)#{Regexp.escape(token.downcase)}(?!\w)/
55
+ locations = []
56
+ spec_paths.each do |path|
57
+ blocks = @cache.fetch(path)
58
+ matches = blocks.select { |b| pattern.match?(b.body_text) }
59
+ innermost = filter_innermost(matches)
60
+ innermost.each { |b| locations << "#{path}:#{b.line}" }
61
+ end
62
+ locations.uniq
63
+ end
64
+
65
+ def filter_innermost(matches)
66
+ matches.reject do |outer|
67
+ matches.any? do |inner|
68
+ next false if inner.equal?(outer)
69
+
70
+ contained?(inner, outer)
71
+ end
72
+ end
73
+ end
74
+
75
+ def contained?(inner, outer)
76
+ inner.line >= outer.line && inner.end_line <= outer.end_line &&
77
+ !(inner.line == outer.line && inner.end_line == outer.end_line)
78
+ end
79
+
80
+ class EnclosingNodeFinder < Prism::Visitor
81
+ attr_reader :token
82
+
83
+ def initialize(target_line)
84
+ @target_line = target_line
85
+ @def_stack = []
86
+ @class_stack = []
87
+ @token = nil
88
+ @found = false
89
+ super()
90
+ end
91
+
92
+ def visit_def_node(node)
93
+ return if @found
94
+ return unless target_within?(node)
95
+
96
+ @def_stack.push(node.name.to_s)
97
+ capture_if_match(node)
98
+ super
99
+ @def_stack.pop
100
+ end
101
+
102
+ def visit_class_node(node)
103
+ return if @found
104
+ return unless target_within?(node)
105
+
106
+ @class_stack.push(unqualified_name(node.constant_path))
107
+ capture_if_match(node)
108
+ super
109
+ @class_stack.pop
110
+ end
111
+
112
+ def visit_module_node(node)
113
+ return if @found
114
+ return unless target_within?(node)
115
+
116
+ @class_stack.push(unqualified_name(node.constant_path))
117
+ capture_if_match(node)
118
+ super
119
+ @class_stack.pop
120
+ end
121
+
122
+ private
123
+
124
+ def capture_if_match(node)
125
+ return if @found
126
+ return unless target_within?(node)
127
+
128
+ @token = @def_stack.last || @class_stack.last
129
+ @found = true if @def_stack.any?
130
+ end
131
+
132
+ def target_within?(node)
133
+ loc = node.location
134
+ @target_line.between?(loc.start_line, loc.end_line)
135
+ end
136
+
137
+ def unqualified_name(constant_path)
138
+ raw = constant_path.respond_to?(:name) ? constant_path.name.to_s : constant_path.to_s
139
+ raw.split("::").last
140
+ end
141
+ end
142
+ private_constant :EnclosingNodeFinder
143
+ end