evilution 0.18.0 → 0.20.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 (43) hide show
  1. checksums.yaml +4 -4
  2. data/.beads/.migration-hint-ts +1 -1
  3. data/.beads/issues.jsonl +85 -15
  4. data/CHANGELOG.md +37 -0
  5. data/README.md +24 -4
  6. data/lib/evilution/baseline.rb +9 -1
  7. data/lib/evilution/cli.rb +11 -0
  8. data/lib/evilution/equivalent/detector.rb +3 -1
  9. data/lib/evilution/equivalent/heuristic/alias_swap.rb +2 -1
  10. data/lib/evilution/equivalent/heuristic/void_context.rb +77 -0
  11. data/lib/evilution/integration/crash_detector.rb +55 -0
  12. data/lib/evilution/integration/rspec.rb +102 -6
  13. data/lib/evilution/isolation/fork.rb +10 -6
  14. data/lib/evilution/isolation/in_process.rb +14 -10
  15. data/lib/evilution/mutator/operator/begin_unwrap.rb +21 -0
  16. data/lib/evilution/mutator/operator/block_param_removal.rb +57 -0
  17. data/lib/evilution/mutator/operator/case_when.rb +55 -0
  18. data/lib/evilution/mutator/operator/equality_to_identity.rb +22 -0
  19. data/lib/evilution/mutator/operator/lambda_body.rb +18 -0
  20. data/lib/evilution/mutator/operator/loop_flip.rb +27 -0
  21. data/lib/evilution/mutator/operator/method_body_replacement.rb +10 -6
  22. data/lib/evilution/mutator/operator/predicate_replacement.rb +27 -0
  23. data/lib/evilution/mutator/operator/retry_removal.rb +16 -0
  24. data/lib/evilution/mutator/operator/send_mutation.rb +8 -1
  25. data/lib/evilution/mutator/operator/string_interpolation.rb +32 -0
  26. data/lib/evilution/mutator/registry.rb +10 -1
  27. data/lib/evilution/parallel/pool.rb +2 -2
  28. data/lib/evilution/parallel/work_queue.rb +54 -13
  29. data/lib/evilution/related_spec_heuristic.rb +63 -0
  30. data/lib/evilution/reporter/cli.rb +14 -8
  31. data/lib/evilution/reporter/html.rb +32 -2
  32. data/lib/evilution/reporter/json.rb +15 -0
  33. data/lib/evilution/result/coverage_gap.rb +35 -0
  34. data/lib/evilution/result/coverage_gap_grouper.rb +22 -0
  35. data/lib/evilution/result/mutation_result.rb +5 -2
  36. data/lib/evilution/result/summary.rb +5 -0
  37. data/lib/evilution/runner.rb +7 -4
  38. data/lib/evilution/session/store.rb +13 -0
  39. data/lib/evilution/spec_resolver.rb +13 -1
  40. data/lib/evilution/version.rb +1 -1
  41. data/lib/evilution.rb +9 -0
  42. data/script/memory_check +22 -0
  43. metadata +16 -2
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../operator"
4
+
5
+ class Evilution::Mutator::Operator::EqualityToIdentity < Evilution::Mutator::Base
6
+ def visit_call_node(node)
7
+ if node.name == :== && node.receiver && node.arguments
8
+ receiver_text = @file_source.byteslice(node.receiver.location.start_offset, node.receiver.location.length)
9
+ arg = node.arguments.arguments.first
10
+ arg_text = @file_source.byteslice(arg.location.start_offset, arg.location.length)
11
+
12
+ add_mutation(
13
+ offset: node.location.start_offset,
14
+ length: node.location.length,
15
+ replacement: "#{receiver_text}.equal?(#{arg_text})",
16
+ node: node
17
+ )
18
+ end
19
+
20
+ super
21
+ end
22
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../operator"
4
+
5
+ class Evilution::Mutator::Operator::LambdaBody < Evilution::Mutator::Base
6
+ def visit_lambda_node(node)
7
+ if node.body
8
+ add_mutation(
9
+ offset: node.body.location.start_offset,
10
+ length: node.body.location.length,
11
+ replacement: "nil",
12
+ node: node
13
+ )
14
+ end
15
+
16
+ super
17
+ end
18
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../operator"
4
+
5
+ class Evilution::Mutator::Operator::LoopFlip < Evilution::Mutator::Base
6
+ def visit_while_node(node)
7
+ add_mutation(
8
+ offset: node.keyword_loc.start_offset,
9
+ length: node.keyword_loc.length,
10
+ replacement: "until",
11
+ node: node
12
+ )
13
+
14
+ super
15
+ end
16
+
17
+ def visit_until_node(node)
18
+ add_mutation(
19
+ offset: node.keyword_loc.start_offset,
20
+ length: node.keyword_loc.length,
21
+ replacement: "while",
22
+ node: node
23
+ )
24
+
25
+ super
26
+ end
27
+ end
@@ -3,14 +3,18 @@
3
3
  require_relative "../operator"
