evilution 0.28.0 → 0.29.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 (81) hide show
  1. checksums.yaml +4 -4
  2. data/.beads/interactions.jsonl +52 -0
  3. data/CHANGELOG.md +7 -0
  4. data/lib/evilution/ast/constant_names.rb +28 -11
  5. data/lib/evilution/ast/pattern/parser.rb +29 -17
  6. data/lib/evilution/cli/commands/session_diff.rb +6 -4
  7. data/lib/evilution/cli/commands/subjects.rb +6 -3
  8. data/lib/evilution/cli/commands/util_mutation.rb +24 -19
  9. data/lib/evilution/cli/parser/command_extractor.rb +9 -11
  10. data/lib/evilution/cli/parser/file_args.rb +3 -1
  11. data/lib/evilution/cli/parser/options_builder.rb +29 -1
  12. data/lib/evilution/cli/parser/stdin_reader.rb +2 -2
  13. data/lib/evilution/cli/parser.rb +18 -20
  14. data/lib/evilution/cli/printers/environment.rb +19 -19
  15. data/lib/evilution/cli/printers/session_diff.rb +8 -8
  16. data/lib/evilution/compare/normalizer.rb +10 -5
  17. data/lib/evilution/config.rb +10 -10
  18. data/lib/evilution/disable_comment.rb +21 -12
  19. data/lib/evilution/integration/loading/mutation_applier.rb +17 -12
  20. data/lib/evilution/integration/minitest.rb +25 -16
  21. data/lib/evilution/integration/rspec.rb +4 -0
  22. data/lib/evilution/isolation/fork.rb +27 -17
  23. data/lib/evilution/mcp/info_tool/actions/subjects.rb +32 -23
  24. data/lib/evilution/mcp/info_tool/actions/tests.rb +22 -12
  25. data/lib/evilution/mcp/info_tool/request_parser.rb +3 -1
  26. data/lib/evilution/mcp/info_tool.rb +7 -1
  27. data/lib/evilution/mcp/mutate_tool/option_parser.rb +3 -1
  28. data/lib/evilution/mcp/mutate_tool/survived_enricher.rb +19 -9
  29. data/lib/evilution/mcp/mutate_tool.rb +27 -14
  30. data/lib/evilution/mcp/session_tool.rb +27 -18
  31. data/lib/evilution/mutation.rb +13 -15
  32. data/lib/evilution/mutator/base.rb +17 -15
  33. data/lib/evilution/mutator/operator/argument_nil_substitution.rb +11 -14
  34. data/lib/evilution/mutator/operator/argument_removal.rb +11 -14
  35. data/lib/evilution/mutator/operator/begin_unwrap.rb +17 -5
  36. data/lib/evilution/mutator/operator/bitwise_complement.rb +26 -19
  37. data/lib/evilution/mutator/operator/block_param_removal.rb +18 -8
  38. data/lib/evilution/mutator/operator/block_pass_removal.rb +19 -15
  39. data/lib/evilution/mutator/operator/case_when.rb +7 -5
  40. data/lib/evilution/mutator/operator/conditional_branch.rb +22 -22
  41. data/lib/evilution/mutator/operator/equality_to_identity.rb +8 -3
  42. data/lib/evilution/mutator/operator/explicit_super_mutation.rb +17 -13
  43. data/lib/evilution/mutator/operator/index_to_at.rb +5 -4
  44. data/lib/evilution/mutator/operator/index_to_dig.rb +12 -6
  45. data/lib/evilution/mutator/operator/index_to_fetch.rb +5 -4
  46. data/lib/evilution/mutator/operator/keyword_argument.rb +30 -25
  47. data/lib/evilution/mutator/operator/mixin_removal.rb +20 -14
  48. data/lib/evilution/mutator/operator/multiple_assignment.rb +12 -13
  49. data/lib/evilution/mutator/operator/receiver_replacement.rb +9 -6
  50. data/lib/evilution/mutator/operator/regex_simplification.rb +62 -67
  51. data/lib/evilution/mutator/operator/rescue_body_replacement.rb +9 -8
  52. data/lib/evilution/mutator/operator/rescue_removal.rb +4 -7
  53. data/lib/evilution/mutator/operator/superclass_removal.rb +21 -15
  54. data/lib/evilution/parallel/work_queue/dispatcher.rb +15 -8
  55. data/lib/evilution/parallel/work_queue/worker.rb +10 -7
  56. data/lib/evilution/parallel/work_queue.rb +35 -18
  57. data/lib/evilution/reporter/cli/item_formatters/coverage_gap.rb +13 -8
  58. data/lib/evilution/reporter/cli/line_formatters/mutations.rb +17 -8
  59. data/lib/evilution/reporter/json.rb +52 -18
  60. data/lib/evilution/reporter/suggestion/diff_helpers.rb +0 -13
  61. data/lib/evilution/reporter/suggestion/diff_lines.rb +28 -0
  62. data/lib/evilution/reporter/suggestion/templates/minitest.rb +20 -14
  63. data/lib/evilution/reporter/suggestion/templates/rspec.rb +19 -13
  64. data/lib/evilution/runner/baseline_runner.rb +15 -8
  65. data/lib/evilution/runner/diagnostics.rb +13 -9
  66. data/lib/evilution/runner/isolation_resolver.rb +11 -9
  67. data/lib/evilution/runner/mutation_executor/result_cache.rb +3 -1
  68. data/lib/evilution/runner/mutation_executor/strategy/parallel.rb +32 -10
  69. data/lib/evilution/runner/mutation_executor/strategy/sequential.rb +1 -1
  70. data/lib/evilution/runner/mutation_executor.rb +2 -0
  71. data/lib/evilution/runner/mutation_planner.rb +37 -17
  72. data/lib/evilution/runner/subject_pipeline.rb +21 -11
  73. data/lib/evilution/runner.rb +3 -3
  74. data/lib/evilution/session/diff.rb +15 -6
  75. data/lib/evilution/spec_ast_cache.rb +26 -12
  76. data/lib/evilution/version.rb +1 -1
  77. data/script/memory_check +11 -5
  78. data/scripts/benchmark_density +10 -9
  79. data/scripts/compare_mutations +38 -21
  80. data/scripts/mutant_json_adapter +7 -4
  81. metadata +3 -2
