evilution 0.5.0 → 0.7.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.
@@ -18,32 +18,21 @@ module Evilution
18
18
  quiet: false,
19
19
  jobs: 1,
20
20
  fail_fast: nil,
21
+ baseline: true,
22
+ isolation: :auto,
21
23
  line_ranges: {},
22
24
  spec_files: []
23
25
  }.freeze
24
26
 
25
27
  attr_reader :target_files, :timeout, :format, :diff_base,
26
28
  :target, :min_score, :integration, :coverage, :verbose, :quiet,
27
- :jobs, :fail_fast, :line_ranges, :spec_files
29
+ :jobs, :fail_fast, :baseline, :isolation, :line_ranges, :spec_files
28
30
 
29
31
  def initialize(**options)
30
32
  file_options = options.delete(:skip_config_file) ? {} : load_config_file
31
33
  merged = DEFAULTS.merge(file_options).merge(options)
32
34
  warn_removed_options(merged, file_options)
33
- @target_files = Array(merged[:target_files])
34
- @timeout = merged[:timeout]
35
- @format = merged[:format].to_sym
36
- @diff_base = merged[:diff_base]
37
- @target = merged[:target]
38
- @min_score = merged[:min_score].to_f
39
- @integration = merged[:integration].to_sym
40
- @coverage = merged[:coverage]
41
- @verbose = merged[:verbose]
42
- @quiet = merged[:quiet]
43
- @jobs = validate_jobs(merged[:jobs])
44
- @fail_fast = validate_fail_fast(merged[:fail_fast])
45
- @line_ranges = merged[:line_ranges] || {}
46
- @spec_files = Array(merged[:spec_files])
35
+ assign_attributes(merged)
47
36
  freeze
48
37
  end
49
38
 
@@ -71,6 +60,10 @@ module Evilution
71
60
  !fail_fast.nil?
72
61
  end
73
62
 
63
+ def baseline?
64
+ baseline
65
+ end
66
+
74
67
  def self.file_options
75
68
  CONFIG_FILES.each do |path|
76
69
  next unless File.exist?(path)
@@ -128,6 +121,34 @@ module Evilution
128
121
  raise ConfigError, "fail_fast must be a positive integer, got #{value.inspect}"
129
122
  end
130
123
 
124
+ def assign_attributes(merged)
125
+ @target_files = Array(merged[:target_files])
126
+ @timeout = merged[:timeout]
127
+ @format = merged[:format].to_sym
128
+ @diff_base = merged[:diff_base]
129
+ @target = merged[:target]
130
+ @min_score = merged[:min_score].to_f
131
+ @integration = merged[:integration].to_sym
132
+ @coverage = merged[:coverage]
133
+ @verbose = merged[:verbose]
134
+ @quiet = merged[:quiet]
135
+ @jobs = validate_jobs(merged[:jobs])
136
+ @fail_fast = validate_fail_fast(merged[:fail_fast])
137
+ @baseline = merged[:baseline]
138
+ @isolation = validate_isolation(merged[:isolation])
139
+ @line_ranges = merged[:line_ranges] || {}
140
+ @spec_files = Array(merged[:spec_files])
141
+ end
142
+
143
+ def validate_isolation(value)
144
+ raise ConfigError, "isolation must be auto, fork, or in_process, got nil" if value.nil?
145
+
146
+ value = value.to_sym
147
+ raise ConfigError, "isolation must be auto, fork, or in_process, got #{value.inspect}" unless %i[auto fork in_process].include?(value)
148
+
149
+ value
150
+ end
151
+
131
152
  def validate_jobs(value)
132
153
  raise ConfigError, "jobs must be a positive integer, got #{value.inspect}" if value.is_a?(Float)
133
154
 
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "fileutils"
4
4
  require "tmpdir"
5
+ require_relative "../memory"
5
6
 
6
7
  module Evilution
7
8
  module Isolation
@@ -51,7 +52,8 @@ module Evilution
51
52
  end
52
53
 
53
54
  def execute_in_child(mutation, test_command)
54
- test_command.call(mutation)
55
+ result = test_command.call(mutation)
56
+ { child_rss_kb: Memory.rss_kb }.merge(result)
55
57
  rescue StandardError => e
56
58
  { passed: false, error: e.message }
57
59
  end
@@ -98,7 +100,8 @@ module Evilution
98
100
  mutation: mutation,
99
101
  status: status,
100
102
  duration: duration,
101
- test_command: result[:test_command]
103
+ test_command: result[:test_command],
104
+ child_rss_kb: result[:child_rss_kb]
102
105
  )
103
106
  end
104
107
  end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "timeout"
