evilution 1.3.0 → 1.4.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 (125) hide show
  1. checksums.yaml +4 -4
  2. data/.beads/interactions.jsonl +62 -0
  3. data/.rubocop_todo.yml +5 -0
  4. data/CHANGELOG.md +77 -0
  5. data/README.md +80 -24
  6. data/docs/architecture.md +89 -9
  7. data/docs/ast_pattern_syntax.md +50 -5
  8. data/docs/isolation.md +39 -2
  9. data/docs/migration-from-mutant.md +1 -1
  10. data/lib/evilution/ast/aasm_declaration.rb +133 -0
  11. data/lib/evilution/ast/callback_declaration.rb +91 -0
  12. data/lib/evilution/ast/included_block.rb +27 -0
  13. data/lib/evilution/ast/literal_callable.rb +21 -0
  14. data/lib/evilution/ast/parser.rb +119 -16
  15. data/lib/evilution/ast/pattern/method_name.rb +63 -0
  16. data/lib/evilution/ast/pattern/parser.rb +14 -15
  17. data/lib/evilution/ast/regexp_pattern.rb +104 -0
  18. data/lib/evilution/ast/scope_declaration.rb +45 -0
  19. data/lib/evilution/ast/uncovered_code.rb +88 -0
  20. data/lib/evilution/ast/value_object_definition.rb +25 -0
  21. data/lib/evilution/baseline/failure_formatter.rb +27 -0
  22. data/lib/evilution/baseline/report.rb +66 -0
  23. data/lib/evilution/baseline/spec_failure.rb +38 -0
  24. data/lib/evilution/baseline.rb +67 -30
  25. data/lib/evilution/cli/parser/file_args.rb +2 -1
  26. data/lib/evilution/cli/parser/options_builder.rb +1 -1
  27. data/lib/evilution/cli.rb +3 -2
  28. data/lib/evilution/config/validators/spec_mappings.rb +2 -1
  29. data/lib/evilution/config.rb +3 -2
  30. data/lib/evilution/equivalent/detector.rb +3 -1
  31. data/lib/evilution/equivalent/heuristic/guarded_index_fetch/condition.rb +66 -0
  32. data/lib/evilution/equivalent/heuristic/guarded_index_fetch/disturbance.rb +54 -0
  33. data/lib/evilution/equivalent/heuristic/guarded_index_fetch/early_exit.rb +73 -0
  34. data/lib/evilution/equivalent/heuristic/guarded_index_fetch/guard.rb +36 -0
  35. data/lib/evilution/equivalent/heuristic/guarded_index_fetch/index_read.rb +82 -0
  36. data/lib/evilution/equivalent/heuristic/guarded_index_fetch/node_path.rb +43 -0
  37. data/lib/evilution/equivalent/heuristic/guarded_index_fetch.rb +65 -0
  38. data/lib/evilution/example_filter.rb +45 -0
  39. data/lib/evilution/hooks/registry.rb +2 -1
  40. data/lib/evilution/integration/base.rb +2 -0
  41. data/lib/evilution/integration/known_failures.rb +26 -0
  42. data/lib/evilution/integration/loading/body_call_neutralizer.rb +132 -13
  43. data/lib/evilution/integration/loading/callback_redeclaration.rb +186 -0
  44. data/lib/evilution/integration/loading/concern_redeclaration.rb +61 -0
  45. data/lib/evilution/integration/loading/concern_state_cleaner.rb +19 -12
  46. data/lib/evilution/integration/loading/mutation_applier.rb +16 -0
  47. data/lib/evilution/integration/loading/test_class_cache.rb +46 -0
  48. data/lib/evilution/integration/minitest/test_ids.rb +21 -0
  49. data/lib/evilution/integration/minitest.rb +45 -6
  50. data/lib/evilution/integration/rspec/baseline_runner.rb +47 -3
  51. data/lib/evilution/integration/rspec/example_ids.rb +35 -0
  52. data/lib/evilution/integration/rspec/result_builder.rb +6 -1
  53. data/lib/evilution/integration/rspec/state_guard/anonymous_example_group_examples.rb +36 -0
  54. data/lib/evilution/integration/rspec/state_guard.rb +4 -1
  55. data/lib/evilution/integration/rspec.rb +22 -4
  56. data/lib/evilution/integration/test_unit/result_builder.rb +6 -0
  57. data/lib/evilution/integration/test_unit/test_ids.rb +18 -0
  58. data/lib/evilution/integration/test_unit.rb +37 -9
  59. data/lib/evilution/isolation/fork.rb +2 -1
  60. data/lib/evilution/isolation/in_process.rb +21 -4
  61. data/lib/evilution/mcp/info_tool/status_glossary.rb +2 -2
  62. data/lib/evilution/mcp/mutate_tool/progress_streamer.rb +2 -1
  63. data/lib/evilution/memory/leak_check.rb +27 -3
  64. data/lib/evilution/mutation.rb +7 -2
  65. data/lib/evilution/mutator/base.rb +49 -5
  66. data/lib/evilution/mutator/operator/alias_removal.rb +126 -0
  67. data/lib/evilution/mutator/operator/argument_order_permutation.rb +101 -0
  68. data/lib/evilution/mutator/operator/argument_removal.rb +9 -1
  69. data/lib/evilution/mutator/operator/comparison_operand_swap.rb +54 -0
  70. data/lib/evilution/mutator/operator/data_struct_member.rb +82 -0
  71. data/lib/evilution/mutator/operator/exception_swallow.rb +84 -0
  72. data/lib/evilution/mutator/operator/format_specifier_swap.rb +100 -0
  73. data/lib/evilution/mutator/operator/forwarded_argument_drop.rb +142 -0
  74. data/lib/evilution/mutator/operator/integer_division_to_fdiv.rb +62 -0
  75. data/lib/evilution/mutator/operator/keyword_argument.rb +25 -2
  76. data/lib/evilution/mutator/operator/keyword_value_swap.rb +75 -0
  77. data/lib/evilution/mutator/operator/no_matching_pattern_else.rb +47 -0
  78. data/lib/evilution/mutator/operator/numbered_parameter_swap.rb +78 -0
  79. data/lib/evilution/mutator/operator/off_by_one_boundary.rb +68 -0
  80. data/lib/evilution/mutator/operator/optional_parameter_to_required.rb +14 -3
  81. data/lib/evilution/mutator/operator/pattern_matching_array.rb +12 -1
  82. data/lib/evilution/mutator/operator/pattern_wildcard_widening.rb +117 -0
  83. data/lib/evilution/mutator/operator/pin_operator_removal.rb +48 -0
  84. data/lib/evilution/mutator/operator/regex_simplification.rb +53 -132
  85. data/lib/evilution/mutator/operator/regexp_alternation_branch_deletion.rb +54 -0
  86. data/lib/evilution/mutator/operator/regexp_anchor_promotion.rb +43 -0
  87. data/lib/evilution/mutator/operator/regexp_capture_to_passive.rb +61 -0
  88. data/lib/evilution/mutator/operator/regexp_character_type_complement.rb +54 -0
  89. data/lib/evilution/mutator/operator/regexp_named_group_rename.rb +106 -0
  90. data/lib/evilution/mutator/operator/regexp_option_removal.rb +51 -0
  91. data/lib/evilution/mutator/operator/regexp_quantifier_minimum_swap.rb +45 -0
  92. data/lib/evilution/mutator/operator/rescue_else_concatenation.rb +62 -0
  93. data/lib/evilution/mutator/operator/rescue_handler_concatenation.rb +69 -0
  94. data/lib/evilution/mutator/operator/rescue_handler_promotion.rb +65 -0
  95. data/lib/evilution/mutator/operator/return_keyword_removal.rb +79 -0
  96. data/lib/evilution/mutator/operator/rightward_assignment.rb +46 -0
  97. data/lib/evilution/mutator/operator/send_mutation.rb +2 -0
  98. data/lib/evilution/mutator/operator/splat_operator.rb +38 -13
  99. data/lib/evilution/mutator/operator/statement_reorder.rb +153 -0
  100. data/lib/evilution/mutator/registry.rb +31 -2
  101. data/lib/evilution/mutator/rescue_handlers.rb +81 -0
  102. data/lib/evilution/process_supervisor.rb +3 -2
  103. data/lib/evilution/reporter/cli/line_formatters/baseline_neutralized_notice.rb +55 -0
  104. data/lib/evilution/reporter/cli/metrics_block.rb +2 -0
  105. data/lib/evilution/reporter/json/baseline.rb +15 -0
  106. data/lib/evilution/reporter/json.rb +7 -2
  107. data/lib/evilution/result/baseline_neutralization.rb +10 -0
  108. data/lib/evilution/result/mutation_result.rb +9 -1
  109. data/lib/evilution/result/summary.rb +42 -2
  110. data/lib/evilution/runner/baseline_runner.rb +9 -4
  111. data/lib/evilution/runner/canary.rb +2 -57
  112. data/lib/evilution/runner/canary_failure_message.rb +101 -0
  113. data/lib/evilution/runner/mutation_executor/neutralizer/baseline_failed.rb +22 -8
  114. data/lib/evilution/runner/mutation_executor/result_cache.rb +3 -0
  115. data/lib/evilution/runner/mutation_executor/result_packer.rb +4 -2
  116. data/lib/evilution/runner/mutation_executor.rb +1 -1
  117. data/lib/evilution/runner/mutation_planner.rb +11 -2
  118. data/lib/evilution/runner/report_publisher.rb +3 -2
  119. data/lib/evilution/runner/subject_pipeline.rb +46 -1
  120. data/lib/evilution/runner.rb +4 -1
  121. data/lib/evilution/subject.rb +8 -2
  122. data/lib/evilution/version.rb +1 -1
  123. data/lib/evilution.rb +28 -0
  124. data/script/memory_check +62 -16
  125. metadata +83 -4