4
4
 
5
5
  class Evilution::Mutator::Operator::MethodBodyReplacement < Evilution::Mutator::Base
6
+ REPLACEMENTS = %w[nil self super].freeze
7
+
6
8
  def visit_def_node(node)
7
9
  if node.body
8
- add_mutation(
9
- offset: node.body.location.start_offset,
10
- length: node.body.location.length,
11
- replacement: "nil",
12
- node: node
13
- )
10
+ REPLACEMENTS.each do |replacement|
11
+ add_mutation(
12
+ offset: node.body.location.start_offset,
13
+ length: node.body.location.length,
14
+ replacement: replacement,
15
+ node: node
16
+ )
17
+ end
14
18
  end
15
19
 
16
20
  super
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../operator"
4
+
5
+ class Evilution::Mutator::Operator::PredicateReplacement < Evilution::Mutator::Base
6
+ def visit_call_node(node)
7
+ if node.name.to_s.end_with?("?")
8
+ loc = node.location
9
+
10
+ add_mutation(
11
+ offset: loc.start_offset,
12
+ length: loc.length,
13
+ replacement: "true",
14
+ node: node
15
+ )
16
+
17
+ add_mutation(
18
+ offset: loc.start_offset,
19
+ length: loc.length,
20
+ replacement: "false",
21
+ node: node
22
+ )
23
+ end
24
+
25
+ super
26
+ end
27
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../operator"
4
+
5
+ class Evilution::Mutator::Operator::RetryRemoval < Evilution::Mutator::Base
6
+ def visit_retry_node(node)
7
+ add_mutation(
8
+ offset: node.location.start_offset,
9
+ length: node.location.length,
10
+ replacement: "nil",
11
+ node: node
12
+ )
13
+
14
+ super
15
+ end
16
+ end
@@ -29,7 +29,14 @@ class Evilution::Mutator::Operator::SendMutation < Evilution::Mutator::Base
29
29
  to_i: [:to_s],
30
30
  to_f: [:to_i],
31
31
  to_a: [:to_h],
32
- to_h: [:to_a]
32
+ to_h: [:to_a],
33
+ downcase: [:upcase],
34
+ upcase: [:downcase],
35
+ strip: %i[lstrip rstrip],
36
+ lstrip: [:strip],
37
+ rstrip: [:strip],
38
+ chomp: [:chop],
39
+ chop: [:chomp]
33
40
  }.freeze
34
41
 
35
42
  def visit_call_node(node)
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../operator"
4
+
5
+ class Evilution::Mutator::Operator::StringInterpolation < Evilution::Mutator::Base
6
+ def visit_interpolated_string_node(node)
7
+ mutate_embedded_statements(node)
8
+ super
9
+ end
10
+
11
+ def visit_interpolated_symbol_node(node)
12
+ mutate_embedded_statements(node)
13
+ super
14
+ end
15
+
16
+ private
17
+
18
+ def mutate_embedded_statements(node)
19
+ node.parts.each do |part|
20
+ next unless part.is_a?(Prism::EmbeddedStatementsNode)
21
+ next if part.statements.nil? || part.statements.body.empty?
22
+
23
+ stmt = part.statements
24
+ add_mutation(
25
+ offset: stmt.location.start_offset,
26
+ length: stmt.location.length,
27
+ replacement: "nil",
28
+ node: part
29
+ )
30
+ end
31
+ end
32
+ end
@@ -65,7 +65,16 @@ class Evilution::Mutator::Registry
65
65
  Evilution::Mutator::Operator::YieldStatement,
