evilution 0.13.0 → 0.15.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 (90) hide show
  1. checksums.yaml +4 -4
  2. data/.beads/.migration-hint-ts +1 -1
  3. data/.beads/issues.jsonl +17 -17
  4. data/CHANGELOG.md +39 -0
  5. data/lib/evilution/ast/inheritance_scanner.rb +70 -0
  6. data/lib/evilution/ast/parser.rb +73 -68
  7. data/lib/evilution/ast/source_surgeon.rb +7 -9
  8. data/lib/evilution/ast.rb +4 -0
  9. data/lib/evilution/baseline.rb +73 -75
  10. data/lib/evilution/cache.rb +75 -77
  11. data/lib/evilution/cli.rb +412 -173
  12. data/lib/evilution/config.rb +141 -136
  13. data/lib/evilution/equivalent/detector.rb +29 -27
  14. data/lib/evilution/equivalent/heuristic/alias_swap.rb +32 -33
  15. data/lib/evilution/equivalent/heuristic/arithmetic_identity.rb +30 -0
  16. data/lib/evilution/equivalent/heuristic/comment_marking.rb +21 -0
  17. data/lib/evilution/equivalent/heuristic/dead_code.rb +41 -45
  18. data/lib/evilution/equivalent/heuristic/method_body_nil.rb +11 -15
  19. data/lib/evilution/equivalent/heuristic/noop_source.rb +5 -9
  20. data/lib/evilution/equivalent/heuristic.rb +6 -0
  21. data/lib/evilution/equivalent.rb +4 -0
  22. data/lib/evilution/git/changed_files.rb +35 -37
  23. data/lib/evilution/git.rb +4 -0
  24. data/lib/evilution/integration/base.rb +5 -7
  25. data/lib/evilution/integration/rspec.rb +114 -116
  26. data/lib/evilution/integration.rb +4 -0
  27. data/lib/evilution/isolation/fork.rb +98 -100
  28. data/lib/evilution/isolation/in_process.rb +59 -61
  29. data/lib/evilution/isolation.rb +4 -0
  30. data/lib/evilution/mcp/mutate_tool.rb +172 -143
  31. data/lib/evilution/mcp/server.rb +12 -11
  32. data/lib/evilution/mcp/session_diff_tool.rb +89 -0
  33. data/lib/evilution/mcp/session_list_tool.rb +46 -0
  34. data/lib/evilution/mcp/session_show_tool.rb +53 -0
  35. data/lib/evilution/mcp.rb +4 -0
  36. data/lib/evilution/memory/leak_check.rb +80 -84
  37. data/lib/evilution/memory.rb +34 -36
  38. data/lib/evilution/mutation.rb +40 -42
  39. data/lib/evilution/mutator/base.rb +62 -48
  40. data/lib/evilution/mutator/operator/argument_nil_substitution.rb +32 -36
  41. data/lib/evilution/mutator/operator/argument_removal.rb +32 -36
  42. data/lib/evilution/mutator/operator/arithmetic_replacement.rb +26 -30
  43. data/lib/evilution/mutator/operator/array_literal.rb +18 -22
  44. data/lib/evilution/mutator/operator/block_removal.rb +16 -20
  45. data/lib/evilution/mutator/operator/boolean_literal_replacement.rb +38 -42
  46. data/lib/evilution/mutator/operator/boolean_operator_replacement.rb +41 -45
  47. data/lib/evilution/mutator/operator/collection_replacement.rb +32 -36
  48. data/lib/evilution/mutator/operator/comparison_replacement.rb +24 -28
  49. data/lib/evilution/mutator/operator/compound_assignment.rb +114 -118
  50. data/lib/evilution/mutator/operator/conditional_branch.rb +25 -29
  51. data/lib/evilution/mutator/operator/conditional_flip.rb +26 -30
  52. data/lib/evilution/mutator/operator/conditional_negation.rb +25 -29
  53. data/lib/evilution/mutator/operator/float_literal.rb +22 -26
  54. data/lib/evilution/mutator/operator/hash_literal.rb +18 -22
  55. data/lib/evilution/mutator/operator/integer_literal.rb +2 -0
  56. data/lib/evilution/mutator/operator/method_body_replacement.rb +12 -16
  57. data/lib/evilution/mutator/operator/method_call_removal.rb +12 -16
  58. data/lib/evilution/mutator/operator/mixin_removal.rb +80 -0
  59. data/lib/evilution/mutator/operator/negation_insertion.rb +12 -16
  60. data/lib/evilution/mutator/operator/nil_replacement.rb +13 -17
  61. data/lib/evilution/mutator/operator/range_replacement.rb +12 -16
  62. data/lib/evilution/mutator/operator/receiver_replacement.rb +16 -20
  63. data/lib/evilution/mutator/operator/regexp_mutation.rb +15 -19
  64. data/lib/evilution/mutator/operator/return_value_removal.rb +12 -16
  65. data/lib/evilution/mutator/operator/send_mutation.rb +36 -40
  66. data/lib/evilution/mutator/operator/statement_deletion.rb +13 -17
  67. data/lib/evilution/mutator/operator/string_literal.rb +18 -22
  68. data/lib/evilution/mutator/operator/superclass_removal.rb +65 -0
  69. data/lib/evilution/mutator/operator/symbol_literal.rb +17 -21
  70. data/lib/evilution/mutator/operator.rb +6 -0
  71. data/lib/evilution/mutator/registry.rb +56 -56
  72. data/lib/evilution/mutator.rb +4 -0
  73. data/lib/evilution/parallel/pool.rb +56 -58
  74. data/lib/evilution/parallel.rb +4 -0
  75. data/lib/evilution/reporter/cli.rb +99 -101
  76. data/lib/evilution/reporter/html.rb +242 -244
  77. data/lib/evilution/reporter/json.rb +57 -59
  78. data/lib/evilution/reporter/suggestion.rb +354 -328
  79. data/lib/evilution/reporter.rb +4 -0
  80. data/lib/evilution/result/mutation_result.rb +43 -46
  81. data/lib/evilution/result/summary.rb +80 -81
  82. data/lib/evilution/result.rb +4 -0
  83. data/lib/evilution/runner.rb +401 -316
  84. data/lib/evilution/session/store.rb +147 -0
  85. data/lib/evilution/session.rb +4 -0
  86. data/lib/evilution/spec_resolver.rb +49 -47
  87. data/lib/evilution/subject.rb +14 -16
  88. data/lib/evilution/version.rb +1 -1
  89. data/lib/evilution.rb +16 -0
  90. metadata +24 -2
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "mcp"
5
+ require_relative "../session/store"
6
+
7
+ require_relative "../mcp"
8
+
9
+ class Evilution::MCP::SessionShowTool < MCP::Tool
10
+ tool_name "evilution-session-show"
11
+ description "Show full details of a past mutation testing session, " \
12
+ "including survived mutations with diffs."
13
+ input_schema(
14
+ properties: {
15
+ path: {
16
+ type: "string",
17
+ description: "Path to the session JSON file (as returned by evilution-session-list)"
18
+ }
19
+ }
20
+ )
21
+
22
+ class << self
23
+ # rubocop:disable Lint/UnusedMethodArgument
24
+ def call(server_context:, path: nil)
25
+ unless path
26
+ return ::MCP::Tool::Response.new(
27
+ [{ type: "text", text: ::JSON.generate({ error: { type: "config_error", message: "path is required" } }) }],
28
+ error: true
29
+ )
30
+ end
31
+
32
+ store = Evilution::Session::Store.new
33
+ data = store.load(path)
34
+ ::MCP::Tool::Response.new([{ type: "text", text: ::JSON.generate(data) }])
35
+ rescue Evilution::Error => e
36
+ ::MCP::Tool::Response.new(
37
+ [{ type: "text", text: ::JSON.generate({ error: { type: "not_found", message: e.message } }) }],
38
+ error: true
39
+ )
40
+ rescue ::JSON::ParserError => e
41
+ ::MCP::Tool::Response.new(
42
+ [{ type: "text", text: ::JSON.generate({ error: { type: "parse_error", message: e.message } }) }],
43
+ error: true
44
+ )
45
+ rescue SystemCallError => e
46
+ ::MCP::Tool::Response.new(
47
+ [{ type: "text", text: ::JSON.generate({ error: { type: "runtime_error", message: e.message } }) }],
48
+ error: true
49
+ )
50
+ end
51
+ # rubocop:enable Lint/UnusedMethodArgument
52
+ end
53
+ end
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Evilution::MCP
4
+ end
@@ -2,90 +2,86 @@
2
2
 
