evilution 0.26.0 → 0.28.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 (159) hide show
  1. checksums.yaml +4 -4
  2. data/.beads/interactions.jsonl +23 -0
  3. data/.rubocop_todo.yml +6 -0
  4. data/CHANGELOG.md +54 -0
  5. data/README.md +76 -3
  6. data/lib/evilution/baseline.rb +5 -4
  7. data/lib/evilution/cache.rb +2 -0
  8. data/lib/evilution/child_output.rb +24 -0
  9. data/lib/evilution/cli/commands/run.rb +9 -0
  10. data/lib/evilution/cli/commands/version.rb +2 -0
  11. data/lib/evilution/cli/parser/options_builder.rb +23 -2
  12. data/lib/evilution/compare/diff_extractor/evilution.rb +22 -0
  13. data/lib/evilution/compare/diff_extractor/mutant.rb +30 -0
  14. data/lib/evilution/compare/diff_extractor.rb +6 -0
  15. data/lib/evilution/compare/fingerprint.rb +15 -72
  16. data/lib/evilution/compare/line_normalizer.rb +72 -0
  17. data/lib/evilution/compare/normalizer.rb +17 -4
  18. data/lib/evilution/config/builders/spec_resolver.rb +15 -0
  19. data/lib/evilution/config/builders/spec_selector.rb +16 -0
  20. data/lib/evilution/config/builders.rb +4 -0
  21. data/lib/evilution/config/env_loader.rb +12 -0
  22. data/lib/evilution/config/file_loader.rb +22 -0
  23. data/lib/evilution/config/sources.rb +14 -0
  24. data/lib/evilution/config/validators/base.rb +37 -0
  25. data/lib/evilution/config/validators/example_targeting_cache.rb +37 -0
  26. data/lib/evilution/config/validators/example_targeting_fallback.rb +22 -0
  27. data/lib/evilution/config/validators/fail_fast.rb +11 -0
  28. data/lib/evilution/config/validators/hooks.rb +12 -0
  29. data/lib/evilution/config/validators/ignore_patterns.rb +16 -0
  30. data/lib/evilution/config/validators/integration.rb +11 -0
  31. data/lib/evilution/config/validators/isolation.rb +19 -0
  32. data/lib/evilution/config/validators/jobs.rb +9 -0
  33. data/lib/evilution/config/validators/preload.rb +13 -0
  34. data/lib/evilution/config/validators/profile.rb +11 -0
  35. data/lib/evilution/config/validators/spec_mappings.rb +56 -0
  36. data/lib/evilution/config/validators/spec_pattern.rb +12 -0
  37. data/lib/evilution/config/validators.rb +4 -0
  38. data/lib/evilution/config.rb +93 -266
  39. data/lib/evilution/feedback/detector.rb +15 -0
  40. data/lib/evilution/feedback/messages.rb +42 -0
  41. data/lib/evilution/feedback.rb +5 -0
  42. data/lib/evilution/integration/crash_detector.rb +2 -2
  43. data/lib/evilution/integration/loading/source_evaluator.rb +6 -2
  44. data/lib/evilution/integration/minitest_crash_detector.rb +2 -2
  45. data/lib/evilution/integration/rspec/baseline_runner.rb +16 -0
  46. data/lib/evilution/integration/rspec/crash_detector_lifecycle.rb +17 -0
  47. data/lib/evilution/integration/rspec/example_filter_applier.rb +21 -0
  48. data/lib/evilution/integration/rspec/framework_loader.rb +28 -0
  49. data/lib/evilution/integration/rspec/result_builder.rb +40 -0
  50. data/lib/evilution/integration/rspec/state_guard/example_groups_constants.rb +28 -0
  51. data/lib/evilution/integration/rspec/state_guard/internals.rb +19 -0
  52. data/lib/evilution/integration/rspec/state_guard/object_space_example_groups.rb +43 -0
  53. data/lib/evilution/integration/rspec/state_guard/reporter_arrays.rb +32 -0
  54. data/lib/evilution/integration/rspec/state_guard/world_example_groups.rb +20 -0
  55. data/lib/evilution/integration/rspec/state_guard/world_filtered_examples.rb +20 -0
  56. data/lib/evilution/integration/rspec/state_guard/world_sources_by_path.rb +20 -0
  57. data/lib/evilution/integration/rspec/state_guard.rb +40 -0
  58. data/lib/evilution/integration/rspec/test_file_resolver.rb +30 -0
  59. data/lib/evilution/integration/rspec/unresolved_spec_warner.rb +18 -0
  60. data/lib/evilution/integration/rspec.rb +61 -232
  61. data/lib/evilution/isolation/fork.rb +23 -13
  62. data/lib/evilution/isolation/in_process.rb +10 -6
  63. data/lib/evilution/mcp/info_tool/actions/base.rb +22 -0
  64. data/lib/evilution/mcp/info_tool/actions/environment.rb +42 -0
  65. data/lib/evilution/mcp/info_tool/actions/feedback.rb +16 -0
  66. data/lib/evilution/mcp/info_tool/actions/statuses.rb +10 -0
  67. data/lib/evilution/mcp/info_tool/actions/subjects.rb +47 -0
  68. data/lib/evilution/mcp/info_tool/actions/tests.rb +60 -0
  69. data/lib/evilution/mcp/info_tool/actions.rb +16 -0
  70. data/lib/evilution/mcp/info_tool/config_factory.rb +24 -0
  71. data/lib/evilution/mcp/info_tool/error_mapper.rb +15 -0
  72. data/lib/evilution/mcp/info_tool/request_parser.rb +34 -0
  73. data/lib/evilution/mcp/info_tool/response_formatter.rb +24 -0
  74. data/lib/evilution/mcp/info_tool/status_glossary.rb +75 -0
  75. data/lib/evilution/mcp/info_tool.rb +43 -263
  76. data/lib/evilution/mcp/mutate_tool/error_payload.rb +8 -1
  77. data/lib/evilution/mcp/mutate_tool/progress_streamer.rb +5 -1
  78. data/lib/evilution/mcp/mutate_tool/report_trimmer.rb +13 -1
  79. data/lib/evilution/mcp/mutate_tool.rb +5 -2
  80. data/lib/evilution/mcp/session_tool.rb +0 -2
  81. data/lib/evilution/mutation.rb +47 -27
  82. data/lib/evilution/mutator/base.rb +8 -8
  83. data/lib/evilution/mutator/operator/block_removal.rb +1 -1
  84. data/lib/evilution/mutator/operator/method_body_replacement.rb +18 -2
  85. data/lib/evilution/mutator/operator/predicate_to_nil.rb +20 -0
  86. data/lib/evilution/mutator/registry.rb +20 -0
  87. data/lib/evilution/parallel/work_queue/channel/frame.rb +25 -0
  88. data/lib/evilution/parallel/work_queue/channel.rb +23 -0
  89. data/lib/evilution/parallel/work_queue/collection_state.rb +14 -0
  90. data/lib/evilution/parallel/work_queue/dispatcher.rb +133 -0
  91. data/lib/evilution/parallel/work_queue/validators/optional_positive_int.rb +11 -0
  92. data/lib/evilution/parallel/work_queue/validators/optional_positive_number.rb +11 -0
  93. data/lib/evilution/parallel/work_queue/validators/positive_int.rb +11 -0
  94. data/lib/evilution/parallel/work_queue/validators.rb +6 -0
  95. data/lib/evilution/parallel/work_queue/worker/loop.rb +45 -0
  96. data/lib/evilution/parallel/work_queue/worker.rb +114 -0
  97. data/lib/evilution/parallel/work_queue/worker_stat.rb +17 -0
  98. data/lib/evilution/parallel/work_queue.rb +42 -327
  99. data/lib/evilution/process_cleanup.rb +19 -0
  100. data/lib/evilution/reporter/cli/item_formatters/coverage_gap.rb +18 -0
  101. data/lib/evilution/reporter/cli/item_formatters/disabled.rb +9 -0
  102. data/lib/evilution/reporter/cli/item_formatters/error.rb +14 -0
  103. data/lib/evilution/reporter/cli/item_formatters/result_location.rb +10 -0
  104. data/lib/evilution/reporter/cli/item_formatters.rb +6 -0
  105. data/lib/evilution/reporter/cli/line_formatters/duration.rb +9 -0
  106. data/lib/evilution/reporter/cli/line_formatters/efficiency.rb +18 -0
  107. data/lib/evilution/reporter/cli/line_formatters/feedback_footer.rb +13 -0
  108. data/lib/evilution/reporter/cli/line_formatters/header.rb +10 -0
  109. data/lib/evilution/reporter/cli/line_formatters/mutations.rb +16 -0
  110. data/lib/evilution/reporter/cli/line_formatters/peak_memory.rb +12 -0
  111. data/lib/evilution/reporter/cli/line_formatters/result_line.rb +20 -0
  112. data/lib/evilution/reporter/cli/line_formatters/score.rb +14 -0
  113. data/lib/evilution/reporter/cli/line_formatters/truncation_notice.rb +11 -0
  114. data/lib/evilution/reporter/cli/line_formatters.rb +6 -0
  115. data/lib/evilution/reporter/cli/metrics_block.rb +26 -0
  116. data/lib/evilution/reporter/cli/pct.rb +9 -0
  117. data/lib/evilution/reporter/cli/section.rb +13 -0
  118. data/lib/evilution/reporter/cli/section_renderer.rb +15 -0
  119. data/lib/evilution/reporter/cli/trailer.rb +22 -0
  120. data/lib/evilution/reporter/cli.rb +79 -162
  121. data/lib/evilution/reporter/html/baseline_keys.rb +1 -1
  122. data/lib/evilution/reporter/html/diff_formatter.rb +1 -1
  123. data/lib/evilution/reporter/html/escape.rb +1 -1
  124. data/lib/evilution/reporter/html/section.rb +1 -1
  125. data/lib/evilution/reporter/html/sections.rb +4 -2
  126. data/lib/evilution/reporter/html/stylesheet.rb +1 -1
  127. data/lib/evilution/reporter/html.rb +8 -3
  128. data/lib/evilution/reporter/suggestion/registry.rb +1 -5
  129. data/lib/evilution/reporter/suggestion/templates/generic.rb +1 -1
  130. data/lib/evilution/reporter/suggestion/templates/minitest.rb +349 -643
  131. data/lib/evilution/reporter/suggestion/templates/rspec.rb +351 -598
  132. data/lib/evilution/reporter/suggestion/templates.rb +6 -0
  133. data/lib/evilution/result/error_info.rb +20 -0
  134. data/lib/evilution/result/memory_stats.rb +20 -0
  135. data/lib/evilution/result/mutation_result.rb +30 -14
  136. data/lib/evilution/runner/baseline_runner.rb +1 -2
  137. data/lib/evilution/runner/diagnostics.rb +1 -2
  138. data/lib/evilution/runner/isolation_resolver.rb +10 -4
  139. data/lib/evilution/runner/mutation_executor/mutation_runner.rb +30 -0
  140. data/lib/evilution/runner/mutation_executor/neutralization_pipeline.rb +15 -0
  141. data/lib/evilution/runner/mutation_executor/neutralizer/baseline_failed.rb +39 -0
  142. data/lib/evilution/runner/mutation_executor/neutralizer/infra_error.rb +68 -0
  143. data/lib/evilution/runner/mutation_executor/neutralizer.rb +11 -0
  144. data/lib/evilution/runner/mutation_executor/result_cache.rb +67 -0
  145. data/lib/evilution/runner/mutation_executor/result_notifier.rb +46 -0
  146. data/lib/evilution/runner/mutation_executor/result_packer.rb +41 -0
  147. data/lib/evilution/runner/mutation_executor/strategy/parallel.rb +78 -0
  148. data/lib/evilution/runner/mutation_executor/strategy/sequential.rb +32 -0
  149. data/lib/evilution/runner/mutation_executor/strategy.rb +11 -0
  150. data/lib/evilution/runner/mutation_executor.rb +53 -292
  151. data/lib/evilution/runner/mutation_planner.rb +1 -2
  152. data/lib/evilution/runner/report_publisher.rb +1 -2
  153. data/lib/evilution/runner/subject_pipeline.rb +1 -2
  154. data/lib/evilution/runner.rb +53 -30
  155. data/lib/evilution/version.rb +1 -1
  156. data/lib/evilution.rb +1 -0
  157. data/script/memory_check +3 -1
  158. metadata +125 -3
  159. data/lib/evilution/reporter/html/namespace.rb +0 -11
