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.
Files changed (106) hide show
  1. checksums.yaml +4 -4
  2. data/.beads/interactions.jsonl +210 -0
  3. data/CHANGELOG.md +51 -0
  4. data/README.md +81 -4
  5. data/exe/evil +6 -0
  6. data/lib/evilution/ast/source_surgeon.rb +15 -1
  7. data/lib/evilution/cli/commands/compare.rb +68 -0
  8. data/lib/evilution/cli/parser/command_extractor.rb +78 -0
  9. data/lib/evilution/cli/parser/file_args.rb +41 -0
  10. data/lib/evilution/cli/parser/options_builder.rb +123 -0
  11. data/lib/evilution/cli/parser/stdin_reader.rb +28 -0
  12. data/lib/evilution/cli/parser.rb +27 -196
  13. data/lib/evilution/cli/printers/compare.rb +159 -0
  14. data/lib/evilution/cli.rb +1 -0
  15. data/lib/evilution/compare/categorizer.rb +109 -0
  16. data/lib/evilution/compare/detector.rb +21 -0
  17. data/lib/evilution/compare/fingerprint.rb +83 -0
  18. data/lib/evilution/compare/normalizer.rb +106 -0
  19. data/lib/evilution/compare/record.rb +16 -0
  20. data/lib/evilution/compare.rb +15 -0
  21. data/lib/evilution/config.rb +178 -3
  22. data/lib/evilution/example_filter.rb +143 -0
  23. data/lib/evilution/integration/base.rb +11 -57
  24. data/lib/evilution/integration/crash_detector.rb +5 -2
  25. data/lib/evilution/integration/minitest.rb +25 -7
  26. data/lib/evilution/integration/minitest_crash_detector.rb +5 -2
  27. data/lib/evilution/integration/rspec.rb +99 -12
  28. data/lib/evilution/isolation/fork.rb +26 -0
  29. data/lib/evilution/isolation/in_process.rb +1 -0
  30. data/lib/evilution/mcp/info_tool.rb +77 -5
  31. data/lib/evilution/mcp/mutate_tool/config_builder.rb +20 -0
  32. data/lib/evilution/mcp/mutate_tool/error_payload.rb +17 -0
  33. data/lib/evilution/mcp/mutate_tool/option_parser.rb +54 -0
  34. data/lib/evilution/mcp/mutate_tool/progress_streamer.rb +37 -0
  35. data/lib/evilution/mcp/mutate_tool/report_trimmer.rb +31 -0
  36. data/lib/evilution/mcp/mutate_tool/survived_enricher.rb +52 -0
  37. data/lib/evilution/mcp/mutate_tool.rb +34 -186
  38. data/lib/evilution/mutation.rb +43 -3
  39. data/lib/evilution/mutator/base.rb +39 -1
  40. data/lib/evilution/mutator/operator/argument_nil_substitution.rb +5 -1
  41. data/lib/evilution/mutator/operator/argument_removal.rb +5 -1
  42. data/lib/evilution/parallel/work_queue.rb +149 -31
  43. data/lib/evilution/parallel_db_warning.rb +68 -0
  44. data/lib/evilution/reporter/cli.rb +38 -11
  45. data/lib/evilution/reporter/html/assets/style.css +85 -0
  46. data/lib/evilution/reporter/html/baseline_keys.rb +28 -0
  47. data/lib/evilution/reporter/html/diff_formatter.rb +27 -0
  48. data/lib/evilution/reporter/html/escape.rb +12 -0
  49. data/lib/evilution/reporter/html/namespace.rb +11 -0
  50. data/lib/evilution/reporter/html/report.rb +68 -0
  51. data/lib/evilution/reporter/html/section.rb +21 -0
  52. data/lib/evilution/reporter/html/sections/baseline_comparison.rb +46 -0
  53. data/lib/evilution/reporter/html/sections/error_details.rb +30 -0
  54. data/lib/evilution/reporter/html/sections/error_entry.rb +22 -0
  55. data/lib/evilution/reporter/html/sections/file_section.rb +62 -0
  56. data/lib/evilution/reporter/html/sections/header.rb +29 -0
  57. data/lib/evilution/reporter/html/sections/mutation_map.rb +32 -0
  58. data/lib/evilution/reporter/html/sections/neutral_details.rb +25 -0
  59. data/lib/evilution/reporter/html/sections/summary_cards.rb +11 -0
  60. data/lib/evilution/reporter/html/sections/survived_details.rb +35 -0
  61. data/lib/evilution/reporter/html/sections/survived_entry.rb +36 -0
  62. data/lib/evilution/reporter/html/sections/truncation_notice.rb +17 -0
  63. data/lib/evilution/reporter/html/sections/unparseable_details.rb +25 -0
  64. data/lib/evilution/reporter/html/sections/unresolved_details.rb +25 -0
  65. data/lib/evilution/reporter/html/sections.rb +4 -0
  66. data/lib/evilution/reporter/html/stylesheet.rb +14 -0
  67. data/lib/evilution/reporter/html/templates/baseline_comparison.html.erb +8 -0
  68. data/lib/evilution/reporter/html/templates/error_details.html.erb +6 -0
  69. data/lib/evilution/reporter/html/templates/error_entry.html.erb +10 -0
  70. data/lib/evilution/reporter/html/templates/file_section.html.erb +12 -0
  71. data/lib/evilution/reporter/html/templates/header.html.erb +4 -0
  72. data/lib/evilution/reporter/html/templates/mutation_map.html.erb +6 -0
  73. data/lib/evilution/reporter/html/templates/neutral_details.html.erb +14 -0
  74. data/lib/evilution/reporter/html/templates/report.html.erb +17 -0
  75. data/lib/evilution/reporter/html/templates/summary_cards.html.erb +26 -0
  76. data/lib/evilution/reporter/html/templates/survived_details.html.erb +21 -0
  77. data/lib/evilution/reporter/html/templates/survived_entry.html.erb +8 -0
  78. data/lib/evilution/reporter/html/templates/truncation_notice.html.erb +1 -0
  79. data/lib/evilution/reporter/html/templates/unparseable_details.html.erb +11 -0
  80. data/lib/evilution/reporter/html/templates/unresolved_details.html.erb +11 -0
  81. data/lib/evilution/reporter/html.rb +11 -390
  82. data/lib/evilution/reporter/json.rb +19 -9
  83. data/lib/evilution/reporter/suggestion/diff_helpers.rb +28 -0
  84. data/lib/evilution/reporter/suggestion/registry.rb +64 -0
  85. data/lib/evilution/reporter/suggestion/templates/generic.rb +55 -0
  86. data/lib/evilution/reporter/suggestion/templates/minitest.rb +659 -0
  87. data/lib/evilution/reporter/suggestion/templates/rspec.rb +613 -0
  88. data/lib/evilution/reporter/suggestion.rb +8 -1327
  89. data/lib/evilution/result/mutation_result.rb +9 -1
  90. data/lib/evilution/result/summary.rb +21 -1
  91. data/lib/evilution/runner/baseline_runner.rb +92 -0
  92. data/lib/evilution/runner/diagnostics.rb +105 -0
  93. data/lib/evilution/runner/isolation_resolver.rb +134 -0
  94. data/lib/evilution/runner/mutation_executor.rb +325 -0
  95. data/lib/evilution/runner/mutation_planner.rb +126 -0
  96. data/lib/evilution/runner/report_publisher.rb +60 -0
  97. data/lib/evilution/runner/subject_pipeline.rb +121 -0
  98. data/lib/evilution/runner.rb +61 -692
  99. data/lib/evilution/source_ast_cache.rb +39 -0
  100. data/lib/evilution/spec_ast_cache.rb +166 -0
  101. data/lib/evilution/spec_resolver.rb +6 -1
  102. data/lib/evilution/spec_selector.rb +39 -0
  103. data/lib/evilution/temp_dir_tracker.rb +23 -3
  104. data/lib/evilution/version.rb +1 -1
  105. data/script/memory_check +7 -5
  106. 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