3
3
  require_relative "../memory"
4
4
 
5
- module Evilution
6
- module Memory
7
- class LeakCheck
8
- WARMUP_ITERATIONS = 5
9
- DEFAULT_ITERATIONS = 50
10
- DEFAULT_MAX_GROWTH_KB = 10_240 # 10 MB
11
-
12
- attr_reader :samples
13
-
14
- def initialize(iterations: DEFAULT_ITERATIONS, max_growth_kb: DEFAULT_MAX_GROWTH_KB)
15
- @iterations = iterations
16
- @max_growth_kb = max_growth_kb
17
- @samples = []
18
- end
19
-
20
- def run(&)
21
- warmup(&)
22
- measure(&)
23
- result
24
- end
25
-
26
- def rss_available?
27
- !Evilution::Memory.rss_kb.nil?
28
- end
29
-
30
- def growth_kb
31
- return nil if samples.any?(&:nil?)
32
- return 0 if samples.size < 2
33
-
34
- samples.last - samples.first
35
- end
36
-
37
- def passed?
38
- kb = growth_kb
39
- return false if kb.nil?
40
-
41
- kb <= @max_growth_kb
42
- end
43
-
44
- private
45
-
46
- def warmup(&block)
47
- WARMUP_ITERATIONS.times { block.call }
48
- GC.start
49
- GC.compact if GC.respond_to?(:compact)
50
- end
51
-
52
- def measure(&)
53
- @samples << Evilution::Memory.rss_kb
54
-
55
- @iterations.times do |i|
56
- yield
57
-
58
- next unless ((i + 1) % sample_interval).zero?
59
-
60
- GC.start
61
- @samples << Evilution::Memory.rss_kb
62
- end
63
-
64
- take_final_sample
65
- end
66
-
67
- def take_final_sample
68
- return if (@iterations % sample_interval).zero?
69
-
70
- GC.start
71
- @samples << Evilution::Memory.rss_kb
72
- end
73
-
74
- def sample_interval
75
- @sample_interval ||= [@iterations / 10, 1].max
76
- end
77
-
78
- def result
79
- {
80
- passed: passed?,
81
- growth_kb: growth_kb,
82
- growth_mb: growth_kb ? growth_kb / 1024.0 : nil,
83
- samples: samples,
84
- iterations: @iterations,
85
- max_growth_kb: @max_growth_kb,
86
- rss_available: rss_available?
87
- }
88
- end
5
+ class Evilution::Memory::LeakCheck
6
+ WARMUP_ITERATIONS = 5
7
+ DEFAULT_ITERATIONS = 50
8
+ DEFAULT_MAX_GROWTH_KB = 10_240 # 10 MB
9
+
10
+ attr_reader :samples
11
+
12
+ def initialize(iterations: DEFAULT_ITERATIONS, max_growth_kb: DEFAULT_MAX_GROWTH_KB)
13
+ @iterations = iterations
14
+ @max_growth_kb = max_growth_kb
15
+ @samples = []
16
+ end
17
+
18
+ def run(&)
19
+ warmup(&)
20
+ measure(&)
21
+ result
22
+ end
23
+
24
+ def rss_available?
25
+ !Evilution::Memory.rss_kb.nil?
26
+ end
27
+
28
+ def growth_kb
29
+ return nil if samples.any?(&:nil?)
30
+ return 0 if samples.size < 2
31
+
32
+ samples.last - samples.first
33
+ end
34
+
35
+ def passed?
36
+ kb = growth_kb
37
+ return false if kb.nil?
38
+
39
+ kb <= @max_growth_kb
40
+ end
41
+
42
+ private
43
+
44
+ def warmup(&block)
45
+ WARMUP_ITERATIONS.times { block.call }
46
+ GC.start
47
+ GC.compact if GC.respond_to?(:compact)
48
+ end
49
+
50
+ def measure(&)
51
+ @samples << Evilution::Memory.rss_kb
52
+
53
+ @iterations.times do |i|
54
+ yield
55
+
56
+ next unless ((i + 1) % sample_interval).zero?
57
+
58
+ GC.start
59
+ @samples << Evilution::Memory.rss_kb
89
60
  end