@@ -0,0 +1,153 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../operator"
4
+
5
+ # Swap two adjacent commands: `charge(card); send_receipt(user)` becomes
6
+ # `send_receipt(user); charge(card)`.
7
+ #
8
+ # A survivor means no example depends on the order the two side effects
9
+ # happen in. Most pairs of statements could swap without anyone noticing, so
10
+ # only pairs that are likely to matter are touched:
11
+ #
12
+ # - both statements are commands: they call a method, append, write an index,
13
+ # yield or call super. Assigning a local or instance variable stores a
14
+ # value, and statements without such effects swap freely.
15
+ # - neither writes a variable the other reads or writes. Such a pair fails at
16
+ # once when swapped, which tests nothing about order.
17
+ # - neither contains control flow (`return`, `break`, `next`, `redo`, `retry`,
18
+ # `raise`, `fail`), which would skip the other statement.
19
+ # - the second is not the last statement of its body, which gives the body
20
+ # its value.
21
+ # - neither contains a heredoc, whose body sits outside the statement's
22
+ # source range.
23
+ # - they are not writes to different literal keys of the same receiver
24
+ # (`opts[:a] = 1; opts[:b] = 2`), which cannot affect each other.
25
+ class Evilution::Mutator::Operator::StatementReorder < Evilution::Mutator::Base
26
+ CONTROL_FLOW_TYPES = [
27
+ Prism::ReturnNode, Prism::BreakNode, Prism::NextNode, Prism::RedoNode, Prism::RetryNode
28
+ ].freeze
29
+ RAISING_METHODS = %i[raise fail].freeze
30
+
31
+ # Calls that change their receiver without a method name: `a << b`, `a[k] = v`.
32
+ MUTATING_OPERATORS = %i[<< []=].freeze
33
+ METHOD_NAME = /\A[a-zA-Z_]/
34
+
35
+ EFFECT_TYPES = [Prism::YieldNode, Prism::SuperNode, Prism::ForwardingSuperNode].freeze
36
+
37
+ # Statements that store a value rather than act.
38
+ VALUE_WRITE_TYPES = [
39
+ Prism::LocalVariableWriteNode, Prism::LocalVariableOperatorWriteNode,
40
+ Prism::LocalVariableOrWriteNode, Prism::LocalVariableAndWriteNode,
41
+ Prism::InstanceVariableWriteNode, Prism::InstanceVariableOperatorWriteNode,
42
+ Prism::InstanceVariableOrWriteNode, Prism::InstanceVariableAndWriteNode
43
+ ].freeze
44
+
45
+ # Nodes that set a variable, and nodes that read one.
46
+ WRITE_TYPES = [
47
+ *VALUE_WRITE_TYPES, Prism::LocalVariableTargetNode, Prism::InstanceVariableTargetNode,
48
+ Prism::ClassVariableWriteNode, Prism::ClassVariableOperatorWriteNode, Prism::ClassVariableOrWriteNode,
49
+ Prism::ClassVariableAndWriteNode, Prism::ClassVariableTargetNode,
50
+ Prism::GlobalVariableWriteNode, Prism::GlobalVariableOperatorWriteNode, Prism::GlobalVariableOrWriteNode,
51
+ Prism::GlobalVariableAndWriteNode, Prism::GlobalVariableTargetNode
52
+ ].freeze
53
+ LITERAL_KEY_TYPES = [Prism::SymbolNode, Prism::StringNode, Prism::IntegerNode].freeze
54
+
55
+ READ_TYPES = [
56
+ Prism::LocalVariableReadNode, Prism::InstanceVariableReadNode,
57
+ Prism::ClassVariableReadNode, Prism::GlobalVariableReadNode
58
+ ].freeze
59
+
60
+ def visit_statements_node(node)
61
+ statements = node.body
62
+ # The last statement stays put, so pairs end one before it.
63
+ statements[0...-1].each_cons(2) do |first, second|
64
+ emit_swap(first, second) if swappable?(first, second)
65
+ end
66
+ super
67
+ end
68
+
69
+ private
70
+
71
+ def swappable?(first, second)
72
+ [first, second].all? { |statement| command?(statement) && !control_flow?(statement) && !heredoc?(statement) } &&
73
+ independent?(first, second) && !distinct_key_writes?(first, second)
74
+ end
75
+
76
+ def command?(statement)
77
+ return false if VALUE_WRITE_TYPES.include?(statement.class)
78
+
79
+ nodes_in(statement).any? { |node| effect?(node) }
80
+ end
81
+
82
+ def effect?(node)
83
+ return true if EFFECT_TYPES.include?(node.class)
84
+ return false unless node.is_a?(Prism::CallNode)
85
+
86
+ node.name.to_s.match?(METHOD_NAME) || MUTATING_OPERATORS.include?(node.name)
87
+ end
88
+
89
+ def control_flow?(statement)
90
+ nodes_in(statement).any? do |node|
91
+ CONTROL_FLOW_TYPES.include?(node.class) ||
92
+ (node.is_a?(Prism::CallNode) && node.receiver.nil? && RAISING_METHODS.include?(node.name))
93
+ end
94
+ end
95
+
96
+ def heredoc?(statement)
97
+ nodes_in(statement).any? do |node|
98
+ node.respond_to?(:heredoc?) && node.heredoc?
99
+ end
100
+ end
101
+
102
+ def distinct_key_writes?(first, second)
103
+ writes = [index_write(first), index_write(second)]
104
+ return false if writes.include?(nil)
105
+ return false unless writes.first.receiver.slice == writes.last.receiver.slice
106
+
107
+ distinct_literal_keys?(writes.map { |write| write.arguments.arguments.first })
108
+ end
109
+
110
+ def distinct_literal_keys?(keys)
111
+ keys.all? { |key| LITERAL_KEY_TYPES.include?(key.class) } && keys.first.slice != keys.last.slice
112
+ end
113
+
114
+ # `h[k] = v` on its own or under a modifier condition (`h[k] = v if v`).
115
+ def index_write(statement)
116
+ if statement.is_a?(Prism::IfNode) || statement.is_a?(Prism::UnlessNode)
117
+ body = statement.statements
118
+ statement = body.body.first if body && body.body.length == 1
119
+ end
120
+ statement if statement.is_a?(Prism::CallNode) && statement.name == :[]=
121
+ end
122
+
123
+ def independent?(first, second)
124
+ first_writes = names(first, WRITE_TYPES)
125
+ second_writes = names(second, WRITE_TYPES)
126
+
127
+ !first_writes.intersect?(names(second, READ_TYPES) | second_writes) &&
128
+ !second_writes.intersect?(names(first, READ_TYPES))
129
+ end
130
+
131
+ def names(statement, types)
132
+ nodes_in(statement).filter_map { |node| node.name if types.include?(node.class) }.to_set
133
+ end
134
+
135
+ def nodes_in(node)
136
+ [node, *node.compact_child_nodes.flat_map { |child| nodes_in(child) }]
137
+ end
138
+
139
+ # Whatever separates the two statements — a line break and indentation, or
140
+ # `; ` — stays between them.
141
+ def emit_swap(first, second)
142
+ start_offset = first.location.start_offset
143
+ end_offset = second.location.end_offset
144
+ separator = byteslice_source(first.location.end_offset, second.location.start_offset - first.location.end_offset)
145
+
146
+ add_mutation(
147
+ offset: start_offset,
148
+ length: end_offset - start_offset,
149
+ replacement: "#{second.slice}#{separator}#{first.slice}",
150
+ node: first
151
+ )
152
+ end
153
+ end
@@ -4,7 +4,9 @@ require_relative "../mutator"
4
4
 