@@ -13,27 +13,33 @@ class Evilution::Mutator::Operator::MixinRemoval < Evilution::Mutator::Base
13
13
  @mutations = []
14
14
  @filter = filter
15
15
 
16
- tree = self.class.parsed_tree_for(subject.file_path, @file_source)
17
- enclosing = find_enclosing_scope(tree, subject.line_number)
16
+ enclosing = find_target_scope(subject)
18
17
  return @mutations unless enclosing
19
18
 
20
- first_method_line = find_first_method_line(enclosing)
21
- return @mutations unless first_method_line == subject.line_number
22
-
23
- find_mixin_calls(enclosing).each do |call_node|
24
- add_mutation(
25
- offset: call_node.location.start_offset,
26
- length: call_node.location.length,
27
- replacement: "",
28
- node: call_node
29
- )
30
- end
31
-
19
+ find_mixin_calls(enclosing).each { |call_node| emit_mixin_removal(call_node) }
32
20
  @mutations
33
21
  end
34
22
 
35
23
  private
36
24
 
25
+ def find_target_scope(subject)
26
+ tree = self.class.parsed_tree_for(subject.file_path, @file_source)
27
+ enclosing = find_enclosing_scope(tree, subject.line_number)
28
+ return nil unless enclosing
29
+ return nil unless find_first_method_line(enclosing) == subject.line_number
30
+
31
+ enclosing
32
+ end
33
+
34
+ def emit_mixin_removal(call_node)
35
+ add_mutation(
36
+ offset: call_node.location.start_offset,
37
+ length: call_node.location.length,
38
+ replacement: "",
39
+ node: call_node
40
+ )
41
+ end
42
+
37
43
  def find_enclosing_scope(tree, target_line)
38
44
  finder = ScopeFinder.new(target_line)
39
45
  finder.visit(tree)