61
+
62
+ take_final_sample
63
+ end
64
+
65
+ def take_final_sample
66
+ return if (@iterations % sample_interval).zero?
67
+
68
+ GC.start
69
+ @samples << Evilution::Memory.rss_kb
70
+ end
71
+
72
+ def sample_interval
73
+ @sample_interval ||= [@iterations / 10, 1].max
74
+ end
75
+
76
+ def result
77
+ {
78
+ passed: passed?,
79
+ growth_kb: growth_kb,
80
+ growth_mb: growth_kb ? growth_kb / 1024.0 : nil,
81
+ samples: samples,
82
+ iterations: @iterations,
83
+ max_growth_kb: @max_growth_kb,
84
+ rss_available: rss_available?
85
+ }
90
86
  end
91
87
  end
@@ -1,40 +1,38 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module Evilution
4
- module Memory
5
- PROC_STATUS_PATH = "/proc/%d/status"
6
- RSS_PATTERN = /VmRSS:\s+(\d+)\s+kB/
7
-
8
- module_function
9
-
10
- def rss_kb
11
- rss_kb_for(Process.pid)
12
- end
13
-
14
- def rss_mb
15
- kb = rss_kb
16
- return nil unless kb
17
-
18
- kb / 1024.0
19
- end
20
-
21
- def rss_kb_for(pid)
22
- path = format(PROC_STATUS_PATH, pid)
23
- content = File.read(path)
24
- match = content.match(RSS_PATTERN)
25
- return nil unless match
26
-
27
- match[1].to_i
28
- rescue Errno::ENOENT, Errno::EACCES, Errno::ESRCH
29
- nil
30
- end
31
-
32
- def delta
33
- before = rss_kb
34
- result = yield
35
- after = rss_kb
36
- delta_kb = before && after ? after - before : nil
37
- [result, delta_kb]
38
- end
3
+ module Evilution::Memory
4
+ PROC_STATUS_PATH = "/proc/%d/status"
5
+ RSS_PATTERN = /VmRSS:\s+(\d+)\s+kB/
6
+
7
+ module_function
8
+
9
+ def rss_kb
10
+ rss_kb_for(Process.pid)
11
+ end
12
+
13
+ def rss_mb
14
+ kb = rss_kb
15
+ return nil unless kb
16
+
17
+ kb / 1024.0
18
+ end
19
+
20
+ def rss_kb_for(pid)
21
+ path = format(PROC_STATUS_PATH, pid)
22
+ content = File.read(path)
23
+ match = content.match(RSS_PATTERN)
24
+ return nil unless match
25
+
26
+ match[1].to_i
27
+ rescue Errno::ENOENT, Errno::EACCES, Errno::ESRCH
28
+ nil
29
+ end
30
+
31
+ def delta
32
+ before = rss_kb
33
+ result = yield
34
+ after = rss_kb
35
+ delta_kb = before && after ? after - before : nil
36
+ [result, delta_kb]
39
37
  end