@@ -9,20 +9,12 @@ class Evilution::Parallel::WorkQueue
9
9
 
10
10
  TIMING_GRACE_PERIOD = 5
11
11
 
12
- WorkerStat = Struct.new(:pid, :items_completed, :busy_time, :wall_time) do
13
- def idle_time
14
- wall_time - busy_time
15
- end
16
-
17
- def utilization
18
- return 0.0 if wall_time.nil? || wall_time.zero?
19
-
20
- busy_time / wall_time
21
- end
22
- end
23
-
24
12
  def initialize(size:, hooks: nil, prefetch: 1, item_timeout: nil, worker_max_items: nil)
25
- validate_init_args(size, prefetch, item_timeout, worker_max_items)
13
+ Validators::PositiveInt.call!(:size, size)
14
+ Validators::PositiveInt.call!(:prefetch, prefetch)
15
+ Validators::OptionalPositiveNumber.call!(:item_timeout, item_timeout)
16
+ Validators::OptionalPositiveInt.call!(:worker_max_items, worker_max_items)
17
+
26
18
  @size = size
27
19
  @hooks = hooks
28
20
  @prefetch = prefetch
@@ -34,18 +26,25 @@ class Evilution::Parallel::WorkQueue
34
26
  def map(items, &block)
35
27
  return [] if items.empty?
