evilution 0.16.0 → 0.17.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 (39) hide show
  1. checksums.yaml +4 -4
  2. data/.beads/.migration-hint-ts +1 -1
  3. data/.beads/issues.jsonl +19 -18
  4. data/CHANGELOG.md +23 -0
  5. data/docs/ast_pattern_syntax.md +210 -0
  6. data/lib/evilution/ast/pattern/filter.rb +25 -0
  7. data/lib/evilution/ast/pattern/matcher.rb +107 -0
  8. data/lib/evilution/ast/pattern/parser.rb +185 -0
  9. data/lib/evilution/ast/pattern.rb +4 -0
  10. data/lib/evilution/ast/source_surgeon.rb +3 -3
  11. data/lib/evilution/cli.rb +13 -1
  12. data/lib/evilution/config.rb +35 -2
  13. data/lib/evilution/hooks/loader.rb +35 -0
  14. data/lib/evilution/hooks/registry.rb +60 -0
  15. data/lib/evilution/hooks.rb +58 -0
  16. data/lib/evilution/integration/base.rb +4 -0
  17. data/lib/evilution/integration/rspec.rb +6 -2
  18. data/lib/evilution/isolation/fork.rb +5 -0
  19. data/lib/evilution/mutator/base.rb +4 -1
  20. data/lib/evilution/mutator/operator/index_assignment_removal.rb +18 -0
  21. data/lib/evilution/mutator/operator/index_to_dig.rb +58 -0
  22. data/lib/evilution/mutator/operator/index_to_fetch.rb +30 -0
  23. data/lib/evilution/mutator/operator/mixin_removal.rb +2 -1
  24. data/lib/evilution/mutator/operator/pattern_matching_alternative.rb +46 -0
  25. data/lib/evilution/mutator/operator/pattern_matching_array.rb +97 -0
  26. data/lib/evilution/mutator/operator/pattern_matching_guard.rb +44 -0
  27. data/lib/evilution/mutator/operator/superclass_removal.rb +2 -1
  28. data/lib/evilution/mutator/registry.rb +9 -3
  29. data/lib/evilution/parallel/pool.rb +3 -1
  30. data/lib/evilution/reporter/cli.rb +1 -0
  31. data/lib/evilution/reporter/html.rb +7 -0
  32. data/lib/evilution/reporter/json.rb +1 -0
  33. data/lib/evilution/reporter/suggestion.rb +87 -1
  34. data/lib/evilution/result/summary.rb +3 -2
  35. data/lib/evilution/runner.rb +21 -9
  36. data/lib/evilution/session/store.rb +5 -2
  37. data/lib/evilution/version.rb +1 -1
  38. data/lib/evilution.rb +12 -0
  39. metadata +16 -2
