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.
Files changed (58) hide show
  1. checksums.yaml +4 -4
  2. data/.beads/interactions.jsonl +54 -0
  3. data/.rubocop_todo.yml +7 -0
  4. data/CHANGELOG.md +42 -0
  5. data/README.md +194 -8
  6. data/docs/versioning.md +53 -0
  7. data/lib/evilution/ast/heredoc_span.rb +99 -0
  8. data/lib/evilution/baseline.rb +15 -2
  9. data/lib/evilution/cli/commands/compare.rb +13 -0
  10. data/lib/evilution/cli/parser/command_extractor.rb +3 -1
  11. data/lib/evilution/cli/parser/options_builder.rb +2 -2
  12. data/lib/evilution/config/file_loader.rb +40 -1
  13. data/lib/evilution/config.rb +11 -1
  14. data/lib/evilution/equivalent/heuristic/dead_code.rb +8 -1
  15. data/lib/evilution/feedback/setup_warning.rb +79 -0
  16. data/lib/evilution/gem_detector.rb +132 -0
  17. data/lib/evilution/integration/loading/body_call_neutralizer.rb +190 -0
  18. data/lib/evilution/integration/loading/mutation_applier.rb +20 -5
  19. data/lib/evilution/integration/loading/redefinition_recovery.rb +58 -1
  20. data/lib/evilution/integration/minitest.rb +37 -2
  21. data/lib/evilution/integration/rspec/result_builder.rb +20 -1
  22. data/lib/evilution/integration/rspec.rb +16 -1
  23. data/lib/evilution/isolation/fork.rb +77 -10
  24. data/lib/evilution/mcp/info_tool/response_formatter.rb +14 -1
  25. data/lib/evilution/mcp/info_tool.rb +3 -1
  26. data/lib/evilution/mcp/mutate_tool/option_parser.rb +1 -1
  27. data/lib/evilution/mcp/mutate_tool/report_trimmer.rb +58 -1
  28. data/lib/evilution/mcp/mutate_tool.rb +22 -3
  29. data/lib/evilution/mcp/session_tool.rb +7 -4
  30. data/lib/evilution/mcp.rb +6 -0
  31. data/lib/evilution/mutation.rb +13 -1
  32. data/lib/evilution/mutator/base.rb +49 -1
  33. data/lib/evilution/mutator/operator/argument_method_call_replacement.rb +59 -0
  34. data/lib/evilution/mutator/operator/block_param_removal.rb +32 -0
  35. data/lib/evilution/mutator/operator/explicit_super_mutation.rb +20 -2
  36. data/lib/evilution/mutator/operator/index_to_at.rb +13 -1
  37. data/lib/evilution/mutator/operator/last_expression_removal.rb +46 -0
  38. data/lib/evilution/mutator/operator/receiver_replacement.rb +29 -1
  39. data/lib/evilution/mutator/operator/rescue_removal.rb +59 -10
  40. data/lib/evilution/mutator/operator/splat_operator.rb +28 -1
  41. data/lib/evilution/mutator/operator/string_literal.rb +83 -6
  42. data/lib/evilution/mutator/registry.rb +2 -0
  43. data/lib/evilution/reporter/cli/line_formatters/error_rate_warning.rb +29 -0
  44. data/lib/evilution/reporter/cli/metrics_block.rb +2 -0
  45. data/lib/evilution/reporter/json.rb +2 -0
  46. data/lib/evilution/result/mutation_result.rb +12 -6
  47. data/lib/evilution/runner/baseline_runner.rb +5 -1
  48. data/lib/evilution/runner/isolation_resolver.rb +69 -8
  49. data/lib/evilution/runner/mutation_planner.rb +18 -1
  50. data/lib/evilution/session/schema.rb +44 -0
  51. data/lib/evilution/session/store.rb +5 -1
  52. data/lib/evilution/version.rb +1 -1
  53. data/lib/evilution.rb +2 -0
  54. data/schema/evilution.config.schema.json +205 -0
  55. data/script/build_runtime_snapshot +88 -0
  56. data/script/run_self_baseline +79 -0
  57. data/script/run_self_validation +54 -0
  58. 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
- # Returns nil on success or a failure-shaped hash on any error.
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
- syntax_error = @syntax_validator.call(mutation.mutated_source)
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 apply(mutation)
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(mutation.mutated_source, mutation.file_path)
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
- raise unless redefinition_conflict?(e)
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
- ::Minitest.__run(reporter, options)
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
- ::Minitest.__run(reporter, options)
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, write_io)
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
- if read_io.wait_readable(timeout)
96
- data = read_io.read
97
- ::Process.wait(pid)
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
- if data.empty?
100
- { timeout: false, passed: false, error: "empty result from child" }
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
- { timeout: false }.merge(Marshal.load(data))
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
- ::MCP::Tool::Response.new([{ type: "text", text: ::JSON.generate(payload) }])
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)