evilution 1.1.0 → 1.3.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 (161) hide show
  1. checksums.yaml +4 -4
  2. data/.beads/interactions.jsonl +58 -0
  3. data/.rubocop_todo.yml +9 -0
  4. data/CHANGELOG.md +86 -0
  5. data/README.md +202 -14
  6. data/docs/architecture.md +54 -5
  7. data/docs/isolation.md +3 -4
  8. data/exe/evil +8 -1
  9. data/exe/evilution +8 -1
  10. data/lib/evilution/ast/local_reads.rb +43 -0
  11. data/lib/evilution/ast/parser.rb +1 -0
  12. data/lib/evilution/ast/pattern/filter.rb +1 -0
  13. data/lib/evilution/ast/pattern/parser.rb +1 -0
  14. data/lib/evilution/ast/pattern.rb +2 -0
  15. data/lib/evilution/ast/sorbet_sig_detector.rb +1 -0
  16. data/lib/evilution/baseline.rb +37 -15
  17. data/lib/evilution/child_output.rb +1 -1
  18. data/lib/evilution/cli/command.rb +1 -0
  19. data/lib/evilution/cli/commands/tests_list.rb +4 -3
  20. data/lib/evilution/cli/commands.rb +2 -0
  21. data/lib/evilution/cli/dispatcher.rb +2 -0
  22. data/lib/evilution/cli/exit_guard.rb +61 -0
  23. data/lib/evilution/cli/parsed_args.rb +2 -0
  24. data/lib/evilution/cli/parser/command_extractor.rb +2 -0
  25. data/lib/evilution/cli/parser/file_args.rb +2 -0
  26. data/lib/evilution/cli/parser/options_builder.rb +4 -0
  27. data/lib/evilution/cli/parser/stdin_reader.rb +1 -0
  28. data/lib/evilution/cli/parser.rb +1 -0
  29. data/lib/evilution/cli/printers/tests_list.rb +5 -4
  30. data/lib/evilution/cli/printers.rb +2 -0
  31. data/lib/evilution/cli/result.rb +2 -0
  32. data/lib/evilution/cli.rb +6 -0
  33. data/lib/evilution/config/builders.rb +2 -0
  34. data/lib/evilution/config/env_loader.rb +2 -0
  35. data/lib/evilution/config/file_loader.rb +1 -0
  36. data/lib/evilution/config/sources.rb +1 -0
  37. data/lib/evilution/config/validators/example_targeting_cache.rb +1 -0
  38. data/lib/evilution/config/validators/example_targeting_fallback.rb +1 -0
  39. data/lib/evilution/config/validators/example_targeting_strategy.rb +1 -0
  40. data/lib/evilution/config/validators/fail_fast.rb +1 -0
  41. data/lib/evilution/config/validators/hooks.rb +1 -0
  42. data/lib/evilution/config/validators/ignore_patterns.rb +1 -0
  43. data/lib/evilution/config/validators/integration.rb +1 -0
  44. data/lib/evilution/config/validators/isolation.rb +1 -0
  45. data/lib/evilution/config/validators/jobs.rb +1 -0
  46. data/lib/evilution/config/validators/preload.rb +1 -0
  47. data/lib/evilution/config/validators/profile.rb +1 -0
  48. data/lib/evilution/config/validators/spec_mappings.rb +1 -0
  49. data/lib/evilution/config/validators/spec_pattern.rb +1 -0
  50. data/lib/evilution/config/validators/warmup.rb +17 -0
  51. data/lib/evilution/config/validators.rb +2 -0
  52. data/lib/evilution/config.rb +7 -5
  53. data/lib/evilution/coverage.rb +1 -1
  54. data/lib/evilution/coverage_example_filter.rb +1 -1
  55. data/lib/evilution/diagnostic.rb +22 -0
  56. data/lib/evilution/example_filter.rb +1 -1
  57. data/lib/evilution/integration/loading/body_call_neutralizer.rb +10 -2
  58. data/lib/evilution/integration/loading/concern_state_cleaner.rb +20 -4
  59. data/lib/evilution/integration/loading/reeval_warning_filter.rb +72 -0
  60. data/lib/evilution/integration/loading/source_evaluator.rb +4 -1
  61. data/lib/evilution/integration/loading/test_load_path.rb +21 -13
  62. data/lib/evilution/integration/minitest.rb +2 -1
  63. data/lib/evilution/integration/rspec/crash_detector_lifecycle.rb +9 -1
  64. data/lib/evilution/integration/rspec/state_guard/configuration_state.rb +1 -0
  65. data/lib/evilution/integration/rspec/state_guard/configuration_streams.rb +5 -1
  66. data/lib/evilution/integration/rspec/state_guard/example_groups_constants.rb +1 -2
  67. data/lib/evilution/integration/rspec/state_guard/internals.rb +1 -2
  68. data/lib/evilution/integration/rspec/state_guard/object_space_example_groups.rb +1 -2
  69. data/lib/evilution/integration/rspec/state_guard/reporter_arrays.rb +1 -0
  70. data/lib/evilution/integration/rspec/state_guard/world_example_groups.rb +1 -0
  71. data/lib/evilution/integration/rspec/state_guard/world_filtered_examples.rb +1 -0
  72. data/lib/evilution/integration/rspec/state_guard/world_sources_by_path.rb +1 -0
  73. data/lib/evilution/integration/rspec/state_guard.rb +6 -0
  74. data/lib/evilution/integration/rspec/unresolved_spec_warner.rb +2 -1
  75. data/lib/evilution/integration/rspec.rb +48 -2
  76. data/lib/evilution/integration/test_unit/test_file_resolver.rb +2 -1
  77. data/lib/evilution/isolation/fork.rb +29 -8
  78. data/lib/evilution/mcp/info_tool/actions/environment.rb +1 -0
  79. data/lib/evilution/mcp/info_tool/actions/feedback.rb +1 -0
  80. data/lib/evilution/mcp/info_tool/actions/statuses.rb +1 -0
  81. data/lib/evilution/mcp/info_tool/actions/subjects.rb +1 -0
  82. data/lib/evilution/mcp/info_tool/actions/tests.rb +1 -0
  83. data/lib/evilution/mutation.rb +1 -1
  84. data/lib/evilution/mutator/operator/argument_list_removal.rb +52 -0
  85. data/lib/evilution/mutator/operator/argument_propagation.rb +54 -0
  86. data/lib/evilution/mutator/operator/array_coercion_to_literal.rb +34 -0
  87. data/lib/evilution/mutator/operator/attribute_write_to_read.rb +34 -0
  88. data/lib/evilution/mutator/operator/bang_method.rb +11 -3
  89. data/lib/evilution/mutator/operator/binary_operand_promotion.rb +63 -0
  90. data/lib/evilution/mutator/operator/block_body_promotion.rb +45 -0
  91. data/lib/evilution/mutator/operator/block_body_to_nil.rb +52 -0
  92. data/lib/evilution/mutator/operator/block_body_to_raise.rb +34 -0
  93. data/lib/evilution/mutator/operator/block_destructuring_expansion.rb +85 -0
  94. data/lib/evilution/mutator/operator/block_parameter_drop.rb +96 -0
  95. data/lib/evilution/mutator/operator/call_to_nil.rb +34 -0
  96. data/lib/evilution/mutator/operator/coercion_emptying.rb +60 -0
  97. data/lib/evilution/mutator/operator/collection_replacement.rb +19 -9
  98. data/lib/evilution/mutator/operator/comparison_replacement.rb +37 -12
  99. data/lib/evilution/mutator/operator/const_get_to_constant_path.rb +50 -0
  100. data/lib/evilution/mutator/operator/dig_to_fetch_chain.rb +45 -0
  101. data/lib/evilution/mutator/operator/double_negation_removal.rb +22 -0
  102. data/lib/evilution/mutator/operator/dynamic_dispatch_resolution.rb +79 -0
  103. data/lib/evilution/mutator/operator/forwarding_super_to_explicit.rb +71 -0
  104. data/lib/evilution/mutator/operator/inequality_to_negated_identity.rb +43 -0
  105. data/lib/evilution/mutator/operator/keyword_argument_removal.rb +47 -0
  106. data/lib/evilution/mutator/operator/method_body_replacement.rb +10 -1
  107. data/lib/evilution/mutator/operator/method_body_to_raise.rb +59 -0
  108. data/lib/evilution/mutator/operator/method_body_to_super.rb +150 -0
  109. data/lib/evilution/mutator/operator/optional_default_injection.rb +71 -0
  110. data/lib/evilution/mutator/operator/optional_parameter_to_required.rb +41 -0
  111. data/lib/evilution/mutator/operator/proc_to_lambda.rb +43 -0
  112. data/lib/evilution/mutator/operator/receiver_constructor_swap.rb +51 -0
  113. data/lib/evilution/mutator/operator/reduce_to_sum.rb +58 -0
  114. data/lib/evilution/mutator/operator/regexp_anchor_to_predicate.rb +136 -0
  115. data/lib/evilution/mutator/operator/safe_navigation_removal.rb +67 -0
  116. data/lib/evilution/mutator/operator/send_mutation.rb +42 -6
  117. data/lib/evilution/mutator/operator/symbol_to_proc_replacement.rb +59 -0
  118. data/lib/evilution/mutator/operator/to_i_to_integer.rb +38 -0
  119. data/lib/evilution/mutator/operator/typed_default_return.rb +84 -0
  120. data/lib/evilution/mutator/primitives.rb +25 -0
  121. data/lib/evilution/mutator/registry.rb +32 -1
  122. data/lib/evilution/parallel/pool.rb +1 -0
  123. data/lib/evilution/parallel_db_warning.rb +1 -1
  124. data/lib/evilution/process_supervisor.rb +20 -8
  125. data/lib/evilution/rails_warmup.rb +61 -0
  126. data/lib/evilution/reporter/cli/item_formatters/neutral_group.rb +23 -0
  127. data/lib/evilution/reporter/cli/item_formatters/subject_score.rb +42 -0
  128. data/lib/evilution/reporter/cli/item_formatters/subject_score_group.rb +16 -0
  129. data/lib/evilution/reporter/cli/line_formatters/infra_retry_notice.rb +19 -0
  130. data/lib/evilution/reporter/cli/line_formatters/result_line.rb +27 -3
  131. data/lib/evilution/reporter/cli/line_formatters/score.rb +19 -1
  132. data/lib/evilution/reporter/cli/line_formatters/unresolved_targets.rb +35 -0
  133. data/lib/evilution/reporter/cli/metrics_block.rb +4 -0
  134. data/lib/evilution/reporter/cli/trailer.rb +11 -7
  135. data/lib/evilution/reporter/cli.rb +20 -4
  136. data/lib/evilution/reporter/html/report.rb +1 -0
  137. data/lib/evilution/reporter/json/subjects.rb +29 -0
  138. data/lib/evilution/reporter/json.rb +23 -1
  139. data/lib/evilution/result/coverage_gap_grouper.rb +1 -0
  140. data/lib/evilution/result/mutation_result.rb +3 -2
  141. data/lib/evilution/result/neutral_reason.rb +35 -0
  142. data/lib/evilution/result/subject_score.rb +28 -0
  143. data/lib/evilution/result/subject_scorer.rb +38 -0
  144. data/lib/evilution/result/summary.rb +44 -2
  145. data/lib/evilution/runner/baseline_runner.rb +17 -7
  146. data/lib/evilution/runner/canary.rb +52 -5
  147. data/lib/evilution/runner/isolation_resolver.rb +23 -2
  148. data/lib/evilution/runner/mutation_executor/infra_retry.rb +54 -0
  149. data/lib/evilution/runner/mutation_executor/neutralizer/baseline_failed.rb +23 -10
  150. data/lib/evilution/runner/mutation_executor/neutralizer/infra_error.rb +18 -1
  151. data/lib/evilution/runner/mutation_executor/result_cache.rb +11 -0
  152. data/lib/evilution/runner/mutation_executor/strategy/parallel.rb +19 -1
  153. data/lib/evilution/runner/mutation_executor.rb +32 -4
  154. data/lib/evilution/runner/report_publisher.rb +31 -9
  155. data/lib/evilution/runner/target_spec_audit.rb +43 -0
  156. data/lib/evilution/runner.rb +10 -1
  157. data/lib/evilution/source_ast_cache.rb +1 -1
  158. data/lib/evilution/spec_ast_cache.rb +1 -1
  159. data/lib/evilution/version.rb +1 -1
  160. data/lib/evilution.rb +35 -0
  161. metadata +50 -2
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ require_relative "../operator"
6
+
7
+ # Replace a single-expression method body with the empty value of the type it
8
+ # returns: `def names(users); users.map(&:name); end` becomes
9
+ # `def names(users); []; end`.
10
+ #
11
+ # A survivor means the suite asserts the shape of the returned value but never
12
+ # its content — a test that only checks `be_an(Array)`, or that calls the method
13
+ # for its side effects and ignores what comes back.
14
+ #
15
+ # CollectionReturn and ScalarReturn do this for bodies of two or more
16
+ # statements, and the literal operators (array_literal, hash_literal,
17
+ # string_literal, integer_literal, float_literal) cover a body that is itself a
18
+ # literal. What neither reaches is the single-expression body whose type is not
19
+ # written down — the common shape of readers and builders — so the type is taken
20
+ # from the trailing call's selector instead.
21
+ #
22
+ # Only selectors whose return type is fixed by Ruby (or a widely-used extension with a stable contract,
23
+ # e.g. ActiveSupport's `index_by`) are listed.
24
+ # A method of the same name defined elsewhere can still return something else,
25
+ # in which case the mutant raises where it is consumed and dies on contact; the
26
+ # table is kept narrow to make that rare.
27
+ class Evilution::Mutator::Operator::TypedDefaultReturn < Evilution::Mutator::Base
28
+ SELECTOR_TYPES = {
29
+ "[]" => %i[map collect select filter reject sort sort_by to_a compact flatten values keys uniq],
30
+ "{}" => %i[to_h group_by tally index_by transform_values transform_keys],
31
+ "0" => %i[count size length],
32
+ '""' => %i[to_s join upcase downcase strip]
33
+ }.freeze
34
+
35
+ REPLACEMENT_BY_SELECTOR = SELECTOR_TYPES.flat_map do |replacement, selectors|
36
+ selectors.map { |selector| [selector, replacement] }
37
+ end.to_h.freeze
38
+ private_constant :REPLACEMENT_BY_SELECTOR
39
+
40
+ def visit_def_node(node)
41
+ replace_body_with_default(node)
42
+ super
43
+ end
44
+
45
+ private
46
+
47
+ def replace_body_with_default(node)
48
+ statements = body_statements(node.body)
49
+ return if statements.nil?
50
+ return unless statements.body.length == 1
51
+
52
+ replacement = default_for(statements.body.first)
53
+ return if replacement.nil?
54
+
55
+ location = statements.location
56
+
57
+ add_mutation(
58
+ offset: location.start_offset,
59
+ length: location.length,
60
+ replacement: replacement,
61
+ node: node
62
+ )
63
+ end
64
+
65
+ # A method-level rescue/ensure (`def foo; stmts; rescue; ...; end`) makes the
66
+ # body a BeginNode whose location spans the entire `def...end`, keyword and
67
+ # matching `end` included. Replacing that range would delete the method
68
+ # framing, so only the leading statements are replaceable. Returns nil for a
69
+ # rescue/ensure-only body and for an empty method, whose body is nil.
70
+ def body_statements(body)
71
+ return body unless body.is_a?(Prism::BeginNode)
72
+
73
+ body.statements
74
+ end
75
+
76
+ # A body that is already a literal is left to the literal operators, which
77
+ # emit the same empty value for it.
78
+ def default_for(node)
79
+ case node
80
+ when Prism::InterpolatedStringNode then '""'
81
+ when Prism::CallNode then REPLACEMENT_BY_SELECTOR[node.name]
82
+ end
83
+ end
84
+ end
@@ -49,4 +49,29 @@ module Evilution::Mutator::Primitives
49
49
  location = child.location