@@ -0,0 +1,185 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "matcher"
4
+
5
+ class Evilution::AST::Pattern::Parser
6
+ def initialize(input)
7
+ @input = input.strip
8
+ @pos = 0
9
+ end
10
+
11
+ def parse
12
+ raise Evilution::ConfigError, "invalid pattern: empty string" if @input.empty?
13
+
14
+ result = parse_pattern
15
+ skip_whitespace
16
+ raise Evilution::ConfigError, "unexpected characters at position #{@pos}: #{@input[@pos..]}" unless @pos >= @input.length
17
+
18
+ result
19
+ end
20
+
21
+ private
22
+
23
+ def parse_pattern
24
+ skip_whitespace
25
+
26
+ if peek_string("**")
27
+ advance(2)
28
+ Evilution::AST::Pattern::DeepWildcardMatcher.new
29
+ elsif current_char == "_" && !identifier_continues?(1)
30
+ advance(1)
31
+ Evilution::AST::Pattern::AnyNodeMatcher.new
32
+ else
33
+ parse_node_pattern
34
+ end
35
+ end
36
+
37
+ def parse_node_pattern
38
+ node_type = consume_identifier
39
+ skip_whitespace
40
+
41
+ attributes = {}
42
+ if current_char == "{"
43
+ advance(1)
44
+ attributes = parse_attributes
45
+ skip_whitespace
46
+ expect_char("}")
47
+ end
48
+
49
+ Evilution::AST::Pattern::NodeMatcher.new(node_type, attributes)
50
+ end
51
+
52
+ def parse_attributes
53
+ attrs = {}
54
+ skip_whitespace
55
+
56
+ return attrs if current_char == "}"
57
+
58
+ loop do
59
+ skip_whitespace
60
+ name = consume_identifier
61
+ skip_whitespace
62
+ expect_char("=", "expected '=' after attribute name '#{name}'")
63
+ skip_whitespace
64
+ value = parse_value
65
+ attrs[name] = value
66
+ skip_whitespace
67
+
68
+ break unless current_char == ","
69
+
70
+ advance(1)
71
+ end
72
+
73
+ attrs
74
+ end
75
+
76
+ def parse_value
77
+ skip_whitespace
78
+
79
+ if current_char == "!"
80
+ advance(1)
81
+ skip_whitespace
82
+ inner = parse_value
83
+ Evilution::AST::Pattern::NegationMatcher.new(inner)
84
+ elsif current_char == "*" && !peek_string("**")
85
+ advance(1)
86
+ Evilution::AST::Pattern::WildcardValueMatcher.new
87
+ elsif peek_string("**")
88
+ advance(2)
89
+ Evilution::AST::Pattern::DeepWildcardMatcher.new
90
+ elsif current_char == "_" && !identifier_continues?(1)
91
+ advance(1)
92
+ Evilution::AST::Pattern::AnyNodeMatcher.new
93
+ else
94
+ parse_value_or_nested
95
+ end
96
+ end
97
+
98
+ def parse_value_or_nested
99
+ id = consume_identifier
100
+ skip_whitespace
101
+
102
+ if current_char == "{"
103
+ advance(1)
104
+ attrs = parse_attributes
105
+ skip_whitespace
106
+ expect_char("}")
107
+ Evilution::AST::Pattern::NodeMatcher.new(id, attrs)
108
+ else
109
+ parse_alternatives_from(id)
110
+ end
111
+ end
112
+
113
+ def parse_alternatives_from(first)
114
+ values = [first]
115
+
116
+ while current_char == "|"
117
+ advance(1)
118
+ skip_whitespace
119
+ if current_char == "*" && !peek_string("**")
120
+ advance(1)
121
+ values << "*"
122
+ else
123
+ values << consume_identifier
124
+ end
125
+ end
126
+
127
+ if values.length == 1
128
+ Evilution::AST::Pattern::ValueMatcher.new(first)
129
+ else
130
+ Evilution::AST::Pattern::AlternativesMatcher.new(values)
131
+ end
132
+ end
133
+
134
+ def consume_identifier
135
+ raise Evilution::ConfigError, "unexpected end of pattern at position #{@pos}" if current_char.nil?
136
+
137
+ unless current_char.match?(/[a-zA-Z_]/)
138
+ raise Evilution::ConfigError, "invalid identifier starting with '#{current_char}' at position #{@pos}"
139
+ end
140
+
141
+ start = @pos
142
+ advance(1)
143
+ advance(1) while @pos < @input.length && @input[@pos].match?(/[a-zA-Z0-9_]/)
144
+
145
+ @input[start...@pos]
146
+ end
147
+
148
+ def skip_whitespace
149
+ advance(1) while @pos < @input.length && @input[@pos] == " "
150
+ end
151
+
152
+ def current_char
153
+ @pos < @input.length ? @input[@pos] : nil
154
+ end
155
+
156
+ def peek_string(str)
157
+ @input[@pos, str.length] == str
158
+ end
159
+
160
+ def identifier_continues?(offset)
161
+ char = @input[@pos + offset]
162
+ char && char.match?(/[a-zA-Z0-9_]/)
163
+ end
164
+
165
+ def advance(n)
166
+ @pos += n
167
+ end
168
+
169
+ def expect_char(char, message = nil)
170
+ current = current_char
171
+
172
+ if current == char
173
+ advance(1)
174
+ return
175
+ end
176
+
177
+ if current.nil?
178
+ raise Evilution::ConfigError,
179
+ (message || "unexpected end of pattern, expected '#{char}' at position #{@pos}")
180
+ else
181
+ raise Evilution::ConfigError,
182
+ (message || "unexpected character '#{current}', expected '#{char}' at position #{@pos}")
183
+ end
184
+ end
185
+ end
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Evilution::AST::Pattern
4
+ end
@@ -4,8 +4,8 @@ require_relative "../ast"
4
4
 