40
38
  end
@@ -3,57 +3,55 @@
3
3
  require "diff/lcs"
4
4
  require "diff/lcs/hunk"
5
5
 
6
- module Evilution
7
- class Mutation
8
- attr_reader :subject, :operator_name, :original_source,
9
- :mutated_source, :file_path, :line, :column
10
-
11
- def initialize(subject:, operator_name:, original_source:, mutated_source:, file_path:, line:, column: 0)
12
- @subject = subject
13
- @operator_name = operator_name
14
- @original_source = original_source
15
- @mutated_source = mutated_source
16
- @file_path = file_path
17
- @line = line
18
- @column = column
19
- @diff = nil
20
- end
6
+ class Evilution::Mutation
7
+ attr_reader :subject, :operator_name, :original_source,
8
+ :mutated_source, :file_path, :line, :column
9
+
10
+ def initialize(subject:, operator_name:, original_source:, mutated_source:, file_path:, line:, column: 0)
11
+ @subject = subject
12
+ @operator_name = operator_name
13
+ @original_source = original_source
14
+ @mutated_source = mutated_source
15
+ @file_path = file_path
16
+ @line = line
17
+ @column = column
18
+ @diff = nil
19
+ end
21
20
 
22
- def diff
23
- @diff ||= compute_diff
24
- end
21
+ def diff
22
+ @diff ||= compute_diff
23
+ end
25
24
 