50
50
  byteslice_source(location.start_offset, location.length)
51
51
  end
52
+
53
+ # `operand`'s source, parenthesized unless it is a primary expression — so
54
+ # it can take a method call: `a + b` becomes `(a + b)` in `(a + b).eql?(c)`.
55
+ def receiver_source(operand)
56
+ primary_operand?(operand) ? source_of(operand) : "(#{source_of(operand)})"
57
+ end
58
+
59
+ PRIMARY_OPERANDS = [
60
+ Prism::LocalVariableReadNode, Prism::InstanceVariableReadNode, Prism::ClassVariableReadNode,
61
+ Prism::GlobalVariableReadNode, Prism::ConstantReadNode, Prism::ConstantPathNode, Prism::SelfNode,
62
+ Prism::ParenthesesNode, Prism::StringNode, Prism::IntegerNode, Prism::FloatNode, Prism::ArrayNode,
63
+ Prism::RegularExpressionNode
64
+ ].freeze
65
+ private_constant :PRIMARY_OPERANDS
66
+
67
+ METHOD_NAME = /\A[[:alpha:]_]/
68
+ private_constant :METHOD_NAME
69
+
70
+ # A named method call or an index (`a.size`, `a[0]`) binds tighter than a
71
+ # following `.`; an operator send (`a + b`, `!a`, `-a`) does not.
72
+ def primary_operand?(operand)
73
+ return true if PRIMARY_OPERANDS.any? { |type| operand.is_a?(type) }
74
+
75
+ operand.is_a?(Prism::CallNode) && (operand.name.match?(METHOD_NAME) || operand.name == :[])
76
+ end
52
77
  end