4
+ require_relative "../memory"
5
+ require_relative "../result/mutation_result"
6
+
7
+ module Evilution
8
+ module Isolation
9
+ class InProcess
10
+ def call(mutation:, test_command:, timeout:)
11
+ start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
12
+ rss_before = Memory.rss_kb
13
+ result = execute_with_timeout(mutation, test_command, timeout)
14
+ rss_after = Memory.rss_kb
15
+ duration = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
16
+ delta = compute_memory_delta(rss_before, rss_after, result)
17
+
18
+ build_mutation_result(mutation, result, duration, rss_after, delta)
19
+ end
20
+
21
+ private
22
+
23
+ def execute_with_timeout(mutation, test_command, timeout)
24
+ result = Timeout.timeout(timeout) do
25
+ suppress_output { test_command.call(mutation) }
26
+ end
27
+ { timeout: false }.merge(result)
28
+ rescue Timeout::Error
29
+ { timeout: true }
30
+ rescue StandardError => e
31
+ { timeout: false, passed: false, error: e.message }
32
+ end
33
+
34
+ def suppress_output
35
+ saved_stdout = $stdout
36
+ saved_stderr = $stderr
37
+ File.open(File::NULL, "w") do |null_out|
38
+ File.open(File::NULL, "w") do |null_err|
39
+ $stdout = null_out
40
+ $stderr = null_err
41
+ yield
42
+ end
43
+ end
44
+ ensure
45
+ $stdout = saved_stdout
46
+ $stderr = saved_stderr
47
+ end
48
+
49
+ def compute_memory_delta(rss_before, rss_after, result)
50
+ return nil if result[:timeout]
51
+ return nil unless rss_before && rss_after
52
+
53
+ rss_after - rss_before
54
+ end
55
+
56
+ def build_mutation_result(mutation, result, duration, rss_after, memory_delta_kb)
57
+ status = if result[:timeout]
58
+ :timeout
59
+ elsif result[:error]
60
+ :error
61
+ elsif result[:passed]
62
+ :survived
63
+ else
64
+ :killed
65
+ end
66
+
67
+ Result::MutationResult.new(
68
+ mutation: mutation,
69
+ status: status,
70
+ duration: duration,
71
+ test_command: result[:test_command],
72
+ child_rss_kb: rss_after,
73
+ memory_delta_kb: memory_delta_kb
74
+ )
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "mcp"
5
+ require_relative "../config"
6
+ require_relative "../runner"
7
+ require_relative "../reporter/json"
8
+
9
+ module Evilution
10
+ module MCP
11
+ class MutateTool < ::MCP::Tool
12
+ tool_name "evilution-mutate"
13
+ description "Run mutation testing on Ruby source files"
14
+ input_schema(
15
+ properties: {
16
+ files: {
17
+ type: "array",
18
+ items: { type: "string" },
19
+ description: "Target files, supports line-range syntax (e.g. lib/foo.rb:15-30)"
20
+ },
21
+ target: {
22
+ type: "string",
23
+ description: "Only mutate the named method (e.g. Foo#bar)"
24
+ },
25
+ timeout: {
26
+ type: "integer",
27
+ description: "Per-mutation timeout in seconds (default: 30)"
28
+ },
29
+ jobs: {
30
+ type: "integer",
31
+ description: "Number of parallel workers (default: 1)"
32
+ },
33
+ fail_fast: {
34
+ type: "integer",
35
+ description: "Stop after N surviving mutants"
36
+ },
37
+ spec: {
38
+ type: "array",
39
+ items: { type: "string" },
40
+ description: "Spec files to run (overrides auto-detection)"
41
+ }
42
+ }
43
+ )
44
+
45
+ class << self
46
+ def call(server_context:, files: [], target: nil, timeout: nil, jobs: nil, fail_fast: nil, spec: nil) # rubocop:disable Lint/UnusedMethodArgument
47
+ parsed_files, line_ranges = parse_files(Array(files))
48
+ config_opts = build_config_opts(parsed_files, line_ranges, target, timeout, jobs, fail_fast, spec)
49
+ config = Config.new(**config_opts)
50
+ runner = Runner.new(config: config)
51
+ summary = runner.call
52
+ report = Reporter::JSON.new.call(summary)
53
+
54
+ ::MCP::Tool::Response.new([{ type: "text", text: report }])
55
+ rescue Evilution::Error => e
56
+ error_payload = build_error_payload(e)
57
+ ::MCP::Tool::Response.new([{ type: "text", text: ::JSON.generate(error_payload) }], error: true)
58
+ end
59
+
60
+ private
61
+
62
+ def parse_files(raw_files)
63
+ files = []
64
+ ranges = {}
65
+
66
+ raw_files.each do |arg|
67
+ file, range_str = arg.split(":", 2)
68
+ files << file
69
+ next unless range_str
70
+
71
+ ranges[file] = parse_line_range(range_str)
72
+ end
73
+
74
+ [files, ranges]
75
+ end
76
+
77
+ def parse_line_range(str)
78
+ if str.include?("-")
79
+ start_str, end_str = str.split("-", 2)
80
+ start_line = Integer(start_str)
81
+ end_line = end_str.empty? ? Float::INFINITY : Integer(end_str)
82
+ start_line..end_line
83
+ else
84
+ line = Integer(str)
85
+ line..line
86
+ end
87
+ rescue ArgumentError, TypeError
88
+ raise ParseError, "invalid line range: #{str.inspect}"
89
+ end
90
+
91
+ def build_config_opts(files, line_ranges, target, timeout, jobs, fail_fast, spec)
92
+ opts = { target_files: files, line_ranges: line_ranges, format: :json, quiet: true, skip_config_file: true }
93
+ opts[:target] = target if target
94
+ opts[:timeout] = timeout if timeout
95
+ opts[:jobs] = jobs if jobs
96
+ opts[:fail_fast] = fail_fast if fail_fast
97
+ opts[:spec_files] = spec if spec
98
+ opts
99
+ end
100
+
101
+ def build_error_payload(error)
102
+ error_type = case error
103
+ when ConfigError then "config_error"
104
+ when ParseError then "parse_error"
105
+ else "runtime_error"
106
+ end
107
+
108
+ payload = { type: error_type, message: error.message }
109
+ payload[:file] = error.file if error.file
110
+ { error: payload }
111
+ end
112
+ end
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "mcp"
4
+ require_relative "../version"
5
+ require_relative "mutate_tool"
6
+
7
+ module Evilution
8
+ module MCP
9
+ class Server
10
+ def self.build
11
+ ::MCP::Server.new(
12
+ name: "evilution",
13
+ version: Evilution::VERSION,
14
+ tools: [MutateTool]
15
+ )
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../memory"
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
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
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
39
+ end
40
+ end
@@ -16,10 +16,22 @@ module Evilution
16
16
  @file_path = file_path