@@ -18,19 +18,18 @@ class Evilution::Mutator::Operator::MultipleAssignment < Evilution::Mutator::Bas
18
18
  private
19
19
 
20
20
  def mutate_target_removal(node, lefts, values)
21
- lefts.each_index do |i|
22
- remaining_lefts = lefts.each_with_index.filter_map { |l, j| l.slice if j != i }
23
- remaining_values = values.each_with_index.filter_map { |v, j| v.slice if j != i }
24
-
25
- replacement = "#{remaining_lefts.join(", ")} = #{remaining_values.join(", ")}"
26
-
27
- add_mutation(
28
- offset: node.location.start_offset,
29
- length: node.location.length,
30
- replacement: replacement,
31
- node: node
32
- )
33
- end
21
+ lefts.each_index { |i| emit_target_removal_at(node, lefts, values, i) }
22
+ end
23
+
24
+ def emit_target_removal_at(node, lefts, values, i)
25
+ remaining_lefts = lefts.each_with_index.filter_map { |l, j| l.slice if j != i }
26
+ remaining_values = values.each_with_index.filter_map { |v, j| v.slice if j != i }
27
+ add_mutation(
28
+ offset: node.location.start_offset,
29
+ length: node.location.length,
30
+ replacement: "#{remaining_lefts.join(", ")} = #{remaining_values.join(", ")}",
31
+ node: node
32
+ )
34
33
  end
35
34
 
36
35
  def mutate_swap(node, lefts, values)
@@ -5,19 +5,22 @@ require_relative "../operator"
5
5
  class Evilution::Mutator::Operator::ReceiverReplacement < Evilution::Mutator::Base
6
6
  def visit_call_node(node)
7
7
  if node.receiver.is_a?(Prism::SelfNode)
8
- call_without_self = @file_source.byteslice(
9
- node.message_loc.start_offset,
10
- node.location.start_offset + node.location.length - node.message_loc.start_offset
11
- )
12
-
13
8
  add_mutation(
14
9
  offset: node.location.start_offset,
15
10
  length: node.location.length,
16
- replacement: call_without_self,
11
+ replacement: call_without_self_text(node),
17
12
  node: node
18
13
  )
19
14
  end
20
15
 
21
16
  super
22
17
  end
18
+
19
+ private
20
+
21
+ def call_without_self_text(node)
22
+ message_start = node.message_loc.start_offset
23
+ call_end = node.location.start_offset + node.location.length
24
+ @file_source.byteslice(message_start, call_end - message_start)
25
+ end
23
26
  end
@@ -19,31 +19,21 @@ class Evilution::Mutator::Operator::RegexSimplification < Evilution::Mutator::Ba
19
19
  private
20
20
 
21
21
  def remove_quantifiers(node, content, content_offset)
22
- i = 0
23
- while i < content.length
24
- if content[i] == "\\"
25
- i += 2
26
- next
22
+ scan_regex_positions(content) do |kind, i|
23
+ case kind
24
+ when :backslash then 2
25
+ when :class_open then class_skip(content, i)
26
+ when :char then emit_quantifier_at(node, content, content_offset, i)
27
27
  end
28
+ end
29
+ end
28
30
 
29
- if content[i] == "["
30
- i = skip_character_class(content, i)
31
- next
32
- end
31
+ def emit_quantifier_at(node, content, content_offset, i)
32
+ match = match_quantifier(content, i)
33
+ return 1 if match.nil?
33
34
 
34
- match = match_quantifier(content, i)
35
- if match
36
- add_mutation(
37
- offset: content_offset + i,
38
- length: match.length,
39
- replacement: "",
40
- node: node
41
- )
42
- i += match.length
43
- else
44
- i += 1
45
- end
46
- end
35
+ add_mutation(offset: content_offset + i, length: match.length, replacement: "", node: node)
36
+ match.length
47
37
  end
48
38
 
49
39
  def match_quantifier(content, pos)
@@ -58,40 +48,29 @@ class Evilution::Mutator::Operator::RegexSimplification < Evilution::Mutator::Ba
58
48
  end
59
49
 
60
50
  def remove_anchors(node, content, content_offset)