@@ -96,6 +96,14 @@ class Evilution::Mutator::Registry
96
96
  Evilution::Mutator::Operator::RegexCapture,
97
97
  Evilution::Mutator::Operator::LoopFlip,
98
98
  Evilution::Mutator::Operator::LoopBodyToRaise,
99
+ Evilution::Mutator::Operator::MethodBodyToRaise,
100
+ Evilution::Mutator::Operator::MethodBodyToSuper,
101
+ Evilution::Mutator::Operator::TypedDefaultReturn,
102
+ Evilution::Mutator::Operator::BlockParameterDrop,
103
+ Evilution::Mutator::Operator::OptionalParameterToRequired,
104
+ Evilution::Mutator::Operator::OptionalDefaultInjection,
105
+ Evilution::Mutator::Operator::BlockDestructuringExpansion,
106
+ Evilution::Mutator::Operator::ForwardingSuperToExplicit,
99
107
  Evilution::Mutator::Operator::StringInterpolation,
100
108
  Evilution::Mutator::Operator::RetryRemoval,
101
109
  Evilution::Mutator::Operator::CaseWhen,
@@ -105,7 +113,30 @@ class Evilution::Mutator::Registry
105
113
  Evilution::Mutator::Operator::EqualityToIdentity,
106
114
  Evilution::Mutator::Operator::LambdaBody,