17
17
  @line = line
18
18
  @column = column
19
- freeze
19
+ @diff = nil
20
20
  end
21
21
 
22
22
  def diff
23
+ @diff ||= compute_diff
24
+ end
25
+
26
+ def strip_sources!
27
+ diff # ensure diff is cached before clearing sources
28
+ @original_source = nil
29
+ @mutated_source = nil
30
+ end
31
+
32
+ private
33
+
34
+ def compute_diff
23
35
  original_lines = original_source.lines
24
36
  mutated_lines = mutated_source.lines
25
37
  diffs = ::Diff::LCS.diff(original_lines, mutated_lines)
@@ -38,6 +50,8 @@ module Evilution
38
50
  result.join("\n")
39
51
  end
40
52
 
53
+ public
54
+
41
55
  def to_s
42
56
  "#{operator_name}: #{file_path}:#{line}"
43
57
  end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
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
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Evilution
4
+ module Mutator
5
+ module Operator
6
+ class MethodCallRemoval < Base
7
+ def visit_call_node(node)
8
+ if node.receiver
9
+ add_mutation(
10
+ offset: node.location.start_offset,
11
+ length: node.location.length,
12
+ replacement: node.receiver.slice,
13
+ node: node
14
+ )
15
+ end
16
+
17
+ super
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
@@ -23,7 +23,9 @@ module Evilution
23
23
  Operator::MethodBodyReplacement,
24
24
  Operator::NegationInsertion,
25
25
  Operator::ReturnValueRemoval,
26
- Operator::CollectionReplacement
26
+ Operator::CollectionReplacement,
27
+ Operator::MethodCallRemoval,
28
+ Operator::ArgumentRemoval
27
29
  ].each { |op| registry.register(op) }
28
30
  registry
29
31
  end
@@ -13,15 +13,10 @@ module Evilution
13
13
  lines << mutations_line(summary)
14
14
  lines << score_line(summary)
15
15
  lines << duration_line(summary)
16
-
17
- if summary.survived_results.any?
18
- lines << ""
19
- lines << "Survived mutations:"
20
- summary.survived_results.each do |result|
21
- lines << format_survived(result)
22
- end
23
- end
24
-
16
+ peak = summary.peak_memory_mb
17
+ lines << peak_memory_line(peak) if peak
18
+ append_survived(lines, summary)
19
+ append_neutral(lines, summary)
25
20
  lines << ""
26
21
  lines << "[TRUNCATED] Stopped early due to --fail-fast" if summary.truncated?
27
22
  lines << result_line(summary)
@@ -31,17 +26,35 @@ module Evilution
31
26
 
32
27
  private
33
28
 