5
5
  class Evilution::Mutator::Registry
6
6
  STRICT_EXTRA_OPERATORS = [
7
- Evilution::Mutator::Operator::PredicateToNil
7
+ Evilution::Mutator::Operator::PredicateToNil,
8
+ Evilution::Mutator::Operator::ExceptionSwallow,
9
+ Evilution::Mutator::Operator::StatementReorder
8
10
  ].freeze
9
11
 
10
12
  def self.for_profile(profile)
@@ -136,7 +138,32 @@ class Evilution::Mutator::Registry
136
138
  Evilution::Mutator::Operator::ReceiverConstructorSwap,
137
139
  Evilution::Mutator::Operator::BlockBodyToNil,
138
140
  Evilution::Mutator::Operator::BlockBodyToRaise,
139
- Evilution::Mutator::Operator::BlockBodyPromotion
141
+ Evilution::Mutator::Operator::BlockBodyPromotion,
142
+ Evilution::Mutator::Operator::DataStructMember,
143
+ Evilution::Mutator::Operator::PinOperatorRemoval,
144
+ Evilution::Mutator::Operator::RightwardAssignment,
145
+ Evilution::Mutator::Operator::NumberedParameterSwap,
146
+ Evilution::Mutator::Operator::ForwardedArgumentDrop,
147
+ Evilution::Mutator::Operator::PatternWildcardWidening,
148
+ Evilution::Mutator::Operator::NoMatchingPatternElse,
149
+ Evilution::Mutator::Operator::ArgumentOrderPermutation,
150
+ Evilution::Mutator::Operator::KeywordValueSwap,
151
+ Evilution::Mutator::Operator::ComparisonOperandSwap,
152
+ Evilution::Mutator::Operator::IntegerDivisionToFdiv,
153
+ Evilution::Mutator::Operator::OffByOneBoundary,
154
+ Evilution::Mutator::Operator::FormatSpecifierSwap,
155
+ Evilution::Mutator::Operator::AliasRemoval,
156
+ Evilution::Mutator::Operator::RegexpCharacterTypeComplement,
157
+ Evilution::Mutator::Operator::RegexpAnchorPromotion,
158
+ Evilution::Mutator::Operator::RegexpAlternationBranchDeletion,
159
+ Evilution::Mutator::Operator::RegexpQuantifierMinimumSwap,
160
+ Evilution::Mutator::Operator::RegexpCaptureToPassive,
161
+ Evilution::Mutator::Operator::RegexpNamedGroupRename,
162
+ Evilution::Mutator::Operator::RegexpOptionRemoval,
163
+ Evilution::Mutator::Operator::RescueHandlerPromotion,
164
+ Evilution::Mutator::Operator::RescueHandlerConcatenation,
165
+ Evilution::Mutator::Operator::RescueElseConcatenation,
166
+ Evilution::Mutator::Operator::ReturnKeywordRemoval
140
167
  ].each { |op| registry.register(op) }
