evilution 0.23.0 → 0.25.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/interactions.jsonl +210 -0
- data/CHANGELOG.md +51 -0
- data/README.md +81 -4
- data/exe/evil +6 -0
- data/lib/evilution/ast/source_surgeon.rb +15 -1
- data/lib/evilution/cli/commands/compare.rb +68 -0
- data/lib/evilution/cli/parser/command_extractor.rb +78 -0
- data/lib/evilution/cli/parser/file_args.rb +41 -0
- data/lib/evilution/cli/parser/options_builder.rb +123 -0
- data/lib/evilution/cli/parser/stdin_reader.rb +28 -0
- data/lib/evilution/cli/parser.rb +27 -196
- data/lib/evilution/cli/printers/compare.rb +159 -0
- data/lib/evilution/cli.rb +1 -0
- data/lib/evilution/compare/categorizer.rb +109 -0
- data/lib/evilution/compare/detector.rb +21 -0
- data/lib/evilution/compare/fingerprint.rb +83 -0
- data/lib/evilution/compare/normalizer.rb +106 -0
- data/lib/evilution/compare/record.rb +16 -0
- data/lib/evilution/compare.rb +15 -0
- data/lib/evilution/config.rb +178 -3
- data/lib/evilution/example_filter.rb +143 -0
- data/lib/evilution/integration/base.rb +11 -57
- data/lib/evilution/integration/crash_detector.rb +5 -2
- data/lib/evilution/integration/minitest.rb +25 -7
- data/lib/evilution/integration/minitest_crash_detector.rb +5 -2
- data/lib/evilution/integration/rspec.rb +99 -12
- data/lib/evilution/isolation/fork.rb +26 -0
- data/lib/evilution/isolation/in_process.rb +1 -0
- data/lib/evilution/mcp/info_tool.rb +77 -5
- data/lib/evilution/mcp/mutate_tool/config_builder.rb +20 -0
- data/lib/evilution/mcp/mutate_tool/error_payload.rb +17 -0
- data/lib/evilution/mcp/mutate_tool/option_parser.rb +54 -0
- data/lib/evilution/mcp/mutate_tool/progress_streamer.rb +37 -0
- data/lib/evilution/mcp/mutate_tool/report_trimmer.rb +31 -0
- data/lib/evilution/mcp/mutate_tool/survived_enricher.rb +52 -0
- data/lib/evilution/mcp/mutate_tool.rb +34 -186
- data/lib/evilution/mutation.rb +43 -3
- data/lib/evilution/mutator/base.rb +39 -1
- data/lib/evilution/mutator/operator/argument_nil_substitution.rb +5 -1
- data/lib/evilution/mutator/operator/argument_removal.rb +5 -1
- data/lib/evilution/parallel/work_queue.rb +149 -31
- data/lib/evilution/parallel_db_warning.rb +68 -0
- data/lib/evilution/reporter/cli.rb +38 -11
- data/lib/evilution/reporter/html/assets/style.css +85 -0
- data/lib/evilution/reporter/html/baseline_keys.rb +28 -0
- data/lib/evilution/reporter/html/diff_formatter.rb +27 -0
- data/lib/evilution/reporter/html/escape.rb +12 -0
- data/lib/evilution/reporter/html/namespace.rb +11 -0
- data/lib/evilution/reporter/html/report.rb +68 -0
- data/lib/evilution/reporter/html/section.rb +21 -0
- data/lib/evilution/reporter/html/sections/baseline_comparison.rb +46 -0
- data/lib/evilution/reporter/html/sections/error_details.rb +30 -0
- data/lib/evilution/reporter/html/sections/error_entry.rb +22 -0
- data/lib/evilution/reporter/html/sections/file_section.rb +62 -0
- data/lib/evilution/reporter/html/sections/header.rb +29 -0
- data/lib/evilution/reporter/html/sections/mutation_map.rb +32 -0
- data/lib/evilution/reporter/html/sections/neutral_details.rb +25 -0
- data/lib/evilution/reporter/html/sections/summary_cards.rb +11 -0
- data/lib/evilution/reporter/html/sections/survived_details.rb +35 -0
- data/lib/evilution/reporter/html/sections/survived_entry.rb +36 -0
- data/lib/evilution/reporter/html/sections/truncation_notice.rb +17 -0
- data/lib/evilution/reporter/html/sections/unparseable_details.rb +25 -0
- data/lib/evilution/reporter/html/sections/unresolved_details.rb +25 -0
- data/lib/evilution/reporter/html/sections.rb +4 -0
- data/lib/evilution/reporter/html/stylesheet.rb +14 -0
- data/lib/evilution/reporter/html/templates/baseline_comparison.html.erb +8 -0
- data/lib/evilution/reporter/html/templates/error_details.html.erb +6 -0
- data/lib/evilution/reporter/html/templates/error_entry.html.erb +10 -0
- data/lib/evilution/reporter/html/templates/file_section.html.erb +12 -0
- data/lib/evilution/reporter/html/templates/header.html.erb +4 -0
- data/lib/evilution/reporter/html/templates/mutation_map.html.erb +6 -0
- data/lib/evilution/reporter/html/templates/neutral_details.html.erb +14 -0
- data/lib/evilution/reporter/html/templates/report.html.erb +17 -0
- data/lib/evilution/reporter/html/templates/summary_cards.html.erb +26 -0
- data/lib/evilution/reporter/html/templates/survived_details.html.erb +21 -0
- data/lib/evilution/reporter/html/templates/survived_entry.html.erb +8 -0
- data/lib/evilution/reporter/html/templates/truncation_notice.html.erb +1 -0
- data/lib/evilution/reporter/html/templates/unparseable_details.html.erb +11 -0
- data/lib/evilution/reporter/html/templates/unresolved_details.html.erb +11 -0
- data/lib/evilution/reporter/html.rb +11 -390
- data/lib/evilution/reporter/json.rb +19 -9
- data/lib/evilution/reporter/suggestion/diff_helpers.rb +28 -0
- data/lib/evilution/reporter/suggestion/registry.rb +64 -0
- data/lib/evilution/reporter/suggestion/templates/generic.rb +55 -0
- data/lib/evilution/reporter/suggestion/templates/minitest.rb +659 -0
- data/lib/evilution/reporter/suggestion/templates/rspec.rb +613 -0
- data/lib/evilution/reporter/suggestion.rb +8 -1327
- data/lib/evilution/result/mutation_result.rb +9 -1
- data/lib/evilution/result/summary.rb +21 -1
- data/lib/evilution/runner/baseline_runner.rb +92 -0
- data/lib/evilution/runner/diagnostics.rb +105 -0
- data/lib/evilution/runner/isolation_resolver.rb +134 -0
- data/lib/evilution/runner/mutation_executor.rb +325 -0
- data/lib/evilution/runner/mutation_planner.rb +126 -0
- data/lib/evilution/runner/report_publisher.rb +60 -0
- data/lib/evilution/runner/subject_pipeline.rb +121 -0
- data/lib/evilution/runner.rb +61 -692
- data/lib/evilution/source_ast_cache.rb +39 -0
- data/lib/evilution/spec_ast_cache.rb +166 -0
- data/lib/evilution/spec_resolver.rb +6 -1
- data/lib/evilution/spec_selector.rb +39 -0
- data/lib/evilution/temp_dir_tracker.rb +23 -3
- data/lib/evilution/version.rb +1 -1
- data/script/memory_check +7 -5
- metadata +75 -2
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../parallel/pool"
|
|
4
|
+
require_relative "../reporter/progress_bar"
|
|
5
|
+
require_relative "../result/mutation_result"
|
|
6
|
+
|
|
7
|
+
class Evilution::Runner; end unless defined?(Evilution::Runner) # rubocop:disable Lint/EmptyClass
|
|
8
|
+
|
|
9
|
+
class Evilution::Runner::MutationExecutor
|
|
10
|
+
def initialize(config, isolator:, baseline_runner:, cache:, hooks:, diagnostics:, on_result: nil)
|
|
11
|
+
@config = config
|
|
12
|
+
@isolator = isolator
|
|
13
|
+
@baseline_runner = baseline_runner
|
|
14
|
+
@cache = cache
|
|
15
|
+
@hooks = hooks
|
|
16
|
+
@diagnostics = diagnostics
|
|
17
|
+
@on_result = on_result
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def call(mutations, baseline_result = nil)
|
|
21
|
+
@progress_bar = build_progress_bar(mutations.length)
|
|
22
|
+
result = if config.jobs > 1
|
|
23
|
+
run_parallel(mutations, baseline_result)
|
|
24
|
+
else
|
|
25
|
+
run_sequential(mutations, baseline_result)
|
|
26
|
+
end
|
|
27
|
+
@progress_bar&.finish
|
|
28
|
+
result
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
attr_reader :config, :isolator, :baseline_runner, :cache, :hooks, :diagnostics, :on_result
|
|
34
|
+
|
|
35
|
+
def run_sequential(mutations, baseline_result)
|
|
36
|
+
integration = baseline_runner.build_integration
|
|
37
|
+
spec_resolver = baseline_result&.failed? ? baseline_runner.neutralization_resolver : nil
|
|
38
|
+
results = []
|
|
39
|
+
survived_count = 0
|
|
40
|
+
truncated = false
|
|
41
|
+
|
|
42
|
+
mutations.each_with_index do |mutation, index|
|
|
43
|
+
result = execute_one(mutation, integration)
|
|
44
|
+
mutation.strip_sources!
|
|
45
|
+
result = neutralize_if_infra_error(result)
|
|
46
|
+
result = neutralize_if_baseline_failed(result, baseline_result, spec_resolver)
|
|
47
|
+
results << result
|
|
48
|
+
survived_count += 1 if result.survived?
|
|
49
|
+
notify_result(result, index + 1)
|
|
50
|
+
|
|
51
|
+
if config.fail_fast? && survived_count >= config.fail_fast
|
|
52
|
+
truncated = true
|
|
53
|
+
break
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
[results, truncated]
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def run_parallel(mutations, baseline_result)
|
|
61
|
+
integration = baseline_runner.build_integration
|
|
62
|
+
pool = build_pool
|
|
63
|
+
spec_resolver = baseline_result&.failed? ? baseline_runner.neutralization_resolver : nil
|
|
64
|
+
state = { results: [], survived_count: 0, truncated: false, completed: 0 }
|
|
65
|
+
all_worker_stats = []
|
|
66
|
+
|
|
67
|
+
mutations.each_slice(config.jobs) do |batch|
|
|
68
|
+
break if state[:truncated]
|
|
69
|
+
|
|
70
|
+
batch_results = run_parallel_batch(batch, pool, isolator, integration)
|
|
71
|
+
all_worker_stats.concat(pool.worker_stats)
|
|
72
|
+
process_batch(batch_results, baseline_result, spec_resolver, state)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
diagnostics.log_worker_stats(diagnostics.aggregate_worker_stats(all_worker_stats))
|
|
76
|
+
[state[:results], state[:truncated]]
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def build_pool
|
|
80
|
+
Evilution::Parallel::Pool.new(
|
|
81
|
+
size: config.jobs,
|
|
82
|
+
hooks: hooks,
|
|
83
|
+
item_timeout: config.timeout ? config.timeout * 2 : nil
|
|
84
|
+
)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def run_parallel_batch(batch, pool, worker_isolator, integration)
|
|
88
|
+
uncached_indices, cached_results = partition_cached(batch)
|
|
89
|
+
worker_results = run_uncached_workers(batch, uncached_indices, pool, worker_isolator, integration)
|
|
90
|
+
compact_results = merge_parallel_results(batch, uncached_indices, cached_results, worker_results)
|
|
91
|
+
batch.each(&:strip_sources!)
|
|
92
|
+
batch_results = rebuild_results(batch, compact_results)
|
|
93
|
+
batch_results.each { |r| store_cached_result(r.mutation, r) }
|
|
94
|
+
batch_results
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def run_uncached_workers(batch, uncached_indices, pool, worker_isolator, integration)
|
|
98
|
+
return [] if uncached_indices.empty?
|
|
99
|
+
|
|
100
|
+
uncached = uncached_indices.map { |i| batch[i] }
|
|
101
|
+
pool.map(uncached) do |mutation|
|
|
102
|
+
test_command = ->(m) { integration.call(m) }
|
|
103
|
+
result = worker_isolator.call(mutation: mutation, test_command: test_command, timeout: config.timeout)
|
|
104
|
+
compact_result(result)
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def process_batch(batch_results, baseline_result, spec_resolver, state)
|
|
109
|
+
batch_results.each do |result|
|
|
110
|
+
result = neutralize_if_infra_error(result)
|
|
111
|
+
result = neutralize_if_baseline_failed(result, baseline_result, spec_resolver)
|
|
112
|
+
state[:results] << result
|
|
113
|
+
state[:survived_count] += 1 if result.survived?
|
|
114
|
+
state[:completed] += 1
|
|
115
|
+
notify_result(result, state[:completed])
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
diagnostics.log_memory("after batch", "#{state[:completed]} complete")
|
|
119
|
+
state[:truncated] = true if should_truncate?(state[:survived_count])
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Reclassify results as :neutral when the failure was caused by test
|
|
123
|
+
# infrastructure rather than by the mutation. Two independent paths:
|
|
124
|
+
#
|
|
125
|
+
# 1) :error from a missing require / spec_helper / rails_helper / spec/support
|
|
126
|
+
# initialization — detected by error_class ∈ INFRA_ERROR_CLASSES and
|
|
127
|
+
# first backtrace frame matching INFRA_BACKTRACE_PATHS. Origin-only match
|
|
128
|
+
# (not `any?`): Ruby backtraces typically carry spec_helper frames below
|
|
129
|
+
# mutation-caused errors, so matching any frame would misclassify real
|
|
130
|
+
# mutation NameError/LoadError as :neutral.
|
|
131
|
+
#
|
|
132
|
+
# 2) :killed from a CrashDetector test_crashed whose sole crash class is in
|
|
133
|
+
# INFRA_CRASH_CLASSES (ActiveRecord::StatementTimeout, Timeout::Error,
|
|
134
|
+
# etc.). These surface under parallel workers sharing a DB file or on a
|
|
135
|
+
# slow CI; fork.rb initially reports them as :killed, and without this
|
|
136
|
+
# demotion the kill count inflates with infra noise. No backtrace check:
|
|
137
|
+
# the single-class signal from CrashDetector already rules out mixed
|
|
138
|
+
# mutation-caused failures. See EV-toid / GH #814.
|
|
139
|
+
INFRA_ERROR_CLASSES = %w[LoadError NameError].freeze
|
|
140
|
+
INFRA_BACKTRACE_PATHS = %r{(?:^|/)(?:spec_helper\.rb|rails_helper\.rb|spec/support/)}
|
|
141
|
+
INFRA_CRASH_CLASSES = %w[
|
|
142
|
+
Timeout::Error
|
|
143
|
+
ActiveRecord::StatementTimeout
|
|
144
|
+
ActiveRecord::Deadlocked
|
|
145
|
+
ActiveRecord::ConnectionTimeoutError
|
|
146
|
+
ActiveRecord::LockWaitTimeout
|
|
147
|
+
SQLite3::BusyException
|
|
148
|
+
].freeze
|
|
149
|
+
private_constant :INFRA_ERROR_CLASSES, :INFRA_BACKTRACE_PATHS, :INFRA_CRASH_CLASSES
|
|
150
|
+
|
|
151
|
+
def neutralize_if_infra_error(result)
|
|
152
|
+
return neutralize(result) if infra_crash?(result)
|
|
153
|
+
return result unless result.error?
|
|
154
|
+
return result unless INFRA_ERROR_CLASSES.include?(result.error_class)
|
|
155
|
+
return result unless infra_origin?(result.error_backtrace)
|
|
156
|
+
|
|
157
|
+
neutralize(result)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def infra_crash?(result)
|
|
161
|
+
result.killed? && INFRA_CRASH_CLASSES.include?(result.error_class)
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def infra_origin?(backtrace)
|
|
165
|
+
frames = Array(backtrace)
|
|
166
|
+
return false if frames.empty?
|
|
167
|
+
|
|
168
|
+
frames.first =~ INFRA_BACKTRACE_PATHS ? true : false
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def neutralize(result)
|
|
172
|
+
Evilution::Result::MutationResult.new(
|
|
173
|
+
mutation: result.mutation,
|
|
174
|
+
status: :neutral,
|
|
175
|
+
duration: result.duration,
|
|
176
|
+
test_command: result.test_command,
|
|
177
|
+
child_rss_kb: result.child_rss_kb,
|
|
178
|
+
memory_delta_kb: result.memory_delta_kb,
|
|
179
|
+
parent_rss_kb: result.parent_rss_kb,
|
|
180
|
+
error_message: result.error_message,
|
|
181
|
+
error_class: result.error_class,
|
|
182
|
+
error_backtrace: result.error_backtrace
|
|
183
|
+
)
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def neutralize_if_baseline_failed(result, baseline_result, spec_resolver)
|
|
187
|
+
return result unless result.survived? && baseline_result && baseline_result.failed?
|
|
188
|
+
|
|
189
|
+
if config.spec_files.any?
|
|
190
|
+
should_neutralize = true
|
|
191
|
+
else
|
|
192
|
+
spec_file = spec_resolver.call(result.mutation.file_path) || baseline_runner.neutralization_fallback_dir
|
|
193
|
+
should_neutralize = baseline_result.failed_spec_files.include?(spec_file)
|
|
194
|
+
end
|
|
195
|
+
return result unless should_neutralize
|
|
196
|
+
|
|
197
|
+
neutralize(result)
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def compact_result(result)
|
|
201
|
+
{
|
|
202
|
+
status: result.status,
|
|
203
|
+
duration: result.duration,
|
|
204
|
+
killing_test: result.killing_test,
|
|
205
|
+
test_command: result.test_command,
|
|
206
|
+
child_rss_kb: result.child_rss_kb,
|
|
207
|
+
memory_delta_kb: result.memory_delta_kb,
|
|
208
|
+
parent_rss_kb: result.parent_rss_kb,
|
|
209
|
+
error_message: result.error_message,
|
|
210
|
+
error_class: result.error_class,
|
|
211
|
+
error_backtrace: result.error_backtrace
|
|
212
|
+
}
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def rebuild_results(batch, compact_results)
|
|
216
|
+
batch.zip(compact_results).map do |mutation, data|
|
|
217
|
+
Evilution::Result::MutationResult.new(
|
|
218
|
+
mutation: mutation,
|
|
219
|
+
status: data[:status],
|
|
220
|
+
duration: data[:duration],
|
|
221
|
+
killing_test: data[:killing_test],
|
|
222
|
+
test_command: data[:test_command],
|
|
223
|
+
child_rss_kb: data[:child_rss_kb],
|
|
224
|
+
memory_delta_kb: data[:memory_delta_kb],
|
|
225
|
+
parent_rss_kb: data[:parent_rss_kb],
|
|
226
|
+
error_message: data[:error_message],
|
|
227
|
+
error_class: data[:error_class],
|
|
228
|
+
error_backtrace: data[:error_backtrace]
|
|
229
|
+
)
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def should_truncate?(survived_count)
|
|
234
|
+
config.fail_fast? && survived_count >= config.fail_fast
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def partition_cached(batch)
|
|
238
|
+
uncached_indices = []
|
|
239
|
+
cached_results = {}
|
|
240
|
+
|
|
241
|
+
batch.each_with_index do |mutation, i|
|
|
242
|
+
if mutation.unparseable?
|
|
243
|
+
cached_results[i] = compact_result(build_unparseable_result(mutation))
|
|
244
|
+
next
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
cached = fetch_cached_result(mutation)
|
|
248
|
+
if cached
|
|
249
|
+
cached_results[i] = compact_result(cached)
|
|
250
|
+
else
|
|
251
|
+
uncached_indices << i
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
[uncached_indices, cached_results]
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def execute_one(mutation, integration)
|
|
259
|
+
return build_unparseable_result(mutation) if mutation.unparseable?
|
|
260
|
+
|
|
261
|
+
execute_or_fetch(mutation) do
|
|
262
|
+
test_command = ->(m) { integration.call(m) }
|
|
263
|
+
isolator.call(mutation: mutation, test_command: test_command, timeout: config.timeout)
|
|
264
|
+
end
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def build_unparseable_result(mutation)
|
|
268
|
+
Evilution::Result::MutationResult.new(mutation: mutation, status: :unparseable)
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
def merge_parallel_results(batch, uncached_indices, cached_results, worker_results)
|
|
272
|
+
result_map = cached_results.dup
|
|
273
|
+
uncached_indices.each_with_index { |batch_idx, worker_idx| result_map[batch_idx] = worker_results[worker_idx] }
|
|
274
|
+
batch.each_index.map { |i| result_map[i] }
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
def execute_or_fetch(mutation)
|
|
278
|
+
cached = fetch_cached_result(mutation)
|
|
279
|
+
return cached if cached
|
|
280
|
+
|
|
281
|
+
result = yield
|
|
282
|
+
store_cached_result(mutation, result)
|
|
283
|
+
result
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
def fetch_cached_result(mutation)
|
|
287
|
+
return nil unless cache
|
|
288
|
+
|
|
289
|
+
data = cache.fetch(mutation)
|
|
290
|
+
return nil unless data
|
|
291
|
+
return nil unless %i[killed timeout].include?(data[:status])
|
|
292
|
+
|
|
293
|
+
Evilution::Result::MutationResult.new(
|
|
294
|
+
mutation: mutation,
|
|
295
|
+
status: data[:status],
|
|
296
|
+
duration: data[:duration],
|
|
297
|
+
killing_test: data[:killing_test],
|
|
298
|
+
test_command: data[:test_command]
|
|
299
|
+
)
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
def store_cached_result(mutation, result)
|
|
303
|
+
return unless cache
|
|
304
|
+
return unless result.killed? || result.timeout?
|
|
305
|
+
|
|
306
|
+
cache.store(mutation,
|
|
307
|
+
status: result.status,
|
|
308
|
+
duration: result.duration,
|
|
309
|
+
killing_test: result.killing_test,
|
|
310
|
+
test_command: result.test_command)
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
def notify_result(result, index)
|
|
314
|
+
on_result&.call(result)
|
|
315
|
+
@progress_bar&.tick(status: result.status)
|
|
316
|
+
diagnostics.log_progress(index, result.status)
|
|
317
|
+
diagnostics.log_mutation_diagnostics(result)
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
def build_progress_bar(total)
|
|
321
|
+
return nil if !config.progress? || config.quiet || config.verbose || !config.text? || !$stderr.tty?
|
|
322
|
+
|
|
323
|
+
Evilution::Reporter::ProgressBar.new(total: total, output: $stderr)
|
|
324
|
+
end
|
|
325
|
+
end
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../disable_comment"
|
|
4
|
+
require_relative "../ast/sorbet_sig_detector"
|
|
5
|
+
require_relative "../ast/pattern/filter"
|
|
6
|
+
require_relative "../equivalent/detector"
|
|
7
|
+
|
|
8
|
+
class Evilution::Runner; end unless defined?(Evilution::Runner) # rubocop:disable Lint/EmptyClass
|
|
9
|
+
|
|
10
|
+
class Evilution::Runner::MutationPlanner
|
|
11
|
+
Plan = Struct.new(:enabled, :equivalent, :skipped_count, :disabled_mutations, keyword_init: true)
|
|
12
|
+
|
|
13
|
+
def initialize(config, registry:, disable_detector: Evilution::DisableComment.new,
|
|
14
|
+
sig_detector: Evilution::AST::SorbetSigDetector.new)
|
|
15
|
+
@config = config
|
|
16
|
+
@registry = registry
|
|
17
|
+
@disable_detector = disable_detector
|
|
18
|
+
@sig_detector = sig_detector
|
|
19
|
+
@disabled_ranges_cache = {}
|
|
20
|
+
@sig_ranges_cache = {}
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def call(subjects)
|
|
24
|
+
mutations, generation_skipped = generate(subjects)
|
|
25
|
+
mutations, disabled = filter_disabled(mutations)
|
|
26
|
+
disabled.each(&:strip_sources!) if config.show_disabled?
|
|
27
|
+
disabled_mutations = config.show_disabled? ? disabled : []
|
|
28
|
+
|
|
29
|
+
mutations, sig_skipped = filter_sig_blocks(mutations)
|
|
30
|
+
equivalent, enabled = filter_equivalent(mutations)
|
|
31
|
+
|
|
32
|
+
Plan.new(
|
|
33
|
+
enabled: enabled,
|
|
34
|
+
equivalent: equivalent,
|
|
35
|
+
skipped_count: generation_skipped + disabled.length + sig_skipped,
|
|
36
|
+
disabled_mutations: disabled_mutations
|
|
37
|
+
)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
attr_reader :config, :registry
|
|
43
|
+
|
|
44
|
+
def generate(subjects)
|
|
45
|
+
filter = build_ignore_filter
|
|
46
|
+
operator_options = build_operator_options
|
|
47
|
+
mutations = subjects.flat_map do |subject|
|
|
48
|
+
registry.mutations_for(subject, filter: filter, operator_options: operator_options)
|
|
49
|
+
end
|
|
50
|
+
skipped = filter ? filter.skipped_count : 0
|
|
51
|
+
[mutations, skipped]
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def build_operator_options
|
|
55
|
+
{ skip_heredoc_literals: config.skip_heredoc_literals? }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def build_ignore_filter
|
|
59
|
+
patterns = config.ignore_patterns
|
|
60
|
+
return nil if patterns.nil? || patterns.empty?
|
|
61
|
+
|
|
62
|
+
Evilution::AST::Pattern::Filter.new(patterns)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def filter_disabled(mutations)
|
|
66
|
+
enabled = []
|
|
67
|
+
disabled = []
|
|
68
|
+
|
|
69
|
+
mutations.each do |mutation|
|
|
70
|
+
if mutation_disabled?(mutation)
|
|
71
|
+
disabled << mutation
|
|
72
|
+
else
|
|
73
|
+
enabled << mutation
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
[enabled, disabled]
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def mutation_disabled?(mutation)
|
|
81
|
+
ranges = disabled_ranges_for(mutation.file_path)
|
|
82
|
+
ranges.any? { |range| range.cover?(mutation.line) }
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def disabled_ranges_for(file_path)
|
|
86
|
+
@disabled_ranges_cache[file_path] ||= begin
|
|
87
|
+
source = File.read(file_path)
|
|
88
|
+
@disable_detector.call(source)
|
|
89
|
+
rescue SystemCallError
|
|
90
|
+
[]
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def filter_sig_blocks(mutations)
|
|
95
|
+
enabled = []
|
|
96
|
+
skipped = 0
|
|
97
|
+
|
|
98
|
+
mutations.each do |mutation|
|
|
99
|
+
if mutation_in_sig_block?(mutation)
|
|
100
|
+
skipped += 1
|
|
101
|
+
else
|
|
102
|
+
enabled << mutation
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
[enabled, skipped]
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def mutation_in_sig_block?(mutation)
|
|
110
|
+
ranges = sig_line_ranges_for(mutation.file_path)
|
|
111
|
+
ranges.any? { |range| range.cover?(mutation.line) }
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def sig_line_ranges_for(file_path)
|
|
115
|
+
@sig_ranges_cache[file_path] ||= begin
|
|
116
|
+
source = File.read(file_path)
|
|
117
|
+
@sig_detector.line_ranges(source)
|
|
118
|
+
rescue SystemCallError
|
|
119
|
+
[]
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def filter_equivalent(mutations)
|
|
124
|
+
Evilution::Equivalent::Detector.new.call(mutations)
|
|
125
|
+
end
|
|
126
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../reporter/json"
|
|
4
|
+
require_relative "../reporter/cli"
|
|
5
|
+
require_relative "../reporter/html"
|
|
6
|
+
require_relative "../session/store"
|
|
7
|
+
|
|
8
|
+
class Evilution::Runner; end unless defined?(Evilution::Runner) # rubocop:disable Lint/EmptyClass
|
|
9
|
+
|
|
10
|
+
class Evilution::Runner::ReportPublisher
|
|
11
|
+
def initialize(config)
|
|
12
|
+
@config = config
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def publish(summary)
|
|
16
|
+
reporter = build_reporter
|
|
17
|
+
return unless reporter
|
|
18
|
+
|
|
19
|
+
output = reporter.call(summary)
|
|
20
|
+
return if config.quiet
|
|
21
|
+
|
|
22
|
+
if config.html?
|
|
23
|
+
path = "evilution-report.html"
|
|
24
|
+
File.write(path, output)
|
|
25
|
+
warn "HTML report written to #{path}"
|
|
26
|
+
else
|
|
27
|
+
$stdout.puts(output)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def save_session(summary)
|
|
32
|
+
return unless config.save_session?
|
|
33
|
+
|
|
34
|
+
Evilution::Session::Store.new.save(summary)
|
|
35
|
+
rescue StandardError => e
|
|
36
|
+
warn "[evilution] failed to save session: #{e.message}" unless config.quiet
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
attr_reader :config
|
|
42
|
+
|
|
43
|
+
def build_reporter
|
|
44
|
+
case config.format
|
|
45
|
+
when :json
|
|
46
|
+
Evilution::Reporter::JSON.new(integration: config.integration)
|
|
47
|
+
when :text
|
|
48
|
+
Evilution::Reporter::CLI.new
|
|
49
|
+
when :html
|
|
50
|
+
Evilution::Reporter::HTML.new(baseline: load_baseline_session, integration: config.integration)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def load_baseline_session
|
|
55
|
+
path = config.baseline_session
|
|
56
|
+
return nil unless path
|
|
57
|
+
|
|
58
|
+
Evilution::Session::Store.new.load(path)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../ast/inheritance_scanner"
|
|
4
|
+
require_relative "../git/changed_files"
|
|
5
|
+
|
|
6
|
+
class Evilution::Runner; end unless defined?(Evilution::Runner) # rubocop:disable Lint/EmptyClass
|
|
7
|
+
|
|
8
|
+
class Evilution::Runner::SubjectPipeline
|
|
9
|
+
def initialize(config, parser:)
|
|
10
|
+
@config = config
|
|
11
|
+
@parser = parser
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def call
|
|
15
|
+
subjects = parse_subjects
|
|
16
|
+
subjects = filter_by_descendants(subjects) if descendants_target?
|
|
17
|
+
subjects = filter_by_target(subjects) if method_target?
|
|
18
|
+
subjects = filter_by_line_ranges(subjects) if config.line_ranges?
|
|
19
|
+
subjects
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def target_files
|
|
23
|
+
@target_files ||= if source_glob_target?
|
|
24
|
+
resolve_source_glob
|
|
25
|
+
elsif !config.target_files.empty?
|
|
26
|
+
config.target_files
|
|
27
|
+
else
|
|
28
|
+
Evilution::Git::ChangedFiles.new.call
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
attr_reader :config, :parser
|
|
35
|
+
|
|
36
|
+
def parse_subjects
|
|
37
|
+
target_files.flat_map { |file| parser.call(file) }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def source_glob_target?
|
|
41
|
+
config.target&.start_with?("source:")
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def descendants_target?
|
|
45
|
+
config.target&.start_with?("descendants:")
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def method_target?
|
|
49
|
+
config.target? && !source_glob_target? && !descendants_target?
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def resolve_source_glob
|
|
53
|
+
pattern = config.target.delete_prefix("source:")
|
|
54
|
+
files = Dir.glob(pattern)
|
|
55
|
+
raise Evilution::Error, "no files found matching '#{pattern}'" if files.empty?
|
|
56
|
+
|
|
57
|
+
files.sort
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def filter_by_descendants(subjects)
|
|
61
|
+
base_name = config.target.delete_prefix("descendants:")
|
|
62
|
+
inheritance = Evilution::AST::InheritanceScanner.call(target_files)
|
|
63
|
+
class_names = resolve_descendant_set(base_name, inheritance)
|
|
64
|
+
raise Evilution::Error, "no classes found matching '#{config.target}'" if class_names.empty?
|
|
65
|
+
|
|
66
|
+
subjects.select { |s| class_names.include?(s.name.split(/[#.]/).first) }
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def resolve_descendant_set(base_name, inheritance)
|
|
70
|
+
descendants = Set.new
|
|
71
|
+
known = inheritance.key?(base_name) || inheritance.value?(base_name)
|
|
72
|
+
return descendants unless known
|
|
73
|
+
|
|
74
|
+
descendants.add(base_name)
|
|
75
|
+
changed = true
|
|
76
|
+
while changed
|
|
77
|
+
changed = false
|
|
78
|
+
inheritance.each do |child, parent|
|
|
79
|
+
next unless descendants.include?(parent)
|
|
80
|
+
next if descendants.include?(child)
|
|
81
|
+
|
|
82
|
+
descendants.add(child)
|
|
83
|
+
changed = true
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
descendants
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def filter_by_target(subjects)
|
|
90
|
+
matched = subjects.select(&target_matcher)
|
|
91
|
+
raise Evilution::Error, "no method found matching '#{config.target}'" if matched.empty?
|
|
92
|
+
|
|
93
|
+
matched
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def target_matcher
|
|
97
|
+
target = config.target
|
|
98
|
+
if target.end_with?("*")
|
|
99
|
+
prefix = target.chomp("*")
|
|
100
|
+
->(s) { s.name.split(/[#.]/).first.start_with?(prefix) }
|
|
101
|
+
elsif target.end_with?("#", ".")
|
|
102
|
+
prefix = target
|
|
103
|
+
->(s) { s.name.start_with?(prefix) }
|
|
104
|
+
elsif target.include?("#") || target.include?(".")
|
|
105
|
+
->(s) { s.name == target }
|
|
106
|
+
else
|
|
107
|
+
->(s) { s.name.start_with?("#{target}#") || s.name.start_with?("#{target}.") }
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def filter_by_line_ranges(subjects)
|
|
112
|
+
subjects.select do |subject|
|
|
113
|
+
range = config.line_ranges[subject.file_path]
|
|
114
|
+
next true unless range
|
|
115
|
+
|
|
116
|
+
subject_start = subject.line_number
|
|
117
|
+
subject_end = subject_start + subject.source.count("\n")
|
|
118
|
+
subject_start <= range.last && subject_end >= range.first
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|