61
- i = 0
62
- while i < content.length
63
- if content[i] == "\\"
64
- anchor = match_backslash_anchor(content, i)
65
- if anchor
66
- add_mutation(
67
- offset: content_offset + i,
68
- length: anchor.length,
69
- replacement: "",
70
- node: node
71
- )
72
- i += anchor.length
73
- else
74
- i += 2
75
- end
76
- next
51
+ scan_regex_positions(content) do |kind, i|
52
+ case kind
53
+ when :backslash then try_emit_backslash_anchor(node, content, content_offset, i)
54
+ when :class_open then class_skip(content, i)
55
+ when :char
56
+ try_emit_caret_dollar(node, content, content_offset, i)
57
+ 1
77
58
  end
59
+ end
60
+ end
78
61
 
79
- if content[i] == "["
80
- i = skip_character_class(content, i)
81
- next
82
- end
62
+ def try_emit_backslash_anchor(node, content, content_offset, i)
63
+ anchor = match_backslash_anchor(content, i)
64
+ return 2 if anchor.nil?
83
65
 
84
- if %w[^ $].include?(content[i])
85
- add_mutation(
86
- offset: content_offset + i,
87
- length: 1,
88
- replacement: "",
89
- node: node
90
- )
91
- end
66
+ add_mutation(offset: content_offset + i, length: anchor.length, replacement: "", node: node)
67
+ anchor.length
68
+ end
92
69
 
93
- i += 1
94
- end
70
+ def try_emit_caret_dollar(node, content, content_offset, i)
71
+ return unless %w[^ $].include?(content[i])
72
+
73
+ add_mutation(offset: content_offset + i, length: 1, replacement: "", node: node)
95
74
  end
96
75
 
97
76
  def match_backslash_anchor(content, pos)
@@ -104,18 +83,13 @@ class Evilution::Mutator::Operator::RegexSimplification < Evilution::Mutator::Ba
104
83
  end
105
84
 
106
85
  def remove_character_class_ranges(node, content, content_offset)
107
- i = 0
108
- while i < content.length
109
- if content[i] == "\\"
110
- i += 2
111
- next
112
- end
113
-
114
- if content[i] == "["
86
+ scan_regex_positions(content) do |kind, i|
87
+ case kind
88
+ when :backslash then 2
89
+ when :class_open
115
90
  scan_ranges_in_class(node, content, content_offset, i)
116
- i = skip_character_class(content, i)
117
- else
118
- i += 1
91
+ class_skip(content, i)
92
+ when :char then 1
119
93
  end
120
94
  end
121
95
  end
@@ -153,17 +127,38 @@ class Evilution::Mutator::Operator::RegexSimplification < Evilution::Mutator::Ba
153
127
  )
154
128
  end
155
129
 
130
+ # Walks `content` yielding (kind, position) for each significant token:
131
+ # :backslash for an escape sequence, :class_open for `[`, :char for any
132
+ # other byte. The block returns the number of characters to advance from
133
+ # `position` — callers decide how to handle each case (skip, emit a
134
+ # mutation, descend into a character class, etc.).
135
+ def scan_regex_positions(content)
136
+ i = 0
137
+ while i < content.length
138
+ advance = case content[i]
139
+ when "\\" then yield(:backslash, i)
140
+ when "[" then yield(:class_open, i)
141
+ else yield(:char, i)
142
+ end
143
+ i += advance
144
+ end
145
+ end
146
+
147
+ def class_skip(content, pos)
148
+ skip_character_class(content, pos) - pos
149
+ end
150
+
156
151
  def skip_character_class(content, pos)
157
- i = pos + 1
158
- i += 1 if i < content.length && content[i] == "^"
159
- i += 1 if i < content.length && content[i] == "]"
152
+ scan_to_class_close(content, skip_class_prefix(content, pos))
153
+ end
160
154
 
155
+ def scan_to_class_close(content, start)
156
+ i = start
161
157
  while i < content.length
162
158
  return i + 1 if content[i] == "]"
163
159
 
164
160
  i += content[i] == "\\" ? 2 : 1
165
161
  end
166
-
167
162
  i
168
163
  end
169
164
  end