66
66
  Evilution::Mutator::Operator::SplatOperator,
67
67
  Evilution::Mutator::Operator::DefinedCheck,
68
- Evilution::Mutator::Operator::RegexCapture
68
+ Evilution::Mutator::Operator::RegexCapture,
69
+ Evilution::Mutator::Operator::LoopFlip,
70
+ Evilution::Mutator::Operator::StringInterpolation,
71
+ Evilution::Mutator::Operator::RetryRemoval,
72
+ Evilution::Mutator::Operator::CaseWhen,
73
+ Evilution::Mutator::Operator::PredicateReplacement,
74
+ Evilution::Mutator::Operator::EqualityToIdentity,
75
+ Evilution::Mutator::Operator::LambdaBody,
76
+ Evilution::Mutator::Operator::BeginUnwrap,
77
+ Evilution::Mutator::Operator::BlockParamRemoval
69
78
  ].each { |op| registry.register(op) }
70
79
  registry
71
80
  end
@@ -3,8 +3,8 @@
3
3
  require_relative "work_queue"
4
4
 
5
5
  class Evilution::Parallel::Pool
6
- def initialize(size:, hooks: nil)
7
- @queue = Evilution::Parallel::WorkQueue.new(size: size, hooks: hooks)
6
+ def initialize(size:, hooks: nil, item_timeout: nil)
7
+ @queue = Evilution::Parallel::WorkQueue.new(size: size, hooks: hooks, item_timeout: item_timeout)
8
8
  end
9
9
 
10
10
  def map(items, &)
@@ -7,6 +7,8 @@ class Evilution::Parallel::WorkQueue
7
7
 
8
8
  STATS = :__stats__
9
9
 
10
+ TIMING_GRACE_PERIOD = 5
11
+
10
12
  WorkerStat = Struct.new(:pid, :items_completed, :busy_time, :wall_time) do
11
13
  def idle_time
12
14
  wall_time - busy_time
@@ -19,13 +21,17 @@ class Evilution::Parallel::WorkQueue
19
21
  end
20
22
  end
21
23
 
22
- def initialize(size:, hooks: nil, prefetch: 1)
24
+ def initialize(size:, hooks: nil, prefetch: 1, item_timeout: nil)
23
25
  raise ArgumentError, "pool size must be a positive integer, got #{size.inspect}" unless size.is_a?(Integer) && size >= 1
24
26
  raise ArgumentError, "prefetch must be a positive integer, got #{prefetch.inspect}" unless prefetch.is_a?(Integer) && prefetch >= 1
27
+ unless item_timeout.nil? || (item_timeout.is_a?(Numeric) && item_timeout.positive?)
28
+ raise ArgumentError, "item_timeout must be nil or a positive number, got #{item_timeout.inspect}"
29
+ end
25
30
 
26
31
  @size = size
27
32
  @hooks = hooks
28
33
  @prefetch = prefetch
34
+ @item_timeout = item_timeout
29
35
  @worker_stats = []
30
36
  end
31
37
 
@@ -63,7 +69,7 @@ class Evilution::Parallel::WorkQueue
63
69
  cmd_read.close
64
70
  res_write.close
65
71
 
66
- { pid: pid, cmd_write: cmd_write, res_read: res_read, items_completed: 0 }
72
+ { pid: pid, cmd_write: cmd_write, res_read: res_read, items_completed: 0, pending: 0 }
67
73
  end
68
74
  end
69
75
 
@@ -120,8 +126,18 @@ class Evilution::Parallel::WorkQueue
120
126
  result_ios = io_to_worker.keys
121
127
 
122
128
  while state.in_flight.positive?