36
28
 
37
- @block = block
38
- @retired_workers = []
39
- worker_count = [@size, items.length].min
40
- workers = spawn_workers(worker_count, &block)
29
+ workers = (0...[@size, items.length].min).map { |i| spawn_one(i, &block) }
30
+ dispatcher = Dispatcher.new(
31
+ workers: workers, items: items, prefetch: @prefetch,
32
+ item_timeout: @item_timeout, worker_max_items: @worker_max_items,
33
+ recycle_factory: ->(old) { spawn_one(old.worker_index, &block) }
34
+ )
41
35
 
36
+ retired = []
42
37
  begin
43
- distribute_and_collect(items, workers)
38
+ results, retired = dispatcher.run
39
+ raise dispatcher.first_error if dispatcher.first_error
40
+
41
+ results
44
42
  ensure
45
- shutdown_workers(workers)
46
- @worker_stats = @retired_workers + build_worker_stats(workers)
47
- @block = nil
48
- @retired_workers = nil
43
+ workers.each(&:shutdown)
44
+ collect_final_timings(workers)
45
+ workers.each(&:close_pipes)
46
+ workers.each(&:reap)
47
+ @worker_stats = retired + workers.map(&:to_stat)
49
48
  end
50
49
  end
51
50
 
@@ -55,272 +54,12 @@ class Evilution::Parallel::WorkQueue
55
54
 
