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.
- checksums.yaml +4 -4
- data/.beads/interactions.jsonl +58 -0
- data/.rubocop_todo.yml +9 -0
- data/CHANGELOG.md +86 -0
- data/README.md +202 -14
- data/docs/architecture.md +54 -5
- data/docs/isolation.md +3 -4
- data/exe/evil +8 -1
- data/exe/evilution +8 -1
- data/lib/evilution/ast/local_reads.rb +43 -0
- data/lib/evilution/ast/parser.rb +1 -0
- data/lib/evilution/ast/pattern/filter.rb +1 -0
- data/lib/evilution/ast/pattern/parser.rb +1 -0
- data/lib/evilution/ast/pattern.rb +2 -0
- data/lib/evilution/ast/sorbet_sig_detector.rb +1 -0
- data/lib/evilution/baseline.rb +37 -15
- data/lib/evilution/child_output.rb +1 -1
- data/lib/evilution/cli/command.rb +1 -0
- data/lib/evilution/cli/commands/tests_list.rb +4 -3
- data/lib/evilution/cli/commands.rb +2 -0
- data/lib/evilution/cli/dispatcher.rb +2 -0
- data/lib/evilution/cli/exit_guard.rb +61 -0
- data/lib/evilution/cli/parsed_args.rb +2 -0
- data/lib/evilution/cli/parser/command_extractor.rb +2 -0
- data/lib/evilution/cli/parser/file_args.rb +2 -0
- data/lib/evilution/cli/parser/options_builder.rb +4 -0
- data/lib/evilution/cli/parser/stdin_reader.rb +1 -0
- data/lib/evilution/cli/parser.rb +1 -0
- data/lib/evilution/cli/printers/tests_list.rb +5 -4
- data/lib/evilution/cli/printers.rb +2 -0
- data/lib/evilution/cli/result.rb +2 -0
- data/lib/evilution/cli.rb +6 -0
- data/lib/evilution/config/builders.rb +2 -0
- data/lib/evilution/config/env_loader.rb +2 -0
- data/lib/evilution/config/file_loader.rb +1 -0
- data/lib/evilution/config/sources.rb +1 -0
- data/lib/evilution/config/validators/example_targeting_cache.rb +1 -0
- data/lib/evilution/config/validators/example_targeting_fallback.rb +1 -0
- data/lib/evilution/config/validators/example_targeting_strategy.rb +1 -0
- data/lib/evilution/config/validators/fail_fast.rb +1 -0
- data/lib/evilution/config/validators/hooks.rb +1 -0
- data/lib/evilution/config/validators/ignore_patterns.rb +1 -0
- data/lib/evilution/config/validators/integration.rb +1 -0
- data/lib/evilution/config/validators/isolation.rb +1 -0
- data/lib/evilution/config/validators/jobs.rb +1 -0
- data/lib/evilution/config/validators/preload.rb +1 -0
- data/lib/evilution/config/validators/profile.rb +1 -0
- data/lib/evilution/config/validators/spec_mappings.rb +1 -0
- data/lib/evilution/config/validators/spec_pattern.rb +1 -0
- data/lib/evilution/config/validators/warmup.rb +17 -0
- data/lib/evilution/config/validators.rb +2 -0
- data/lib/evilution/config.rb +7 -5
- data/lib/evilution/coverage.rb +1 -1
- data/lib/evilution/coverage_example_filter.rb +1 -1
- data/lib/evilution/diagnostic.rb +22 -0
- data/lib/evilution/example_filter.rb +1 -1
- data/lib/evilution/integration/loading/body_call_neutralizer.rb +10 -2
- data/lib/evilution/integration/loading/concern_state_cleaner.rb +20 -4
- data/lib/evilution/integration/loading/reeval_warning_filter.rb +72 -0
- data/lib/evilution/integration/loading/source_evaluator.rb +4 -1
- data/lib/evilution/integration/loading/test_load_path.rb +21 -13
- data/lib/evilution/integration/minitest.rb +2 -1
- data/lib/evilution/integration/rspec/crash_detector_lifecycle.rb +9 -1
- data/lib/evilution/integration/rspec/state_guard/configuration_state.rb +1 -0
- data/lib/evilution/integration/rspec/state_guard/configuration_streams.rb +5 -1
- data/lib/evilution/integration/rspec/state_guard/example_groups_constants.rb +1 -2
- data/lib/evilution/integration/rspec/state_guard/internals.rb +1 -2
- data/lib/evilution/integration/rspec/state_guard/object_space_example_groups.rb +1 -2
- data/lib/evilution/integration/rspec/state_guard/reporter_arrays.rb +1 -0
- data/lib/evilution/integration/rspec/state_guard/world_example_groups.rb +1 -0
- data/lib/evilution/integration/rspec/state_guard/world_filtered_examples.rb +1 -0
- data/lib/evilution/integration/rspec/state_guard/world_sources_by_path.rb +1 -0
- data/lib/evilution/integration/rspec/state_guard.rb +6 -0
- data/lib/evilution/integration/rspec/unresolved_spec_warner.rb +2 -1
- data/lib/evilution/integration/rspec.rb +48 -2
- data/lib/evilution/integration/test_unit/test_file_resolver.rb +2 -1
- data/lib/evilution/isolation/fork.rb +29 -8
- data/lib/evilution/mcp/info_tool/actions/environment.rb +1 -0
- data/lib/evilution/mcp/info_tool/actions/feedback.rb +1 -0
- data/lib/evilution/mcp/info_tool/actions/statuses.rb +1 -0
- data/lib/evilution/mcp/info_tool/actions/subjects.rb +1 -0
- data/lib/evilution/mcp/info_tool/actions/tests.rb +1 -0
- data/lib/evilution/mutation.rb +1 -1
- data/lib/evilution/mutator/operator/argument_list_removal.rb +52 -0
- data/lib/evilution/mutator/operator/argument_propagation.rb +54 -0
- data/lib/evilution/mutator/operator/array_coercion_to_literal.rb +34 -0
- data/lib/evilution/mutator/operator/attribute_write_to_read.rb +34 -0
- data/lib/evilution/mutator/operator/bang_method.rb +11 -3
- data/lib/evilution/mutator/operator/binary_operand_promotion.rb +63 -0
- data/lib/evilution/mutator/operator/block_body_promotion.rb +45 -0
- data/lib/evilution/mutator/operator/block_body_to_nil.rb +52 -0
- data/lib/evilution/mutator/operator/block_body_to_raise.rb +34 -0
- data/lib/evilution/mutator/operator/block_destructuring_expansion.rb +85 -0
- data/lib/evilution/mutator/operator/block_parameter_drop.rb +96 -0
- data/lib/evilution/mutator/operator/call_to_nil.rb +34 -0
- data/lib/evilution/mutator/operator/coercion_emptying.rb +60 -0
- data/lib/evilution/mutator/operator/collection_replacement.rb +19 -9
- data/lib/evilution/mutator/operator/comparison_replacement.rb +37 -12
- data/lib/evilution/mutator/operator/const_get_to_constant_path.rb +50 -0
- data/lib/evilution/mutator/operator/dig_to_fetch_chain.rb +45 -0
- data/lib/evilution/mutator/operator/double_negation_removal.rb +22 -0
- data/lib/evilution/mutator/operator/dynamic_dispatch_resolution.rb +79 -0
- data/lib/evilution/mutator/operator/forwarding_super_to_explicit.rb +71 -0
- data/lib/evilution/mutator/operator/inequality_to_negated_identity.rb +43 -0
- data/lib/evilution/mutator/operator/keyword_argument_removal.rb +47 -0
- data/lib/evilution/mutator/operator/method_body_replacement.rb +10 -1
- data/lib/evilution/mutator/operator/method_body_to_raise.rb +59 -0
- data/lib/evilution/mutator/operator/method_body_to_super.rb +150 -0
- data/lib/evilution/mutator/operator/optional_default_injection.rb +71 -0
- data/lib/evilution/mutator/operator/optional_parameter_to_required.rb +41 -0
- data/lib/evilution/mutator/operator/proc_to_lambda.rb +43 -0
- data/lib/evilution/mutator/operator/receiver_constructor_swap.rb +51 -0
- data/lib/evilution/mutator/operator/reduce_to_sum.rb +58 -0
- data/lib/evilution/mutator/operator/regexp_anchor_to_predicate.rb +136 -0
- data/lib/evilution/mutator/operator/safe_navigation_removal.rb +67 -0
- data/lib/evilution/mutator/operator/send_mutation.rb +42 -6
- data/lib/evilution/mutator/operator/symbol_to_proc_replacement.rb +59 -0
- data/lib/evilution/mutator/operator/to_i_to_integer.rb +38 -0
- data/lib/evilution/mutator/operator/typed_default_return.rb +84 -0
- data/lib/evilution/mutator/primitives.rb +25 -0
- data/lib/evilution/mutator/registry.rb +32 -1
- data/lib/evilution/parallel/pool.rb +1 -0
- data/lib/evilution/parallel_db_warning.rb +1 -1
- data/lib/evilution/process_supervisor.rb +20 -8
- data/lib/evilution/rails_warmup.rb +61 -0
- data/lib/evilution/reporter/cli/item_formatters/neutral_group.rb +23 -0
- data/lib/evilution/reporter/cli/item_formatters/subject_score.rb +42 -0
- data/lib/evilution/reporter/cli/item_formatters/subject_score_group.rb +16 -0
- data/lib/evilution/reporter/cli/line_formatters/infra_retry_notice.rb +19 -0
- data/lib/evilution/reporter/cli/line_formatters/result_line.rb +27 -3
- data/lib/evilution/reporter/cli/line_formatters/score.rb +19 -1
- data/lib/evilution/reporter/cli/line_formatters/unresolved_targets.rb +35 -0
- data/lib/evilution/reporter/cli/metrics_block.rb +4 -0
- data/lib/evilution/reporter/cli/trailer.rb +11 -7
- data/lib/evilution/reporter/cli.rb +20 -4
- data/lib/evilution/reporter/html/report.rb +1 -0
- data/lib/evilution/reporter/json/subjects.rb +29 -0
- data/lib/evilution/reporter/json.rb +23 -1
- data/lib/evilution/result/coverage_gap_grouper.rb +1 -0
- data/lib/evilution/result/mutation_result.rb +3 -2
- data/lib/evilution/result/neutral_reason.rb +35 -0
- data/lib/evilution/result/subject_score.rb +28 -0
- data/lib/evilution/result/subject_scorer.rb +38 -0
- data/lib/evilution/result/summary.rb +44 -2
- data/lib/evilution/runner/baseline_runner.rb +17 -7
- data/lib/evilution/runner/canary.rb +52 -5
- data/lib/evilution/runner/isolation_resolver.rb +23 -2
- data/lib/evilution/runner/mutation_executor/infra_retry.rb +54 -0
- data/lib/evilution/runner/mutation_executor/neutralizer/baseline_failed.rb +23 -10
- data/lib/evilution/runner/mutation_executor/neutralizer/infra_error.rb +18 -1
- data/lib/evilution/runner/mutation_executor/result_cache.rb +11 -0
- data/lib/evilution/runner/mutation_executor/strategy/parallel.rb +19 -1
- data/lib/evilution/runner/mutation_executor.rb +32 -4
- data/lib/evilution/runner/report_publisher.rb +31 -9
- data/lib/evilution/runner/target_spec_audit.rb +43 -0
- data/lib/evilution/runner.rb +10 -1
- data/lib/evilution/source_ast_cache.rb +1 -1
- data/lib/evilution/spec_ast_cache.rb +1 -1
- data/lib/evilution/version.rb +1 -1
- data/lib/evilution.rb +35 -0
- 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
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
182
|
-
# still running, and -- treating an already-reaped child as
|
|
183
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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:
|
|
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:
|
|
64
|
-
|
|
65
|
-
|
|
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):",
|
|
@@ -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
|