evilution 0.29.0 → 0.30.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 +54 -0
- data/.rubocop_todo.yml +7 -0
- data/CHANGELOG.md +42 -0
- data/README.md +194 -8
- data/docs/versioning.md +53 -0
- data/lib/evilution/ast/heredoc_span.rb +99 -0
- data/lib/evilution/baseline.rb +15 -2
- data/lib/evilution/cli/commands/compare.rb +13 -0
- data/lib/evilution/cli/parser/command_extractor.rb +3 -1
- data/lib/evilution/cli/parser/options_builder.rb +2 -2
- data/lib/evilution/config/file_loader.rb +40 -1
- data/lib/evilution/config.rb +11 -1
- data/lib/evilution/equivalent/heuristic/dead_code.rb +8 -1
- data/lib/evilution/feedback/setup_warning.rb +79 -0
- data/lib/evilution/gem_detector.rb +132 -0
- data/lib/evilution/integration/loading/body_call_neutralizer.rb +190 -0
- data/lib/evilution/integration/loading/mutation_applier.rb +20 -5
- data/lib/evilution/integration/loading/redefinition_recovery.rb +58 -1
- data/lib/evilution/integration/minitest.rb +37 -2
- data/lib/evilution/integration/rspec/result_builder.rb +20 -1
- data/lib/evilution/integration/rspec.rb +16 -1
- data/lib/evilution/isolation/fork.rb +77 -10
- data/lib/evilution/mcp/info_tool/response_formatter.rb +14 -1
- data/lib/evilution/mcp/info_tool.rb +3 -1
- data/lib/evilution/mcp/mutate_tool/option_parser.rb +1 -1
- data/lib/evilution/mcp/mutate_tool/report_trimmer.rb +58 -1
- data/lib/evilution/mcp/mutate_tool.rb +22 -3
- data/lib/evilution/mcp/session_tool.rb +7 -4
- data/lib/evilution/mcp.rb +6 -0
- data/lib/evilution/mutation.rb +13 -1
- data/lib/evilution/mutator/base.rb +49 -1
- data/lib/evilution/mutator/operator/argument_method_call_replacement.rb +59 -0
- data/lib/evilution/mutator/operator/block_param_removal.rb +32 -0
- data/lib/evilution/mutator/operator/explicit_super_mutation.rb +20 -2
- data/lib/evilution/mutator/operator/index_to_at.rb +13 -1
- data/lib/evilution/mutator/operator/last_expression_removal.rb +46 -0
- data/lib/evilution/mutator/operator/receiver_replacement.rb +29 -1
- data/lib/evilution/mutator/operator/rescue_removal.rb +59 -10
- data/lib/evilution/mutator/operator/splat_operator.rb +28 -1
- data/lib/evilution/mutator/operator/string_literal.rb +83 -6
- data/lib/evilution/mutator/registry.rb +2 -0
- data/lib/evilution/reporter/cli/line_formatters/error_rate_warning.rb +29 -0
- data/lib/evilution/reporter/cli/metrics_block.rb +2 -0
- data/lib/evilution/reporter/json.rb +2 -0
- data/lib/evilution/result/mutation_result.rb +12 -6
- data/lib/evilution/runner/baseline_runner.rb +5 -1
- data/lib/evilution/runner/isolation_resolver.rb +69 -8
- data/lib/evilution/runner/mutation_planner.rb +18 -1
- data/lib/evilution/session/schema.rb +44 -0
- data/lib/evilution/session/store.rb +5 -1
- data/lib/evilution/version.rb +1 -1
- data/lib/evilution.rb +2 -0
- data/schema/evilution.config.schema.json +205 -0
- data/script/build_runtime_snapshot +88 -0
- data/script/run_self_baseline +79 -0
- data/script/run_self_validation +54 -0
- metadata +15 -2
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
require_relative "../loading"
|
|
5
|
+
|
|
6
|
+
# Strip non-idempotent class/module-body side-effect calls from a mutated
|
|
7
|
+
# source before re-eval. Such calls (e.g. dry-monads `register_mixin`, plugin
|
|
8
|
+
# registries) raise on second invocation because they assume single-eval
|
|
9
|
+
# semantics. The first invocation already ran in the parent process during
|
|
10
|
+
# preload — the child fork inherits the resulting state, so re-running them
|
|
11
|
+
# is wasted work that aborts the eval before the mutated method takes effect.
|
|
12
|
+
#
|
|
13
|
+
# Strategy: walk Prism tree, find CallNodes that sit directly under a class
|
|
14
|
+
# or module body (not inside a def). Calls on a small allowlist of patterns
|
|
15
|
+
# known to be idempotent (`include`, `attr_*`, visibility modifiers, etc.)
|
|
16
|
+
# are preserved; everything else is replaced byte-for-byte with `nil`.
|
|
17
|
+
class Evilution::Integration::Loading::BodyCallNeutralizer
|
|
18
|
+
IDEMPOTENT_CALLS = %i[
|
|
19
|
+
include extend prepend using
|
|
20
|
+
attr_reader attr_writer attr_accessor
|
|
21
|
+
private public protected module_function private_class_method public_class_method
|
|
22
|
+
alias_method
|
|
23
|
+
define_method define_singleton_method
|
|
24
|
+
delegate
|
|
25
|
+
require require_relative autoload
|
|
26
|
+
].to_set.freeze
|
|
27
|
+
|
|
28
|
+
class << self
|
|
29
|
+
attr_writer :preloaded_features
|
|
30
|
+
|
|
31
|
+
# Snapshot of `$LOADED_FEATURES` captured at parent preload-end (or lazily
|
|
32
|
+
# initialised on first access). Forks inherit this set via copy-on-write,
|
|
33
|
+
# so worker processes see the same membership the parent saw when it
|
|
34
|
+
# finished its preload phase. Frozen so in-place mutation cannot silently
|
|
35
|
+
# change neutralization semantics or force child forks to copy the page.
|
|
36
|
+
def preloaded_features
|
|
37
|
+
@preloaded_features ||= $LOADED_FEATURES.to_set.freeze
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def reset_preload_snapshot!
|
|
41
|
+
@preloaded_features = nil
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# `file_path` (optional) lets callers gate neutralization on whether the
|
|
46
|
+
# target file was actually preloaded into the parent. The neutralizer's
|
|
47
|
+
# premise — "this body has already run once, re-running it would double-
|
|
48
|
+
# register" — only holds when the parent loaded the file. Lazy-loaded
|
|
49
|
+
# plugin files (e.g. roda's `lib/roda/plugins/typecast_params.rb`, which
|
|
50
|
+
# the gem only requires when the user opts in via `plugin :typecast_params`)
|
|
51
|
+
# are first-loaded inside the child fork, so neutralizing their DSL calls
|
|
52
|
+
# strips method definitions that subsequent sibling statements (alias, etc.)
|
|
53
|
+
# depend on, producing cascading NameError. Callers that don't pass a path
|
|
54
|
+
# get the legacy always-neutralize behavior.
|
|
55
|
+
def call(source, file_path: nil)
|
|
56
|
+
return source if file_path && !preloaded?(file_path)
|
|
57
|
+
|
|
58
|
+
result = Prism.parse(source)
|
|
59
|
+
return source if result.failure?
|
|
60
|
+
|
|
61
|
+
edits = collect_edits(result.value)
|
|
62
|
+
return source if edits.empty?
|
|
63
|
+
|
|
64
|
+
apply_edits(source, edits)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
def preloaded?(file_path)
|
|
70
|
+
self.class.preloaded_features.include?(File.expand_path(file_path))
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def collect_edits(tree)
|
|
74
|
+
edits = []
|
|
75
|
+
walker = Walker.new(IDEMPOTENT_CALLS, edits)
|
|
76
|
+
walker.visit(tree)
|
|
77
|
+
edits
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def apply_edits(source, edits)
|
|
81
|
+
bytes = source.b
|
|
82
|
+
edits.sort_by!(&:first).reverse_each do |start_offset, end_offset|
|
|
83
|
+
bytes[start_offset, end_offset - start_offset] = "nil"
|
|
84
|
+
end
|
|
85
|
+
bytes.force_encoding(source.encoding)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
class Walker < Prism::Visitor
|
|
89
|
+
def initialize(allowlist, edits)
|
|
90
|
+
super()
|
|
91
|
+
@allowlist = allowlist
|
|
92
|
+
@edits = edits
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def visit_class_node(node)
|
|
96
|
+
scan_body(node.body)
|
|
97
|
+
super
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def visit_module_node(node)
|
|
101
|
+
scan_body(node.body)
|
|
102
|
+
super
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def visit_singleton_class_node(node)
|
|
106
|
+
scan_body(node.body)
|
|
107
|
+
super
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
private
|
|
111
|
+
|
|
112
|
+
# Examine top-level statements in a class/module body. Only direct-child
|
|
113
|
+
# CallNodes are candidates for neutralization. Calls nested inside any
|
|
114
|
+
# other expression (constant assignments, conditionals, etc.) are left
|
|
115
|
+
# alone — neutralizing them would break the surrounding expression.
|
|
116
|
+
# Nested class/module bodies are walked through the normal visitor.
|
|
117
|
+
def scan_body(body_node)
|
|
118
|
+
return unless body_node.is_a?(Prism::StatementsNode)
|
|
119
|
+
|
|
120
|
+
body_node.body.each do |stmt|
|
|
121
|
+
next unless stmt.is_a?(Prism::CallNode)
|
|
122
|
+
next if @allowlist.include?(stmt.name)
|
|
123
|
+
next if stmt.receiver && !stmt.receiver.is_a?(Prism::SelfNode)
|
|
124
|
+
|
|
125
|
+
@edits << [stmt.location.start_offset, replacement_end_offset(stmt)]
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Prism CallNode location ends at the close of the call syntax (e.g. the
|
|
130
|
+
# closing `)` or the `<<~MARKER` opener for a heredoc argument). It does
|
|
131
|
+
# NOT include the heredoc body lines or the trailing terminator. Replacing
|
|
132
|
+
# only the CallNode range leaves the heredoc body orphaned, producing a
|
|
133
|
+
# parse error. Extend the range to cover any heredoc terminators inside
|
|
134
|
+
# the call.
|
|
135
|
+
def replacement_end_offset(call)
|
|
136
|
+
collector = HeredocEndCollector.new
|
|
137
|
+
collector.visit(call)
|
|
138
|
+
[call.location.end_offset, *collector.end_offsets].max
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
private_constant :Walker
|
|
142
|
+
|
|
143
|
+
class HeredocEndCollector < Prism::Visitor
|
|
144
|
+
attr_reader :end_offsets
|
|
145
|
+
|
|
146
|
+
def initialize
|
|
147
|
+
super
|
|
148
|
+
@end_offsets = []
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def visit_string_node(node)
|
|
152
|
+
record_if_heredoc(node)
|
|
153
|
+
super
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def visit_interpolated_string_node(node)
|
|
157
|
+
record_if_heredoc(node)
|
|
158
|
+
super
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def visit_interpolated_x_string_node(node)
|
|
162
|
+
record_if_heredoc(node)
|
|
163
|
+
super
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def visit_x_string_node(node)
|
|
167
|
+
record_if_heredoc(node)
|
|
168
|
+
super
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
private
|
|
172
|
+
|
|
173
|
+
def record_if_heredoc(node)
|
|
174
|
+
return unless node.respond_to?(:heredoc?) && node.heredoc?
|
|
175
|
+
|
|
176
|
+
closing = node.closing_loc
|
|
177
|
+
return unless closing
|
|
178
|
+
|
|
179
|
+
# Prism's heredoc closing_loc includes the leading whitespace and the
|
|
180
|
+
# trailing newline of the terminator line (e.g. " CODE\n"). Excluding
|
|
181
|
+
# that newline preserves line structure so subsequent code lands on its
|
|
182
|
+
# own line after the replacement.
|
|
183
|
+
end_off = closing.end_offset
|
|
184
|
+
slice = closing.slice
|
|
185
|
+
end_off -= 1 if slice && slice.end_with?("\n")
|
|
186
|
+
@end_offsets << end_off
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
private_constant :HeredocEndCollector
|
|
190
|
+
end
|
|
@@ -10,7 +10,15 @@ require_relative "redefinition_recovery"
|
|
|
10
10
|
# Composes the load-time pipeline that applies a mutation's new source to the
|
|
11
11
|
# running VM: syntax-validate -> pin top-level constants (beats Zeitwerk) ->
|
|
12
12
|
# clear AS::Concern state -> eval inside a redefinition-recovery wrapper.
|
|
13
|
-
#
|
|
13
|
+
# The eval target is mutation.eval_source, which Mutator::Base pre-populates
|
|
14
|
+
# with the neutralized form (non-idempotent class-body calls replaced with
|
|
15
|
+
# `nil`). The neutralization itself happens once at mutation-generation time
|
|
16
|
+
# rather than per-iter — SyntaxValidator still runs Prism per mutation, but
|
|
17
|
+
# the extra neutralizer parse stays out of the hot path. Falls back to
|
|
18
|
+
# mutation.mutated_source when no pre-eval transform was attached.
|
|
19
|
+
# RedefinitionRecovery stays as a safety net for cases the neutralizer's
|
|
20
|
+
# allowlist heuristic misses. Returns nil on success or a failure-shaped
|
|
21
|
+
# hash on any error.
|
|
14
22
|
class Evilution::Integration::Loading::MutationApplier
|
|
15
23
|
def initialize(syntax_validator: Evilution::Integration::Loading::SyntaxValidator.new,
|
|
16
24
|
constant_pinner: Evilution::Integration::Loading::ConstantPinner.new,
|
|
@@ -25,10 +33,11 @@ class Evilution::Integration::Loading::MutationApplier
|
|
|
25
33
|
end
|
|
26
34
|
|
|
27
35
|
def call(mutation)
|
|
28
|
-
|
|
36
|
+
eval_target = resolve_eval_target(mutation)
|
|
37
|
+
syntax_error = @syntax_validator.call(eval_target)
|
|
29
38
|
return syntax_error if syntax_error
|
|
30
39
|
|
|
31
|
-
apply(mutation)
|
|
40
|
+
apply(mutation, eval_target)
|
|
32
41
|
nil
|
|
33
42
|
rescue SyntaxError => e
|
|
34
43
|
failure_result(e, "syntax error in mutated source: #{e.message}")
|
|
@@ -38,11 +47,17 @@ class Evilution::Integration::Loading::MutationApplier
|
|
|
38
47
|
|
|
39
48
|
private
|
|
40
49
|
|
|
41
|
-
def
|
|
50
|
+
def resolve_eval_target(mutation)
|
|
51
|
+
return mutation.eval_source if mutation.respond_to?(:eval_source)
|
|
52
|
+
|
|
53
|
+
mutation.mutated_source
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def apply(mutation, eval_target)
|
|
42
57
|
@constant_pinner.call(mutation.original_source)
|
|
43
58
|
@concern_state_cleaner.call(mutation.file_path)
|
|
44
59
|
@redefinition_recovery.call(mutation.original_source) do
|
|
45
|
-
@source_evaluator.call(
|
|
60
|
+
@source_evaluator.call(eval_target, mutation.file_path)
|
|
46
61
|
end
|
|
47
62
|
end
|
|
48
63
|
|
|
@@ -6,7 +6,22 @@ require_relative "../../ast/constant_names"
|
|
|
6
6
|
# Some DSLs (Rails 8 enum, define_method guards) raise ArgumentError on
|
|
7
7
|
# re-declaration. On such a conflict we strip constants declared in the source
|
|
8
8
|
# and retry the load once against a fresh namespace.
|
|
9
|
+
#
|
|
10
|
+
# A second class of idempotency violation comes from gem-internal registries
|
|
11
|
+
# (dry-monads `register_mixin`, Rails plugins, etc.) which raise when called
|
|
12
|
+
# a second time in the same process. For these we swallow the error: the
|
|
13
|
+
# class body executed up to the raise point — method defs preceding the
|
|
14
|
+
# registry call are already in place — and any state change blocked by the
|
|
15
|
+
# guard was intentional duplicate-prevention from the gem's side. Mutations
|
|
16
|
+
# that target a def *after* such a class-body call would not be applied;
|
|
17
|
+
# emit a one-shot warning so that mode is visible.
|
|
9
18
|
class Evilution::Integration::Loading::RedefinitionRecovery
|
|
19
|
+
IDEMPOTENCY_PATTERNS = [
|
|
20
|
+
"already registered",
|
|
21
|
+
"already initialized",
|
|
22
|
+
"already exists"
|
|
23
|
+
].freeze
|
|
24
|
+
|
|
10
25
|
def initialize(constant_names: Evilution::AST::ConstantNames.new)
|
|
11
26
|
@constant_names = constant_names
|
|
12
27
|
end
|
|
@@ -14,8 +29,28 @@ class Evilution::Integration::Loading::RedefinitionRecovery
|
|
|
14
29
|
def call(source, &block)
|
|
15
30
|
block.call
|
|
16
31
|
rescue ArgumentError => e
|
|
17
|
-
|
|
32
|
+
if redefinition_conflict?(e)
|
|
33
|
+
remove_defined_constants(source)
|
|
34
|
+
block.call
|
|
35
|
+
elsif idempotency_violation?(e)
|
|
36
|
+
warn_once_for(e)
|
|
37
|
+
nil
|
|
38
|
+
else
|
|
39
|
+
raise
|
|
40
|
+
end
|
|
41
|
+
rescue TypeError => e
|
|
42
|
+
raise unless superclass_mismatch?(e)
|
|
18
43
|
|
|
44
|
+
# `class X < Struct.new(...)` (or Data.define / Class.new in the same
|
|
45
|
+
# position) returns a fresh anonymous parent class on every call, so the
|
|
46
|
+
# recorded superclass of the existing X differs from the re-eval's. Ruby
|
|
47
|
+
# raises TypeError. Simply swallowing would leave the *original* class
|
|
48
|
+
# in place and silently report the mutation as survived — a false
|
|
49
|
+
# negative. Instead, strip the constants this source declares and retry
|
|
50
|
+
# exactly once, mirroring the ArgumentError 'already defined' path. If
|
|
51
|
+
# the retry still mismatches (genuine inheritance conflict the mutation
|
|
52
|
+
# cannot resolve), propagate so the mutation reports :error rather than
|
|
53
|
+
# being silently miscounted.
|
|
19
54
|
remove_defined_constants(source)
|
|
20
55
|
block.call
|
|
21
56
|
end
|
|
@@ -26,6 +61,28 @@ class Evilution::Integration::Loading::RedefinitionRecovery
|
|
|
26
61
|
error.message.include?("already defined")
|
|
27
62
|
end
|
|
28
63
|
|
|
64
|
+
def idempotency_violation?(error)
|
|
65
|
+
msg = error.message
|
|
66
|
+
IDEMPOTENCY_PATTERNS.any? { |pat| msg.include?(pat) }
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def superclass_mismatch?(error)
|
|
70
|
+
error.message.include?("superclass mismatch")
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def warn_once_for(error)
|
|
74
|
+
return if @warned_messages&.include?(error.message)
|
|
75
|
+
|
|
76
|
+
@warned_messages ||= []
|
|
77
|
+
@warned_messages << error.message
|
|
78
|
+
$stderr.write(
|
|
79
|
+
"[evilution] swallowed idempotency violation on re-eval: " \
|
|
80
|
+
"#{error.class}: #{error.message}. " \
|
|
81
|
+
"Method defs preceding the raise point were re-applied; " \
|
|
82
|
+
"mutations targeting code after the raise will not take effect.\n"
|
|
83
|
+
)
|
|
84
|
+
end
|
|
85
|
+
|
|
29
86
|
def remove_defined_constants(source)
|
|
30
87
|
@constant_names.call(source).reverse_each do |name|
|
|
31
88
|
parent_name, _, local_name = name.rpartition("::")
|
|
@@ -31,12 +31,42 @@ class Evilution::Integration::Minitest < Evilution::Integration::Base
|
|
|
31
31
|
options[:io] = out
|
|
32
32
|
reporter = ::Minitest::CompositeReporter.new
|
|
33
33
|
reporter << ::Minitest::SummaryReporter.new(out, options)
|
|
34
|
+
initialize_minitest_state(reporter, options)
|
|
34
35
|
reporter.start
|
|
35
|
-
|
|
36
|
+
dispatch_minitest_suites(reporter, options)
|
|
36
37
|
reporter.report
|
|
37
38
|
reporter.passed?
|
|
38
39
|
end
|
|
39
40
|
|
|
41
|
+
# Mirror Minitest.run's preamble: seed setup + plugin init. Without seeding
|
|
42
|
+
# Minitest.seed before dispatching suites, Minitest 5.x raises
|
|
43
|
+
# `TypeError: no implicit conversion of nil into Integer` from
|
|
44
|
+
# Minitest::Test.runnable_methods calling `srand(Minitest.seed)` on nil.
|
|
45
|
+
# init_plugins also needs Minitest.reporter set first because some plugins
|
|
46
|
+
# (pride) read it during init.
|
|
47
|
+
def self.initialize_minitest_state(reporter, options)
|
|
48
|
+
::Minitest.seed = options[:seed]
|
|
49
|
+
srand(::Minitest.seed) if ::Minitest.seed
|
|
50
|
+
|
|
51
|
+
::Minitest.reporter = reporter
|
|
52
|
+
::Minitest.init_plugins(options) if ::Minitest.respond_to?(:init_plugins)
|
|
53
|
+
::Minitest.reporter = nil
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Dispatch to the version-appropriate suite runner. Minitest 6 removed
|
|
57
|
+
# ::Minitest.__run; the equivalent public entry point is run_all_suites.
|
|
58
|
+
# Minitest 5.x still exposes __run.
|
|
59
|
+
def self.dispatch_minitest_suites(reporter, options)
|
|
60
|
+
if ::Minitest.respond_to?(:run_all_suites)
|
|
61
|
+
::Minitest.run_all_suites(reporter, options)
|
|
62
|
+
elsif ::Minitest.respond_to?(:__run)
|
|
63
|
+
::Minitest.__run(reporter, options)
|
|
64
|
+
else
|
|
65
|
+
raise Evilution::Error,
|
|
66
|
+
"Minitest #{::Minitest::VERSION} has neither run_all_suites nor __run"
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
40
70
|
def self.baseline_options
|
|
41
71
|
{
|
|
42
72
|
runner: baseline_runner,
|
|
@@ -116,13 +146,18 @@ class Evilution::Integration::Minitest < Evilution::Integration::Base
|
|
|
116
146
|
reporter << ::Minitest::SummaryReporter.new(out, options)
|
|
117
147
|
reporter << detector
|
|
118
148
|
|
|
149
|
+
self.class.initialize_minitest_state(reporter, options)
|
|
119
150
|
reporter.start
|
|
120
|
-
|
|
151
|
+
dispatch_minitest_suites(reporter, options)
|
|
121
152
|
reporter.report
|
|
122
153
|
|
|
123
154
|
reporter.passed?
|
|
124
155
|
end
|
|
125
156
|
|
|
157
|
+
def dispatch_minitest_suites(reporter, options)
|
|
158
|
+
self.class.dispatch_minitest_suites(reporter, options)
|
|
159
|
+
end
|
|
160
|
+
|
|
126
161
|
def reset_crash_detector
|
|
127
162
|
if @crash_detector
|
|
128
163
|
@crash_detector.reset
|
|
@@ -21,7 +21,7 @@ class Evilution::Integration::RSpec::ResultBuilder
|
|
|
21
21
|
}
|
|
22
22
|
end
|
|
23
23
|
|
|
24
|
-
def from_run(status, command, detector)
|
|
24
|
+
def from_run(status, command, detector, examples_loaded: nil)
|
|
25
25
|
return { passed: true, test_command: command } if status.zero?
|
|
26
26
|
|
|
27
27
|
if detector.only_crashes?
|
|
@@ -35,6 +35,25 @@ class Evilution::Integration::RSpec::ResultBuilder
|
|
|
35
35
|
}
|
|
36
36
|
end
|
|
37
37
|
|
|
38
|
+
# Nonzero exit + zero examples loaded = the spec file did not register any
|
|
39
|
+
# examples (load error, autoload mismatch, etc.), so nothing ran against
|
|
40
|
+
# the mutation. Surfacing this as a generic fail would let classify_status
|
|
41
|
+
# fall through to its :killed default and silently inflate the kill count
|
|
42
|
+
# even though no example ever observed the mutation (EV-720r: macOS Rails
|
|
43
|
+
# users hit autoload / fail_if_no_examples paths that yielded killed=100%
|
|
44
|
+
# with empty worker output). Note: this checks LOADED count, not executed
|
|
45
|
+
# count — filters/skip/--fail-fast can leave loaded > executed, but the
|
|
46
|
+
# "spec failed to load entirely" case is the failure mode this guard targets.
|
|
47
|
+
if !examples_loaded.nil? && examples_loaded.zero?
|
|
48
|
+
return {
|
|
49
|
+
passed: false,
|
|
50
|
+
error: "RSpec exited #{status} but loaded 0 examples — no examples ran against the mutation. " \
|
|
51
|
+
"Likely the spec file failed to load, --spec was misrouted, or RSpec is configured " \
|
|
52
|
+
"with fail_if_no_examples. The mutation cannot be counted as killed.",
|
|
53
|
+
test_command: command
|
|
54
|
+
}
|
|
55
|
+
end
|
|
56
|
+
|
|
38
57
|
{ passed: false, test_command: command }
|
|
39
58
|
end
|
|
40
59
|
end
|
|
@@ -82,7 +82,7 @@ class Evilution::Integration::RSpec < Evilution::Integration::Base
|
|
|
82
82
|
snapshot = @state_guard.snapshot
|
|
83
83
|
begin
|
|
84
84
|
status = ::RSpec::Core::Runner.run(args, StringIO.new, StringIO.new)
|
|
85
|
-
@result_builder.from_run(status, command, detector)
|
|
85
|
+
@result_builder.from_run(status, command, detector, examples_loaded:)
|
|
86
86
|
rescue StandardError => e
|
|
87
87
|
{ passed: false, error: e.message, test_command: command }
|
|
88
88
|
ensure
|
|
@@ -90,6 +90,21 @@ class Evilution::Integration::RSpec < Evilution::Integration::Base
|
|
|
90
90
|
end
|
|
91
91
|
end
|
|
92
92
|
|
|
93
|
+
# Count of examples loaded into RSpec.world by this run. Used to distinguish
|
|
94
|
+
# "test failed → mutation killed" (positive count, nonzero status) from
|
|
95
|
+
# "spec file failed to load / RSpec returned nonzero with nothing observed"
|
|
96
|
+
# (zero count, nonzero status). The latter must NOT be classified as killed
|
|
97
|
+
# — silently counting it would inflate scores with mutations no example
|
|
98
|
+
# actually saw (EV-720r).
|
|
99
|
+
def examples_loaded
|
|
100
|
+
world = ::RSpec.world
|
|
101
|
+
return nil unless world.respond_to?(:example_count)
|
|
102
|
+
|
|
103
|
+
world.example_count
|
|
104
|
+
rescue StandardError
|
|
105
|
+
nil
|
|
106
|
+
end
|
|
107
|
+
|
|
93
108
|
def reset_examples
|
|
94
109
|
::RSpec.respond_to?(:clear_examples) ? ::RSpec.clear_examples : ::RSpec.reset
|
|
95
110
|
end
|
|
@@ -52,7 +52,9 @@ class Evilution::Isolation::Fork
|
|
|
52
52
|
suppress_child_output
|
|
53
53
|
@hooks.fire(:worker_process_start, mutation:) if @hooks
|
|
54
54
|
result = execute_in_child(mutation, test_command)
|
|
55
|
-
Marshal.dump(result
|
|
55
|
+
payload = Marshal.dump(result)
|
|
56
|
+
write_io.write([payload.bytesize].pack("N"))
|
|
57
|
+
write_io.write(payload)
|
|
56
58
|
write_io.close
|
|
57
59
|
exit!(result[:passed] ? 0 : 1)
|
|
58
60
|
end
|
|
@@ -91,20 +93,85 @@ class Evilution::Isolation::Fork
|
|
|
91
93
|
}
|
|
92
94
|
end
|
|
93
95
|
|
|
96
|
+
# Length-prefixed read with waitpid polling. Subject specs that exercise
|
|
97
|
+
# Process.fork inside test_command leave a grandchild that inherits write_io
|
|
98
|
+
# via fork — if the grandchild outlives the child, a plain `read_io.read`
|
|
99
|
+
# never sees EOF and hangs forever. The length prefix makes payload reads
|
|
100
|
+
# bounded; the waitpid-WNOHANG check inside the poll loop lets us exit
|
|
101
|
+
# promptly when the child died without writing anything.
|
|
94
102
|
def wait_for_result(pid, read_io, timeout)
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
103
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
|
|
104
|
+
loop do
|
|
105
|
+
remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
106
|
+
return timeout_result(pid) if remaining <= 0
|
|
107
|
+
|
|
108
|
+
if read_io.wait_readable([remaining, 0.5].min)
|
|
109
|
+
payload = read_payload(read_io, deadline)
|
|
110
|
+
return reap_and_decode(pid, payload) if payload
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
next unless ::Process.waitpid(pid, ::Process::WNOHANG)
|
|
114
|
+
|
|
115
|
+
# Child exited. Drain any final payload that arrived between
|
|
116
|
+
# wait_readable timeout and waitpid (race) before declaring empty.
|
|
117
|
+
final = read_payload(read_io, Process.clock_gettime(Process::CLOCK_MONOTONIC) + 0.1)
|
|
118
|
+
return decode_payload(final) if final
|
|
119
|
+
|
|
120
|
+
return empty_result
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def reap_and_decode(pid, payload)
|
|
125
|
+
::Process.wait(pid)
|
|
126
|
+
decode_payload(payload)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def read_payload(read_io, deadline)
|
|
130
|
+
header = read_n_bytes(read_io, 4, deadline)
|
|
131
|
+
return nil unless header
|
|
132
|
+
|
|
133
|
+
size = header.unpack1("N")
|
|
134
|
+
read_n_bytes(read_io, size, deadline)
|
|
135
|
+
end
|
|
98
136
|
|
|
99
|
-
|
|
100
|
-
|
|
137
|
+
# Bounded non-blocking read. Returns `count` bytes or nil on EOF / deadline.
|
|
138
|
+
# Uses `read_nonblock` so a child that wrote a partial frame (e.g. wrote the
|
|
139
|
+
# header then died with a grandchild keeping write_io open) cannot extend
|
|
140
|
+
# past the polling deadline.
|
|
141
|
+
def read_n_bytes(read_io, count, deadline)
|
|
142
|
+
return "" if count.zero?
|
|
143
|
+
|
|
144
|
+
buf = +""
|
|
145
|
+
while buf.bytesize < count
|
|
146
|
+
remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
147
|
+
return nil if remaining <= 0
|
|
148
|
+
|
|
149
|
+
chunk = read_io.read_nonblock(count - buf.bytesize, exception: false)
|
|
150
|
+
case chunk
|
|
151
|
+
when :wait_readable
|
|
152
|
+
return nil unless read_io.wait_readable([remaining, 0.5].min)
|
|
153
|
+
when nil
|
|
154
|
+
return nil
|
|
101
155
|
else
|
|
102
|
-
|
|
156
|
+
buf << chunk
|
|
103
157
|
end
|
|
104
|
-
else
|
|
105
|
-
terminate_child(pid)
|
|
106
|
-
{ timeout: true }
|
|
107
158
|
end
|
|
159
|
+
buf
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def decode_payload(data)
|
|
163
|
+
return empty_result if data.nil? || data.empty?
|
|
164
|
+
|
|
165
|
+
{ timeout: false }.merge(Marshal.load(data))
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def empty_result
|
|
169
|
+
{ timeout: false, passed: false, error: "empty result from child" }
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def timeout_result(pid)
|
|
173
|
+
terminate_child(pid)
|
|
174
|
+
{ timeout: true }
|
|
108
175
|
end
|
|
109
176
|
|
|
110
177
|
# Defensive reap: if normal control flow raised before wait_for_result
|
|
@@ -7,10 +7,23 @@ require_relative "error_mapper"
|
|
|
7
7
|
module Evilution::MCP::InfoTool::ResponseFormatter
|
|
8
8
|
module_function
|
|
9
9
|
|
|
10
|
+
# Wraps the payload as a successful MCP text response and injects the
|
|
11
|
+
# outer-envelope schema_version so agents that cache contracts can detect
|
|
12
|
+
# incompatible servers. Existing schema_version keys in the payload are
|
|
13
|
+
# preserved (e.g. session JSON keeps its own schema_version unchanged).
|
|
10
14
|
def success(payload)
|
|
11
|
-
|
|
15
|
+
versioned = inject_schema_version(payload)
|
|
16
|
+
::MCP::Tool::Response.new([{ type: "text", text: ::JSON.generate(versioned) }])
|
|
12
17
|
end
|
|
13
18
|
|
|
19
|
+
def inject_schema_version(payload)
|
|
20
|
+
return payload unless payload.is_a?(Hash)
|
|
21
|
+
return payload if payload.key?("schema_version") || payload.key?(:schema_version)
|
|
22
|
+
|
|
23
|
+
{ "schema_version" => Evilution::MCP::CONTRACT_VERSION }.merge(payload)
|
|
24
|
+
end
|
|
25
|
+
private_class_method :inject_schema_version
|
|
26
|
+
|
|
14
27
|
def error(type, message)
|
|
15
28
|
::MCP::Tool::Response.new(
|
|
16
29
|
[{ type: "text", text: ::JSON.generate({ error: { type: type, message: message } }) }],
|
|
@@ -27,7 +27,9 @@ class Evilution::MCP::InfoTool < MCP::Tool
|
|
|
27
27
|
"'feedback' returns the public discussion URL plus consent and privacy guidance for posting " \
|
|
28
28
|
"feedback on errors, usage problems, friction, or missing capabilities. " \
|
|
29
29
|
"Use this instead of shelling out to 'evilution subjects', 'evilution tests list', or 'evilution environment show' — " \
|
|
30
|
-
"the response is structured JSON so you can plan the next mutation run without parsing CLI text."
|
|
30
|
+
"the response is structured JSON so you can plan the next mutation run without parsing CLI text. " \
|
|
31
|
+
"Contract: input schema, action enum, and per-action output shapes are stable for the 1.x line; " \
|
|
32
|
+
"see README \"MCP Server\" section for the full deprecation policy."
|
|
31
33
|
input_schema(
|
|
32
34
|
properties: {
|
|
33
35
|
action: {
|
|
@@ -5,7 +5,7 @@ require_relative "../mutate_tool"
|
|
|
5
5
|
module Evilution::MCP::MutateTool::OptionParser
|
|
6
6
|
VALID_VERBOSITIES = %w[full summary minimal].freeze
|
|
7
7
|
PASSTHROUGH_KEYS = %i[target timeout jobs fail_fast suggest_tests incremental integration
|
|
8
|
-
isolation baseline save_session].freeze
|
|
8
|
+
isolation baseline save_session preload].freeze
|
|
9
9
|
ALLOWED_OPT_KEYS = (PASSTHROUGH_KEYS + %i[spec skip_config]).freeze
|
|
10
10
|
|
|
11
11
|
ParsedPaths = Data.define(:files, :ranges)
|