26
- def strip_sources!
27
- diff # ensure diff is cached before clearing sources
28
- @original_source = nil
29
- @mutated_source = nil
30
- end
25
+ def strip_sources!
26
+ diff # ensure diff is cached before clearing sources
27
+ @original_source = nil
28
+ @mutated_source = nil
29
+ end
31
30
 
32
- private
31
+ private
33
32
 
34
- def compute_diff
35
- original_lines = original_source.lines
36
- mutated_lines = mutated_source.lines
37
- diffs = ::Diff::LCS.diff(original_lines, mutated_lines)
33
+ def compute_diff
34
+ original_lines = original_source.lines
35
+ mutated_lines = mutated_source.lines
36
+ diffs = ::Diff::LCS.diff(original_lines, mutated_lines)
38
37
 
39
- return "" if diffs.empty?
38
+ return "" if diffs.empty?
40
39
 
41
- result = []
42
- diffs.flatten(1).each do |change|
43
- case change.action
44
- when "-"
45
- result << "- #{change.element.chomp}"
46
- when "+"
47
- result << "+ #{change.element.chomp}"
48
- end
40
+ result = []
41
+ diffs.flatten(1).each do |change|
42
+ case change.action
43
+ when "-"
44
+ result << "- #{change.element.chomp}"
45
+ when "+"
46
+ result << "+ #{change.element.chomp}"
49
47
  end
50
- result.join("\n")
51
48
  end
49
+ result.join("\n")
50
+ end
52
51
 
53
- public
52
+ public
54
53
 
55
- def to_s
56
- "#{operator_name}: #{file_path}:#{line}"
57
- end
54
+ def to_s
55
+ "#{operator_name}: #{file_path}:#{line}"
58
56
  end
59
57
  end
@@ -2,53 +2,67 @@
2
2
 
3
3
  require "prism"
4
4
 