5
5
  module Evilution::AST::SourceSurgeon
6
6
  def self.apply(source, offset:, length:, replacement:)
7
- result = source.dup
8
- result[offset, length] = replacement
9
- result
7
+ binary = source.b
8
+ binary[offset, length] = replacement.b
9
+ binary.force_encoding(source.encoding)
10
10
  end
11
11
  end
data/lib/evilution/cli.rb CHANGED
@@ -4,6 +4,9 @@ require "json"
4
4
  require "optparse"
5
5
  require_relative "version"
6
6
  require_relative "config"
7
+ require_relative "hooks"
8
+ require_relative "hooks/registry"
9
+ require_relative "hooks/loader"
7
10
  require_relative "runner"
8
11
 
9
12
  class Evilution::CLI
@@ -434,7 +437,8 @@ class Evilution::CLI
434
437
 
435
438
  file_options = Evilution::Config.file_options
436
439
  config = Evilution::Config.new(**@options, target_files: @files, line_ranges: @line_ranges)
437
- runner = Evilution::Runner.new(config: config)
440
+ hooks = build_hooks(config)
441
+ runner = Evilution::Runner.new(config: config, hooks: hooks)
438
442
  summary = runner.call
439
443
  summary.success?(min_score: config.min_score) ? 0 : 1
440
444
  rescue Evilution::Error => e
@@ -446,6 +450,14 @@ class Evilution::CLI
446
450
  2
447
451
  end
448
452
 
453
+ def build_hooks(config)
454
+ return nil if config.hooks.empty?
455
+
456
+ registry = Evilution::Hooks::Registry.new
457
+ Evilution::Hooks::Loader.call(registry, config.hooks)
458
+ registry
459
+ end
460
+
449
461
  def json_format?(config, file_options)
450
462
  return config.json? if config
451
463
 
@@ -22,13 +22,15 @@ 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: []
26
27
  }.freeze
27
28
 
28
29
  attr_reader :target_files, :timeout, :format,
29
30
  :target, :min_score, :integration, :verbose, :quiet,
30
31
  :jobs, :fail_fast, :baseline, :isolation, :incremental, :suggest_tests,
31
- :progress, :save_session, :line_ranges, :spec_files
32
+ :progress, :save_session, :line_ranges, :spec_files, :hooks,
33
+ :ignore_patterns
32
34
 
33
35
  def initialize(**options)
34
36
  file_options = options.delete(:skip_config_file) ? {} : load_config_file
@@ -122,6 +124,17 @@ class Evilution::Config
122
124
 
123
125
  # Generate concrete RSpec test code in suggestions (default: false)
124
126
  # suggest_tests: false
127
+
128
+ # Hooks: Ruby files returning a Proc, keyed by lifecycle event
129
+ # hooks:
130
+ # worker_process_start: config/evilution_hooks/worker_start.rb
131
+ # mutation_insert_pre: config/evilution_hooks/mutation_pre.rb
132
+
133
+ # AST patterns to skip during mutation generation (default: [])
134
+ # See docs/ast_pattern_syntax.md for pattern syntax
135
+ # ignore_patterns:
136
+ # - "call{name=info, receiver=call{name=logger}}"
137
+ # - "call{name=debug|warn}"
125
138
  YAML
126
139
  end
127
140
 
@@ -157,6 +170,8 @@ class Evilution::Config
157
170
  @save_session = merged[:save_session]
158
171
  @line_ranges = merged[:line_ranges] || {}
159
172
  @spec_files = Array(merged[:spec_files])
173
+ @ignore_patterns = validate_ignore_patterns(merged[:ignore_patterns])
174
+ @hooks = validate_hooks(merged[:hooks])
160
175
  end
161
176
 
162
177
  def validate_isolation(value)
@@ -180,6 +195,24 @@ class Evilution::Config
180
195
  raise Evilution::ConfigError, "jobs must be a positive integer, got #{value.inspect}"
181
196
  end
182
197
 