141
168
  registry
142
169
  end
@@ -152,6 +179,8 @@ class Evilution::Mutator::Registry
152
179
 
153
180
  def mutations_for(subject, filter: nil, operator_options: {})
154
181
  @operators.flat_map do |operator_class|
182
+ next [] unless operator_class.subject_kinds.include?(subject.kind)
183
+
155
184
  operator = build_operator(operator_class, operator_options)
156
185
  operator.call(subject, filter: filter)
157
186
  end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ require_relative "../mutator"
6
+ require_relative "../ast/local_reads"
7
+
8
+ # The rescue clauses of a `begin` (or of a method or block body with a rescue),
9
+ # and the rule for which of their handlers can run outside the rescue.
10
+ #
11
+ # Operators that move a handler — promoting it over the body, or running it
12
+ # after the body as well — share the rule: a handler that reads the rescued
13
+ # exception (its `=> e` variable or `$!` / `$@`), re-raises it with a bare
14
+ # `raise` / `fail`, or retries only makes sense inside the rescue, and moved
15
+ # out it would fail for that reason alone.
16
+ class Evilution::Mutator::RescueHandlers
17
+ EXCEPTION_GLOBALS = %i[$! $@].freeze
18
+ RERAISING_METHODS = %i[raise fail].freeze
19
+
20
+ attr_reader :clauses
21
+
22
+ def initialize(begin_node)
23
+ @clauses = []
24
+ clause = begin_node.rescue_clause
25
+ while clause
26
+ @clauses << clause
27
+ clause = clause.subsequent
28
+ end
29
+ end
30
+
31
+ # Whether a handler can run outside its rescue. A missing handler (an empty
32
+ # rescue clause) can.
33
+ def self.movable?(handler, reference)
34
+ return true if handler.nil?
35
+
36
+ !reads_exception?(handler, reference) && !rescue_only?(handler)
37
+ end
38
+
39
+ # Binding the exception to anything but a local (`=> @error`) leaves it set
40
+ # for code after the rescue, which the handler alone cannot speak for, so
41
+ # such a handler counts as reading it. An underscore name announces a binding
42
+ # that is not read.
43
+ def self.reads_exception?(handler, reference)
44
+ return true if nodes_in(handler).any? { |node| exception_global?(node) }
45
+ return false if reference.nil?
46
+ return true unless reference.is_a?(Prism::LocalVariableTargetNode)
47
+
48
+ name = reference.name.to_s
49
+ !name.start_with?("_") && Evilution::AST::LocalReads.new.call(handler, name)
50
+ end
51
+
52
+ def self.exception_global?(node)
53
+ node.is_a?(Prism::GlobalVariableReadNode) && EXCEPTION_GLOBALS.include?(node.name)
54
+ end
55
+
56
+ def self.rescue_only?(handler)
57
+ nodes_in(handler).any? do |node|
58
+ node.is_a?(Prism::RetryNode) ||
59
+ (node.is_a?(Prism::CallNode) && node.receiver.nil? && RERAISING_METHODS.include?(node.name) &&
60
+ node.arguments.nil?)
61
+ end
62
+ end
63
+
64
+ # What goes between the last statement of a body and code appended after
65
+ # it: a new line at that statement's indentation, or `; ` when the body
66
+ # does not end its line (`begin; a; rescue ...`).
67
+ def self.continuation(source, statements)
68
+ end_offset = statements.location.end_offset
69
+ return "; " unless source.byteslice(end_offset, 1) == "\n"
70
+
71
+ start_offset = statements.body.last.location.start_offset
72
+ line_start = (source.byterindex("\n", start_offset - 1) || -1) + 1
73
+ "\n#{source.byteslice(line_start, start_offset - line_start)}"
74
+ end
75
+
76
+ def self.nodes_in(node)
77
+ [node, *node.compact_child_nodes.flat_map { |child| nodes_in(child) }]
78
+ end
79
+
80
+ private_class_method :reads_exception?, :exception_global?, :rescue_only?, :nodes_in
81
+ end
@@ -3,6 +3,7 @@
3
3
  require "fileutils"