107
115
  Evilution::Mutator::Operator::BeginUnwrap,
108
- Evilution::Mutator::Operator::BlockParamRemoval
116
+ Evilution::Mutator::Operator::BlockParamRemoval,
117
+ Evilution::Mutator::Operator::CallToNil,
118
+ Evilution::Mutator::Operator::SafeNavigationRemoval,
119
+ Evilution::Mutator::Operator::AttributeWriteToRead,
120
+ Evilution::Mutator::Operator::ArgumentPropagation,
121
+ Evilution::Mutator::Operator::ArgumentListRemoval,
122
+ Evilution::Mutator::Operator::SymbolToProcReplacement,
123
+ Evilution::Mutator::Operator::DynamicDispatchResolution,
124
+ Evilution::Mutator::Operator::KeywordArgumentRemoval,
125
+ Evilution::Mutator::Operator::DoubleNegationRemoval,
126
+ Evilution::Mutator::Operator::RegexpAnchorToPredicate,
127
+ Evilution::Mutator::Operator::ReduceToSum,
128
+ Evilution::Mutator::Operator::ArrayCoercionToLiteral,
129
+ Evilution::Mutator::Operator::ToIToInteger,
130
+ Evilution::Mutator::Operator::CoercionEmptying,
131
+ Evilution::Mutator::Operator::ConstGetToConstantPath,
132
+ Evilution::Mutator::Operator::ProcToLambda,
133
+ Evilution::Mutator::Operator::DigToFetchChain,
134
+ Evilution::Mutator::Operator::InequalityToNegatedIdentity,
135
+ Evilution::Mutator::Operator::BinaryOperandPromotion,
136
+ Evilution::Mutator::Operator::ReceiverConstructorSwap,
137
+ Evilution::Mutator::Operator::BlockBodyToNil,
138
+ Evilution::Mutator::Operator::BlockBodyToRaise,
139
+ Evilution::Mutator::Operator::BlockBodyPromotion
109
140
  ].each { |op| registry.register(op) }
110
141
  registry
111
142
  end
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../parallel"
3
4
  require_relative "work_queue"
4
5
 