123
- readable, = IO.select(result_ios)
124
- readable.each { |io| handle_result(io, io_to_worker[io], items, state) }
129
+ readable, = IO.select(result_ios, nil, nil, @item_timeout)
130
+
131
+ if readable.nil?
132
+ terminate_stuck_workers(workers)
133
+ state.first_error = Evilution::Error.new("worker timed out after #{@item_timeout}s") if state.first_error.nil?
134
+ break
135
+ end
136
+
137
+ readable.each do |io|
138
+ alive = handle_result(io, io_to_worker[io], items, state)
139
+ result_ios.delete(io) unless alive
140
+ end
125
141
  end
126
142
  end
127
143
 
@@ -130,23 +146,27 @@ class Evilution::Parallel::WorkQueue
130
146
 
131
147
  if message.nil?
132
148
  state.first_error = Evilution::Error.new("worker process exited unexpectedly") if state.first_error.nil?
133
- state.in_flight -= 1
134
- return
149
+ state.in_flight -= worker[:pending]
150
+ worker[:pending] = 0
151
+ return false
135
152
  end
136
153
 
137
154
  index, status, value = message
138
155
  state.first_error = value if status == :error && state.first_error.nil?
139
156
  state.results[index] = value if status == :ok
140
157
  state.in_flight -= 1
158
+ worker[:pending] -= 1
141
159
  worker[:items_completed] += 1
142
160
 
143
161
  send_item(worker, items, state) if state.next_index < items.length && state.first_error.nil?
162
+ true
144
163
  end
145
164
 
146
165
  def send_item(worker, items, state)
147
166
  write_message(worker[:cmd_write], [state.next_index, items[state.next_index]])
148
167
  state.next_index += 1
149
168
  state.in_flight += 1
169
+ worker[:pending] += 1
150
170
  end
151
171
 
152
172
  def build_worker_stats(workers)
@@ -155,6 +175,14 @@ class Evilution::Parallel::WorkQueue
155
175
  end
156
176
  end
157
177
 
178
+ def terminate_stuck_workers(workers)
179
+ workers.each do |worker|
180
+ Process.kill("KILL", worker[:pid])
181
+ rescue Errno::ESRCH
182
+ nil # Already exited
183
+ end
184
+ end
185
+
158
186
  def shutdown_workers(workers)
159
187
  workers.each do |worker|
160
188
  write_message(worker[:cmd_write], SHUTDOWN)
@@ -174,18 +202,31 @@ class Evilution::Parallel::WorkQueue
174
202
  end
175
203
 
176
204
  def collect_worker_timing(workers)
177
- workers.each do |worker|
178
- message = read_result(worker[:res_read])
179
- next if message.nil?
205
+ io_to_worker = workers.reject { |w| w[:res_read].closed? }.to_h { |w| [w[:res_read], w] }
206
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + TIMING_GRACE_PERIOD
180
207
 
181
- tag, busy_time, wall_time = message
182
- next unless tag == STATS
208
+ until io_to_worker.empty?
209
+ remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
210
+ break if remaining <= 0
183
211
 
184
- worker[:busy_time] = busy_time
185
- worker[:wall_time] = wall_time
212
+ readable, = IO.select(io_to_worker.keys, nil, nil, remaining)
213
+ break unless readable
214
+
215
+ readable.each { |io| apply_worker_timing(io_to_worker.delete(io), io) }
186
216
  end
187
217
  end
188
218
 
219
+ def apply_worker_timing(worker, io)
220
+ message = read_result(io)
221
+ return if message.nil?
222
+
223
+ tag, busy_time, wall_time = message
224
+ return unless tag == STATS
225
+
226
+ worker[:busy_time] = busy_time
227
+ worker[:wall_time] = wall_time
228
+ end
229
+
189
230
  def write_message(io, data)
190
231
  payload = Marshal.dump(data)
