evilution 0.18.0 → 0.20.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 (43) hide show
  1. checksums.yaml +4 -4
  2. data/.beads/.migration-hint-ts +1 -1
  3. data/.beads/issues.jsonl +85 -15
  4. data/CHANGELOG.md +37 -0
  5. data/README.md +24 -4
  6. data/lib/evilution/baseline.rb +9 -1
  7. data/lib/evilution/cli.rb +11 -0
  8. data/lib/evilution/equivalent/detector.rb +3 -1
  9. data/lib/evilution/equivalent/heuristic/alias_swap.rb +2 -1
  10. data/lib/evilution/equivalent/heuristic/void_context.rb +77 -0
  11. data/lib/evilution/integration/crash_detector.rb +55 -0
  12. data/lib/evilution/integration/rspec.rb +102 -6
  13. data/lib/evilution/isolation/fork.rb +10 -6
  14. data/lib/evilution/isolation/in_process.rb +14 -10
  15. data/lib/evilution/mutator/operator/begin_unwrap.rb +21 -0
  16. data/lib/evilution/mutator/operator/block_param_removal.rb +57 -0
  17. data/lib/evilution/mutator/operator/case_when.rb +55 -0
  18. data/lib/evilution/mutator/operator/equality_to_identity.rb +22 -0
  19. data/lib/evilution/mutator/operator/lambda_body.rb +18 -0
  20. data/lib/evilution/mutator/operator/loop_flip.rb +27 -0
  21. data/lib/evilution/mutator/operator/method_body_replacement.rb +10 -6
  22. data/lib/evilution/mutator/operator/predicate_replacement.rb +27 -0
  23. data/lib/evilution/mutator/operator/retry_removal.rb +16 -0
  24. data/lib/evilution/mutator/operator/send_mutation.rb +8 -1
  25. data/lib/evilution/mutator/operator/string_interpolation.rb +32 -0
  26. data/lib/evilution/mutator/registry.rb +10 -1
  27. data/lib/evilution/parallel/pool.rb +2 -2
  28. data/lib/evilution/parallel/work_queue.rb +54 -13
  29. data/lib/evilution/related_spec_heuristic.rb +63 -0
  30. data/lib/evilution/reporter/cli.rb +14 -8
  31. data/lib/evilution/reporter/html.rb +32 -2
  32. data/lib/evilution/reporter/json.rb +15 -0
  33. data/lib/evilution/result/coverage_gap.rb +35 -0
  34. data/lib/evilution/result/coverage_gap_grouper.rb +22 -0
  35. data/lib/evilution/result/mutation_result.rb +5 -2
  36. data/lib/evilution/result/summary.rb +5 -0
  37. data/lib/evilution/runner.rb +7 -4
  38. data/lib/evilution/session/store.rb +13 -0
  39. data/lib/evilution/spec_resolver.rb +13 -1
  40. data/lib/evilution/version.rb +1 -1
  41. data/lib/evilution.rb +9 -0
  42. data/script/memory_check +22 -0
  43. metadata +16 -2
data/README.md CHANGED
@@ -51,7 +51,8 @@ evilution [command] [options] [files...]
51
51
  | `-f`, `--format FORMAT` | String | `text` | Output format: `text`, `json`, or `html`. |
52
52
  | `--target EXPR` | String | _(none)_ | Only mutate matching methods. Supports method name (`Foo::Bar#calculate`), class (`Foo`), namespace wildcards (`Foo::Bar*`), method-type selectors (`Foo#`, `Foo.`), descendants (`descendants:Foo`), and source globs (`source:lib/**/*.rb`). |
53
53
  | `--min-score FLOAT` | Float | 0.0 | Minimum mutation score (0.0–1.0) to pass. |
54
- | `--spec FILES` | Array | _(none)_ | Spec files to run (comma-separated). Defaults to `spec/`. |
54
+ | `--spec FILES` | Array | _(none)_ | Spec files to run (comma-separated). Defaults to auto-detection via `SpecResolver`. |
55
+ | `--spec-dir DIR` | String | _(none)_ | Include all `*_spec.rb` files in DIR recursively. Composable with `--spec`. |
55
56
  | `-j`, `--jobs N` | Integer | 1 | Number of parallel workers. Uses demand-driven work distribution with pipe-based IPC. |