4
4
  require_relative "version"
5
5
  require_relative "temp_dir_tracker"
6
+ require_relative "diagnostic"
6
7
 
7
8
  # Single owner of the process-lifecycle invariant: every pid spawned here is
8
9
  # group-isolated, tracked in a signal-safe registry, group-signalled through a
@@ -219,8 +220,8 @@ class Evilution::ProcessSupervisor
219
220
  # group: a later group-kill won't sweep its subtree. Don't raise (spawn
220
221
  # must still return a usable handle), but surface it so the leak is
221
222
  # debuggable rather than silent.
222
- warn "evilution: could not isolate process #{pid} into its own process " \
223
- "group (#{e.class}: #{e.message}); grandchildren may survive a kill."
223
+ Evilution::Diagnostic.warn("evilution: could not isolate process #{pid} into its own process " \
224
+ "group (#{e.class}: #{e.message}); grandchildren may survive a kill.")
224
225
  end
225
226
 
226
227
  # True once the child has been reaped (now or earlier). WNOHANG returns the
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../line_formatters"
4
+ require_relative "../../../baseline"
5
+
6
+ # An example that was failing before any mutation ran fails in every mutation
7
+ # run it takes part in. Where nothing else failed, that failure is not a kill:
8
+ # the mutation is recorded neutral and drops out of the score. Left unsaid, a
9
+ # red spec file would read as full marks, so the score never goes out without
10
+ # saying how many kills were not counted, for which spec file, and why it was
11
+ # red.
12
+ class Evilution::Reporter::CLI::LineFormatters::BaselineNeutralizedNotice
13
+ DETAIL_INDENT = " "
14
+
15
+ def initialize(failure_formatter: Evilution::Baseline::FailureFormatter.new)
16
+ @failure_formatter = failure_formatter
17
+ end
18
+
19
+ def format(summary)
20
+ neutralizations = summary.baseline_neutralizations
21
+ return nil if neutralizations.empty?
22
+
23
+ neutralizations.flat_map { |neutralization| [headline(neutralization), *detail(neutralization)] }.join("\n")
24
+ end
25
+
26
+ private
27
+
28
+ def headline(neutralization)
29
+ count = neutralization.count
30
+ subject = count == 1 ? "1 kill" : "#{count} kills"
31
+ verdict = count == 1 ? "that mutation has no verdict" : "those mutations have no verdict"
32
+ "! #{subject} not counted#{spec_files(neutralization)}: only examples already failing in the baseline " \
33
+ "failed, so #{verdict}."
34
+ end
35
+
36
+ def spec_files(neutralization)
37
+ names = neutralization.spec_file ? [neutralization.spec_file] : neutralization.failures.map(&:spec_file)
38
+ names.empty? ? "" : " for #{names.join(", ")}"
39
+ end
40
+
41
+ # One spec file's detail sits straight under the headline that names it;
42
+ # several are each put under their own name.
43
+ def detail(neutralization)
44
+ failures = neutralization.failures
45
+ return failure_lines(failures.first, DETAIL_INDENT) if failures.length == 1
46
+
47
+ failures.flat_map do |failure|
48
+ ["#{DETAIL_INDENT}#{failure.spec_file}:", *failure_lines(failure, "#{DETAIL_INDENT} ")]
49
+ end
50
+ end
51
+
52
+ def failure_lines(failure, indent)
53
+ @failure_formatter.call(failure).map { |line| "#{indent}#{line}" }
54
+ end
55
+ end
@@ -3,6 +3,7 @@
3
3
  require_relative "../cli"