198
+ def validate_ignore_patterns(value)
199
+ patterns = Array(value)
200
+ patterns.each do |pattern|
201
+ unless pattern.is_a?(String)
202
+ raise Evilution::ConfigError,
203
+ "ignore_patterns must be an array of strings, got #{pattern.class} (#{pattern.inspect})"
204
+ end
205
+ end
206
+ patterns
207
+ end
208
+
209
+ def validate_hooks(value)
210
+ return {} if value.nil?
211
+ raise Evilution::ConfigError, "hooks must be a mapping of event names to file paths, got #{value.class}" unless value.is_a?(Hash)
212
+
213
+ value
214
+ end
215
+
183
216
  def load_config_file
184
217
  self.class.file_options
185
218
  end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../hooks"
4
+
5
+ class Evilution::Hooks::Loader
6
+ def self.call(registry, config_hooks = nil)
7
+ return registry if config_hooks.nil?
8
+
9
+ unless config_hooks.is_a?(Hash)
10
+ raise Evilution::ConfigError, "hooks must be a mapping of event names to file paths, got #{config_hooks.class}"
11
+ end
12
+ return registry if config_hooks.empty?
13
+
14
+ config_hooks.each do |event, paths|
15
+ event = event.to_sym
16
+ Array(paths).each do |path|
17
+ handler = load_hook_file(path)
18
+ registry.register(event) { |payload| handler.call(payload) }
19
+ end
20
+ end
21
+
22
+ registry
23
+ end
24
+
25
+ def self.load_hook_file(path)
26
+ raise Evilution::ConfigError, "hook file not found: #{path}" unless File.exist?(path)
27
+
28
+ result = Module.new.module_eval(File.read(path), path, 1)
29
+ raise Evilution::ConfigError, "hook file #{path} must return a Proc, got #{result.class}" unless result.is_a?(Proc)
30
+
31
+ result
32
+ end
33
+
34
+ private_class_method :load_hook_file
35
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../hooks"
4
+
5
+ class Evilution::Hooks::Registry
6
+ def initialize(on_error: nil)
7
+ @handlers = Evilution::Hooks::EVENTS.to_h { |event| [event, []] }
8
+ @on_error = on_error
9
+ end
10
+
11
+ def register(event, &block)
12
+ validate_event!(event)
13
+ raise ArgumentError, "a block must be provided when registering handler for #{event}" unless block
14
+
15
+ @handlers[event] << block
16
+ self
17
+ end
18
+
19
+ def fire(event, **payload)
20
+ validate_event!(event)
21
+ errors = []
22
+
23
+ @handlers[event].each do |handler|
24
+ handler.call(payload)
25
+ rescue StandardError => e
26
+ errors << e
27
+ report_error(event, e)
28
+ end
29
+
30
+ errors
31
+ end
32
+
33
+ def clear(event = nil)
34
+ if event
35
+ validate_event!(event)
36
+ @handlers[event].clear
37
+ else
38
+ @handlers.each_value(&:clear)
39
+ end
40
+ end
41
+
42
+ def handlers_for(event)
43
+ validate_event!(event)
44
+ @handlers[event].dup
45
+ end
46
+
47
+ private
48
+
49
+ def validate_event!(event)
50
+ raise ArgumentError, "unknown hook event: #{event}" unless Evilution::Hooks::EVENTS.include?(event)
51
+ end
52
+
53
+ def report_error(event, error)
54
+ if @on_error
55
+ @on_error.call(event, error)
56
+ else
57
+ warn "[evilution] hook error in #{event}: #{error.message}"
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Evilution::Hooks
4
+ EVENTS = %i[
5
+ worker_process_start
6
+ mutation_insert_pre
7
+ mutation_insert_post
8
+ setup_integration_pre
9
+ setup_integration_post
10
+ ].freeze
11
+
12
+ def initialize
13
+ @handlers = EVENTS.to_h { |event| [event, []] }
14
+ end
15
+
16
+ def register(event, &block)
17
+ validate_event!(event)
18
+ raise ArgumentError, "a block must be provided when registering handler for #{event}" unless block
19
+
20
+ @handlers[event] << block
21
+ self
22
+ end
23
+
24
+ def fire(event, **payload)
25
+ validate_event!(event)
26
+ @handlers[event].each { |handler| handler.call(payload) }
27
+ end
28
+
29
+ def clear(event = nil)
30
+ if event
31
+ validate_event!(event)
32
+ @handlers[event].clear
33
+ else
34
+ @handlers.each_value(&:clear)
35
+ end
36
+ end
37
+
38
+ def handlers_for(event)
39
+ validate_event!(event)
40
+ @handlers[event].dup
41
+ end
42
+
43
+ def self.from_config(config_hooks)
44
+ hooks = new
45
+ return hooks if config_hooks.nil? || config_hooks.empty?
46
+
47
+ config_hooks.each do |event, callables|
48
+ Array(callables).each { |callable| hooks.register(event) { |payload| callable.call(payload) } }
49
+ end
50
+ hooks
51
+ end
52
+
53
+ private
54
+
55
+ def validate_event!(event)
56
+ raise ArgumentError, "unknown hook event: #{event}" unless EVENTS.include?(event)
57
+ end
58
+ end
@@ -3,6 +3,10 @@
3
3
  require_relative "../integration"