56
57
  | `--no-baseline` | Boolean | _(enabled)_ | Skip baseline test suite check. By default, a baseline run detects pre-existing failures and marks those mutations as `neutral`. |
57
58
  | `--fail-fast [N]` | Integer | _(none)_ | Stop after N surviving mutants (default 1 if no value given). |
@@ -147,6 +148,16 @@ Use `--format json` for machine-readable output. Schema:
147
148
  "suggestion": "string — actionable hint for surviving mutants (survived only)"
148
149
  }
149
150
  ],
151
+ "coverage_gaps": [
152
+ {
153
+ "file": "string — relative path to source file",
154
+ "subject": "string — method name (e.g. 'Foo#bar')",
155
+ "line": "integer — line number",
156
+ "operators": ["string — operator names involved"],
157
+ "count": "integer — number of survived mutations in this gap",
158
+ "mutations": ["... same shape as survived entries ..."]
159
+ }
160
+ ],
150
161
  "killed": ["... same shape as survived entries ..."],
151
162
  "timed_out": ["... same shape as survived entries ..."],
152
163
  "errors": ["... same shape as survived entries ..."]
@@ -155,7 +166,7 @@ Use `--format json` for machine-readable output. Schema:
155
166
 
156
167
  **Key metric**: `summary.score` — the mutation score. Higher is better. 1.0 means all mutations were caught.
157
168
 
158
- ## Mutation Operators (60 total)
169
+ ## Mutation Operators (69 total)
159
170
 
160
171
  Each operator name is stable and appears in JSON output under `survived[].operator`.
161
172
 
@@ -176,7 +187,7 @@ Each operator name is stable and appears in JSON output under `survived[].operat
176
187
  | `conditional_branch` | Remove if/else branch | Deletes branch body |
177
188
  | `conditional_flip` | Flip `if` to `unless` and vice versa | `if cond` -> `unless cond` |
178
189
  | `statement_deletion` | Remove statements from method bodies | Deletes a statement |
179
- | `method_body_replacement` | Replace entire method body with `nil` | Method body -> `nil` |
190
+ | `method_body_replacement` | Replace entire method body | Method body -> `nil`, `self`, `super` |
180
191
  | `negation_insertion` | Negate predicate methods | `x.empty?` -> `!x.empty?` |
181
192
  | `return_value_removal` | Strip return values | `return x` -> `return` |
182
193
  | `collection_replacement` | Swap collection methods | `map` -> `each`, `select` <-> `reject` |
@@ -221,6 +232,15 @@ Each operator name is stable and appears in JSON output under `survived[].operat
221
232
  | `splat_operator` | Remove splat/double-splat | `foo(*args)` -> `foo(args)` |
222
233
  | `defined_check` | Replace `defined?` with `true` | `defined?(x)` -> `true` |
223
234
  | `regex_capture` | Swap or nil-ify capture refs | `$1` -> `$2`, `$1` -> `nil` |
235
+ | `loop_flip` | Swap while/until loops | `while cond` -> `until cond` |
236
+ | `string_interpolation` | Replace interpolation content with nil | `"hello #{name}"` -> `"hello #{nil}"` |
237
+ | `retry_removal` | Remove retry statements | `retry` -> `nil` |
238
+ | `case_when` | Remove/replace case/when branches | Remove `when` branch, body -> `nil`, remove `else` |
239
+ | `predicate_replacement` | Replace predicate calls with booleans | `x.empty?` -> `true`, `x.empty?` -> `false` |
240
+ | `equality_to_identity` | Replace equality with identity check | `a == b` -> `a.equal?(b)` |
241
+ | `lambda_body` | Replace lambda body with nil | `-> { expr }` -> `-> { nil }` |
242
+ | `begin_unwrap` | Remove begin/end wrapper | `begin; expr; end` -> `expr` |
243
+ | `block_param_removal` | Remove explicit block parameter | `def foo(&block)` -> `def foo` |
224
244
 
225
245
  ## MCP Server (AI Agent Integration)