191
232
  io.write([payload.bytesize].pack("N"))
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Evilution::RelatedSpecHeuristic
4
+ RELATED_SPEC_DIRS = %w[
5
+ spec/requests
6
+ spec/integration
7
+ spec/features
8
+ spec/system
9
+ ].freeze
10
+
11
+ INCLUDES_PATTERN = /\bincludes\(/
12
+
13
+ def call(mutation)
14
+ return [] unless includes_mutation?(mutation)
15
+
16
+ domain = extract_domain(mutation.file_path)
17
+ return [] unless domain
18
+
19
+ find_related_specs(domain)
20
+ end
21
+
22
+ private
23
+
24
+ def includes_mutation?(mutation)
25
+ diff = mutation.diff
26
+ return false unless diff
27
+
28
+ diff.split("\n").any? { |line| line.start_with?("- ") && line.match?(INCLUDES_PATTERN) }
29
+ end
30
+
31
+ def extract_domain(file_path)
32
+ normalized = normalize_path(file_path)
33
+
34
+ # Strip common prefixes and get the relative path under app/ or lib/
35
+ relative = normalized
36
+ .delete_prefix("app/controllers/")
37
+ .delete_prefix("app/models/")
38
+ .delete_prefix("app/")
39
+ .delete_prefix("lib/")
40
+
41
+ # Remove .rb extension and _controller suffix
42
+ basename = relative.sub(/\.rb\z/, "")
43
+ basename = basename.sub(/_controller\z/, "")
44
+
45
+ basename.empty? ? nil : basename
46
+ end
47
+
48
+ def normalize_path(path)
49
+ path = path.delete_prefix("./")
50
+ path = path.delete_prefix("#{Dir.pwd}/") if path.start_with?("/")
51
+ path
52
+ end
53
+
54
+ def find_related_specs(domain)
55
+ RELATED_SPEC_DIRS.flat_map { |dir| find_specs_in_dir(dir, domain) }.sort
56
+ end
57
+
58
+ def find_specs_in_dir(dir, domain)
59
+ return [] unless Dir.exist?(dir)
60
+
61
+ Dir.glob(File.join(dir, "**", "#{domain}_spec.rb"))
62
+ end
63
+ end
@@ -30,11 +30,12 @@ class Evilution::Reporter::CLI
30
30
  private
31
31
 
32
32
  def append_survived(lines, summary)
33
- return unless summary.survived_results.any?
33
+ gaps = summary.coverage_gaps
34
+ return unless gaps.any?
34
35
 
35
36
  lines << ""
36
- lines << "Survived mutations:"
37
- summary.survived_results.each { |result| lines << format_survived(result) }
37
+ lines << "Survived mutations (#{gaps.length} coverage gap#{"s" unless gaps.length == 1}):"
38
+ gaps.each { |gap| lines << format_coverage_gap(gap) }
38
39
  end
39
40
 
40
41
  def append_neutral(lines, summary)
@@ -90,11 +91,16 @@ class Evilution::Reporter::CLI
90
91
  "Efficiency: #{pct} killtime, #{rate} mutations/s"
91
92
  end
92
93
 
93
- def format_survived(result)
94
- mutation = result.mutation
95
- location = "#{mutation.file_path}:#{mutation.line}"
96
- diff_lines = mutation.diff.split("\n").map { |l| " #{l}" }.join("\n")
97
- " #{mutation.operator_name}: #{location} (#{mutation.subject.name})\n#{diff_lines}"
94
+ def format_coverage_gap(gap)
95
+ location = "#{gap.file_path}:#{gap.line}"
96
+ header = if gap.single?
97
+ " #{gap.primary_operator}: #{location} (#{gap.subject_name})"
98
+ else
99
+ operators = gap.operator_names.join(", ")
100
+ " #{location} (#{gap.subject_name}) [#{gap.count} mutations: #{operators}]"
101
+ end
102
+ diff_lines = gap.primary_diff.split("\n").map { |l| " #{l}" }.join("\n")
103
+ "#{header}\n#{diff_lines}"
98
104
  end
99
105
 
100
106
  def format_neutral(result)
@@ -4,6 +4,7 @@ require "cgi"
4
4
  require_relative "suggestion"
5
5
 
6
6
  require_relative "../reporter"
7
+ require_relative "../result/coverage_gap_grouper"
7
8
 
8
9
  class Evilution::Reporter::HTML
9
10
  def initialize(baseline: nil)
@@ -156,15 +157,39 @@ class Evilution::Reporter::HTML
156
157
  def build_survived_details(survived)
157
158
  return "" if survived.empty?
158
159
 
159
- entries = survived.map { |r| build_survived_entry(r) }.join("\n")
160
+ gaps = Evilution::Result::CoverageGapGrouper.new.call(survived)
161
+ entries = gaps.map { |gap| build_gap_entry(gap) }.join("\n")
160
162
  <<~HTML
161
163
  <div class="survived-details">
162
- <h3>Survived Mutations</h3>
164
+ <h3>Coverage Gaps (#{gaps.length})</h3>
163
165
  #{entries}
164
166
  </div>
165
167
  HTML
166
168
  end
167
169
 
170
+ def build_gap_entry(gap)
171
+ if gap.single?
172
+ build_survived_entry(gap.mutation_results.first)
173
+ else
174
+ build_grouped_gap_entry(gap)
175
+ end
176
+ end
177
+
178
+ def build_grouped_gap_entry(gap)
179
+ operator_tags = gap.operator_names.map { |op| %(<span class="operator-tag">#{h(op)}</span>) }.join(" ")
180
+ entries_html = gap.mutation_results.map { |r| build_survived_entry(r) }.join("\n")
181
+ <<~HTML
182
+ <div class="coverage-gap">
183
+ <div class="gap-header">
184
+ <span class="location">#{h(gap.file_path)}:#{gap.line} (#{h(gap.subject_name)})</span>
185
+ <span class="gap-count">#{gap.count} mutations</span>
186
+ #{operator_tags}
187
+ </div>
188
+ #{entries_html}
189
+ </div>
190
+ HTML
191
+ end
192
+
168
193
  def build_survived_entry(result)
169
194
  mutation = result.mutation
170
195
  suggestion_text = @suggestion.suggestion_for(mutation)
@@ -307,6 +332,11 @@ class Evilution::Reporter::HTML
307
332
  .diff-removed { color: #f85149; display: block; }
308
333
  .diff-added { color: #3fb950; display: block; }
309
334
  .suggestion { color: #d29922; font-size: 0.8rem; margin-top: 0.5rem; font-style: italic; }
335
+ .coverage-gap { border: 1px solid #30363d; border-radius: 6px; padding: 0.75rem; margin-bottom: 0.75rem; background: #161b22; }
336
+ .gap-header { display: flex; align-items: center; gap: 0.75rem; margin-bottom: 0.5rem; font-size: 0.85rem; padding-bottom: 0.5rem; border-bottom: 1px solid #21262d; }
337
+ .gap-header .location { color: #58a6ff; font-family: monospace; }
338
+ .gap-count { background: #4a1a1a; color: #f85149; font-size: 0.7rem; padding: 0.1rem 0.5rem; border-radius: 10px; font-weight: bold; }
339
+ .operator-tag { background: #21262d; color: #8b949e; font-size: 0.7rem; padding: 0.1rem 0.4rem; border-radius: 4px; font-family: monospace; }
310
340
  .empty { color: #8b949e; text-align: center; padding: 2rem; }
311
341
  .baseline-comparison { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 1rem; margin-bottom: 2rem; }
312
342
  .baseline-comparison h2 { font-size: 1rem; color: #f0f6fc; margin-bottom: 0.75rem; }
@@ -24,6 +24,7 @@ class Evilution::Reporter::JSON
24
24
  timestamp: Time.now.iso8601,
25
25
  summary: build_summary(summary),
26
26
  survived: summary.survived_results.map { |r| build_mutation_detail(r) },
27
+ coverage_gaps: build_coverage_gaps(summary),
27
28
  killed: summary.killed_results.map { |r| build_mutation_detail(r) },
28
29
  neutral: summary.neutral_results.map { |r| build_mutation_detail(r) },
29
30
  timed_out: summary.results.select(&:timeout?).map { |r| build_mutation_detail(r) },
@@ -75,11 +76,25 @@ class Evilution::Reporter::JSON
75
76
  }
76
77
  detail[:suggestion] = @suggestion.suggestion_for(mutation) if result.status == :survived
77
78
  detail[:test_command] = result.test_command if result.test_command
79
+ detail[:parent_rss_kb] = result.parent_rss_kb if result.parent_rss_kb
78
80
  detail[:child_rss_kb] = result.child_rss_kb if result.child_rss_kb
79
81
  detail[:memory_delta_kb] = result.memory_delta_kb if result.memory_delta_kb
80
82
  detail
81
83
  end
82
84
 
85
+ def build_coverage_gaps(summary)
86
+ summary.coverage_gaps.map do |gap|
87
+ {
88
+ file: gap.file_path,
89
+ subject: gap.subject_name,
90
+ line: gap.line,
91
+ operators: gap.operator_names,
92
+ count: gap.count,
93
+ mutations: gap.mutation_results.map { |r| build_mutation_detail(r) }
94
+ }
95
+ end
96
+ end
97
+
83
98
  def build_disabled_detail(mutation)
84
99
  {
85
100
  operator: mutation.operator_name,
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../result"
4
+
5
+ class Evilution::Result::CoverageGap
6
+ attr_reader :file_path, :subject_name, :line, :mutation_results
7
+
8
+ def initialize(file_path:, subject_name:, line:, mutation_results:)
9
+ @file_path = file_path
10
+ @subject_name = subject_name
11
+ @line = line
12
+ @mutation_results = mutation_results.dup.freeze
13
+ freeze
14
+ end
15
+
16
+ def operator_names
17
+ mutation_results.map { |r| r.mutation.operator_name }.uniq
18
+ end
19
+
20
+ def primary_operator
21
+ mutation_results.first.mutation.operator_name
22
+ end
23
+
24
+ def primary_diff
25
+ mutation_results.first.mutation.diff
26
+ end
27
+
28
+ def count
29
+ mutation_results.length
30
+ end
31
+
32
+ def single?
33
+ count == 1
34
+ end
35
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "coverage_gap"
4
+
5
+ class Evilution::Result::CoverageGapGrouper
6
+ def call(survived_results)
7
+ grouped = survived_results.group_by do |r|
8
+ [r.mutation.file_path, r.mutation.subject.name, r.mutation.line]
9
+ end
10
+
11
+ gaps = grouped.map do |(file_path, subject_name, line), results|
12
+ Evilution::Result::CoverageGap.new(
13
+ file_path: file_path,
14
+ subject_name: subject_name,
15
+ line: line,
16
+ mutation_results: results
17
+ )
18
+ end
19
+
20
+ gaps.sort_by { |gap| [gap.file_path, gap.line, gap.subject_name] }
21
+ end
22
+ end
@@ -6,9 +6,11 @@ class Evilution::Result::MutationResult
6
6
  STATUSES = %i[killed survived timeout error neutral equivalent].freeze
7
7
 
8
8
  attr_reader :mutation, :status, :duration, :killing_test, :test_command,
9
- :child_rss_kb, :memory_delta_kb
9
+ :child_rss_kb, :memory_delta_kb, :parent_rss_kb
10
10
 
11
- def initialize(mutation:, status:, duration: 0.0, killing_test: nil, test_command: nil, child_rss_kb: nil, memory_delta_kb: nil)
11
+ def initialize(mutation:, status:, duration: 0.0, killing_test: nil,
12
+ test_command: nil, child_rss_kb: nil, memory_delta_kb: nil,
13
+ parent_rss_kb: nil)
12
14
  raise ArgumentError, "invalid status: #{status}" unless STATUSES.include?(status)
13
15
 
14
16
  @mutation = mutation
@@ -18,6 +20,7 @@ class Evilution::Result::MutationResult
18
20
  @test_command = test_command
19
21
  @child_rss_kb = child_rss_kb
20
22
  @memory_delta_kb = memory_delta_kb
23
+ @parent_rss_kb = parent_rss_kb
21
24
  freeze
22
25
  end
23
26
 
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "../result"
4
+ require_relative "coverage_gap_grouper"
4
5
 
5
6
  class Evilution::Result::Summary
6
7
  attr_reader :results, :duration, :skipped, :disabled_mutations
@@ -73,6 +74,10 @@ class Evilution::Result::Summary
73
74
  results.select(&:equivalent?)
74
75
  end
75
76
 
77
+ def coverage_gaps
78
+ Evilution::Result::CoverageGapGrouper.new.call(survived_results)
79
+ end
80
+
76
81
  def killtime
77
82
  results.sum(0.0, &:duration)
78
83
  end