5
6
  class Evilution::Parallel::Pool
@@ -2,7 +2,7 @@
2
2
 
3
3
  require "erb"
4
4
  require "yaml"
5
- require_relative "../evilution"
5
+ require_relative "version"
6
6
 
7
7
  # EV-kdns / GH #817: nudge users running parallel jobs against SQLite to adopt
8
8
  # the parallel_tests convention. WorkQueue sets TEST_ENV_NUMBER per worker, but
@@ -30,7 +30,9 @@ class Evilution::ProcessSupervisor
30
30
  # One tracked child: leader pid, its process-group id (== pid for a group
31
31
  # leader), the parent-side fds to close on reap, and an optional sandbox dir
32
32
  # to remove on reap.
33
- Handle = Struct.new(:pid, :pgid, :fds, :sandbox_dir, keyword_init: true)
33
+ # `status` is filled in when the child is reaped, so a caller that got no
34
+ # payload out of it can still say how it died.
35
+ Handle = Struct.new(:pid, :pgid, :fds, :sandbox_dir, :status, keyword_init: true)
34
36
 
35
37
  @registry = [].freeze
36
38
 
@@ -158,7 +160,7 @@ class Evilution::ProcessSupervisor
158
160
  # release the resources the handle owns: close parent-side fds, remove the
159
161
  # sandbox dir, and drop the handle from the registry.
160
162
  def reap(handle)
161
- safe_wait(handle.pid)
163
+ handle.status = safe_wait(handle.pid)
162
164
  ensure
163
165
  release(handle)
164
166
  end
@@ -170,21 +172,28 @@ class Evilution::ProcessSupervisor
170
172
  # it releases the handle in the same step it reaps, so the process-global
171
173
  # registry never holds a stale, already-reaped pgid.
172
174
  def reap_nonblock(handle)
173
- return false unless nonblocking_wait(handle.pid)
175
+ outcome = nonblocking_wait(handle.pid)
176
+ return false unless outcome
174
177
 
178
+ handle.status = outcome if outcome.is_a?(::Process::Status)
175
179
  release(handle)
176
180
  true
177
181
  end
178
182
 
179
183
  private
180
184
 
181
- # WNOHANG wait: returns the pid once the child has exited, nil while it is
182
- # still running, and -- treating an already-reaped child as exited -- the pid
183
- # again on ECHILD so the caller still releases the handle.
185
+ # WNOHANG wait: returns the child's Process::Status once it has exited, nil
186
+ # while it is still running, and -- treating an already-reaped child as
187
+ # exited -- the :already_reaped sentinel on ECHILD so the caller still
188
+ # releases the handle. The sentinel is deliberately not a status: $? would
189
+ # then hold some earlier child's, and attributing that one's death to this
190
+ # child is worse than admitting the status is unknown.
184
191
  def nonblocking_wait(pid)
185
- ::Process.waitpid(pid, ::Process::WNOHANG)
192
+ return nil unless ::Process.waitpid(pid, ::Process::WNOHANG)
193
+
194
+ ::Process.last_status
186
195
  rescue Errno::ECHILD
187
- pid
196
+ :already_reaped
188
197
  end
189
198
 
190
199
  def release(handle)
@@ -229,8 +238,11 @@ class Evilution::ProcessSupervisor
229
238
  nil
230
239
  end
231
240
 
241
+ # Returns the reaped child's Process::Status, or nil when it was already
242
+ # reaped elsewhere and the status is no longer attributable.
232
243
  def safe_wait(pid)
233
244
  ::Process.wait(pid)
245
+ ::Process.last_status
234
246
  rescue Errno::ECHILD
235
247
  nil
236
248
  end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "diagnostic"