226
246
 
@@ -367,7 +387,7 @@ Tests 4 paths (InProcess isolation, Fork isolation, mutation generation + stripp
367
387
  1. **Parse** — Prism parses Ruby files into ASTs with exact byte offsets
368
388
  2. **Extract** — Methods are identified as mutation subjects
369
389
  3. **Filter** — Disable comments, Sorbet `sig` blocks, and AST ignore patterns exclude mutations before execution
370
- 4. **Mutate** — 60 operators produce text replacements at precise byte offsets (source-level surgery, no AST unparsing)
390
+ 4. **Mutate** — 69 operators produce text replacements at precise byte offsets (source-level surgery, no AST unparsing)
371
391
  5. **Isolate** — Default isolation is in-process; `--isolation fork` uses forked child processes. Parallel mode (`--jobs N`) always uses in-process isolation inside pool workers to avoid double forking
372
392
  6. **Test** — RSpec executes against the mutated source
373
393
  7. **Collect** — Source strings and AST nodes are released after use to minimize memory retention
@@ -93,6 +93,14 @@ class Evilution::Baseline
93
93
  private
94
94
 
95
95
  def resolve_unique_spec_files(subjects)
96
- subjects.map { |s| @spec_resolver.call(s.file_path) || "spec" }.uniq
96
+ warned = Set.new
97
+ subjects.map do |s|
98
+ resolved = @spec_resolver.call(s.file_path)
99
+ if resolved.nil? && warned.add?(s.file_path)
100
+ warn "[evilution] No matching spec found for #{s.file_path}, running full suite. " \
101
+ "Use --spec to specify the spec file."
102
+ end
103
+ resolved || "spec"
104
+ end.uniq
97
105
  end
98
106
  end
data/lib/evilution/cli.rb CHANGED
@@ -53,6 +53,16 @@ class Evilution::CLI
53
53
  0
54
54
  end
55
55
 
56
+ def expand_spec_dir(dir)
57
+ unless File.directory?(dir)
58
+ warn("Error: #{dir} is not a directory")
59
+ return
60
+ end
61
+
62
+ specs = Dir.glob(File.join(dir, "**/*_spec.rb"))
63
+ @options[:spec_files] = Array(@options[:spec_files]) + specs
64
+ end
65
+
56
66
  def run_subcommand_error(message)
57
67
  warn("Error: #{message}")
58
68
  2
@@ -224,6 +234,7 @@ class Evilution::CLI
224
234
  def add_filter_options(opts)
225
235
  opts.on("--min-score FLOAT", Float, "Minimum mutation score to pass") { |s| @options[:min_score] = s }
226
236
  opts.on("--spec FILES", Array, "Spec files to run (comma-separated)") { |f| @options[:spec_files] = f }
237
+ opts.on("--spec-dir DIR", "Include all specs in DIR") { |d| expand_spec_dir(d) }
227
238
  opts.on("--target EXPR",
228
239
  "Filter: method (Foo#bar), type (Foo#/Foo.), namespace (Foo*),",
229
240
  "class (Foo), glob (source:**/*.rb), hierarchy (descendants:Foo)") do |m|
@@ -6,6 +6,7 @@ require_relative "heuristic/alias_swap"
6
6
  require_relative "heuristic/dead_code"
7
7
  require_relative "heuristic/arithmetic_identity"
8
8
  require_relative "heuristic/comment_marking"
9
+ require_relative "heuristic/void_context"
9
10
 
10
11
  require_relative "../equivalent"
11
12
 
@@ -38,7 +39,8 @@ class Evilution::Equivalent::Detector
38
39
  Evilution::Equivalent::Heuristic::AliasSwap.new,
39
40
  Evilution::Equivalent::Heuristic::DeadCode.new,
40
41
  Evilution::Equivalent::Heuristic::ArithmeticIdentity.new,
41
- Evilution::Equivalent::Heuristic::CommentMarking.new
42
+ Evilution::Equivalent::Heuristic::CommentMarking.new,
43
+ Evilution::Equivalent::Heuristic::VoidContext.new
42
44
  ]
43
45
  end
44
46
  end
