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.
- checksums.yaml +4 -4
- data/.beads/interactions.jsonl +62 -0
- data/.rubocop_todo.yml +5 -0
- data/CHANGELOG.md +77 -0
- data/README.md +80 -24
- data/docs/architecture.md +89 -9
- data/docs/ast_pattern_syntax.md +50 -5
- data/docs/isolation.md +39 -2
- data/docs/migration-from-mutant.md +1 -1
- data/lib/evilution/ast/aasm_declaration.rb +133 -0
- data/lib/evilution/ast/callback_declaration.rb +91 -0
- data/lib/evilution/ast/included_block.rb +27 -0
- data/lib/evilution/ast/literal_callable.rb +21 -0
- data/lib/evilution/ast/parser.rb +119 -16
- data/lib/evilution/ast/pattern/method_name.rb +63 -0
- data/lib/evilution/ast/pattern/parser.rb +14 -15
- data/lib/evilution/ast/regexp_pattern.rb +104 -0
- data/lib/evilution/ast/scope_declaration.rb +45 -0
- data/lib/evilution/ast/uncovered_code.rb +88 -0
- data/lib/evilution/ast/value_object_definition.rb +25 -0
- data/lib/evilution/baseline/failure_formatter.rb +27 -0
- data/lib/evilution/baseline/report.rb +66 -0
- data/lib/evilution/baseline/spec_failure.rb +38 -0
- data/lib/evilution/baseline.rb +67 -30
- data/lib/evilution/cli/parser/file_args.rb +2 -1
- data/lib/evilution/cli/parser/options_builder.rb +1 -1
- data/lib/evilution/cli.rb +3 -2
- data/lib/evilution/config/validators/spec_mappings.rb +2 -1
- data/lib/evilution/config.rb +3 -2
- data/lib/evilution/equivalent/detector.rb +3 -1
- data/lib/evilution/equivalent/heuristic/guarded_index_fetch/condition.rb +66 -0
- data/lib/evilution/equivalent/heuristic/guarded_index_fetch/disturbance.rb +54 -0
- data/lib/evilution/equivalent/heuristic/guarded_index_fetch/early_exit.rb +73 -0
- data/lib/evilution/equivalent/heuristic/guarded_index_fetch/guard.rb +36 -0
- data/lib/evilution/equivalent/heuristic/guarded_index_fetch/index_read.rb +82 -0
- data/lib/evilution/equivalent/heuristic/guarded_index_fetch/node_path.rb +43 -0
- data/lib/evilution/equivalent/heuristic/guarded_index_fetch.rb +65 -0
- data/lib/evilution/example_filter.rb +45 -0
- data/lib/evilution/hooks/registry.rb +2 -1
- data/lib/evilution/integration/base.rb +2 -0
- data/lib/evilution/integration/known_failures.rb +26 -0
- data/lib/evilution/integration/loading/body_call_neutralizer.rb +132 -13
- data/lib/evilution/integration/loading/callback_redeclaration.rb +186 -0
- data/lib/evilution/integration/loading/concern_redeclaration.rb +61 -0
- data/lib/evilution/integration/loading/concern_state_cleaner.rb +19 -12
- data/lib/evilution/integration/loading/mutation_applier.rb +16 -0
- data/lib/evilution/integration/loading/test_class_cache.rb +46 -0
- data/lib/evilution/integration/minitest/test_ids.rb +21 -0
- data/lib/evilution/integration/minitest.rb +45 -6
- data/lib/evilution/integration/rspec/baseline_runner.rb +47 -3
- data/lib/evilution/integration/rspec/example_ids.rb +35 -0
- data/lib/evilution/integration/rspec/result_builder.rb +6 -1
- data/lib/evilution/integration/rspec/state_guard/anonymous_example_group_examples.rb +36 -0
- data/lib/evilution/integration/rspec/state_guard.rb +4 -1
- data/lib/evilution/integration/rspec.rb +22 -4
- data/lib/evilution/integration/test_unit/result_builder.rb +6 -0
- data/lib/evilution/integration/test_unit/test_ids.rb +18 -0
- data/lib/evilution/integration/test_unit.rb +37 -9
- data/lib/evilution/isolation/fork.rb +2 -1
- data/lib/evilution/isolation/in_process.rb +21 -4
- data/lib/evilution/mcp/info_tool/status_glossary.rb +2 -2
- data/lib/evilution/mcp/mutate_tool/progress_streamer.rb +2 -1
- data/lib/evilution/memory/leak_check.rb +27 -3
- data/lib/evilution/mutation.rb +7 -2
- data/lib/evilution/mutator/base.rb +49 -5
- data/lib/evilution/mutator/operator/alias_removal.rb +126 -0
- data/lib/evilution/mutator/operator/argument_order_permutation.rb +101 -0
- data/lib/evilution/mutator/operator/argument_removal.rb +9 -1
- data/lib/evilution/mutator/operator/comparison_operand_swap.rb +54 -0
- data/lib/evilution/mutator/operator/data_struct_member.rb +82 -0
- data/lib/evilution/mutator/operator/exception_swallow.rb +84 -0
- data/lib/evilution/mutator/operator/format_specifier_swap.rb +100 -0
- data/lib/evilution/mutator/operator/forwarded_argument_drop.rb +142 -0
- data/lib/evilution/mutator/operator/integer_division_to_fdiv.rb +62 -0
- data/lib/evilution/mutator/operator/keyword_argument.rb +25 -2
- data/lib/evilution/mutator/operator/keyword_value_swap.rb +75 -0
- data/lib/evilution/mutator/operator/no_matching_pattern_else.rb +47 -0
- data/lib/evilution/mutator/operator/numbered_parameter_swap.rb +78 -0
- data/lib/evilution/mutator/operator/off_by_one_boundary.rb +68 -0
- data/lib/evilution/mutator/operator/optional_parameter_to_required.rb +14 -3
- data/lib/evilution/mutator/operator/pattern_matching_array.rb +12 -1
- data/lib/evilution/mutator/operator/pattern_wildcard_widening.rb +117 -0
- data/lib/evilution/mutator/operator/pin_operator_removal.rb +48 -0
- data/lib/evilution/mutator/operator/regex_simplification.rb +53 -132
- data/lib/evilution/mutator/operator/regexp_alternation_branch_deletion.rb +54 -0
- data/lib/evilution/mutator/operator/regexp_anchor_promotion.rb +43 -0
- data/lib/evilution/mutator/operator/regexp_capture_to_passive.rb +61 -0
- data/lib/evilution/mutator/operator/regexp_character_type_complement.rb +54 -0
- data/lib/evilution/mutator/operator/regexp_named_group_rename.rb +106 -0
- data/lib/evilution/mutator/operator/regexp_option_removal.rb +51 -0
- data/lib/evilution/mutator/operator/regexp_quantifier_minimum_swap.rb +45 -0
- data/lib/evilution/mutator/operator/rescue_else_concatenation.rb +62 -0
- data/lib/evilution/mutator/operator/rescue_handler_concatenation.rb +69 -0
- data/lib/evilution/mutator/operator/rescue_handler_promotion.rb +65 -0
- data/lib/evilution/mutator/operator/return_keyword_removal.rb +79 -0
- data/lib/evilution/mutator/operator/rightward_assignment.rb +46 -0
- data/lib/evilution/mutator/operator/send_mutation.rb +2 -0
- data/lib/evilution/mutator/operator/splat_operator.rb +38 -13
- data/lib/evilution/mutator/operator/statement_reorder.rb +153 -0
- data/lib/evilution/mutator/registry.rb +31 -2
- data/lib/evilution/mutator/rescue_handlers.rb +81 -0
- data/lib/evilution/process_supervisor.rb +3 -2
- data/lib/evilution/reporter/cli/line_formatters/baseline_neutralized_notice.rb +55 -0
- data/lib/evilution/reporter/cli/metrics_block.rb +2 -0
- data/lib/evilution/reporter/json/baseline.rb +15 -0
- data/lib/evilution/reporter/json.rb +7 -2
- data/lib/evilution/result/baseline_neutralization.rb +10 -0
- data/lib/evilution/result/mutation_result.rb +9 -1
- data/lib/evilution/result/summary.rb +42 -2
- data/lib/evilution/runner/baseline_runner.rb +9 -4
- data/lib/evilution/runner/canary.rb +2 -57
- data/lib/evilution/runner/canary_failure_message.rb +101 -0
- data/lib/evilution/runner/mutation_executor/neutralizer/baseline_failed.rb +22 -8
- data/lib/evilution/runner/mutation_executor/result_cache.rb +3 -0
- data/lib/evilution/runner/mutation_executor/result_packer.rb +4 -2
- data/lib/evilution/runner/mutation_executor.rb +1 -1
- data/lib/evilution/runner/mutation_planner.rb +11 -2
- data/lib/evilution/runner/report_publisher.rb +3 -2
- data/lib/evilution/runner/subject_pipeline.rb +46 -1
- data/lib/evilution/runner.rb +4 -1
- data/lib/evilution/subject.rb +8 -2
- data/lib/evilution/version.rb +1 -1
- data/lib/evilution.rb +28 -0
- data/script/memory_check +62 -16
- 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
|
|
223
|
-
|
|
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
|
-
|
|
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
|
|
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,
|
|
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
|