29
+ def append_survived(lines, summary)
30
+ return unless summary.survived_results.any?
31
+
32
+ lines << ""
33
+ lines << "Survived mutations:"
34
+ summary.survived_results.each { |result| lines << format_survived(result) }
35
+ end
36
+
37
+ def append_neutral(lines, summary)
38
+ return unless summary.neutral_results.any?
39
+
40
+ lines << ""
41
+ lines << "Neutral mutations (test already failing):"
42
+ summary.neutral_results.each { |result| lines << format_neutral(result) }
43
+ end
44
+
34
45
  def header
35
46
  "Evilution v#{Evilution::VERSION} — Mutation Testing Results"
36
47
  end
37
48
 
38
49
  def mutations_line(summary)
39
- "Mutations: #{summary.total} total, #{summary.killed} killed, " \
40
- "#{summary.survived} survived, #{summary.timed_out} timed out"
50
+ parts = "Mutations: #{summary.total} total, #{summary.killed} killed, " \
51
+ "#{summary.survived} survived, #{summary.timed_out} timed out"
52
+ parts += ", #{summary.neutral} neutral" if summary.neutral.positive?
53
+ parts
41
54
  end
42
55
 
43
56
  def score_line(summary)
44
- denominator = summary.total - summary.errors
57
+ denominator = summary.total - summary.errors - summary.neutral
45
58
  score_pct = format_pct(summary.score)
46
59
  "Score: #{score_pct} (#{summary.killed}/#{denominator})"
47
60
  end
@@ -57,6 +70,11 @@ module Evilution
57
70
  " #{mutation.operator_name}: #{location}\n#{diff_lines}"
58
71
  end
59
72
 
73
+ def format_neutral(result)
74
+ mutation = result.mutation
75
+ " #{mutation.operator_name}: #{mutation.file_path}:#{mutation.line}"
76
+ end
77
+
60
78
  def result_line(summary)
61
79
  min_score = 0.8
62
80
  pass_fail = summary.success?(min_score: min_score) ? "PASS" : "FAIL"
@@ -65,6 +83,10 @@ module Evilution
65
83
  "Result: #{pass_fail} (score #{score_pct} #{pass_fail == "PASS" ? ">=" : "<"} #{threshold_pct})"
66
84
  end
67
85
 
86
+ def peak_memory_line(peak_mb)
87
+ format("Peak memory: %<mb>.1f MB", mb: peak_mb)
88
+ end
89
+
68
90
  def format_pct(value)
69
91
  format("%.2f%%", value * 100)
70
92
  end
@@ -24,6 +24,7 @@ module Evilution
24
24
  summary: build_summary(summary),
25
25
  survived: summary.survived_results.map { |r| build_mutation_detail(r) },
26
26
  killed: summary.killed_results.map { |r| build_mutation_detail(r) },
27
+ neutral: summary.neutral_results.map { |r| build_mutation_detail(r) },
27
28
  timed_out: summary.results.select(&:timeout?).map { |r| build_mutation_detail(r) },
28
29
  errors: summary.results.select(&:error?).map { |r| build_mutation_detail(r) }
29
30
  }
@@ -36,10 +37,13 @@ module Evilution
36
37
  survived: summary.survived,
37
38
  timed_out: summary.timed_out,
38
39
  errors: summary.errors,
40
+ neutral: summary.neutral,
39
41
  score: summary.score.round(4),
40
42
  duration: summary.duration.round(4)
41
43
  }
42
44
  data[:truncated] = true if summary.truncated?
45
+ peak = summary.peak_memory_mb
46
+ data[:peak_memory_mb] = peak.round(1) if peak
43
47
  data
44
48
  end
45
49
 
@@ -55,6 +59,8 @@ module Evilution
55
59
  }
56
60
  detail[:suggestion] = @suggestion.suggestion_for(mutation) if result.status == :survived
57
61
  detail[:test_command] = result.test_command if result.test_command
62
+ detail[:child_rss_kb] = result.child_rss_kb if result.child_rss_kb
63
+ detail[:memory_delta_kb] = result.memory_delta_kb if result.memory_delta_kb
58
64
  detail
59
65
  end
60
66
  end
@@ -21,7 +21,9 @@ module Evilution
21
21
  "method_body_replacement" => "Add a test that checks the method's return value or side effects",
22
22
  "negation_insertion" => "Add a test where the predicate result matters (not just truthiness)",
23
23
  "return_value_removal" => "Add a test that uses the return value of this method",
24
- "collection_replacement" => "Add a test that checks the return value of the collection operation, not just side effects"
24
+ "collection_replacement" => "Add a test that checks the return value of the collection operation, not just side effects",
25
+ "method_call_removal" => "Add a test that depends on the return value or side effect of this method call",
26
+ "argument_removal" => "Add a test that verifies the correct arguments are passed to this method call"
25
27
  }.freeze
26
28
 
27
29
  DEFAULT_SUGGESTION = "Add a more specific test that detects this mutation"