@@ -7,7 +7,8 @@ class Evilution::Equivalent::Heuristic::AliasSwap
7
7
  Set[:detect, :find],
8
8
  Set[:length, :size],
9
9
  Set[:collect, :map],
10
- Set[:count, :length]
10
+ Set[:count, :length],
11
+ Set[:count, :size]
11
12
  ].freeze
12
13
 
13
14
  MATCHING_OPERATORS = Set["send_mutation", "collection_replacement"].freeze
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../heuristic"
4
+
5
+ class Evilution::Equivalent::Heuristic::VoidContext
6
+ # Method pairs where the only difference is the return value.
7
+ # In void context (return value unused), these are equivalent.
8
+ VOID_EQUIVALENT_PAIRS = Set[
9
+ Set[:each, :map],
10
+ Set[:each, :reverse_each]
11
+ ].freeze
12
+
13
+ MATCHING_OPERATORS = Set["send_mutation", "collection_replacement"].freeze
14
+
15
+ def match?(mutation)
16
+ return false unless MATCHING_OPERATORS.include?(mutation.operator_name)
17
+
18
+ pair = extract_method_pair(mutation.diff)
19
+ return false unless pair
20
+ return false unless VOID_EQUIVALENT_PAIRS.include?(pair)
21
+
22
+ void_context?(mutation)
23
+ end
24
+
25
+ private
26
+
27
+ def extract_method_pair(diff)
28
+ removed = extract_method(diff, "- ")
29
+ added = extract_method(diff, "+ ")
30
+ return nil unless removed && added
31
+
32
+ Set[removed.to_sym, added.to_sym]
33
+ end
34
+
35
+ def extract_method(diff, prefix)
36
+ line = diff.split("\n").find { |l| l.start_with?(prefix) }
37
+ return nil unless line
38
+
39
+ match = line.match(/\.(\w+)(?:[\s({]|$)/)
40
+ match && match[1]
41
+ end
42
+
43
+ def void_context?(mutation)
44
+ node = mutation.subject.node
45
+ return false unless node
46
+
47
+ body = node.body
48
+ return false unless body.is_a?(Prism::StatementsNode)
49
+
50
+ statements = body.body
51
+ call_node = find_call_at_line(statements, mutation.line)
52
+ return false unless call_node
53
+
54
+ # The call is in void context if:
55
+ # 1. It's a direct statement (not wrapped in assignment)
56
+ # 2. It's not the last statement in the method body
57
+ statement_index = statements.index { |s| contains_line?(s, mutation.line) && direct_call?(s) }
58
+ return false unless statement_index
59
+
60
+ statement_index < statements.length - 1
61
+ end
62
+
63
+ def find_call_at_line(statements, line)
64
+ statements.each do |stmt|
65
+ return stmt if stmt.is_a?(Prism::CallNode) && stmt.location.start_line == line
66
+ end
67
+ nil
68
+ end
69
+
70
+ def direct_call?(statement)
71
+ statement.is_a?(Prism::CallNode)
72
+ end
73
+
74
+ def contains_line?(node, line)
75
+ line.between?(node.location.start_line, node.location.end_line)
76
+ end
77
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../integration"
4
+
5
+ class Evilution::Integration::CrashDetector
6
+ def self.register_with_rspec
7
+ ::RSpec::Core::Formatters.register self, :example_failed
8
+ end
9
+
10
+ def initialize(_output)
11
+ reset
12
+ end
13
+
14
+ def reset
15
+ @assertion_failures = 0
16
+ @crashes = []
17
+ end
18
+
19
+ def example_failed(notification)
20
+ exception = notification.example.exception
21
+
22
+ if assertion_exception?(exception)
23
+ @assertion_failures += 1
24
+ else
25
+ @crashes << exception
26
+ end
27
+ end
28
+
29
+ def has_assertion_failure? # rubocop:disable Naming/PredicatePrefix
30
+ @assertion_failures.positive?
31
+ end
32
+
33
+ def has_crash? # rubocop:disable Naming/PredicatePrefix
34
+ @crashes.any?
35
+ end
36
+
37
+ def only_crashes?
38
+ @crashes.any? && @assertion_failures.zero?
39
+ end
40
+
41
+ def crash_summary
42
+ return nil if @crashes.empty?
43
+
44
+ types = @crashes.map { |e| e.class.name }.uniq
45
+ "#{types.join(", ")} (#{@crashes.length} crash#{"es" unless @crashes.length == 1})"
46
+ end
47
+
48
+ private
49
+
50
+ def assertion_exception?(exception)
51
+ exception.is_a?(::RSpec::Expectations::ExpectationNotMetError) ||
52
+ (defined?(::RSpec::Mocks::MockExpectationError) &&
53
+ exception.is_a?(::RSpec::Mocks::MockExpectationError))
54
+ end
55
+ end
@@ -4,7 +4,9 @@ require "fileutils"
4
4
  require "stringio"
5
5
  require "tmpdir"
6
6
  require_relative "base"
7
+ require_relative "crash_detector"
7
8
  require_relative "../spec_resolver"
9
+ require_relative "../related_spec_heuristic"
8
10
 
9
11
  require_relative "../integration"
10
12
 
@@ -12,6 +14,10 @@ class Evilution::Integration::RSpec < Evilution::Integration::Base
12
14
  def initialize(test_files: nil, hooks: nil)
13
15
  @test_files = test_files
14
16
  @rspec_loaded = false
17
+ @spec_resolver = Evilution::SpecResolver.new
18
+ @related_spec_heuristic = Evilution::RelatedSpecHeuristic.new
19
+ @crash_detector = nil
20
+ @warned_files = Set.new
15
21
  super(hooks: hooks)
16
22
  end
17
23
 
@@ -37,6 +43,7 @@ class Evilution::Integration::RSpec < Evilution::Integration::Base
37
43
 
38
44
  @hooks.fire(:setup_integration_pre, integration: :rspec) if @hooks
39
45
  require "rspec/core"
46
+ Evilution::Integration::CrashDetector.register_with_rspec
40
47
  @rspec_loaded = true
41
48
  @hooks.fire(:setup_integration_post, integration: :rspec) if @hooks
42
49
  rescue LoadError => e
@@ -98,9 +105,14 @@ class Evilution::Integration::RSpec < Evilution::Integration::Base
98
105
  # process exits after each run.
99
106
  #
100
107
  # This integration can also be invoked directly (e.g. in specs or alternative
101
- # runners) without fork isolation. RSpec.reset is called here as
102
- # defense-in-depth to clear RSpec state between mutation runs in those cases.
103
- ::RSpec.reset
108
+ # runners) without fork isolation. clear_examples reuses the existing World
109
+ # and Configuration (avoiding per-run instance growth) while clearing loaded
110
+ # example groups, constants, and configuration state.
111
+ if ::RSpec.respond_to?(:clear_examples)
112
+ ::RSpec.clear_examples
113
+ else
114
+ ::RSpec.reset
115
+ end
104
116
 
105
117
  out = StringIO.new
106
118
  err = StringIO.new
@@ -108,11 +120,81 @@ class Evilution::Integration::RSpec < Evilution::Integration::Base
108
120
  args = build_args(mutation)
109
121
  command = "rspec #{args.join(" ")}"
110
122
 
123
+ detector = reset_crash_detector
124
+ eg_before = snapshot_example_groups
111
125
  status = ::RSpec::Core::Runner.run(args, out, err)
112
126
 
113
- { passed: status.zero?, test_command: command }
127
+ build_rspec_result(status, command, detector)
114
128
  rescue StandardError => e
115
129
  { passed: false, error: e.message, test_command: command }
130
+ ensure
131
+ release_rspec_state(eg_before)
132
+ end
133
+
134
+ def snapshot_example_groups
135
+ groups = Set.new
136
+ ObjectSpace.each_object(Class) do |klass|
137
+ groups << klass.object_id if klass < ::RSpec::Core::ExampleGroup
138
+ rescue TypeError # rubocop:disable Lint/SuppressedException
139
+ end
140
+ groups
141
+ end
142
+
143
+ def release_rspec_state(eg_before)
144
+ release_example_groups(eg_before)
145
+ # Remove ExampleGroups constants so the named reference is dropped.
146
+ # We avoid a full RSpec.reset here because it creates new World and
147
+ # Configuration instances each call; the pre-run reset already handles
148
+ # that. Instead, clear the world's example_groups array (which holds
149
+ # direct class references) and the source cache.
150
+ ::RSpec::ExampleGroups.remove_all_constants if defined?(::RSpec::ExampleGroups)
151
+ release_world_example_groups
152
+ end
153
+
154
+ def release_example_groups(eg_before)
155
+ return unless eg_before
156
+
157
+ ObjectSpace.each_object(Class) do |klass|
158
+ next unless klass < ::RSpec::Core::ExampleGroup
159
+ next if eg_before.include?(klass.object_id)
160
+
161
+ # Remove nested module constants (LetDefinitions, NamedSubjectPreventSuper)
162
+ klass.constants(false).each do |const|
163
+ klass.send(:remove_const, const)
164
+ rescue NameError # rubocop:disable Lint/SuppressedException
165
+ end
166
+
167
+ klass.instance_variables.each do |ivar|
168
+ klass.remove_instance_variable(ivar)
169
+ end
170
+ rescue TypeError # rubocop:disable Lint/SuppressedException
171
+ end
172
+ end
173
+
174
+ def release_world_example_groups
175
+ world = ::RSpec.world
176
+ world.instance_variable_get(:@example_groups).clear if world.instance_variable_defined?(:@example_groups)
177
+ world.instance_variable_set(:@sources_by_path, {}) if world.instance_variable_defined?(:@sources_by_path)
178
+ end
179
+
180
+ def reset_crash_detector
181
+ if @crash_detector
182
+ @crash_detector.reset
183
+ else
184
+ @crash_detector = Evilution::Integration::CrashDetector.new(StringIO.new)
185
+ ::RSpec.configuration.add_formatter(@crash_detector)
186
+ end
187
+ @crash_detector
188
+ end
189
+
190
+ def build_rspec_result(status, command, detector)
191
+ if status.zero?
192
+ { passed: true, test_command: command }
193
+ elsif detector.only_crashes?
194
+ { passed: false, error: "test crashes: #{detector.crash_summary}", test_command: command }
195
+ else
196
+ { passed: false, test_command: command }
197
+ end
116
198
  end
117
199
 
118
200
  def build_args(mutation)
@@ -123,7 +205,21 @@ class Evilution::Integration::RSpec < Evilution::Integration::Base
123
205
  def resolve_test_files(mutation)
124
206
  return test_files if test_files
125
207
 
126
- resolved = Evilution::SpecResolver.new.call(mutation.file_path)
127
- resolved ? [resolved] : ["spec"]
208
+ resolved = @spec_resolver.call(mutation.file_path)
209
+ unless resolved
210
+ warn_unresolved_spec(mutation.file_path)
211
+ return ["spec"]
212
+ end
213
+
214
+ related = @related_spec_heuristic.call(mutation)
215
+ ([resolved] + related).uniq
216
+ end
217
+
218
+ def warn_unresolved_spec(file_path)
219
+ return if @warned_files.include?(file_path)
220
+
221
+ @warned_files << file_path
222
+ warn "[evilution] No matching spec found for #{file_path}, running full suite. " \
223
+ "Use --spec to specify the spec file."
128
224
  end
129
225
  end
@@ -16,6 +16,7 @@ class Evilution::Isolation::Fork
16
16
  def call(mutation:, test_command:, timeout:)
17
17
  sandbox_dir = Dir.mktmpdir("evilution-run")
18
18
  start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
19
+ parent_rss = Evilution::Memory.rss_kb
19
20
  read_io, write_io = IO.pipe
20
21
 
21
22
  pid = ::Process.fork do
@@ -33,7 +34,7 @@ class Evilution::Isolation::Fork
33
34
  result = wait_for_result(pid, read_io, timeout)
34
35
  duration = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
35
36
 
36
- build_mutation_result(mutation, result, duration)
37
+ build_mutation_result(mutation, result, duration, parent_rss)
37
38
  ensure
38
39
  read_io&.close
39
40
  write_io&.close
@@ -67,10 +68,12 @@ class Evilution::Isolation::Fork
67
68
  if read_io.wait_readable(timeout)
68
69
  data = read_io.read
69
70
  ::Process.wait(pid)
70
- return { timeout: false }.merge(Marshal.load(data)) unless data.empty? # rubocop:disable Security/MarshalLoad
71
71
 
72
- ::Process.wait(pid) rescue nil # rubocop:disable Style/RescueModifier
73
- { timeout: false, passed: false, error: "empty result from child" }
72
+ if data.empty?
73
+ { timeout: false, passed: false, error: "empty result from child" }
74
+ else
75
+ { timeout: false }.merge(Marshal.load(data)) # rubocop:disable Security/MarshalLoad
76
+ end
74
77
  else
75
78
  terminate_child(pid)
76
79
  { timeout: true }
@@ -90,7 +93,7 @@ class Evilution::Isolation::Fork
90
93
  ::Process.wait(pid) rescue nil # rubocop:disable Style/RescueModifier
91
94
  end
92
95
 
93
- def build_mutation_result(mutation, result, duration)
96
+ def build_mutation_result(mutation, result, duration, parent_rss_kb)
94
97
  status = if result[:timeout]
95
98
  :timeout
96
99
  elsif result[:error]
@@ -106,7 +109,8 @@ class Evilution::Isolation::Fork
106
109
  status: status,
107
110
  duration: duration,
108
111
  test_command: result[:test_command],
109
- child_rss_kb: result[:child_rss_kb]
112
+ child_rss_kb: result[:child_rss_kb],
113
+ parent_rss_kb: parent_rss_kb
110
114
  )
111
115
  end
112
116
  end
@@ -7,6 +7,13 @@ require_relative "../result/mutation_result"
7
7
  require_relative "../isolation"
8
8
 
9
9
  class Evilution::Isolation::InProcess
10
+ @null_out = File.open(File::NULL, "w")
11
+ @null_err = File.open(File::NULL, "w")
12
+
13
+ class << self
14
+ attr_reader :null_out, :null_err
15
+ end
16
+
10
17
  def call(mutation:, test_command:, timeout:)
11
18
  start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
12
19
  rss_before = Evilution::Memory.rss_kb
@@ -15,7 +22,7 @@ class Evilution::Isolation::InProcess
15
22
  duration = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
16
23
  delta = compute_memory_delta(rss_before, rss_after, result)
17
24
 
18
- build_mutation_result(mutation, result, duration, rss_after, delta)
25
+ build_mutation_result(mutation, result, duration, rss_before, rss_after, delta)
19
26
  end
20
27
 
21
28
  private
@@ -34,13 +41,9 @@ class Evilution::Isolation::InProcess
34
41
  def suppress_output
35
42
  saved_stdout = $stdout
36
43
  saved_stderr = $stderr
37
- File.open(File::NULL, "w") do |null_out|
38
- File.open(File::NULL, "w") do |null_err|
39
- $stdout = null_out
40
- $stderr = null_err
41
- yield
42
- end
43
- end
44
+ $stdout = self.class.null_out
45
+ $stderr = self.class.null_err
46
+ yield
44
47
  ensure
45
48
  $stdout = saved_stdout
46
49
  $stderr = saved_stderr
@@ -53,7 +56,7 @@ class Evilution::Isolation::InProcess
53
56
  rss_after - rss_before
54
57
  end
55
58
 
56
- def build_mutation_result(mutation, result, duration, rss_after, memory_delta_kb)
59
+ def build_mutation_result(mutation, result, duration, rss_before, rss_after, memory_delta_kb)
57
60
  status = if result[:timeout]
58
61
  :timeout
59
62
  elsif result[:error]
@@ -70,7 +73,8 @@ class Evilution::Isolation::InProcess
70
73
  duration: duration,
71
74
  test_command: result[:test_command],
72
75
  child_rss_kb: rss_after,
73
- memory_delta_kb: memory_delta_kb
76
+ memory_delta_kb: memory_delta_kb,
77
+ parent_rss_kb: rss_before
74
78
  )