5
- module Evilution
6
- module Mutator
7
- class Base < Prism::Visitor
8
- attr_reader :mutations
9
-
10
- def initialize
11
- @mutations = []
12
- @subject = nil
13
- @file_source = nil
14
- end
15
-
16
- def call(subject)
17
- @subject = subject
18
- @file_source = File.read(subject.file_path)
19
- @mutations = []
20
- visit(subject.node)
21
- @mutations
22
- end
23
-
24
- private
25
-
26
- def add_mutation(offset:, length:, replacement:, node:)
27
- mutated_source = AST::SourceSurgeon.apply(
28
- @file_source,
29
- offset: offset,
30
- length: length,
31
- replacement: replacement
32
- )
33
-
34
- @mutations << Mutation.new(
35
- subject: @subject,
36
- operator_name: self.class.operator_name,
37
- original_source: @file_source,
38
- mutated_source: mutated_source,
39
- file_path: @subject.file_path,
40
- line: node.location.start_line,
41
- column: node.location.start_column
42
- )
43
- end
44
-
45
- def self.operator_name
46
- class_name = name || "anonymous"
47
- class_name.split("::").last
48
- .gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
49
- .gsub(/([a-z\d])([A-Z])/, '\1_\2')
50
- .downcase
51
- end
52
- end
5
+ require_relative "../mutator"
6
+
7
+ class Evilution::Mutator::Base < Prism::Visitor
8
+ attr_reader :mutations
9
+
10
+ def initialize
11
+ @mutations = []
12
+ @subject = nil
13
+ @file_source = nil
14
+ end
15
+
16
+ def call(subject)
17
+ @subject = subject
18
+ @file_source = File.read(subject.file_path)
19
+ @mutations = []
20
+ visit(subject.node)
21
+ @mutations
22
+ end
23
+
24
+ private
25
+
26
+ def add_mutation(offset:, length:, replacement:, node:)
27
+ mutated_source = Evilution::AST::SourceSurgeon.apply(
28
+ @file_source,
29
+ offset: offset,
30
+ length: length,
31
+ replacement: replacement
32
+ )
33
+
34
+ @mutations << Evilution::Mutation.new(
35
+ subject: @subject,
36
+ operator_name: self.class.operator_name,
37
+ original_source: @file_source,
38
+ mutated_source: mutated_source,
39
+ file_path: @subject.file_path,
40
+ line: node.location.start_line,
41
+ column: node.location.start_column
42
+ )
43
+ end
44
+
45
+ def self.operator_name
46
+ class_name = name || "anonymous"
47
+ class_name.split("::").last
48
+ .gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
49
+ .gsub(/([a-z\d])([A-Z])/, '\1_\2')
50
+ .downcase
51
+ end
52
+
53
+ @parse_cache = {}
54
+
55
+ def self.parsed_tree_for(file_path, file_source)
56
+ cache = Evilution::Mutator::Base.instance_variable_get(:@parse_cache)
57
+ entry = cache[file_path]
58
+ return entry[:tree] if entry && entry[:source_hash] == file_source.hash
59
+
60
+ tree = Prism.parse(file_source).value
61
+ cache[file_path] = { source_hash: file_source.hash, tree: tree }
62
+ tree
63
+ end
64
+
65
+ def self.clear_parse_cache!
66
+ Evilution::Mutator::Base.instance_variable_set(:@parse_cache, {})
53
67
  end
54
68
  end