56
55
  private
57
56
 
58
- def validate_init_args(size, prefetch, item_timeout, worker_max_items)
59
- validate_positive_int!(:size, size)
60
- validate_positive_int!(:prefetch, prefetch)
61
- validate_optional_positive_number!(:item_timeout, item_timeout)
62
- validate_optional_positive_int!(:worker_max_items, worker_max_items)
63
- end
64
-
65
- def validate_positive_int!(name, value)
66
- return if value.is_a?(Integer) && value >= 1
67
-
68
- raise ArgumentError, "#{name} must be a positive integer, got #{value.inspect}"
69
- end
70
-
71
- def validate_optional_positive_int!(name, value)
72
- return if value.nil? || (value.is_a?(Integer) && value.positive?)
73
-
74
- raise ArgumentError, "#{name} must be nil or a positive integer, got #{value.inspect}"
57
+ def spawn_one(worker_index, &)
58
+ Worker.spawn(worker_index: worker_index, hooks: @hooks, &)
75
59
  end
76
60
 
77
- def validate_optional_positive_number!(name, value)
78
- return if value.nil? || (value.is_a?(Numeric) && value.positive?)
79
-
80
- raise ArgumentError, "#{name} must be nil or a positive number, got #{value.inspect}"
81
- end
82
-
83
- def spawn_workers(count, &block)
84
- count.times.map { |slot| spawn_one_worker(worker_index: slot, &block) }
85
- end
86
-
87
- # EV-kdns / GH #817: translate 0-based worker slot to parallel_tests'
88
- # TEST_ENV_NUMBER convention ("" for slot 0, "2" for slot 1, ...). Rails
89
- # apps interpolating TEST_ENV_NUMBER into database.yml get per-worker
90
- # SQLite files, avoiding lock contention under jobs > 1.
91
- def test_env_number_for(worker_index)
92
- worker_index.zero? ? "" : (worker_index + 1).to_s
93
- end
94
-
95
- def spawn_one_worker(worker_index:, &block)
96
- cmd_read, cmd_write = IO.pipe
97
- res_read, res_write = IO.pipe
98
- # Marshal payloads are ASCII-8BIT; pipes default to text mode and may
99
- # transcode according to their external/internal encodings (influenced by
100
- # Encoding.default_external and/or Encoding.default_internal — Rails sets
101
- # the latter to UTF-8), failing on bytes with no mapping. Force binmode on
102
- # all four ends.
103
- [cmd_read, cmd_write, res_read, res_write].each(&:binmode)
104
-
105
- pid = Process.fork do
106
- cmd_write.close
107
- res_read.close
108
- ENV["TEST_ENV_NUMBER"] = test_env_number_for(worker_index)
109
- worker_loop(cmd_read, res_write, &block)
110
- end
111
-
112
- cmd_read.close
113
- res_write.close
114
-
115
- { pid: pid, cmd_write: cmd_write, res_read: res_read, items_completed: 0, pending: 0, worker_index: worker_index }
116
- end
117
-
118
- def worker_loop(cmd_read, res_write, &block)
119
- @hooks.fire(:worker_process_start) if @hooks
120
- start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
121
- busy_time = 0.0
122
-
123
- loop do
124
- data = read_command(cmd_read)
125
- break if data == SHUTDOWN
126
-
127
- index, item = data
128
- begin
129
- t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
130
- result = block.call(item)
131
- busy_time += Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0
132
- write_message(res_write, [index, :ok, result])
133
- rescue Exception => e # rubocop:disable Lint/RescueException
134
- busy_time += Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0
135
- write_message(res_write, [index, :error, e])
136
- end
137
- end
138
-
139
- wall_time = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
140
- write_message(res_write, [STATS, busy_time, wall_time])
141
- ensure
142
- cmd_read.close
143
- res_write.close
144
- exit!
145
- end
146
-
147
- def distribute_and_collect(items, workers)
148
- state = CollectionState.new(items.length)
149
- seed_workers(items, workers, state)
150
- collect_results(items, workers, state)
151
- raise state.first_error if state.first_error
152
-
153
- state.results
154
- end
155
-
156
- def seed_workers(items, workers, state)
157
- @prefetch.times do
158
- workers.each do |worker|
159
- break unless state.next_index < items.length
160
-
161
- send_item(worker, items, state)
162
- end
163
- end
164
- end
165
-
166
- def collect_results(items, workers, state)
167
- io_to_worker = workers.to_h { |w| [w[:res_read], w] }
168
- result_ios = io_to_worker.keys
169
-
170
- while state.in_flight.positive?
171
- readable, = IO.select(result_ios, nil, nil, @item_timeout)
172
-
173
- if readable.nil?
174
- terminate_stuck_workers(workers)
175
- state.first_error = Evilution::Error.new("worker timed out after #{@item_timeout}s") if state.first_error.nil?
176
- break
177
- end
178
-
179
- readable.each do |io|
180
- alive = handle_result(io, io_to_worker[io], items, state, workers, io_to_worker, result_ios)
181
- result_ios.delete(io) unless alive
182
- end
183
- end
184
- end
185
-
186
- def handle_result(io, worker, items, state, workers, io_to_worker, result_ios)
187
- message = read_result(io)
188
- return handle_dead_worker(worker, state) if message.nil?
189
-
190
- record_result(message, worker, state)
191
- return false if recycle_and_dispatch(worker, items, state, workers, io_to_worker, result_ios)
192
- return true if draining_for_recycle?(worker)
193
-
194
- send_item(worker, items, state) if state.next_index < items.length && state.first_error.nil?
195
- true
196
- end
197
-
198
- # Once worker hits K, stop dispatching so pending drains to 0; recycle fires
199
- # on the next result. Prevents prefetch > 1 from refilling pending forever.
200
- def draining_for_recycle?(worker)
201
- @worker_max_items && worker[:items_completed] >= @worker_max_items && worker[:pending].positive?
202
- end
203
-
204
- def handle_dead_worker(worker, state)
205
- state.first_error = Evilution::Error.new("worker process exited unexpectedly") if state.first_error.nil?
206
- state.in_flight -= worker[:pending]
207
- worker[:pending] = 0
208
- false
209
- end
210
-
211
- def record_result(message, worker, state)
212
- index, status, value = message
213
- state.first_error = value if status == :error && state.first_error.nil?
214
- state.results[index] = value if status == :ok
215
- state.in_flight -= 1
216
- worker[:pending] -= 1
217
- worker[:items_completed] += 1
218
- end
219
-
220
- def recycle_and_dispatch(worker, items, state, workers, io_to_worker, result_ios)
221
- return false unless should_recycle?(worker, state, items)
222
-
223
- new_worker = recycle_worker(worker, workers, io_to_worker, result_ios)
224
- send_item(new_worker, items, state) if state.next_index < items.length && state.first_error.nil?
225
- true
226
- end
227
-
228
- def should_recycle?(worker, state, items)
229
- return false unless @worker_max_items
230
- return false if worker[:items_completed] < @worker_max_items
231
- return false unless worker[:pending].zero?
232
- return false unless state.next_index < items.length
233
- return false unless state.first_error.nil?
234
-
235
- true
236
- end
237
-
238
- def recycle_worker(old_worker, workers, io_to_worker, result_ios)
239
- io_to_worker.delete(old_worker[:res_read])
240
- result_ios.delete(old_worker[:res_read])
241
- retire_worker(old_worker)
242
-
243
- new_worker = spawn_one_worker(worker_index: old_worker[:worker_index], &@block)
244
- workers[workers.index(old_worker)] = new_worker
245
- io_to_worker[new_worker[:res_read]] = new_worker
246
- result_ios << new_worker[:res_read]
247
-
248
- new_worker
249
- end
250
-
251
- def retire_worker(worker)
252
- begin
253
- write_message(worker[:cmd_write], SHUTDOWN)
254
- rescue Errno::EPIPE
255
- nil
256
- end
257
-
258
- busy, wall = drain_worker_stats(worker)
259
-
260
- worker[:cmd_write].close unless worker[:cmd_write].closed?
261
- worker[:res_read].close unless worker[:res_read].closed?
262
- begin
263
- Process.wait(worker[:pid])
264
- rescue Errno::ECHILD
265
- nil
266
- end
267
-
268
- @retired_workers << WorkerStat.new(worker[:pid], worker[:items_completed], busy, wall)
269
- end
270
-
271
- def drain_worker_stats(worker)
272
- return [0.0, 0.0] unless worker[:res_read].wait_readable(TIMING_GRACE_PERIOD)
273
-
274
- message = read_result(worker[:res_read])
275
- return [0.0, 0.0] if message.nil?
276
-
277
- tag, busy_time, wall_time = message
278
- return [0.0, 0.0] unless tag == STATS
279
-
280
- [busy_time, wall_time]
281
- end
282
-
283
- def send_item(worker, items, state)
284
- write_message(worker[:cmd_write], [state.next_index, items[state.next_index]])
285
- state.next_index += 1
286
- state.in_flight += 1
287
- worker[:pending] += 1
288
- end
289
-
290
- def build_worker_stats(workers)
291
- workers.map do |worker|
292
- WorkerStat.new(worker[:pid], worker[:items_completed], worker[:busy_time] || 0.0, worker[:wall_time] || 0.0)
293
- end
294
- end
295
-
296
- def terminate_stuck_workers(workers)
297
- workers.each do |worker|
298
- Process.kill("KILL", worker[:pid])
299
- rescue Errno::ESRCH
300
- nil # Already exited
301
- end
302
- end
303
-
304
- def shutdown_workers(workers)
305
- workers.each do |worker|
306
- write_message(worker[:cmd_write], SHUTDOWN)
307
- rescue Errno::EPIPE
308
- # Worker already exited
309
- end
310
-
311
- collect_worker_timing(workers)
312
-
313
- workers.each do |worker|
314
- worker[:cmd_write].close unless worker[:cmd_write].closed?
315
- worker[:res_read].close unless worker[:res_read].closed?
316
- Process.wait(worker[:pid])
317
- rescue Errno::ECHILD
318
- # Already reaped
319
- end
320
- end
321
-
322
- def collect_worker_timing(workers)
323
- io_to_worker = workers.reject { |w| w[:res_read].closed? }.to_h { |w| [w[:res_read], w] }
61
+ def collect_final_timings(workers)
62
+ io_to_worker = workers.reject { |w| w.res_io.closed? }.to_h { |w| [w.res_io, w] }
324
63
  deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + TIMING_GRACE_PERIOD