@@ -72,14 +72,15 @@ class Evilution::Mutator::Operator::RescueBodyReplacement < Evilution::Mutator::
72
72
  end
73
73
 
74
74
  def rescue_line_end(node)
75
- if node.reference
76
- node.reference.location.start_offset + node.reference.location.length
77
- elsif node.exceptions.any?
78
- last_exc = node.exceptions.last
79
- last_exc.location.start_offset + last_exc.location.length
80
- else
81
- node.keyword_loc.start_offset + node.keyword_loc.length
82
- end
75
+ loc = if node.reference
76
+ node.reference.location
77
+ elsif node.exceptions.any?
78
+ node.exceptions.last.location
79
+ else
80
+ node.keyword_loc
81
+ end
82
+
83
+ loc.start_offset + loc.length
83
84
  end
84
85
 
85
86
  def indentation_of(offset)
@@ -20,13 +20,10 @@ class Evilution::Mutator::Operator::RescueRemoval < Evilution::Mutator::Base
20
20
  private
21
21
 
22
22
  def rescue_end_offset(node)
23
- if node.subsequent
24
- line_start_before(node.subsequent.keyword_loc.start_offset)
25
- elsif node.statements
26
- node.statements.location.start_offset + node.statements.location.length
27
- else
28
- node.keyword_loc.start_offset + node.keyword_loc.length
29
- end
23
+ return line_start_before(node.subsequent.keyword_loc.start_offset) if node.subsequent
24
+
25
+ loc = node.statements ? node.statements.location : node.keyword_loc
26
+ loc.start_offset + loc.length
30
27
  end
31
28
 
32
29
  def line_start_before(offset)
@@ -11,29 +11,35 @@ class Evilution::Mutator::Operator::SuperclassRemoval < Evilution::Mutator::Base
11
11
  @mutations = []
12
12
  @filter = filter
13
13
 
14
- tree = self.class.parsed_tree_for(subject.file_path, @file_source)
15
- enclosing = find_enclosing_class(tree, subject.line_number)
14
+ enclosing = find_target_class(subject)
16
15
  return @mutations unless enclosing
17
- return @mutations unless enclosing.superclass
18
-
19
- first_method_line = find_first_method_line(enclosing)
20
- return @mutations unless first_method_line == subject.line_number
21
16
 
22
- name_end = enclosing.constant_path.location.start_offset + enclosing.constant_path.location.length
23
- superclass_end = enclosing.superclass.location.start_offset + enclosing.superclass.location.length
24
-
25
- add_mutation(
26
- offset: name_end,
27
- length: superclass_end - name_end,
28
- replacement: "",
29
- node: enclosing
30
- )
17
+ offset, length = superclass_range(enclosing)
18
+ add_mutation(offset: offset, length: length, replacement: "", node: enclosing)
31
19
 
32
20
  @mutations
33
21
  end
34
22
 
35
23
  private
36
24
 
25
+ def find_target_class(subject)
26
+ tree = self.class.parsed_tree_for(subject.file_path, @file_source)
27
+ enclosing = find_enclosing_class(tree, subject.line_number)
28
+ return nil unless enclosing && enclosing.superclass
29
+ return nil unless find_first_method_line(enclosing) == subject.line_number
30
+
31
+ enclosing
32
+ end
33
+
34
+ def superclass_range(class_node)
35
+ name_loc = class_node.constant_path.location
36
+ superclass_loc = class_node.superclass.location
37
+ name_end = name_loc.start_offset + name_loc.length
38
+ superclass_end = superclass_loc.start_offset + superclass_loc.length
39
+
40
+ [name_end, superclass_end - name_end]
41
+ end
42
+
37
43
  def find_enclosing_class(tree, target_line)
38
44
  finder = ClassFinder.new(target_line)
39
45
  finder.visit(tree)
@@ -4,6 +4,8 @@ require_relative "../work_queue"
4
4
  require_relative "collection_state"
5
5
 
6
6
  class Evilution::Parallel::WorkQueue::Dispatcher
7
+ RunResult = Data.define(:results, :retired)
8
+
7
9
  attr_reader :first_error