4
4
  require_relative "line_formatters/mutations"
5
5
  require_relative "line_formatters/score"
6
+ require_relative "line_formatters/baseline_neutralized_notice"
6
7
  require_relative "line_formatters/error_rate_warning"
7
8
  require_relative "line_formatters/unresolved_rate_warning"
8
9
  require_relative "line_formatters/unresolved_targets"
@@ -15,6 +16,7 @@ class Evilution::Reporter::CLI::MetricsBlock
15
16
  DEFAULT_LINES = [
16
17
  Evilution::Reporter::CLI::LineFormatters::Mutations.new,
17
18
  Evilution::Reporter::CLI::LineFormatters::Score.new,
19
+ Evilution::Reporter::CLI::LineFormatters::BaselineNeutralizedNotice.new,
18
20
  Evilution::Reporter::CLI::LineFormatters::ErrorRateWarning.new,
19
21
  Evilution::Reporter::CLI::LineFormatters::UnresolvedRateWarning.new,
20
22
  Evilution::Reporter::CLI::LineFormatters::UnresolvedTargets.new,
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../json"
4
+
5
+ # The summary fields that speak for a red baseline: the kills it took out of
6
+ # the score because only examples that were already failing failed, and why
7
+ # each spec file was red. Each is present only when there is something to say.
8
+ class Evilution::Reporter::JSON::Baseline
9
+ def call(summary)
10
+ fields = {}
11
+ fields[:baseline_neutralized] = summary.baseline_neutralized if summary.baseline_neutralized.positive?
12
+ fields[:baseline_failures] = summary.baseline_failures.map(&:to_h) unless summary.baseline_failures.empty?
13
+ fields
14
+ end
15
+ end
@@ -8,9 +8,10 @@ require_relative "../reporter"
8
8
  require_relative "../session/schema"
9
9
 
10
10
  class Evilution::Reporter::JSON
11
- def initialize(suggest_tests: false, integration: :rspec, subjects: Subjects.new)
11
+ def initialize(suggest_tests: false, integration: :rspec, subjects: Subjects.new, baseline: Baseline.new)
12
12
  @suggestion = Evilution::Reporter::Suggestion.new(suggest_tests: suggest_tests, integration: integration)
13
13
  @subjects = subjects
14
+ @baseline = baseline
14
15
  end
15
16
 
16
17
  def call(summary)
@@ -104,10 +105,13 @@ class Evilution::Reporter::JSON
104
105
  end
105
106
 
106
107
  # What the run had to say about itself rather than about the mutations: a
107
- # target that was never tested, work that had to be redone serially.
108
+ # target that was never tested, work that had to be redone serially,
109
+ # survivors a red baseline took out of the score.
108
110
  def append_diagnostic_summary_fields(data, summary)
109
111
  data[:unresolved_target_files] = summary.unresolved_target_files if summary.unresolved_targets?
110
112
  data[:infra_retried] = summary.infra_retried if summary.infra_retried.positive?
113
+ data[:uncovered_code] = summary.uncovered_code if summary.uncovered_code?
114
+ data.merge!(@baseline.call(summary))
111
115
  end
112
116
 
113
117
  def build_mutation_detail(result)
@@ -183,3 +187,4 @@ class Evilution::Reporter::JSON
183
187
  end
184
188
 
185
189
  require_relative "json/subjects"
190
+ require_relative "json/baseline"
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../result"
4
+
5
+ # The kills one red spec file left uncounted -- mutations whose tests failed
6
+ # on nothing but examples that were already failing -- with why it was red.
7
+ #
8
+ # spec_file is nil when no single red file can be named, as in a run given
9
+ # its spec files explicitly; failures then holds all of them.
10
+ Evilution::Result::BaselineNeutralization = Data.define(:spec_file, :count, :failures)
@@ -9,8 +9,11 @@ class Evilution::Result::MutationResult
9
9
 
10
10
  attr_reader :mutation, :status, :duration, :killing_test, :test_command, :memory, :error, :neutral_reason
11
11
 
12
+ # known_failures_only: the tests failed, but every example that did was
13
+ # already failing before any mutation ran, so the failure says nothing
14
+ # about this one.
12
15
  def initialize(mutation:, status:, duration: 0.0, killing_test: nil,
13
- test_command: nil, memory: nil, error: nil, neutral_reason: nil)
16
+ test_command: nil, memory: nil, error: nil, neutral_reason: nil, known_failures_only: false)
14
17
  raise ArgumentError, "invalid status: #{status}" unless STATUSES.include?(status)