325
64
 
326
65
  until io_to_worker.empty?
@@ -330,54 +69,30 @@ class Evilution::Parallel::WorkQueue
330
69
  readable, = IO.select(io_to_worker.keys, nil, nil, remaining)
331
70
  break unless readable
332
71
 
333
- readable.each { |io| apply_worker_timing(io_to_worker.delete(io), io) }
72
+ readable.each { |io| apply_final_timing(io_to_worker.delete(io), io) }
334
73
  end
335
74
  end
336
75
 
337
- def apply_worker_timing(worker, io)
338
- message = read_result(io)
76
+ def apply_final_timing(worker, io)
77
+ message = Evilution::Parallel::WorkQueue::Channel.read(io)
339
78
  return if message.nil?
340
79
 
341
80
  tag, busy_time, wall_time = message
342
81
  return unless tag == STATS
343
82
 
344
- worker[:busy_time] = busy_time
345
- worker[:wall_time] = wall_time
83
+ worker.busy_time = busy_time
84
+ worker.wall_time = wall_time
346
85
  end
347
-
348
- def write_message(io, data)
349
- payload = Marshal.dump(data)
350
- io.write([payload.bytesize].pack("N"))
351
- io.write(payload)
352
- io.flush
353
- end
354
-
355
- def read_command(io)
356
- header = io.read(4)
357
- return SHUTDOWN if header.nil? || header.bytesize < 4
358
-
359
- length = header.unpack1("N")
360
- payload = io.read(length)
361
- return SHUTDOWN if payload.nil? || payload.bytesize < length
362
-
363
- Marshal.load(payload) # rubocop:disable Security/MarshalLoad
364
- end
365
-
366
- def read_result(io)
367
- header = io.read(4)
368
- return nil if header.nil? || header.bytesize < 4
369
-
370
- length = header.unpack1("N")
371
- payload = io.read(length)
372
- return nil if payload.nil? || payload.bytesize < length
373
-
374
- Marshal.load(payload) # rubocop:disable Security/MarshalLoad
375
- end
376
-
377
- CollectionState = Struct.new(:results, :in_flight, :next_index, :first_error) do
378
- def initialize(item_count)
379
- super(Array.new(item_count), 0, 0, nil)
380
- end
381
- end
382
- private_constant :CollectionState
383
86
  end