@@ -1,42 +1,38 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module Evilution
4
- module Mutator
5
- module Operator
6
- class ArgumentNilSubstitution < Base
7
- SKIP_TYPES = [
8
- Prism::SplatNode,
9
- Prism::KeywordHashNode,
10
- Prism::BlockArgumentNode,
11
- Prism::ForwardingArgumentsNode
12
- ].freeze
13
-
14
- def visit_call_node(node)
15
- args = node.arguments&.arguments
16
-
17
- if args && args.length >= 1 && positional_only?(args)
18
- args.each_index do |i|
19
- parts = args.each_with_index.map { |a, j| j == i ? "nil" : a.slice }
20
- replacement = parts.join(", ")
21
-
22
- add_mutation(
23
- offset: node.arguments.location.start_offset,
24
- length: node.arguments.location.length,
25
- replacement: replacement,
26
- node: node
27
- )
28
- end
29
- end
30
-
31
- super
32
- end
33
-
34
- private
35
-
36
- def positional_only?(args)
37
- args.none? { |arg| SKIP_TYPES.any? { |type| arg.is_a?(type) } }
38
- end
3
+ require_relative "../operator"
4
+
5
+ class Evilution::Mutator::Operator::ArgumentNilSubstitution < Evilution::Mutator::Base
6
+ SKIP_TYPES = [
7
+ Prism::SplatNode,
8
+ Prism::KeywordHashNode,
9
+ Prism::BlockArgumentNode,
10
+ Prism::ForwardingArgumentsNode
11
+ ].freeze
12
+
13
+ def visit_call_node(node)
14
+ args = node.arguments&.arguments
15
+
16
+ if args && args.length >= 1 && positional_only?(args)
17
+ args.each_index do |i|
18
+ parts = args.each_with_index.map { |a, j| j == i ? "nil" : a.slice }
19
+ replacement = parts.join(", ")
20
+
21
+ add_mutation(
22
+ offset: node.arguments.location.start_offset,
23
+ length: node.arguments.location.length,
24
+ replacement: replacement,
25
+ node: node
26
+ )
39
27
  end
40
28
  end
29
+
30
+ super
31
+ end
32
+
33
+ private
34
+
35
+ def positional_only?(args)
36
+ args.none? { |arg| SKIP_TYPES.any? { |type| arg.is_a?(type) } }
41
37
  end
42
38
  end
@@ -1,42 +1,38 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module Evilution
4
- module Mutator
5
- module Operator
6
- class ArgumentRemoval < Base
7
- SKIP_TYPES = [
8
- Prism::SplatNode,
9
- Prism::KeywordHashNode,
10
- Prism::BlockArgumentNode,
11
- Prism::ForwardingArgumentsNode
12
- ].freeze
13
-
14
- def visit_call_node(node)
15
- args = node.arguments&.arguments
16
-
17
- if args && args.length >= 2 && positional_only?(args)
18
- args.each_index do |i|
19
- remaining = args.each_with_index.filter_map { |a, j| a.slice if j != i }
20
- replacement = remaining.join(", ")
21
-
22
- add_mutation(
23
- offset: node.arguments.location.start_offset,
24
- length: node.arguments.location.length,
25
- replacement:,
26
- node:
27
- )
28
- end
29
- end
30
-
31
- super
32
- end
33
-
34
- private
35
-
36
- def positional_only?(args)
37
- args.none? { |arg| SKIP_TYPES.any? { |type| arg.is_a?(type) } }
38
- end
3
+ require_relative "../operator"
4
+
5
+ class Evilution::Mutator::Operator::ArgumentRemoval < Evilution::Mutator::Base
6
+ SKIP_TYPES = [
7
+ Prism::SplatNode,
8
+ Prism::KeywordHashNode,
9
+ Prism::BlockArgumentNode,
10
+ Prism::ForwardingArgumentsNode
11
+ ].freeze
12
+
13
+ def visit_call_node(node)
14
+ args = node.arguments&.arguments
15
+
16
+ if args && args.length >= 2 && positional_only?(args)
17
+ args.each_index do |i|
18
+ remaining = args.each_with_index.filter_map { |a, j| a.slice if j != i }
19
+ replacement = remaining.join(", ")
20
+
21
+ add_mutation(
22
+ offset: node.arguments.location.start_offset,
23
+ length: node.arguments.location.length,
24
+ replacement:,
25
+ node:
26
+ )
39
27
  end
40
28
  end
29
+
30
+ super
31
+ end
32
+
33
+ private
34
+
35
+ def positional_only?(args)
36
+ args.none? { |arg| SKIP_TYPES.any? { |type| arg.is_a?(type) } }
41
37
  end
42
38
  end