15
18
 
16
19
  @mutation = mutation
@@ -21,6 +24,7 @@ class Evilution::Result::MutationResult
21
24
  @memory = memory
22
25
  @error = error
23
26
  @neutral_reason = neutral_reason
27
+ @known_failures_only = known_failures_only == true
24
28
  freeze
25
29
  end
26
30
 
@@ -54,6 +58,10 @@ class Evilution::Result::MutationResult
54
58
  @error.is_a?(Evilution::Result::ErrorInfo) ? @error.backtrace : nil
55
59
  end
56
60
 
61
+ def known_failures_only?
62
+ @known_failures_only
63
+ end
64
+
57
65
  def killed?
58
66
  status == :killed
59
67
  end
@@ -3,13 +3,15 @@
3
3
  require_relative "../result"
4
4
  require_relative "coverage_gap_grouper"
5
5
  require_relative "subject_scorer"
6
+ require_relative "baseline_neutralization"
6
7
 
7
8
  class Evilution::Result::Summary
8
9
  attr_reader :results, :duration, :skipped, :disabled_mutations, :unresolved_target_files,
9
- :target_file_count, :infra_retried
10
+ :target_file_count, :infra_retried, :uncovered_code, :baseline_failures
10
11
 
11
12
  def initialize(results:, duration: 0.0, truncated: false, skipped: 0, disabled_mutations: [],
12
- unresolved_target_files: [], target_file_count: nil, infra_retried: 0)
13
+ unresolved_target_files: [], target_file_count: nil, infra_retried: 0, uncovered_code: [],
14
+ baseline_failures: [])
13
15
  @results = results
14
16
  @duration = duration
15
17
  @truncated = truncated
@@ -18,9 +20,27 @@ class Evilution::Result::Summary
18
20
  @unresolved_target_files = unresolved_target_files.freeze
19
21
  @target_file_count = target_file_count
20
22
  @infra_retried = infra_retried
23
+ @uncovered_code = uncovered_code.freeze
24
+ @baseline_failures = baseline_failures.freeze
21
25
  freeze
22
26
  end
23
27
 
28
+ # The kills not counted because only examples already failing in the
29
+ # baseline failed, gathered under the spec file that was red and paired with
30
+ # why it was. They are out of the score, so a red spec file would read as
31
+ # full marks unless the report says they are there.
32
+ def baseline_neutralizations
33
+ baseline_neutralized_results.group_by { |result| result.neutral_reason.detail }.map do |spec_file, results|
34
+ Evilution::Result::BaselineNeutralization.new(
35
+ spec_file: spec_file, count: results.length, failures: baseline_failures_for(spec_file)
36
+ )
37
+ end
38
+ end
39
+
40
+ def baseline_neutralized
41
+ baseline_neutralized_results.length
42
+ end
43
+
24
44
  # Neutral results gathered by the reason they were recorded, which is what the
25
45
  # report needs to say which kind of neutral a reader is looking at
26
46
  # (EV-5pob / GH #1606).
@@ -53,6 +73,12 @@ class Evilution::Result::Summary
53
73
  !unresolved_target_files.empty?
54
74
  end
55
75
 
76
+ # Targeted lines that hold code outside every subject, so no mutation was
77
+ # ever generated for them: `{ file:, lines: ["2-4", "9"] }` per file.
78
+ def uncovered_code?
79
+ !uncovered_code.empty?
80
+ end
81
+
56
82
  def truncated?
57
83
  @truncated
58
84
  end
@@ -167,4 +193,18 @@ class Evilution::Result::Summary
167
193
 
168
194
  max_rss && (max_rss / 1024.0)
169
195
  end
196
+
197
+ private
198
+
199
+ def baseline_neutralized_results
200
+ neutral_results.select { |result| result.neutral_reason && result.neutral_reason.kind == :baseline_failure }
201
+ end
202
+
203
+ # No spec file named means the run was given its spec files explicitly, and
204
+ # any of them being red neutralizes the survivor.
205
+ def baseline_failures_for(spec_file)
206
+ return baseline_failures if spec_file.nil?
207
+
208
+ baseline_failures.select { |failure| failure.spec_file == spec_file }
209
+ end
170
210
  end
@@ -12,6 +12,7 @@ require_relative "../integration/test_unit"
12
12
  require_relative "../example_filter"
13
13
  require_relative "../spec_ast_cache"