87
+
88
+ require_relative "work_queue/worker_stat"
89
+ require_relative "work_queue/validators"
90
+ require_relative "work_queue/validators/positive_int"
91
+ require_relative "work_queue/validators/optional_positive_int"
92
+ require_relative "work_queue/validators/optional_positive_number"
93
+ require_relative "work_queue/channel"
94
+ require_relative "work_queue/channel/frame"
95
+ require_relative "work_queue/worker"
96
+ require_relative "work_queue/worker/loop"
97
+ require_relative "work_queue/collection_state"
98
+ require_relative "work_queue/dispatcher"
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "version"
4
+
5
+ module Evilution::ProcessCleanup
6
+ module_function
7
+
8
+ def safe_kill(signal, pid)
9
+ ::Process.kill(signal, pid)
10
+ rescue Errno::ESRCH
11
+ nil
12
+ end
13
+
14
+ def safe_wait(pid)
15
+ ::Process.wait(pid)
16
+ rescue Errno::ECHILD
17
+ nil
18
+ end
19
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../item_formatters"
4
+
5
+ class Evilution::Reporter::CLI::ItemFormatters::CoverageGap
6
+ def format(gap)
7
+ location = "#{gap.file_path}:#{gap.line}"
8
+ header = if gap.single?
9
+ " #{gap.primary_operator}: #{location} (#{gap.subject_name})"
10
+ else
11
+ operators = gap.operator_names.join(", ")
12
+ " #{location} (#{gap.subject_name}) [#{gap.count} mutations: #{operators}]"
13
+ end
14
+ body = gap.mutation_results.first.mutation.unified_diff || gap.primary_diff
15
+ indented = body.split("\n").map { |l| " #{l}" }.join("\n")
16
+ "#{header}\n#{indented}"
17
+ end
18
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../item_formatters"
4
+
5
+ class Evilution::Reporter::CLI::ItemFormatters::Disabled
6
+ def format(mutation)
7
+ " #{mutation.operator_name}: #{mutation.file_path}:#{mutation.line}"
8
+ end
9
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../item_formatters"
4
+
5
+ class Evilution::Reporter::CLI::ItemFormatters::Error
6
+ def format(result)
7
+ mutation = result.mutation
8
+ header = " #{mutation.operator_name}: #{mutation.file_path}:#{mutation.line}"
9
+ return header unless result.error_message
10
+
11
+ indented = result.error_message.lines.map { |line| " #{line.chomp}" }.join("\n")
12
+ "#{header}\n#{indented}"
13
+ end
14
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../item_formatters"
4
+
5
+ class Evilution::Reporter::CLI::ItemFormatters::ResultLocation
6
+ def format(result)
7
+ mutation = result.mutation
8
+ " #{mutation.operator_name}: #{mutation.file_path}:#{mutation.line}"
9
+ end
10
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../cli"
4
+
5
+ module Evilution::Reporter::CLI::ItemFormatters
6
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../line_formatters"
4
+
5
+ class Evilution::Reporter::CLI::LineFormatters::Duration
6
+ def format(summary)
7
+ "Duration: #{Kernel.format("%.2f", summary.duration)}s"
8
+ end
9
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../line_formatters"
4
+ require_relative "../pct"
5
+
6
+ class Evilution::Reporter::CLI::LineFormatters::Efficiency
7
+ def initialize(pct: Evilution::Reporter::CLI::Pct.new)
8
+ @pct = pct
9
+ end
10
+
11
+ def format(summary)
12
+ return nil unless summary.duration.positive?
13
+
14
+ pct = @pct.format(summary.efficiency)
15
+ rate = Kernel.format("%.2f", summary.mutations_per_second)
16
+ "Efficiency: #{pct} killtime, #{rate} mutations/s"
17
+ end
18
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../line_formatters"
4
+ require_relative "../../../feedback/detector"
5
+ require_relative "../../../feedback/messages"
6
+
7
+ class Evilution::Reporter::CLI::LineFormatters::FeedbackFooter
8
+ def format(summary)
9
+ return nil unless Evilution::Feedback::Detector.friction?(summary)
10
+
11
+ Evilution::Feedback::Messages.cli_footer
12
+ end
13
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../line_formatters"
4
+ require_relative "../../../version"
5
+
6
+ class Evilution::Reporter::CLI::LineFormatters::Header
7
+ def format(_summary)
8
+ "Evilution v#{Evilution::VERSION} — Mutation Testing Results"
9
+ end
10
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../line_formatters"
4
+
5
+ class Evilution::Reporter::CLI::LineFormatters::Mutations
6
+ def format(summary)
7
+ parts = "Mutations: #{summary.total} total, #{summary.killed} killed, " \
8
+ "#{summary.survived} survived, #{summary.timed_out} timed out"
9
+ parts += ", #{summary.neutral} neutral" if summary.neutral.positive?
10
+ parts += ", #{summary.equivalent} equivalent" if summary.equivalent.positive?
11
+ parts += ", #{summary.unresolved} unresolved" if summary.unresolved.positive?
12
+ parts += ", #{summary.unparseable} unparseable" if summary.unparseable.positive?
13
+ parts += ", #{summary.skipped} skipped" if summary.skipped.positive?
14
+ parts
15
+ end
16
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../line_formatters"
4
+
5
+ class Evilution::Reporter::CLI::LineFormatters::PeakMemory
6
+ def format(summary)
7
+ peak = summary.peak_memory_mb
8
+ return nil unless peak
9
+
10
+ Kernel.format("Peak memory: %<mb>.1f MB", mb: peak)
11
+ end
12
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../line_formatters"
4
+ require_relative "../pct"
5
+
6
+ class Evilution::Reporter::CLI::LineFormatters::ResultLine
7
+ DEFAULT_MIN_SCORE = 0.8
8
+
9
+ def initialize(pct: Evilution::Reporter::CLI::Pct.new, min_score: DEFAULT_MIN_SCORE)
10
+ @pct = pct
11
+ @min_score = min_score
12
+ end
13
+
14
+ def format(summary)
15
+ pass_fail = summary.success?(min_score: @min_score) ? "PASS" : "FAIL"
16
+ score_pct = @pct.format(summary.score)
17
+ threshold_pct = @pct.format(@min_score)
18
+ "Result: #{pass_fail} (score #{score_pct} #{pass_fail == "PASS" ? ">=" : "<"} #{threshold_pct})"
19
+ end
20
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../line_formatters"
4
+ require_relative "../pct"
5
+
6
+ class Evilution::Reporter::CLI::LineFormatters::Score
7
+ def initialize(pct: Evilution::Reporter::CLI::Pct.new)
8
+ @pct = pct
9
+ end
10
+
11
+ def format(summary)
12
+ "Score: #{@pct.format(summary.score)} (#{summary.killed}/#{summary.score_denominator})"
13
+ end
14
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../line_formatters"
4
+
5
+ class Evilution::Reporter::CLI::LineFormatters::TruncationNotice
6
+ def format(summary)
7
+ return nil unless summary.truncated?
8
+
9
+ "[TRUNCATED] Stopped early due to --fail-fast"
10
+ end
11
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../cli"
4
+
5
+ module Evilution::Reporter::CLI::LineFormatters
6
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../cli"
4
+ require_relative "line_formatters/mutations"
5
+ require_relative "line_formatters/score"
6
+ require_relative "line_formatters/duration"
7
+ require_relative "line_formatters/efficiency"
8
+ require_relative "line_formatters/peak_memory"
9
+
10
+ class Evilution::Reporter::CLI::MetricsBlock
11
+ DEFAULT_LINES = [
12
+ Evilution::Reporter::CLI::LineFormatters::Mutations.new,
13
+ Evilution::Reporter::CLI::LineFormatters::Score.new,
14
+ Evilution::Reporter::CLI::LineFormatters::Duration.new,
15
+ Evilution::Reporter::CLI::LineFormatters::Efficiency.new,
16
+ Evilution::Reporter::CLI::LineFormatters::PeakMemory.new
17
+ ].freeze
18
+
19
+ def initialize(lines: DEFAULT_LINES)
20
+ @lines = lines
21
+ end
22
+
23
+ def call(summary)
24
+ @lines.filter_map { |line| line.format(summary) }
25
+ end
26
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../cli"
4
+
5
+ class Evilution::Reporter::CLI::Pct
6
+ def format(value)
7
+ Kernel.format("%.2f%%", value * 100)
8
+ end
9
+ end