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.
- checksums.yaml +4 -4
- data/.beads/.migration-hint-ts +1 -1
- data/.beads/issues.jsonl +85 -15
- data/CHANGELOG.md +37 -0
- data/README.md +24 -4
- data/lib/evilution/baseline.rb +9 -1
- data/lib/evilution/cli.rb +11 -0
- data/lib/evilution/equivalent/detector.rb +3 -1
- data/lib/evilution/equivalent/heuristic/alias_swap.rb +2 -1
- data/lib/evilution/equivalent/heuristic/void_context.rb +77 -0
- data/lib/evilution/integration/crash_detector.rb +55 -0
- data/lib/evilution/integration/rspec.rb +102 -6
- data/lib/evilution/isolation/fork.rb +10 -6
- data/lib/evilution/isolation/in_process.rb +14 -10
- data/lib/evilution/mutator/operator/begin_unwrap.rb +21 -0
- data/lib/evilution/mutator/operator/block_param_removal.rb +57 -0
- data/lib/evilution/mutator/operator/case_when.rb +55 -0
- data/lib/evilution/mutator/operator/equality_to_identity.rb +22 -0
- data/lib/evilution/mutator/operator/lambda_body.rb +18 -0
- data/lib/evilution/mutator/operator/loop_flip.rb +27 -0
- data/lib/evilution/mutator/operator/method_body_replacement.rb +10 -6
- data/lib/evilution/mutator/operator/predicate_replacement.rb +27 -0
- data/lib/evilution/mutator/operator/retry_removal.rb +16 -0
- data/lib/evilution/mutator/operator/send_mutation.rb +8 -1
- data/lib/evilution/mutator/operator/string_interpolation.rb +32 -0
- data/lib/evilution/mutator/registry.rb +10 -1
- data/lib/evilution/parallel/pool.rb +2 -2
- data/lib/evilution/parallel/work_queue.rb +54 -13
- data/lib/evilution/related_spec_heuristic.rb +63 -0
- data/lib/evilution/reporter/cli.rb +14 -8
- data/lib/evilution/reporter/html.rb +32 -2
- data/lib/evilution/reporter/json.rb +15 -0
- data/lib/evilution/result/coverage_gap.rb +35 -0
- data/lib/evilution/result/coverage_gap_grouper.rb +22 -0
- data/lib/evilution/result/mutation_result.rb +5 -2
- data/lib/evilution/result/summary.rb +5 -0
- data/lib/evilution/runner.rb +7 -4
- data/lib/evilution/session/store.rb +13 -0
- data/lib/evilution/spec_resolver.rb +13 -1
- data/lib/evilution/version.rb +1 -1
- data/lib/evilution.rb +9 -0
- data/script/memory_check +22 -0
- 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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
-
|
|
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 -=
|
|
134
|
-
|
|
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.
|
|
178
|
-
|
|
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
|
-
|
|
182
|
-
|
|
208
|
+
until io_to_worker.empty?
|
|
209
|
+
remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
210
|
+
break if remaining <= 0
|
|
183
211
|
|
|
184
|
-
|
|
185
|
-
|
|
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
|
-
|
|
33
|
+
gaps = summary.coverage_gaps
|
|
34
|
+
return unless gaps.any?
|
|
34
35
|
|
|
35
36
|
lines << ""
|
|
36
|
-
lines << "Survived mutations:"
|
|
37
|
-
|
|
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
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
|
|
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>
|
|
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,
|
|
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
|