4
4
 
5
5
  class Evilution::Integration::Base
6
+ def initialize(hooks: nil)
7
+ @hooks = hooks
8
+ end
9
+
6
10
  def call(mutation)
7
11
  raise NotImplementedError, "#{self.class}#call must be implemented"
8
12
  end
@@ -9,10 +9,10 @@ require_relative "../spec_resolver"
9
9
  require_relative "../integration"
10
10
 
11
11
  class Evilution::Integration::RSpec < Evilution::Integration::Base
12
- def initialize(test_files: nil)
12
+ def initialize(test_files: nil, hooks: nil)
13
13
  @test_files = test_files
14
14
  @rspec_loaded = false
15
- super()
15
+ super(hooks: hooks)
16
16
  end
17
17
 
18
18
  def call(mutation)
@@ -20,7 +20,9 @@ class Evilution::Integration::RSpec < Evilution::Integration::Base
20
20
  @temp_dir = nil
21
21
  @lock_file = nil
22
22
  ensure_rspec_loaded
23
+ @hooks.fire(:mutation_insert_pre, mutation: mutation, file_path: mutation.file_path) if @hooks
23
24
  apply_mutation(mutation)
25
+ @hooks.fire(:mutation_insert_post, mutation: mutation, file_path: mutation.file_path) if @hooks
24
26
  run_rspec(mutation)
25
27
  ensure
26
28
  restore_original(mutation)
@@ -33,8 +35,10 @@ class Evilution::Integration::RSpec < Evilution::Integration::Base
33
35
  def ensure_rspec_loaded
34
36
  return if @rspec_loaded
35
37
 
38
+ @hooks.fire(:setup_integration_pre, integration: :rspec) if @hooks
36
39
  require "rspec/core"
37
40
  @rspec_loaded = true
41
+ @hooks.fire(:setup_integration_post, integration: :rspec) if @hooks
38
42
  rescue LoadError => e
39
43
  raise Evilution::Error, "rspec-core is required but not available: #{e.message}"
40
44
  end
@@ -9,6 +9,10 @@ require_relative "../isolation"
9
9
  class Evilution::Isolation::Fork
10
10
  GRACE_PERIOD = 2
11
11
 
12
+ def initialize(hooks: nil)
13
+ @hooks = hooks
14
+ end
15
+
12
16
  def call(mutation:, test_command:, timeout:)
13
17
  sandbox_dir = Dir.mktmpdir("evilution-run")
14
18
  start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
@@ -18,6 +22,7 @@ class Evilution::Isolation::Fork
18
22
  ENV["TMPDIR"] = sandbox_dir
19
23
  read_io.close
20
24
  suppress_child_output
25
+ @hooks.fire(:worker_process_start, mutation: mutation) if @hooks
21
26
  result = execute_in_child(mutation, test_command)
22
27
  Marshal.dump(result, write_io)
23
28
  write_io.close
@@ -13,10 +13,11 @@ class Evilution::Mutator::Base < Prism::Visitor
13
13
  @file_source = nil
14
14
  end
15
15
 
16
- def call(subject)
16
+ def call(subject, filter: nil)
17
17
  @subject = subject
18
18
  @file_source = File.read(subject.file_path)
19
19
  @mutations = []