8
10
 
9
11
  def initialize(workers:, items:, prefetch:, item_timeout:, worker_max_items:, recycle_factory:)
@@ -21,7 +23,7 @@ class Evilution::Parallel::WorkQueue::Dispatcher
21
23
  seed
22
24
  collect
23
25
  @first_error = @state.first_error
24
- [@state.results, @retired]
26
+ RunResult.new(results: @state.results, retired: @retired)
25
27
  end
26
28
 
27
29
  private
@@ -42,20 +44,25 @@ class Evilution::Parallel::WorkQueue::Dispatcher
42
44
 
43
45
  while @state.in_flight.positive?
44
46
  readable, = IO.select(result_ios, nil, nil, @item_timeout)
45
-
46
47
  if readable.nil?
47
- terminate_stuck
48
- @state.first_error ||= Evilution::Error.new("worker timed out after #{@item_timeout}s")
48
+ record_timeout
49
49
  break
50
50
  end
51
51
 
52
- readable.each do |io|
53
- alive = handle(io_to_worker[io], io_to_worker, result_ios)
54
- result_ios.delete(io) unless alive
55
- end
52
+ readable.each { |io| process_readable(io, io_to_worker, result_ios) }
56
53
  end
57
54
  end
58
55
 
56
+ def record_timeout
57
+ terminate_stuck
58
+ @state.first_error ||= Evilution::Error.new("worker timed out after #{@item_timeout}s")
59
+ end
60
+
61
+ def process_readable(io, io_to_worker, result_ios)
62
+ alive = handle(io_to_worker[io], io_to_worker, result_ios)
63
+ result_ios.delete(io) unless alive
64
+ end
65
+
59
66
  def handle(worker, io_to_worker, result_ios)
60
67
  message = worker.read_result
61
68
  return handle_dead(worker) if message.nil?
@@ -6,6 +6,8 @@ require_relative "channel"
6
6
  require_relative "channel/frame"
7
7
 
8
8
  class Evilution::Parallel::WorkQueue::Worker
9
+ Timing = Data.define(:busy, :wall)
10
+
9
11
  attr_reader :pid, :worker_index
10
12
  attr_accessor :items_completed, :pending, :busy_time, :wall_time
11
13
 
@@ -84,11 +86,11 @@ class Evilution::Parallel::WorkQueue::Worker
84
86
 
85
87
  def retire
86
88
  shutdown
87
- busy, wall = drain_stats
89
+ timing = drain_stats
88
90
  close_pipes
89
91
  reap
90
- @busy_time = busy
91
- @wall_time = wall
92
+ @busy_time = timing.busy
93
+ @wall_time = timing.wall
92
94
  to_stat
93
95
  end
94
96
 
@@ -101,14 +103,15 @@ class Evilution::Parallel::WorkQueue::Worker
101
103
  private
102
104
 
103
105
  def drain_stats
104
- return [0.0, 0.0] unless @res_read.wait_readable(Evilution::Parallel::WorkQueue::TIMING_GRACE_PERIOD)
106
+ zero = Timing.new(busy: 0.0, wall: 0.0)
107
+ return zero unless @res_read.wait_readable(Evilution::Parallel::WorkQueue::TIMING_GRACE_PERIOD)
105
108
 
106
109
  message = read_result
107
- return [0.0, 0.0] if message.nil?
110
+ return zero if message.nil?
108
111
 
109
112
  tag, busy, wall = message
110
- return [0.0, 0.0] unless tag == Evilution::Parallel::WorkQueue::STATS
113
+ return zero unless tag == Evilution::Parallel::WorkQueue::STATS
111
114
 
112
- [busy, wall]
115
+ Timing.new(busy: busy, wall: wall)
113
116
  end
114
117
  end
@@ -27,24 +27,17 @@ class Evilution::Parallel::WorkQueue
27
27
  return [] if items.empty?
28
28
 
29
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
- )
30
+ dispatcher = build_dispatcher(workers, items, &block)
35
31
 
36
32
  retired = []
37
33
  begin