14
14
  require_relative "../source_ast_cache"
15
+ require_relative "../diagnostic"
15
16
 
16
17
  unless defined?(Evilution::Runner::INTEGRATIONS)
17
18
  Evilution::Runner::INTEGRATIONS = {
@@ -33,15 +34,19 @@ class Evilution::Runner::BaselineRunner
33
34
  end
34
35
  end
35
36
 
36
- def build_integration
37
+ # baseline_result: what the baseline found, when it ran. The tests it saw
38
+ # failing are handed to the integration, which can then tell a mutation run
39
+ # that fails on nothing else from one that kills.
40
+ def build_integration(baseline_result = nil)
37
41
  klass = integration_class
38
- kwargs = base_integration_kwargs
42
+ kwargs = base_integration_kwargs(baseline_result)
39
43
  kwargs.merge!(rspec_integration_kwargs) if klass == Evilution::Integration::RSpec
40
44
  klass.new(**kwargs)
41
45
  end
42
46
 
43
- def base_integration_kwargs
47
+ def base_integration_kwargs(baseline_result = nil)
44
48
  {
49
+ known_failures: baseline_result ? baseline_result.failed_example_ids : [],
45
50
  test_files: config.spec_files.empty? ? nil : config.spec_files,
46
51
  hooks: hooks,
47
52
  fallback_to_full_suite: config.fallback_to_full_suite?,
@@ -119,7 +124,7 @@ class Evilution::Runner::BaselineRunner
119
124
 
120
125
  Evilution::CoverageExampleFilter.new(map: map, lexical: lexical)
121
126
  rescue StandardError => e
122
- warn "evilution: coverage targeting unavailable (#{e.class}: #{e.message}); using lexical targeting"
127
+ Evilution::Diagnostic.warn("evilution: coverage targeting unavailable (#{e.class}: #{e.message}); using lexical targeting")
123
128
  lexical
124
129
  end
125
130
 
@@ -6,6 +6,7 @@ require "securerandom"
6
6
  require_relative "../runner"
7
7
  require_relative "../mutation"
8
8
  require_relative "../subject"
9
+ require_relative "canary_failure_message"
9
10
 
10
11
  # Runs one guaranteed-unobservable synthetic mutation through the configured
11
12
  # integration + isolation at session start. The synthetic spec never references
@@ -34,7 +35,7 @@ class Evilution::Runner::Canary
34
35
  test_command: ->(mutation) { build_integration(spec_path).call(mutation) },
35
36
  timeout: @config.timeout
36
37
  )
37
- raise Failed, failure_message(result) unless result.status == :survived
38
+ raise Failed, Evilution::Runner::CanaryFailureMessage.new(result).to_s unless result.status == :survived
38
39
 
39
40
  nil
40
41
  ensure
@@ -145,60 +146,4 @@ class Evilution::Runner::Canary
145
146
  def build_integration(spec_path)
146
147
  @integration_class.new(test_files: [spec_path], hooks: @hooks)
147
148
  end
148
-
149
- # When the child reported an error, that error IS the diagnosis -- naming it
150
- # beats guessing. The speculative list stays only for the cases that carry no
151
- # error at all (:killed, :timeout), where guesses are the only help there is.
152
- # Diagnosing GH #1581 meant rebuilding this canary by hand to read the field
153
- # this message used to drop, and none of the four guesses was the cause.
154
- # EV-65nf / GH #1586.
155
- def failure_message(result)
156
- "#{failure_preamble(result.status)} #{diagnosis(result)} " \
157
- "Re-run with --no-canary to bypass this check."
158
- end
159
-
160
- def failure_preamble(status)
161
- "evilution proof-of-life canary failed: a guaranteed-unobservable synthetic " \
162
- "mutation was scored #{status.inspect} instead of :survived. The mutation " \
163
- "pipeline is misreporting — every score this run would produce is unreliable."
164
- end
165
-
166
- def diagnosis(result)
167
- reported = reported_error(result)
168
- return speculative_causes if reported.nil?
169
-
170
- "The child reported: #{reported}"
171
- end
172
-
173
- def reported_error(result)
174
- message = result.error_message
175
- return nil if message.nil? || message.empty?
176
-
177
- described = with_class_prefix(message, result.error_class)
178
- frame = first_frame(result)
179
- frame.nil? ? described : "#{described} (at #{frame})"
180
- end
181
-
182
- # MutationApplier already prefixes the class onto the message it packs
183
- # ("#{e.class}: #{e.message}"), while other paths pack the bare message and
184
- # leave the class in its own field. Prefixing unconditionally would print
185
- # "NameError: NameError: ..." for the former.
186
- def with_class_prefix(message, klass)
187
- return message if klass.nil? || klass.empty? || message.start_with?("#{klass}:")
188
-
189
- "#{klass}: #{message}"
190
- end
191
-
192
- def first_frame(result)
193
- backtrace = result.error_backtrace
194
- return nil if backtrace.nil? || backtrace.empty?
195
-
196
- backtrace.first
197
- end
198
-
199
- def speculative_causes
200
- "Likely causes: Rails/Zeitwerk autoloading breaking child eval; an env-specific " \
201
- "RSpec config (e.g. fail_if_no_examples); a classify_status fallback defect; or " \
202
- "an isolation-mode defect."
203
- end
204
149
  end