evilution 0.23.0 → 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.
Files changed (60) hide show
  1. checksums.yaml +4 -4
  2. data/.beads/interactions.jsonl +5 -0
  3. data/CHANGELOG.md +16 -0
  4. data/README.md +1 -0
  5. data/lib/evilution/cli/parser/command_extractor.rb +77 -0
  6. data/lib/evilution/cli/parser/file_args.rb +41 -0
  7. data/lib/evilution/cli/parser/options_builder.rb +103 -0
  8. data/lib/evilution/cli/parser/stdin_reader.rb +28 -0
  9. data/lib/evilution/cli/parser.rb +27 -196
  10. data/lib/evilution/config.rb +14 -1
  11. data/lib/evilution/integration/base.rb +11 -57
  12. data/lib/evilution/integration/minitest.rb +16 -3
  13. data/lib/evilution/integration/rspec.rb +19 -7
  14. data/lib/evilution/isolation/fork.rb +1 -0
  15. data/lib/evilution/isolation/in_process.rb +1 -0
  16. data/lib/evilution/reporter/cli.rb +2 -1
  17. data/lib/evilution/reporter/html/assets/style.css +68 -0
  18. data/lib/evilution/reporter/html/baseline_keys.rb +28 -0
  19. data/lib/evilution/reporter/html/diff_formatter.rb +27 -0
  20. data/lib/evilution/reporter/html/escape.rb +12 -0
  21. data/lib/evilution/reporter/html/namespace.rb +11 -0
  22. data/lib/evilution/reporter/html/report.rb +68 -0
  23. data/lib/evilution/reporter/html/section.rb +21 -0
  24. data/lib/evilution/reporter/html/sections/baseline_comparison.rb +46 -0
  25. data/lib/evilution/reporter/html/sections/error_details.rb +30 -0
  26. data/lib/evilution/reporter/html/sections/error_entry.rb +22 -0
  27. data/lib/evilution/reporter/html/sections/file_section.rb +47 -0
  28. data/lib/evilution/reporter/html/sections/header.rb +29 -0
  29. data/lib/evilution/reporter/html/sections/mutation_map.rb +32 -0
  30. data/lib/evilution/reporter/html/sections/summary_cards.rb +11 -0
  31. data/lib/evilution/reporter/html/sections/survived_details.rb +35 -0
  32. data/lib/evilution/reporter/html/sections/survived_entry.rb +36 -0
  33. data/lib/evilution/reporter/html/sections/truncation_notice.rb +17 -0
  34. data/lib/evilution/reporter/html/sections.rb +4 -0
  35. data/lib/evilution/reporter/html/stylesheet.rb +14 -0
  36. data/lib/evilution/reporter/html/templates/baseline_comparison.html.erb +8 -0
  37. data/lib/evilution/reporter/html/templates/error_details.html.erb +6 -0
  38. data/lib/evilution/reporter/html/templates/error_entry.html.erb +10 -0
  39. data/lib/evilution/reporter/html/templates/file_section.html.erb +9 -0
  40. data/lib/evilution/reporter/html/templates/header.html.erb +4 -0
  41. data/lib/evilution/reporter/html/templates/mutation_map.html.erb +6 -0
  42. data/lib/evilution/reporter/html/templates/report.html.erb +17 -0
  43. data/lib/evilution/reporter/html/templates/summary_cards.html.erb +23 -0
  44. data/lib/evilution/reporter/html/templates/survived_details.html.erb +21 -0
  45. data/lib/evilution/reporter/html/templates/survived_entry.html.erb +8 -0
  46. data/lib/evilution/reporter/html/templates/truncation_notice.html.erb +1 -0
  47. data/lib/evilution/reporter/html.rb +11 -390
  48. data/lib/evilution/reporter/json.rb +12 -8
  49. data/lib/evilution/result/mutation_result.rb +5 -1
  50. data/lib/evilution/result/summary.rb +9 -1
  51. data/lib/evilution/runner/baseline_runner.rb +71 -0
  52. data/lib/evilution/runner/diagnostics.rb +105 -0
  53. data/lib/evilution/runner/isolation_resolver.rb +134 -0
  54. data/lib/evilution/runner/mutation_executor.rb +255 -0
  55. data/lib/evilution/runner/mutation_planner.rb +126 -0
  56. data/lib/evilution/runner/report_publisher.rb +60 -0
  57. data/lib/evilution/runner/subject_pipeline.rb +121 -0
  58. data/lib/evilution/runner.rb +57 -694
  59. data/lib/evilution/version.rb +1 -1
  60. metadata +42 -1
@@ -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