38
- results, retired = dispatcher.run
34
+ run_result = dispatcher.run
35
+ retired = run_result.retired
39
36
  raise dispatcher.first_error if dispatcher.first_error
40
37
 
41
- results
38
+ run_result.results
42
39
  ensure
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)
40
+ cleanup_workers(workers, retired)
48
41
  end
49
42
  end
50
43
 
@@ -58,19 +51,43 @@ class Evilution::Parallel::WorkQueue
58
51
  Worker.spawn(worker_index: worker_index, hooks: @hooks, &)
59
52
  end
60
53
 
54
+ def build_dispatcher(workers, items, &block)
55
+ Dispatcher.new(
56
+ workers: workers, items: items, prefetch: @prefetch,
57
+ item_timeout: @item_timeout, worker_max_items: @worker_max_items,
58
+ recycle_factory: ->(old) { spawn_one(old.worker_index, &block) }
59
+ )
60
+ end
61
+
62
+ def cleanup_workers(workers, retired)
63
+ workers.each(&:shutdown)
64
+ collect_final_timings(workers)
65
+ workers.each(&:close_pipes)
66
+ workers.each(&:reap)
67
+ @worker_stats = retired + workers.map(&:to_stat)
68
+ end
69
+
61
70
  def collect_final_timings(workers)
62
71
  io_to_worker = workers.reject { |w| w.res_io.closed? }.to_h { |w| [w.res_io, w] }
63
- deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + TIMING_GRACE_PERIOD
72
+ deadline = monotonic_now + TIMING_GRACE_PERIOD
64
73
 
65
74
  until io_to_worker.empty?
66
- remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
75
+ remaining = deadline - monotonic_now
67
76
  break if remaining <= 0
77
+ break unless poll_and_apply(io_to_worker, remaining)
78
+ end
79
+ end
68
80
 
69
- readable, = IO.select(io_to_worker.keys, nil, nil, remaining)
70
- break unless readable
81
+ def poll_and_apply(io_to_worker, remaining)
82
+ readable, = IO.select(io_to_worker.keys, nil, nil, remaining)
83
+ return false unless readable
71
84
 
72
- readable.each { |io| apply_final_timing(io_to_worker.delete(io), io) }
73
- end
85
+ readable.each { |io| apply_final_timing(io_to_worker.delete(io), io) }
86
+ true
87
+ end
88
+
89
+ def monotonic_now
90
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
74
91
  end
75
92
 
76
93
  def apply_final_timing(worker, io)
@@ -5,14 +5,19 @@ require_relative "../item_formatters"
5
5
  class Evilution::Reporter::CLI::ItemFormatters::CoverageGap
6
6
  def format(gap)
7
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
8
+ "#{format_header(gap, location)}\n#{format_body(gap)}"
9
+ end
10
+
11
+ private
12
+
13
+ def format_header(gap, location)
14
+ return " #{gap.primary_operator}: #{location} (#{gap.subject_name})" if gap.single?
15
+
16
+ " #{location} (#{gap.subject_name}) [#{gap.count} mutations: #{gap.operator_names.join(", ")}]"
17
+ end
18
+
19
+ def format_body(gap)
14
20
  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}"
21
+ body.split("\n").map { |l| " #{l}" }.join("\n")
17
22
  end
18
23
  end
@@ -3,14 +3,23 @@
3
3
  require_relative "../line_formatters"
4
4
 
5
5
  class Evilution::Reporter::CLI::LineFormatters::Mutations
6
+ OPTIONAL_FIELDS = %i[neutral equivalent unresolved unparseable skipped].freeze
7
+
6
8
  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
9
+ base_line(summary) + optional_sections(summary)
10
+ end
11
+
12
+ private
13
+
14
+ def base_line(summary)
15
+ "Mutations: #{summary.total} total, #{summary.killed} killed, " \
16
+ "#{summary.survived} survived, #{summary.timed_out} timed out"
17
+ end
18
+
19
+ def optional_sections(summary)
20
+ OPTIONAL_FIELDS.filter_map do |field|
21
+ count = summary.public_send(field)
22
+ ", #{count} #{field}" if count.positive?
23
+ end.join
15
24
  end
16
25
  end