4
+
5
+ # Warms Rails state that initialises lazily on a process's first request, once
6
+ # in the parent after the preload, so forked mutations inherit it instead of
7
+ # each paying the cold start.
8
+ #
9
+ # Every step is side-effect free. A real request is deliberately NOT used:
10
+ # controller callbacks change process-global state -- I18n.locale,
11
+ # PaperTrail.request, a session row -- that every fork would then inherit,
12
+ # failing unrelated tests and reporting them as kills.
13
+ #
14
+ # A step whose library is not loaded is skipped silently; a step that raises
15
+ # (no application stylesheet, an unusual asset setup) is skipped with a note.
16
+ # Warm-up is an optimisation and must never fail or skew the run.
17
+ class Evilution::RailsWarmup
18
+ def initialize(application, stream: $stderr)
19
+ @application = application
20
+ @stream = stream
21
+ end
22
+
23
+ def call
24
+ step("i18n") { warm_i18n }
25
+ step("routes") { warm_routes }
26
+ step("assets") { warm_assets }
27
+ step("templates") { warm_templates }
28
+ end
29
+
30
+ private
31
+
32
+ def step(name)
33
+ yield
34
+ rescue StandardError, ScriptError => e
35
+ Evilution::Diagnostic.warn("[evilution] rails warmup: skipped #{name} (#{e.class}: #{e.message})", stream: @stream)
36
+ end
37
+
38
+ def warm_i18n
39
+ ::I18n.eager_load! if defined?(::I18n) && ::I18n.respond_to?(:eager_load!)
40
+ end
41
+
42
+ # Rails 8 can draw routes lazily; building url_helpers compiles the helpers
43
+ # module every request would otherwise build on first use.
44
+ def warm_routes
45
+ routes = @application.routes
46
+ routes.eager_load! if routes.respond_to?(:eager_load!)
47
+ routes.url_helpers
48
+ end
49
+
50
+ def warm_assets
51
+ ::ActionController::Base.helpers.stylesheet_path("application") if defined?(::ActionController::Base)
52
+ end
53
+
54
+ # Optional: jhawthorn/actionview_precompiler compiles templates up front.
55
+ def warm_templates
56
+ return unless Gem.loaded_specs.key?("actionview_precompiler")
57
+
58
+ require "actionview_precompiler"
59
+ ::ActionviewPrecompiler.precompile
60
+ end
61
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../item_formatters"
4
+ require_relative "result_location"
5
+
6
+ # The neutral mutations that share one reason, under a heading naming it.
7
+ #
8
+ # "Neutral" covers a spec that was already red and a test process that died on
9
+ # infrastructure; grouping by reason is what tells the reader which of the two
10
+ # they are looking at, and what to do about it (EV-5pob / GH #1606).
11
+ class Evilution::Reporter::CLI::ItemFormatters::NeutralGroup
12
+ UNKNOWN_REASON = "reason not recorded"
13
+
14
+ def initialize(location: Evilution::Reporter::CLI::ItemFormatters::ResultLocation.new)
15
+ @location = location
16
+ end
17
+
18
+ def format(group)
19
+ reason, results = group
20
+ rows = results.map { |result| " #{@location.format(result)}" }
21
+ [" #{reason ? reason.to_s : UNKNOWN_REASON}:", *rows].join("\n")
22
+ end
23
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../item_formatters"
4
+ require_relative "../pct"
5
+
6
+ # One line per subject, under its file's heading: what the method scored and
7
+ # what that score is made of.
8
+ #
9
+ # A subject nothing reached says so rather than showing a bare 0.00%, which
10
+ # would read like a method whose mutations all survived (EV-nlx1 / GH #1605).
11
+ class Evilution::Reporter::CLI::ItemFormatters::SubjectScore
12
+ UNREACHED_NOTE = "nothing reached this subject"
13
+
14
+ def initialize(pct: Evilution::Reporter::CLI::Pct.new)
15
+ @pct = pct
16
+ end
17
+
18
+ def format(score)
19
+ " #{method_name(score.name)} #{@pct.format(score.score)} #{counts(score)}#{note(score)}"
20
+ end
21
+
22
+ private
23
+
24
+ # The file heading already carries the class, so the row keeps the part that
25
+ # tells the subjects apart, separator included: "#label_for", ".build".
26
+ def method_name(name)
27
+ boundary = name.rindex(/[#.]/)
28
+ boundary ? name[boundary..] : name
29
+ end
30
+
31
+ # Killed over what got a verdict — except where nothing did, in which case the
32
+ # mutations that exist are the more honest denominator.
33
+ def counts(score)
34
+ return "(0/#{score.total})" unless score.reached?
35
+
36
+ "(#{score.killed}/#{score.verified})"
37
+ end
38
+
39
+ def note(score)
40
+ score.reached? ? "" : " #{UNREACHED_NOTE}"
41
+ end
42
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../item_formatters"
4
+ require_relative "subject_score"
5
+
6
+ # The subjects of one file, under that file's name.
7
+ class Evilution::Reporter::CLI::ItemFormatters::SubjectScoreGroup
8
+ def initialize(row: Evilution::Reporter::CLI::ItemFormatters::SubjectScore.new)
9
+ @row = row
10
+ end
11
+
12
+ def format(scores)
13
+ rows = scores.map { |score| @row.format(score) }
14
+ [" #{scores.first.file_path}", *rows].join("\n")
15
+ end
16
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../line_formatters"
4
+
5
+ # EV-j0bv / GH #1607: parallel workers contending on shared infrastructure — a
6
+ # database file, a lock — crash the test process in a way that says nothing
7
+ # about the mutation. Those mutations are re-run serially once the pool is
8
+ # done, and the run says so, because the alternative is a neutral count that
9
+ # moves with --jobs on identical input with no explanation.
10
+ class Evilution::Reporter::CLI::LineFormatters::InfraRetryNotice
11
+ def format(summary)
12
+ count = summary.infra_retried
13
+ return nil if count.zero?
14
+
15
+ noun = count == 1 ? "mutation" : "mutations"
16
+ pronoun = count == 1 ? "it" : "them"
17
+ "! #{count} #{noun} hit infrastructure errors under parallel workers; re-ran #{pronoun} serially."
18
+ end
19
+ end
@@ -3,18 +3,42 @@
3
3
  require_relative "../line_formatters"
4
4
  require_relative "../pct"
5
5
 
6
+ # The threshold comes from the run's own configuration. Printing a verdict
7
+ # against a default the exit code did not share is what made a 0% score report
8
+ # `FAIL` and exit 0 (EV-39t1 / GH #1604); with no minimum configured the line
9
+ # states the score and says so, rather than implying a gate that is not armed.
6
10
  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)
11
+ def initialize(pct: Evilution::Reporter::CLI::Pct.new, min_score: nil)
10
12
  @pct = pct
11
13
  @min_score = min_score
12
14
  end
13
15
 
14
16
  def format(summary)
17
+ return unresolved_targets_line(summary) if summary.unresolved_targets?
18
+ return no_threshold_line(summary) unless gate?
19
+
15
20
  pass_fail = summary.success?(min_score: @min_score) ? "PASS" : "FAIL"
16
21
  score_pct = @pct.format(summary.score)
17
22
  threshold_pct = @pct.format(@min_score)
18
23
  "Result: #{pass_fail} (score #{score_pct} #{pass_fail == "PASS" ? ">=" : "<"} #{threshold_pct})"
19
24
  end
25
+
26
+ private
27
+
28
+ # A minimum of zero passes every score, so it is not a gate either.
29
+ def gate?
30
+ !@min_score.nil? && @min_score.positive?
31
+ end
32
+
33
+ def no_threshold_line(summary)
34
+ "Result: #{@pct.format(summary.score)} (no minimum score set)"
35
+ end
36
+
37
+ # The score covers only the files that resolved to a spec, so reporting it
38
+ # against the threshold here would name the wrong problem.
39
+ def unresolved_targets_line(summary)
40
+ count = summary.unresolved_target_files.length
41
+ subject = count == 1 ? "target file has" : "target files have"
42
+ "Result: FAIL (#{count} #{subject} no resolvable spec)"
43
+ end
20
44
  end
@@ -3,12 +3,30 @@
3
3
  require_relative "../line_formatters"
4
4
  require_relative "../pct"
5
5
 
6
+ # The score covers only the mutations that got a verdict. Where a run left some
7
+ # out — neutral, unresolved, equivalent, errored — full marks over a fraction of
8
+ # it reads as a verdict on the whole, so the line says how much it covered
9
+ # (EV-5pob / GH #1606).
6
10
  class Evilution::Reporter::CLI::LineFormatters::Score
7
11
  def initialize(pct: Evilution::Reporter::CLI::Pct.new)
8
12
  @pct = pct
9
13
  end
10
14
 
11
15
  def format(summary)
12
- "Score: #{@pct.format(summary.score)} (#{summary.killed}/#{summary.score_denominator})"
16
+ "Score: #{@pct.format(summary.score)} (#{counts(summary)})"
17
+ end
18
+
19
+ private
20
+
21
+ def counts(summary)
22
+ verified = summary.score_denominator
23
+ pair = "#{summary.killed}/#{verified}"
24
+ return pair if verified == summary.total
25
+
26
+ "#{pair} verified of #{summary.total} mutations#{neutral_note(summary)}"
27
+ end
28
+
29
+ def neutral_note(summary)
30
+ summary.neutral.positive? ? ", #{summary.neutral} neutral" : ""
13
31
  end
14
32
  end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../line_formatters"
4
+
5
+ # EV-p4sm / GH #1603: a target file that resolves to no test file contributes
6
+ # only a handful of `unresolved` mutations, which UnresolvedRateWarning dilutes
7
+ # against every other file in the run — 14 unresolved out of 98 sits under its
8
+ # threshold and says nothing at all. This formatter reports the stronger,
9
+ # per-file fact instead, and names the files so the reader knows which of the
10
+ # paths they passed was never tested.
11
+ class Evilution::Reporter::CLI::LineFormatters::UnresolvedTargets
12
+ def format(summary)
13
+ return nil unless summary.unresolved_targets?
14
+
15
+ files = summary.unresolved_target_files
16
+ listing = files.map { |path| " #{path}" }.join("\n")
17
+ "! #{headline(files.length, summary.target_file_count)}:\n#{listing}"
18
+ end
19
+
20
+ private
21
+
22
+ # The ratio is dropped rather than guessed when the run's target count is
23
+ # unknown, which is how a summary restored from a saved session arrives. The
24
+ # noun agrees with whichever number it follows ("1 of 2 target files has",
25
+ # "1 target file has"), the verb with how many were unresolved.
26
+ def headline(unresolved_count, target_file_count)
27
+ noun_count = target_file_count.nil? ? unresolved_count : target_file_count
28
+ noun = noun_count == 1 ? "target file" : "target files"
29
+ verb = unresolved_count == 1 ? "has" : "have"
30
+ tail = unresolved_count == 1 ? "it was never tested" : "they were never tested"
31
+ scope = target_file_count.nil? ? unresolved_count.to_s : "#{unresolved_count} of #{target_file_count}"
32
+
33
+ "#{scope} #{noun} #{verb} no resolvable spec — #{tail}"
34
+ end
35
+ end
@@ -5,6 +5,8 @@ require_relative "line_formatters/mutations"
5
5
  require_relative "line_formatters/score"
6
6
  require_relative "line_formatters/error_rate_warning"
7
7
  require_relative "line_formatters/unresolved_rate_warning"
8
+ require_relative "line_formatters/unresolved_targets"
9
+ require_relative "line_formatters/infra_retry_notice"
8
10
  require_relative "line_formatters/duration"
9
11
  require_relative "line_formatters/efficiency"
10
12
  require_relative "line_formatters/peak_memory"
@@ -15,6 +17,8 @@ class Evilution::Reporter::CLI::MetricsBlock
15
17
  Evilution::Reporter::CLI::LineFormatters::Score.new,
16
18
  Evilution::Reporter::CLI::LineFormatters::ErrorRateWarning.new,
17
19
  Evilution::Reporter::CLI::LineFormatters::UnresolvedRateWarning.new,
20
+ Evilution::Reporter::CLI::LineFormatters::UnresolvedTargets.new,
21
+ Evilution::Reporter::CLI::LineFormatters::InfraRetryNotice.new,
18
22
  Evilution::Reporter::CLI::LineFormatters::Duration.new,
19
23
  Evilution::Reporter::CLI::LineFormatters::Efficiency.new,
20
24
  Evilution::Reporter::CLI::LineFormatters::PeakMemory.new
@@ -6,14 +6,18 @@ require_relative "line_formatters/result_line"
6
6
  require_relative "line_formatters/feedback_footer"
7
7
 
8
8
  class Evilution::Reporter::CLI::Trailer
9
- DEFAULT_LINES = [
10
- Evilution::Reporter::CLI::LineFormatters::TruncationNotice.new,
11
- Evilution::Reporter::CLI::LineFormatters::ResultLine.new,
12
- Evilution::Reporter::CLI::LineFormatters::FeedbackFooter.new
13
- ].freeze
9
+ def self.default_lines(min_score: nil)
10
+ [
11
+ Evilution::Reporter::CLI::LineFormatters::TruncationNotice.new,
12
+ Evilution::Reporter::CLI::LineFormatters::ResultLine.new(min_score: min_score),
13
+ Evilution::Reporter::CLI::LineFormatters::FeedbackFooter.new
14
+ ]
15
+ end
16
+
17
+ DEFAULT_LINES = default_lines.freeze
14
18
 
15
- def initialize(lines: DEFAULT_LINES)
16
- @lines = lines
19
+ def initialize(lines: nil, min_score: nil)
20
+ @lines = lines || self.class.default_lines(min_score: min_score)
17
21
  end
18
22
 
19
23
  def call(summary)
@@ -6,11 +6,12 @@ class Evilution::Reporter::CLI
6
6
  SEPARATOR = "=" * 44
7
7
 
8
8
  def initialize(
9
+ min_score: nil,
9
10
  header: LineFormatters::Header.new,
10
11
  metrics_block: MetricsBlock.new,
11
12
  section_renderer: SectionRenderer.new,
12
13
  sections: DEFAULT_SECTIONS,
13
- trailer: Trailer.new
14
+ trailer: Trailer.new(min_score: min_score)
14
15
  )
15
16
  @header = header
16
17
  @metrics_block = metrics_block
@@ -46,6 +47,9 @@ require_relative "cli/line_formatters/result_line"
46
47
  require_relative "cli/line_formatters/feedback_footer"
47
48
  require_relative "cli/item_formatters/coverage_gap"
48
49
  require_relative "cli/item_formatters/result_location"
50
+ require_relative "cli/item_formatters/neutral_group"
51
+ require_relative "cli/item_formatters/subject_score"
52
+ require_relative "cli/item_formatters/subject_score_group"
49
53
  require_relative "cli/item_formatters/error"
50
54
  require_relative "cli/item_formatters/disabled"
51
55
  require_relative "cli/metrics_block"
@@ -60,9 +64,21 @@ Evilution::Reporter::CLI.const_set(
60
64
  formatter: Evilution::Reporter::CLI::ItemFormatters::CoverageGap.new
61
65
  ),
62
66
  Evilution::Reporter::CLI::Section.new(
63
- title: "Neutral mutations (test already failing):",
64
- fetcher: lambda(&:neutral_results),
65
- formatter: Evilution::Reporter::CLI::ItemFormatters::ResultLocation.new
67
+ title: lambda { |groups|
68
+ subjects = groups.sum(&:length)
69
+ "Subjects needing attention (#{subjects} subject#{"s" unless subjects == 1} " \
70
+ "in #{groups.length} file#{"s" unless groups.length == 1}):"
71
+ },
72
+ fetcher: lambda(&:subjects_needing_attention_by_file),
73
+ formatter: Evilution::Reporter::CLI::ItemFormatters::SubjectScoreGroup.new
74
+ ),
75
+ Evilution::Reporter::CLI::Section.new(
76
+ title: lambda { |groups|
77
+ count = groups.sum { |(_reason, results)| results.length }
78
+ "Neutral mutations (#{count}, not verified):"
79
+ },
80
+ fetcher: lambda(&:neutral_results_by_reason),
81
+ formatter: Evilution::Reporter::CLI::ItemFormatters::NeutralGroup.new
66
82
  ),
67
83
  Evilution::Reporter::CLI::Section.new(
68
84
  title: "Equivalent mutations (provably identical behavior):",
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../html"
3
4
  require_relative "section"
4
5
  require_relative "stylesheet"
5
6
  require_relative "baseline_keys"
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../json"
4
+
5
+ # The per-subject rows of a JSON report.
6
+ #
7
+ # Every subject is listed, whether or not it needs attention: a consumer that
8
+ # wants only the gaps filters on `reached` and `score`, while one asserting that
9
+ # a method is covered at all needs to see the rest (EV-nlx1 / GH #1605).
10
+ class Evilution::Reporter::JSON::Subjects
11
+ def call(summary)
12
+ summary.subject_scores.map { |score| row(score) }
13
+ end
14
+
15
+ private
16
+
17
+ def row(score)
18
+ {
19
+ name: score.name,
20
+ file: score.file_path,
21
+ total: score.total,
22
+ killed: score.killed,
23
+ verified: score.verified,
24
+ survived: score.survived,
25
+ score: score.score.round(4),
26
+ reached: score.reached?
27
+ }
28
+ end
29
+ end