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.
- checksums.yaml +4 -4
- data/.beads/.migration-hint-ts +1 -1
- data/.beads/issues.jsonl +85 -15
- data/CHANGELOG.md +37 -0
- data/README.md +24 -4
- data/lib/evilution/baseline.rb +9 -1
- data/lib/evilution/cli.rb +11 -0
- data/lib/evilution/equivalent/detector.rb +3 -1
- data/lib/evilution/equivalent/heuristic/alias_swap.rb +2 -1
- data/lib/evilution/equivalent/heuristic/void_context.rb +77 -0
- data/lib/evilution/integration/crash_detector.rb +55 -0
- data/lib/evilution/integration/rspec.rb +102 -6
- data/lib/evilution/isolation/fork.rb +10 -6
- data/lib/evilution/isolation/in_process.rb +14 -10
- data/lib/evilution/mutator/operator/begin_unwrap.rb +21 -0
- data/lib/evilution/mutator/operator/block_param_removal.rb +57 -0
- data/lib/evilution/mutator/operator/case_when.rb +55 -0
- data/lib/evilution/mutator/operator/equality_to_identity.rb +22 -0
- data/lib/evilution/mutator/operator/lambda_body.rb +18 -0
- data/lib/evilution/mutator/operator/loop_flip.rb +27 -0
- data/lib/evilution/mutator/operator/method_body_replacement.rb +10 -6
- data/lib/evilution/mutator/operator/predicate_replacement.rb +27 -0
- data/lib/evilution/mutator/operator/retry_removal.rb +16 -0
- data/lib/evilution/mutator/operator/send_mutation.rb +8 -1
- data/lib/evilution/mutator/operator/string_interpolation.rb +32 -0
- data/lib/evilution/mutator/registry.rb +10 -1
- data/lib/evilution/parallel/pool.rb +2 -2
- data/lib/evilution/parallel/work_queue.rb +54 -13
- data/lib/evilution/related_spec_heuristic.rb +63 -0
- data/lib/evilution/reporter/cli.rb +14 -8
- data/lib/evilution/reporter/html.rb +32 -2
- data/lib/evilution/reporter/json.rb +15 -0
- data/lib/evilution/result/coverage_gap.rb +35 -0
- data/lib/evilution/result/coverage_gap_grouper.rb +22 -0
- data/lib/evilution/result/mutation_result.rb +5 -2
- data/lib/evilution/result/summary.rb +5 -0
- data/lib/evilution/runner.rb +7 -4
- data/lib/evilution/session/store.rb +13 -0
- data/lib/evilution/spec_resolver.rb +13 -1
- data/lib/evilution/version.rb +1 -1
- data/lib/evilution.rb +9 -0
- data/script/memory_check +22 -0
- 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 `
|
|
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 (
|
|
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
|
|
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** —
|
|
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
|
data/lib/evilution/baseline.rb
CHANGED
|
@@ -93,6 +93,14 @@ class Evilution::Baseline
|
|
|
93
93
|
private
|
|
94
94
|
|
|
95
95
|
def resolve_unique_spec_files(subjects)
|
|
96
|
-
|
|
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.
|
|
102
|
-
#
|
|
103
|
-
|
|
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
|
-
|
|
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 =
|
|
127
|
-
|
|
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
|
-
|
|
73
|
-
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|