evilution 0.22.7 → 0.24.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 +8 -0
- data/CHANGELOG.md +28 -0
- data/README.md +37 -7
- data/lib/evilution/cli/command.rb +37 -0
- data/lib/evilution/cli/commands/environment_show.rb +20 -0
- data/lib/evilution/cli/commands/init.rb +24 -0
- data/lib/evilution/cli/commands/mcp.rb +19 -0
- data/lib/evilution/cli/commands/run.rb +68 -0
- data/lib/evilution/cli/commands/session_diff.rb +30 -0
- data/lib/evilution/cli/commands/session_gc.rb +46 -0
- data/lib/evilution/cli/commands/session_list.rb +51 -0
- data/lib/evilution/cli/commands/session_show.rb +27 -0
- data/lib/evilution/cli/commands/subjects.rb +50 -0
- data/lib/evilution/cli/commands/tests_list.rb +43 -0
- data/lib/evilution/cli/commands/util_mutation.rb +66 -0
- data/lib/evilution/cli/commands/version.rb +17 -0
- data/lib/evilution/cli/commands.rb +4 -0
- data/lib/evilution/cli/dispatcher.rb +23 -0
- data/lib/evilution/cli/parsed_args.rb +12 -0
- data/lib/evilution/cli/parser/command_extractor.rb +77 -0
- data/lib/evilution/cli/parser/file_args.rb +41 -0
- data/lib/evilution/cli/parser/options_builder.rb +103 -0
- data/lib/evilution/cli/parser/stdin_reader.rb +28 -0
- data/lib/evilution/cli/parser.rb +88 -0
- data/lib/evilution/cli/printers/environment.rb +53 -0
- data/lib/evilution/cli/printers/session_detail.rb +76 -0
- data/lib/evilution/cli/printers/session_diff.rb +57 -0
- data/lib/evilution/cli/printers/session_list.rb +48 -0
- data/lib/evilution/cli/printers/subjects.rb +35 -0
- data/lib/evilution/cli/printers/tests_list.rb +45 -0
- data/lib/evilution/cli/printers/util_mutation.rb +35 -0
- data/lib/evilution/cli/printers.rb +4 -0
- data/lib/evilution/cli/result.rb +9 -0
- data/lib/evilution/cli.rb +30 -850
- data/lib/evilution/config.rb +31 -3
- data/lib/evilution/integration/base.rb +23 -55
- data/lib/evilution/integration/minitest.rb +22 -4
- data/lib/evilution/integration/rspec.rb +28 -8
- data/lib/evilution/isolation/fork.rb +11 -9
- data/lib/evilution/isolation/in_process.rb +11 -9
- data/lib/evilution/mcp/info_tool.rb +261 -0
- data/lib/evilution/mcp/mutate_tool.rb +112 -19
- data/lib/evilution/mcp/server.rb +3 -4
- data/lib/evilution/mcp/session_diff_tool.rb +5 -1
- data/lib/evilution/mcp/session_list_tool.rb +5 -1
- data/lib/evilution/mcp/session_show_tool.rb +5 -1
- data/lib/evilution/mcp/session_tool.rb +157 -0
- data/lib/evilution/reporter/cli.rb +2 -1
- data/lib/evilution/reporter/html/assets/style.css +68 -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 +47 -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/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.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 +9 -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/report.html.erb +17 -0
- data/lib/evilution/reporter/html/templates/summary_cards.html.erb +23 -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.rb +11 -349
- data/lib/evilution/reporter/json.rb +12 -8
- data/lib/evilution/result/mutation_result.rb +5 -1
- data/lib/evilution/result/summary.rb +9 -1
- data/lib/evilution/runner/baseline_runner.rb +71 -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 +255 -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 +57 -692
- data/lib/evilution/version.rb +1 -1
- metadata +71 -2
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../isolation/fork"
|
|
4
|
+
require_relative "../isolation/in_process"
|
|
5
|
+
require_relative "../rails_detector"
|
|
6
|
+
|
|
7
|
+
class Evilution::Runner; end unless defined?(Evilution::Runner) # rubocop:disable Lint/EmptyClass
|
|
8
|
+
|
|
9
|
+
class Evilution::Runner::IsolationResolver
|
|
10
|
+
PRELOAD_CANDIDATES = [
|
|
11
|
+
File.join("spec", "rails_helper.rb"),
|
|
12
|
+
File.join("test", "test_helper.rb")
|
|
13
|
+
].freeze
|
|
14
|
+
|
|
15
|
+
def initialize(config, target_files:, hooks:)
|
|
16
|
+
@config = config
|
|
17
|
+
@target_files_callback = target_files
|
|
18
|
+
@hooks = hooks
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def isolator
|
|
22
|
+
@isolator ||= build_isolator
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def rails_root_detected?
|
|
26
|
+
return @rails_root_detected if defined?(@rails_root_detected)
|
|
27
|
+
|
|
28
|
+
@rails_root_detected = !detected_rails_root.nil?
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def perform_preload
|
|
32
|
+
return if config.preload == false
|
|
33
|
+
return unless resolve_isolation == :fork
|
|
34
|
+
|
|
35
|
+
path = resolve_preload_path
|
|
36
|
+
return unless path
|
|
37
|
+
|
|
38
|
+
prepare_load_path_for_preload
|
|
39
|
+
require File.expand_path(path)
|
|
40
|
+
rescue ScriptError, StandardError => e
|
|
41
|
+
raise Evilution::ConfigError.new(
|
|
42
|
+
"failed to preload #{path.inspect}: #{e.class}: #{e.message}",
|
|
43
|
+
file: path
|
|
44
|
+
)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
attr_reader :config, :hooks
|
|
50
|
+
|
|
51
|
+
def target_files
|
|
52
|
+
@target_files ||= @target_files_callback.call
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def build_isolator
|
|
56
|
+
case resolve_isolation
|
|
57
|
+
when :fork then Evilution::Isolation::Fork.new(hooks: hooks)
|
|
58
|
+
when :in_process then Evilution::Isolation::InProcess.new
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def resolve_isolation
|
|
63
|
+
case config.isolation
|
|
64
|
+
when :fork
|
|
65
|
+
:fork
|
|
66
|
+
when :in_process
|
|
67
|
+
warn_in_process_under_rails if rails_root_detected?
|
|
68
|
+
:in_process
|
|
69
|
+
else # :auto
|
|
70
|
+
rails_root_detected? ? :fork : :in_process
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def detected_rails_root
|
|
75
|
+
return @detected_rails_root if defined?(@detected_rails_root)
|
|
76
|
+
|
|
77
|
+
@detected_rails_root = Evilution::RailsDetector.rails_root_for_any(target_files)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Preload files (e.g. spec/rails_helper.rb) typically `require 'spec_helper'`
|
|
81
|
+
# which needs spec/ on $LOAD_PATH, and use `RSpec.configure` which needs
|
|
82
|
+
# rspec/core loaded. The RSpec CLI normally sets this up, but evilution
|
|
83
|
+
# calls Runner.run directly.
|
|
84
|
+
def prepare_load_path_for_preload
|
|
85
|
+
spec_dir = File.expand_path(resolve_spec_dir)
|
|
86
|
+
$LOAD_PATH.unshift(spec_dir) unless $LOAD_PATH.include?(spec_dir)
|
|
87
|
+
require "rspec/core" if config.integration == :rspec
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def resolve_spec_dir
|
|
91
|
+
root = detected_rails_root
|
|
92
|
+
return File.join(root, "spec") if root
|
|
93
|
+
|
|
94
|
+
"spec"
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def resolve_preload_path
|
|
98
|
+
if config.preload.is_a?(String)
|
|
99
|
+
unless File.file?(config.preload)
|
|
100
|
+
raise Evilution::ConfigError.new(
|
|
101
|
+
"preload file not found: #{config.preload.inspect}",
|
|
102
|
+
file: config.preload
|
|
103
|
+
)
|
|
104
|
+
end
|
|
105
|
+
return config.preload
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
root = detected_rails_root
|
|
109
|
+
return nil unless root
|
|
110
|
+
|
|
111
|
+
PRELOAD_CANDIDATES.each do |rel|
|
|
112
|
+
abs = File.join(root, rel)
|
|
113
|
+
return abs if File.file?(abs)
|
|
114
|
+
end
|
|
115
|
+
nil
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# When the user explicitly requests InProcess on a Rails project, warn once
|
|
119
|
+
# per run. Rails wraps ActiveRecord transactions in
|
|
120
|
+
# Thread.handle_interrupt(Exception => :never), which defers Timeout's
|
|
121
|
+
# Thread#raise indefinitely — making InProcess unable to kill runaway mutants.
|
|
122
|
+
def warn_in_process_under_rails
|
|
123
|
+
return if config.quiet
|
|
124
|
+
return if @warned_in_process_under_rails
|
|
125
|
+
|
|
126
|
+
@warned_in_process_under_rails = true
|
|
127
|
+
$stderr.write(
|
|
128
|
+
"[evilution] warning: --isolation in_process is unsafe on Rails projects. " \
|
|
129
|
+
"ActiveRecord wraps transactions in Thread.handle_interrupt(Exception => :never), " \
|
|
130
|
+
"which swallows Timeout.timeout and can cause evilution to hang indefinitely on " \
|
|
131
|
+
"mutants that introduce infinite loops. Use --isolation fork for reliable interruption.\n"
|
|
132
|
+
)
|
|
133
|
+
end
|
|
134
|
+
end
|
|
@@ -0,0 +1,255 @@
|
|
|
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_or_fetch(mutation) do
|
|
44
|
+
test_command = ->(m) { integration.call(m) }
|
|
45
|
+
isolator.call(mutation: mutation, test_command: test_command, timeout: config.timeout)
|
|
46
|
+
end
|
|
47
|
+
mutation.strip_sources!
|
|
48
|
+
result = neutralize_if_baseline_failed(result, baseline_result, spec_resolver)
|
|
49
|
+
results << result
|
|
50
|
+
survived_count += 1 if result.survived?
|
|
51
|
+
notify_result(result, index + 1)
|
|
52
|
+
|
|
53
|
+
if config.fail_fast? && survived_count >= config.fail_fast
|
|
54
|
+
truncated = true
|
|
55
|
+
break
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
[results, truncated]
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def run_parallel(mutations, baseline_result)
|
|
63
|
+
integration = baseline_runner.build_integration
|
|
64
|
+
pool = build_pool
|
|
65
|
+
spec_resolver = baseline_result&.failed? ? baseline_runner.neutralization_resolver : nil
|
|
66
|
+
state = { results: [], survived_count: 0, truncated: false, completed: 0 }
|
|
67
|
+
all_worker_stats = []
|
|
68
|
+
|
|
69
|
+
mutations.each_slice(config.jobs) do |batch|
|
|
70
|
+
break if state[:truncated]
|
|
71
|
+
|
|
72
|
+
batch_results = run_parallel_batch(batch, pool, isolator, integration)
|
|
73
|
+
all_worker_stats.concat(pool.worker_stats)
|
|
74
|
+
process_batch(batch_results, baseline_result, spec_resolver, state)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
diagnostics.log_worker_stats(diagnostics.aggregate_worker_stats(all_worker_stats))
|
|
78
|
+
[state[:results], state[:truncated]]
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def build_pool
|
|
82
|
+
Evilution::Parallel::Pool.new(
|
|
83
|
+
size: config.jobs,
|
|
84
|
+
hooks: hooks,
|
|
85
|
+
item_timeout: config.timeout ? config.timeout * 2 : nil
|
|
86
|
+
)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def run_parallel_batch(batch, pool, worker_isolator, integration)
|
|
90
|
+
uncached_indices, cached_results = partition_cached(batch)
|
|
91
|
+
worker_results = run_uncached_workers(batch, uncached_indices, pool, worker_isolator, integration)
|
|
92
|
+
compact_results = merge_parallel_results(batch, uncached_indices, cached_results, worker_results)
|
|
93
|
+
batch.each(&:strip_sources!)
|
|
94
|
+
batch_results = rebuild_results(batch, compact_results)
|
|
95
|
+
batch_results.each { |r| store_cached_result(r.mutation, r) }
|
|
96
|
+
batch_results
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def run_uncached_workers(batch, uncached_indices, pool, worker_isolator, integration)
|
|
100
|
+
return [] if uncached_indices.empty?
|
|
101
|
+
|
|
102
|
+
uncached = uncached_indices.map { |i| batch[i] }
|
|
103
|
+
pool.map(uncached) do |mutation|
|
|
104
|
+
test_command = ->(m) { integration.call(m) }
|
|
105
|
+
result = worker_isolator.call(mutation: mutation, test_command: test_command, timeout: config.timeout)
|
|
106
|
+
compact_result(result)
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def process_batch(batch_results, baseline_result, spec_resolver, state)
|
|
111
|
+
batch_results.each do |result|
|
|
112
|
+
result = neutralize_if_baseline_failed(result, baseline_result, spec_resolver)
|
|
113
|
+
state[:results] << result
|
|
114
|
+
state[:survived_count] += 1 if result.survived?
|
|
115
|
+
state[:completed] += 1
|
|
116
|
+
notify_result(result, state[:completed])
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
diagnostics.log_memory("after batch", "#{state[:completed]} complete")
|
|
120
|
+
state[:truncated] = true if should_truncate?(state[:survived_count])
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def neutralize_if_baseline_failed(result, baseline_result, spec_resolver)
|
|
124
|
+
return result unless result.survived? && baseline_result && baseline_result.failed?
|
|
125
|
+
|
|
126
|
+
if config.spec_files.any?
|
|
127
|
+
neutralize = true
|
|
128
|
+
else
|
|
129
|
+
spec_file = spec_resolver.call(result.mutation.file_path) || baseline_runner.neutralization_fallback_dir
|
|
130
|
+
neutralize = baseline_result.failed_spec_files.include?(spec_file)
|
|
131
|
+
end
|
|
132
|
+
return result unless neutralize
|
|
133
|
+
|
|
134
|
+
Evilution::Result::MutationResult.new(
|
|
135
|
+
mutation: result.mutation,
|
|
136
|
+
status: :neutral,
|
|
137
|
+
duration: result.duration,
|
|
138
|
+
test_command: result.test_command,
|
|
139
|
+
child_rss_kb: result.child_rss_kb,
|
|
140
|
+
memory_delta_kb: result.memory_delta_kb,
|
|
141
|
+
parent_rss_kb: result.parent_rss_kb,
|
|
142
|
+
error_message: result.error_message,
|
|
143
|
+
error_class: result.error_class,
|
|
144
|
+
error_backtrace: result.error_backtrace
|
|
145
|
+
)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def compact_result(result)
|
|
149
|
+
{
|
|
150
|
+
status: result.status,
|
|
151
|
+
duration: result.duration,
|
|
152
|
+
killing_test: result.killing_test,
|
|
153
|
+
test_command: result.test_command,
|
|
154
|
+
child_rss_kb: result.child_rss_kb,
|
|
155
|
+
memory_delta_kb: result.memory_delta_kb,
|
|
156
|
+
parent_rss_kb: result.parent_rss_kb,
|
|
157
|
+
error_message: result.error_message,
|
|
158
|
+
error_class: result.error_class,
|
|
159
|
+
error_backtrace: result.error_backtrace
|
|
160
|
+
}
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def rebuild_results(batch, compact_results)
|
|
164
|
+
batch.zip(compact_results).map do |mutation, data|
|
|
165
|
+
Evilution::Result::MutationResult.new(
|
|
166
|
+
mutation: mutation,
|
|
167
|
+
status: data[:status],
|
|
168
|
+
duration: data[:duration],
|
|
169
|
+
killing_test: data[:killing_test],
|
|
170
|
+
test_command: data[:test_command],
|
|
171
|
+
child_rss_kb: data[:child_rss_kb],
|
|
172
|
+
memory_delta_kb: data[:memory_delta_kb],
|
|
173
|
+
parent_rss_kb: data[:parent_rss_kb],
|
|
174
|
+
error_message: data[:error_message],
|
|
175
|
+
error_class: data[:error_class],
|
|
176
|
+
error_backtrace: data[:error_backtrace]
|
|
177
|
+
)
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def should_truncate?(survived_count)
|
|
182
|
+
config.fail_fast? && survived_count >= config.fail_fast
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def partition_cached(batch)
|
|
186
|
+
uncached_indices = []
|
|
187
|
+
cached_results = {}
|
|
188
|
+
|
|
189
|
+
batch.each_with_index do |mutation, i|
|
|
190
|
+
cached = fetch_cached_result(mutation)
|
|
191
|
+
if cached
|
|
192
|
+
cached_results[i] = compact_result(cached)
|
|
193
|
+
else
|
|
194
|
+
uncached_indices << i
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
[uncached_indices, cached_results]
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def merge_parallel_results(batch, uncached_indices, cached_results, worker_results)
|
|
202
|
+
result_map = cached_results.dup
|
|
203
|
+
uncached_indices.each_with_index { |batch_idx, worker_idx| result_map[batch_idx] = worker_results[worker_idx] }
|
|
204
|
+
batch.each_index.map { |i| result_map[i] }
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def execute_or_fetch(mutation)
|
|
208
|
+
cached = fetch_cached_result(mutation)
|
|
209
|
+
return cached if cached
|
|
210
|
+
|
|
211
|
+
result = yield
|
|
212
|
+
store_cached_result(mutation, result)
|
|
213
|
+
result
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def fetch_cached_result(mutation)
|
|
217
|
+
return nil unless cache
|
|
218
|
+
|
|
219
|
+
data = cache.fetch(mutation)
|
|
220
|
+
return nil unless data
|
|
221
|
+
return nil unless %i[killed timeout].include?(data[:status])
|
|
222
|
+
|
|
223
|
+
Evilution::Result::MutationResult.new(
|
|
224
|
+
mutation: mutation,
|
|
225
|
+
status: data[:status],
|
|
226
|
+
duration: data[:duration],
|
|
227
|
+
killing_test: data[:killing_test],
|
|
228
|
+
test_command: data[:test_command]
|
|
229
|
+
)
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def store_cached_result(mutation, result)
|
|
233
|
+
return unless cache
|
|
234
|
+
return unless result.killed? || result.timeout?
|
|
235
|
+
|
|
236
|
+
cache.store(mutation,
|
|
237
|
+
status: result.status,
|
|
238
|
+
duration: result.duration,
|
|
239
|
+
killing_test: result.killing_test,
|
|
240
|
+
test_command: result.test_command)
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def notify_result(result, index)
|
|
244
|
+
on_result&.call(result)
|
|
245
|
+
@progress_bar&.tick(status: result.status)
|
|
246
|
+
diagnostics.log_progress(index, result.status)
|
|
247
|
+
diagnostics.log_mutation_diagnostics(result)
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def build_progress_bar(total)
|
|
251
|
+
return nil if !config.progress? || config.quiet || config.verbose || !config.text? || !$stderr.tty?
|
|
252
|
+
|
|
253
|
+
Evilution::Reporter::ProgressBar.new(total: total, output: $stderr)
|
|
254
|
+
end
|
|
255
|
+
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
|