20
+ @filter = filter
20
21
  visit(subject.node)
21
22
  @mutations
22
23
  end
@@ -24,6 +25,8 @@ class Evilution::Mutator::Base < Prism::Visitor
24
25
  private
25
26
 
26
27
  def add_mutation(offset:, length:, replacement:, node:)
28
+ return if @filter && @filter.skip?(node)
29
+
27
30
  mutated_source = Evilution::AST::SourceSurgeon.apply(
28
31
  @file_source,
29
32
  offset: offset,
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../operator"
4
+
5
+ class Evilution::Mutator::Operator::IndexAssignmentRemoval < Evilution::Mutator::Base
6
+ def visit_call_node(node)
7
+ if node.name == :[]= && node.receiver
8
+ add_mutation(
9
+ offset: node.location.start_offset,
10
+ length: node.location.length,
11
+ replacement: "nil",
12
+ node: node
13
+ )
14
+ end
15
+
16
+ super
17
+ end
18
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../operator"
4
+
5
+ class Evilution::Mutator::Operator::IndexToDig < Evilution::Mutator::Base
6
+ def initialize
7
+ super
8
+ @consumed = Set.new
9
+ end
10
+
11
+ def visit_call_node(node)
12
+ if chain_head?(node)
13
+ root, args = collect_chain(node)
14
+ root_source = @file_source[root.location.start_offset, root.location.length]
15
+ arg_sources = args.map { |a| @file_source[a.location.start_offset, a.location.length] }
16
+
17
+ add_mutation(
18
+ offset: node.location.start_offset,
19
+ length: node.location.length,
20
+ replacement: "#{root_source}.dig(#{arg_sources.join(", ")})",
21
+ node: node
22
+ )
23
+ end
24
+
25
+ super
26
+ end
27
+
28
+ private
29
+
30
+ def chain_head?(node)
31
+ return false if @consumed.include?(node.object_id)
32
+ return false unless single_arg_index?(node)
33
+ return false unless single_arg_index?(node.receiver)
34
+
35
+ true
36
+ end
37
+
38
+ def single_arg_index?(node)
39
+ node.is_a?(Prism::CallNode) &&
40
+ node.name == :[] &&
41
+ node.receiver &&
42
+ node.arguments &&
43
+ node.arguments.arguments.length == 1
44
+ end
45
+
46
+ def collect_chain(node)
47
+ args = []
48
+ current = node
49
+
50
+ while single_arg_index?(current)
51
+ @consumed.add(current.object_id)
52
+ args.unshift(current.arguments.arguments.first)
53
+ current = current.receiver
54
+ end
55
+
56
+ [current, args]
57
+ end
58
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../operator"
4
+
5
+ class Evilution::Mutator::Operator::IndexToFetch < Evilution::Mutator::Base
6
+ def visit_call_node(node)
7
+ if indexable?(node)
8
+ receiver_source = @file_source[node.receiver.location.start_offset, node.receiver.location.length]
9
+ arg_source = @file_source[node.arguments.location.start_offset, node.arguments.location.length]
10
+
11
+ add_mutation(
12
+ offset: node.location.start_offset,
13
+ length: node.location.length,
14
+ replacement: "#{receiver_source}.fetch(#{arg_source})",
15
+ node: node
16
+ )
17
+ end
18
+
19
+ super
20
+ end
21
+
22
+ private
23
+
24
+ def indexable?(node)
25
+ node.name == :[] &&
26
+ node.receiver &&
27
+ node.arguments &&
28
+ node.arguments.arguments.length == 1
29
+ end
30
+ end
@@ -7,10 +7,11 @@ require_relative "../operator"
7
7
  class Evilution::Mutator::Operator::MixinRemoval < Evilution::Mutator::Base
8
8
  MIXIN_METHODS = %i[include extend prepend].freeze
9
9
 
10
- def call(subject)
10
+ def call(subject, filter: nil)
11
11
  @subject = subject
12
12
  @file_source = File.read(subject.file_path)
13
13
  @mutations = []
14
+ @filter = filter
14
15
 
15
16
  tree = self.class.parsed_tree_for(subject.file_path, @file_source)
16
17
  enclosing = find_enclosing_scope(tree, subject.line_number)