75
79
  end
76
80
  end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../operator"
4
+
5
+ class Evilution::Mutator::Operator::BeginUnwrap < Evilution::Mutator::Base
6
+ def visit_begin_node(node)
7
+ return super if node.rescue_clause || node.else_clause || node.ensure_clause
8
+ return super if node.statements.nil?
9
+ return super if node.begin_keyword_loc.nil?
10
+
11
+ body_text = @file_source.byteslice(node.statements.location.start_offset, node.statements.location.length)
12
+ add_mutation(
13
+ offset: node.location.start_offset,
14
+ length: node.location.length,
15
+ replacement: body_text,
16
+ node: node
17
+ )
18
+
19
+ super
20
+ end
21
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../operator"
4
+
5
+ class Evilution::Mutator::Operator::BlockParamRemoval < Evilution::Mutator::Base
6
+ def visit_def_node(node)
7
+ return super unless node.parameters
8
+ return super unless node.parameters.block
9
+
10
+ if only_block_param?(node.parameters)
11
+ remove_entire_params(node)
12
+ else
13
+ remove_block_param(node)
14
+ end
15
+
16
+ super
17
+ end
18
+
19
+ private
20
+
21
+ def only_block_param?(params)
22
+ params.requireds.empty? &&
23
+ params.optionals.empty? &&
24
+ params.keywords.empty? &&
25
+ params.rest.nil? &&
26
+ params.keyword_rest.nil?
27
+ end
28
+
29
+ def remove_entire_params(node)
30
+ start_offset = node.lparen_loc.start_offset
31
+ end_offset = node.rparen_loc.start_offset + node.rparen_loc.length
32
+ add_mutation(
33
+ offset: start_offset,
34
+ length: end_offset - start_offset,
35
+ replacement: "",
36
+ node: node
37
+ )
38
+ end
39
+
40
+ def remove_block_param(node)
41
+ block_loc = node.parameters.block.location
42
+ params_text = @file_source.byteslice(node.parameters.location.start_offset, node.parameters.location.length)
43
+ block_rel = block_loc.start_offset - node.parameters.location.start_offset
44
+
45
+ # Find the comma before the block param and remove ", &block"
46
+ comma_pos = params_text.rindex(",", block_rel - 1)
47
+ remove_start = node.parameters.location.start_offset + comma_pos
48
+ remove_end = block_loc.start_offset + block_loc.length
49
+
50
+ add_mutation(
51
+ offset: remove_start,
52
+ length: remove_end - remove_start,
53
+ replacement: "",
54
+ node: node
55
+ )
56
+ end
57
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../operator"
4
+
5
+ class Evilution::Mutator::Operator::CaseWhen < Evilution::Mutator::Base
6
+ def visit_case_node(node)
7
+ remove_when_branches(node)
8
+ replace_when_bodies(node)
9
+ remove_else_branch(node)
10
+
11
+ super
12
+ end
13
+
14
+ private
15
+
16
+ def remove_when_branches(node)
17
+ return if node.conditions.length < 2
18
+
19
+ node.conditions.each do |when_node|
20
+ add_mutation(
21
+ offset: when_node.location.start_offset,
22
+ length: when_node.location.length,
23
+ replacement: "",
24
+ node: when_node
25
+ )
26
+ end
27
+ end
28
+
29
+ def replace_when_bodies(node)
30
+ node.conditions.each do |when_node|
31
+ next if when_node.statements.nil? || when_node.statements.body.empty?
32
+
33
+ add_mutation(
34
+ offset: when_node.statements.location.start_offset,
35
+ length: when_node.statements.location.length,
36
+ replacement: "nil",
37
+ node: when_node
38
+ )
39
+ end
40
+ end
41
+
42
+ def remove_else_branch(node)
43
+ return if node.else_clause.nil?
44
+ return if node.else_clause.statements.nil?
45
+
46
+ start_offset = node.else_clause.else_keyword_loc.start_offset
47
+ end_offset = node.else_clause.statements.location.start_offset + node.else_clause.statements.location.length
48
+ add_mutation(
49
+ offset: start_offset,
50
+ length: end_offset - start_offset,
51
+ replacement: "",
52
+ node: node.else_clause
53
+ )
54
+ end
55
+ end