mutato 0.1.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 +7 -0
- data/CHANGELOG.md +13 -0
- data/LICENSE +21 -0
- data/README.md +218 -0
- data/exe/mutato +21 -0
- data/lib/mutato/abort.rb +12 -0
- data/lib/mutato/annotations.rb +65 -0
- data/lib/mutato/arid.rb +104 -0
- data/lib/mutato/baseline.rb +51 -0
- data/lib/mutato/boot.rb +99 -0
- data/lib/mutato/campaign.rb +52 -0
- data/lib/mutato/change.rb +49 -0
- data/lib/mutato/child.rb +135 -0
- data/lib/mutato/cli.rb +138 -0
- data/lib/mutato/clock.rb +21 -0
- data/lib/mutato/config.rb +39 -0
- data/lib/mutato/console.rb +33 -0
- data/lib/mutato/coverage_listener/tally.rb +26 -0
- data/lib/mutato/coverage_listener.rb +46 -0
- data/lib/mutato/covering.rb +45 -0
- data/lib/mutato/diff.rb +42 -0
- data/lib/mutato/emitter.rb +46 -0
- data/lib/mutato/equivalence.rb +30 -0
- data/lib/mutato/experience.rb +11 -0
- data/lib/mutato/flags.rb +58 -0
- data/lib/mutato/generation.rb +36 -0
- data/lib/mutato/generator.rb +10 -0
- data/lib/mutato/genres/conditions.rb +70 -0
- data/lib/mutato/genres/elements.rb +49 -0
- data/lib/mutato/genres/genre.rb +23 -0
- data/lib/mutato/genres/operators.rb +81 -0
- data/lib/mutato/genres/statements.rb +122 -0
- data/lib/mutato/genres/swaps.rb +58 -0
- data/lib/mutato/genres/values.rb +41 -0
- data/lib/mutato/genres.rb +53 -0
- data/lib/mutato/group_hooks.rb +45 -0
- data/lib/mutato/hits.rb +18 -0
- data/lib/mutato/installer/header.rb +40 -0
- data/lib/mutato/installer.rb +133 -0
- data/lib/mutato/measure.rb +37 -0
- data/lib/mutato/mode/control.rb +43 -0
- data/lib/mutato/mode/run.rb +33 -0
- data/lib/mutato/mode.rb +26 -0
- data/lib/mutato/mutation.rb +85 -0
- data/lib/mutato/nodes.rb +150 -0
- data/lib/mutato/options.rb +33 -0
- data/lib/mutato/output.rb +76 -0
- data/lib/mutato/privates.rb +46 -0
- data/lib/mutato/report.rb +36 -0
- data/lib/mutato/rspec_adapter/progress.rb +20 -0
- data/lib/mutato/rspec_adapter/result.rb +15 -0
- data/lib/mutato/rspec_adapter.rb +129 -0
- data/lib/mutato/run.rb +44 -0
- data/lib/mutato/selection.rb +100 -0
- data/lib/mutato/session.rb +53 -0
- data/lib/mutato/skips.rb +54 -0
- data/lib/mutato/soft_timeout.rb +8 -0
- data/lib/mutato/subject.rb +21 -0
- data/lib/mutato/subjects/scope.rb +44 -0
- data/lib/mutato/subjects.rb +67 -0
- data/lib/mutato/suite.rb +6 -0
- data/lib/mutato/trial.rb +93 -0
- data/lib/mutato/usage.rb +41 -0
- data/lib/mutato/version.rb +6 -0
- data/lib/mutato/visitor/cursor.rb +28 -0
- data/lib/mutato/visitor/walk.rb +17 -0
- data/lib/mutato/visitor.rb +88 -0
- data/lib/mutato/watchdog.rb +54 -0
- data/lib/mutato.rb +55 -0
- metadata +128 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 2aafb54276c4ae2139a14b9422b16ffad91c363f501aa079f1a6579e74031509
|
|
4
|
+
data.tar.gz: 30bf198b8033421c66222b25653c8645d6a84fc6bc46cc02f7fb4929111af55e
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: d01b47493ef62b0df820899c0f92de46d4f93eaee537dcdb232d21db995954b985e98099a3437bc2a0ea45387f7f126c88768a19dd60ff64392c0444e4e47f6a
|
|
7
|
+
data.tar.gz: fb3270a1f902dc9752670d5ecfe6a04c4879186ea0466c9e03cfdcdf2f767baeae4d85a7dcc72a97dee276baa78455083771618e6e5b3b5169b02c7a83b38dba
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
Notable changes for users of mutato. The format follows
|
|
4
|
+
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and versions follow
|
|
5
|
+
[Semantic Versioning](https://semver.org/).
|
|
6
|
+
|
|
7
|
+
## [Unreleased]
|
|
8
|
+
|
|
9
|
+
## [0.1.0] - 2026-10-03
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- First release: mutation testing for RSpec, on Ruby 3.2 and later.
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Mathias Løkke Madsen
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
<p align="center"><img src=".github/mutato.svg" width="150" alt=""></p>
|
|
2
|
+
|
|
3
|
+
# mutato
|
|
4
|
+
|
|
5
|
+
[](https://github.com/LQkkeN/mutato/actions/workflows/ci.yml)
|
|
6
|
+
|
|
7
|
+
Mutation testing for Ruby and RSpec. mutato changes your code one small step
|
|
8
|
+
at a time and runs the tests that cover each change. A change that no test
|
|
9
|
+
notices points at a missing test.
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
```ruby
|
|
14
|
+
# Gemfile
|
|
15
|
+
group :test do
|
|
16
|
+
gem 'mutato'
|
|
17
|
+
end
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Ruby 3.2 or later.
|
|
21
|
+
|
|
22
|
+
## Find what your tests miss
|
|
23
|
+
|
|
24
|
+
Take this class, with specs for VAT on 100 and for a discount of 20:
|
|
25
|
+
|
|
26
|
+
```ruby
|
|
27
|
+
# lib/shop/price.rb
|
|
28
|
+
module Shop
|
|
29
|
+
class Price
|
|
30
|
+
def initialize(amount, vat: 0.25)
|
|
31
|
+
@amount = amount
|
|
32
|
+
@vat = vat
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def total(discount: 0)
|
|
36
|
+
net = [@amount - discount, 0].max
|
|
37
|
+
(net * (1 + @vat)).round(2)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
```
|
|
44
|
+
$ bundle exec mutato run lib
|
|
45
|
+
14 mutants over 1 files, 2 methods
|
|
46
|
+
...
|
|
47
|
+
7/14 caught 0.02s da2574057ce7 lib/shop/price.rb:9:14: [element] drop `@amount - discount`
|
|
48
|
+
8/14 missed 0.01s 4c1d0b45c1fc lib/shop/price.rb:9:34: [element] drop `0`
|
|
49
|
+
...
|
|
50
|
+
results: caught 11, missed 3
|
|
51
|
+
|
|
52
|
+
MISSED:
|
|
53
|
+
4c1d0b45c1fc lib/shop/price.rb:9:34: [element] drop `0`
|
|
54
|
+
5315afcd609d lib/shop/price.rb:10:26: [swap-method] replace `.round` with `.floor`
|
|
55
|
+
efc60b287f00 lib/shop/price.rb:10:26: [swap-method] replace `.round` with `.ceil`
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Each missed mutant is a change no test noticed: no spec gives a discount
|
|
59
|
+
larger than the amount, and none checks rounding up and down. Add those two
|
|
60
|
+
specs, then rerun just these mutants by id:
|
|
61
|
+
|
|
62
|
+
```
|
|
63
|
+
$ bundle exec mutato run lib --only 4c1d0b45c1fc,5315afcd609d,efc60b287f00
|
|
64
|
+
...
|
|
65
|
+
1/3 caught 0.03s 4c1d0b45c1fc lib/shop/price.rb:9:34: [element] drop `0`
|
|
66
|
+
2/3 caught 0.03s 5315afcd609d lib/shop/price.rb:10:26: [swap-method] replace `.round` with `.floor`
|
|
67
|
+
3/3 caught 0.02s efc60b287f00 lib/shop/price.rb:10:26: [swap-method] replace `.round` with `.ceil`
|
|
68
|
+
results: caught 3
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
The exit code is 2 while anything is missed. An id stays the same while its
|
|
72
|
+
method is unchanged and the path is written the same way (`lib` and `./lib`
|
|
73
|
+
differ).
|
|
74
|
+
|
|
75
|
+
## See what would be tried
|
|
76
|
+
|
|
77
|
+
```
|
|
78
|
+
$ bundle exec mutato list lib
|
|
79
|
+
27f4e8ead8ca lib/shop/price.rb:4:7: [value] replace body of Shop::Price#initialize with nil (Shop::Price#initialize)
|
|
80
|
+
f7bfadab08e0 lib/shop/price.rb:4:7: [statement] delete statement `@amount = amount` (Shop::Price#initialize)
|
|
81
|
+
...
|
|
82
|
+
14 mutants over 1 files, 2 methods
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
## Only what a branch changed
|
|
86
|
+
|
|
87
|
+
```sh
|
|
88
|
+
git diff -U0 origin/main | bundle exec mutato run lib --diff -
|
|
89
|
+
hg diff -U0 -r default | bundle exec mutato run lib --diff -
|
|
90
|
+
jj diff --git | bundle exec mutato run lib --diff -
|
|
91
|
+
bundle exec mutato run lib --diff changes.diff
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
Any unified diff works; mutato never calls a version control system itself.
|
|
95
|
+
It reads the diff's paths from the current directory, so from a subdirectory
|
|
96
|
+
of the repository use `git diff --relative`.
|
|
97
|
+
|
|
98
|
+
## On a GitHub pull request
|
|
99
|
+
|
|
100
|
+
```yaml
|
|
101
|
+
- uses: actions/checkout@v7
|
|
102
|
+
with:
|
|
103
|
+
fetch-depth: 0 # git diff needs the base commit
|
|
104
|
+
- run: git diff -U0 ${{ github.event.pull_request.base.sha }} |
|
|
105
|
+
bundle exec mutato run lib --diff - --format github
|
|
106
|
+
shell: bash # pipefail: a failing git diff fails the step
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
Survivors show as warnings on the changed lines, timeouts as notices, and a
|
|
110
|
+
table of survivors goes into the step summary. Elsewhere, `--format plain`
|
|
111
|
+
prints lines that editors and CI systems parse:
|
|
112
|
+
|
|
113
|
+
```
|
|
114
|
+
lib/shop/price.rb:9:34: survived: drop `0`
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
## Choose the specs
|
|
118
|
+
|
|
119
|
+
```sh
|
|
120
|
+
bundle exec mutato run lib --spec spec/unit # default: spec
|
|
121
|
+
bundle exec mutato run app lib --spec 'spec --tag ~slow' # any rspec arguments
|
|
122
|
+
bundle exec mutato run lib --spec "test --pattern '**/*_test.rb'" # files not named _spec.rb
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
## Start small on a big codebase
|
|
126
|
+
|
|
127
|
+
```sh
|
|
128
|
+
bundle exec mutato run lib/billing # one directory or file
|
|
129
|
+
bundle exec mutato run lib --sample 50 # 50 at random, the same 50 every time
|
|
130
|
+
bundle exec mutato run lib --limit 20 # the first 20 in file order
|
|
131
|
+
bundle exec mutato run lib --genre arithmetic,relational # only some kinds of change
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
## Check the setup
|
|
135
|
+
|
|
136
|
+
```
|
|
137
|
+
$ bundle exec mutato control lib --sample 40
|
|
138
|
+
...
|
|
139
|
+
control OK: 2 of 2 reinstalled methods passed their tests
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
`control` puts methods back unchanged and runs their tests. All must pass: a
|
|
143
|
+
method whose tests fail even then gives meaningless results in a real run.
|
|
144
|
+
|
|
145
|
+
## Project hooks
|
|
146
|
+
|
|
147
|
+
`.mutato.rb` in the project root, or `--config FILE`:
|
|
148
|
+
|
|
149
|
+
```ruby
|
|
150
|
+
# Tables cleaned in after hooks: a killed child leaves rows behind.
|
|
151
|
+
Mutato.before_mutant { truncate_tables }
|
|
152
|
+
|
|
153
|
+
# Rails loads lazily: load everything once, or untested files come out unloaded.
|
|
154
|
+
Mutato.after_boot { Rails.application.eager_load! }
|
|
155
|
+
|
|
156
|
+
# A socket must not be shared with the forked children.
|
|
157
|
+
Mutato.before_fork { SomeClient.disconnect }
|
|
158
|
+
Mutato.after_fork { SomeClient.connect }
|
|
159
|
+
|
|
160
|
+
# Accept a known survivor, with the reason on record; `mutato list` shows it.
|
|
161
|
+
Mutato.skip 'Accounts#obtain', 'lock re-check for a race no test reproduces'
|
|
162
|
+
|
|
163
|
+
# The project's own output calls, never worth mutating.
|
|
164
|
+
Mutato.arid :say, 'UI'
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
Sequel and ActiveRecord connections are closed before every fork already.
|
|
168
|
+
Other options: `--out DIR` (default `mutato.out`), `--timeout-min SECONDS`
|
|
169
|
+
(default 10), `--version`, `--help`.
|
|
170
|
+
|
|
171
|
+
## Reading the results
|
|
172
|
+
|
|
173
|
+
`mutato.out/missed.txt` is the list to act on. Every other outcome has a list
|
|
174
|
+
too, `outcomes.json` holds all of them, and `logs/` has one log per mutant
|
|
175
|
+
with whatever the tests printed, capped at 1 MB.
|
|
176
|
+
|
|
177
|
+
| outcome | meaning |
|
|
178
|
+
|---|---|
|
|
179
|
+
| caught | a test failed, and passes without the mutant |
|
|
180
|
+
| missed | no test failed: a test is missing |
|
|
181
|
+
| timeout | the tests ran past five times their usual time, often an infinite loop |
|
|
182
|
+
| unjudged | only flaky tests cover it |
|
|
183
|
+
| equivalent | provably no change: a removed `super` into an `initialize` that does nothing |
|
|
184
|
+
| uncovered | no test runs the line |
|
|
185
|
+
| load-time | the code runs only while the suite loads |
|
|
186
|
+
| unloaded | the suite never loads the file |
|
|
187
|
+
| overwritten | the tests redefined the method, as a code reloader does |
|
|
188
|
+
| unviable | the mutant did not load |
|
|
189
|
+
|
|
190
|
+
Exit codes: 2 when a mutant is missed or unjudged, 3 when there is nothing to
|
|
191
|
+
mutate (0 with `--diff`), 1 on errors, otherwise 0. `control` exits 1 when a
|
|
192
|
+
method fails and 3 when no test ran any.
|
|
193
|
+
|
|
194
|
+
## What it changes
|
|
195
|
+
|
|
196
|
+
| genre | for example |
|
|
197
|
+
|---|---|
|
|
198
|
+
| statement | delete `@vat = vat` |
|
|
199
|
+
| condition | `if paid?` -> `if true`, `a && b` -> `a \|\| b`, `!x` -> `x` |
|
|
200
|
+
| relational | `a < b` -> `a <= b`, `a != b`, `false` |
|
|
201
|
+
| arithmetic | `a + b` -> `a - b`, `x += 1` -> `x -= 1`, `a - b` -> `b - a` |
|
|
202
|
+
| swap-method | `.round` -> `.floor`, `.max` -> `.min`, `.any?` -> `.all?` |
|
|
203
|
+
| value | a method's body -> `nil`, `[]`, `0`, `true` or `false` |
|
|
204
|
+
| element | `[net, 0]` -> `[net]` |
|
|
205
|
+
|
|
206
|
+
It leaves alone what no test could tell apart or should check: logging and
|
|
207
|
+
output, variables nothing reads, `return @x if @x`, and code outside methods.
|
|
208
|
+
`mutato list` names each method it skips, with the reason.
|
|
209
|
+
|
|
210
|
+
## Limits
|
|
211
|
+
|
|
212
|
+
RSpec only, and one worker: a run takes as long as the tests it runs. An
|
|
213
|
+
endless `def x = ...` comes out uncovered, and native extensions are
|
|
214
|
+
invisible. A mutant can reach code the tests never did, such as the network.
|
|
215
|
+
|
|
216
|
+
## Licence
|
|
217
|
+
|
|
218
|
+
MIT.
|
data/exe/mutato
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Before mutato's own files load, so that it can run on itself.
|
|
5
|
+
require "coverage"
|
|
6
|
+
Coverage.start(lines: true) unless Coverage.running?
|
|
7
|
+
require "bundler/setup" if File.exist?("Gemfile")
|
|
8
|
+
begin
|
|
9
|
+
require_relative "../lib/mutato"
|
|
10
|
+
rescue LoadError => error
|
|
11
|
+
hint = "Add mutato to the project's Gemfile; its dependency brings the prism it needs."
|
|
12
|
+
abort "mutato: #{error.message}. #{hint}"
|
|
13
|
+
end
|
|
14
|
+
if Gem::Version.new(Prism::VERSION) < Gem::Version.new("1.8")
|
|
15
|
+
abort "mutato: prism #{Prism::VERSION} is too old, 1.8 is needed"
|
|
16
|
+
end
|
|
17
|
+
require_relative "../lib/mutato/cli"
|
|
18
|
+
|
|
19
|
+
code = Mutato::CLI.call(ARGV)
|
|
20
|
+
# exit!, not exit: the project's at_exit handlers must not run.
|
|
21
|
+
exit! code
|
data/lib/mutato/abort.rb
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mutato
|
|
4
|
+
class Annotations
|
|
5
|
+
FORMATS = { "github" => :github, "plain" => :plain }.freeze
|
|
6
|
+
SUMMARY = <<~MARKDOWN
|
|
7
|
+
## mutato: %<count>d survivors
|
|
8
|
+
|
|
9
|
+
| where | mutant |
|
|
10
|
+
|---|---|
|
|
11
|
+
%<rows>s
|
|
12
|
+
MARKDOWN
|
|
13
|
+
private_constant :FORMATS, :SUMMARY
|
|
14
|
+
|
|
15
|
+
def self.command(level, verdict, outcome)
|
|
16
|
+
file, line, col, description = outcome.fetch_values(:file, :line, :col, :description)
|
|
17
|
+
"::#{level} file=#{file},line=#{line},col=#{col},title=mutato::#{verdict}: #{description}"
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def self.line(outcome)
|
|
21
|
+
file, line, col, description = outcome.fetch_values(:file, :line, :col, :description)
|
|
22
|
+
"#{file}:#{line}:#{col}: survived: #{description}"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def self.step_summary(missed)
|
|
26
|
+
path = ENV.fetch("GITHUB_STEP_SUMMARY", nil)
|
|
27
|
+
return unless path && missed.any?
|
|
28
|
+
|
|
29
|
+
rows = missed.map { |outcome| row(*outcome.fetch_values(:file, :line, :description)) }
|
|
30
|
+
File.write(path, format(SUMMARY, count: missed.size, rows: rows.join("\n")), mode: "a")
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# GitHub splits table cells on every pipe, inside backticks too, unless escaped.
|
|
34
|
+
def self.row(file, line, description)
|
|
35
|
+
"| `#{file}:#{line}` | #{description.gsub("|", "\\|")} |"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def initialize(console)
|
|
39
|
+
@console = console
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def show(outcomes, format)
|
|
43
|
+
style = FORMATS[format]
|
|
44
|
+
public_send(style, outcomes) if style
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def github(outcomes)
|
|
48
|
+
missed = Mode.missed(outcomes)
|
|
49
|
+
missed.each { |outcome| @console.out(Annotations.command("warning", "survived", outcome)) }
|
|
50
|
+
notices(outcomes)
|
|
51
|
+
Annotations.step_summary(missed)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def plain(outcomes)
|
|
55
|
+
Mode.missed(outcomes).each { |outcome| @console.out(Annotations.line(outcome)) }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
def notices(outcomes)
|
|
61
|
+
timeouts = outcomes.select { |outcome| outcome.fetch(:outcome) == :timeout }
|
|
62
|
+
timeouts.each { |outcome| @console.out(Annotations.command("notice", "timeout", outcome)) }
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
data/lib/mutato/arid.rb
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
module Mutato
|
|
6
|
+
# Code no test asserts on (output, logging): mutating it only makes survivors.
|
|
7
|
+
module Arid
|
|
8
|
+
METHODS = %i[puts warn p pp print require require_relative].freeze
|
|
9
|
+
LOGGING = /\Alog(?:ger)?\z/i
|
|
10
|
+
# `log_error(e)`, `logger_info`: a level after the underscore, so `log_in` is not one.
|
|
11
|
+
HELPER = /\Alog(?:ger)?(?:_(?:debug|info|warn|warning|error|fatal|exception|event|message))?\z/i
|
|
12
|
+
VARIABLES = [
|
|
13
|
+
Prism::InstanceVariableReadNode,
|
|
14
|
+
Prism::ClassVariableReadNode,
|
|
15
|
+
Prism::GlobalVariableReadNode,
|
|
16
|
+
Prism::LocalVariableReadNode
|
|
17
|
+
].freeze
|
|
18
|
+
ITERATORS = %i[
|
|
19
|
+
each
|
|
20
|
+
each_with_index
|
|
21
|
+
each_with_object
|
|
22
|
+
each_pair
|
|
23
|
+
each_key
|
|
24
|
+
each_value
|
|
25
|
+
reverse_each
|
|
26
|
+
times
|
|
27
|
+
upto
|
|
28
|
+
downto
|
|
29
|
+
step
|
|
30
|
+
].freeze
|
|
31
|
+
private_constant :METHODS, :LOGGING, :HELPER, :VARIABLES, :ITERATORS
|
|
32
|
+
|
|
33
|
+
module_function
|
|
34
|
+
|
|
35
|
+
def helper_name?(name)
|
|
36
|
+
name.to_s.match?(HELPER)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# A compound of arid parts is arid, and so is one with none: `if x; end`.
|
|
40
|
+
def arid_node?(node)
|
|
41
|
+
case node
|
|
42
|
+
when Prism::CallNode then arid_with_block?(node) || arid_loop?(node)
|
|
43
|
+
when Prism::IfNode, Prism::UnlessNode then all_arid?(branches_of(node))
|
|
44
|
+
when Prism::StatementsNode, Prism::ElseNode then all_arid?(node.compact_child_nodes)
|
|
45
|
+
else false
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def all_arid?(parts)
|
|
50
|
+
parts.all? { |part| arid_node?(part) }
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# A logging call with a block is arid only if its block is: `logger.tagged { work }` works.
|
|
54
|
+
def arid_with_block?(call)
|
|
55
|
+
block = call.block
|
|
56
|
+
body = block.body if block.is_a?(Prism::BlockNode)
|
|
57
|
+
arid_call?(call) && all_arid?([body].compact)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def arid_loop?(call)
|
|
61
|
+
block = call.block
|
|
62
|
+
ITERATORS.include?(call.name) && block.is_a?(Prism::BlockNode) && arid_node?(block.body)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def branches_of(conditional)
|
|
66
|
+
conditional.compact_child_nodes - [conditional.predicate]
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def arid_call?(call)
|
|
70
|
+
name = call.name
|
|
71
|
+
receiver = call.receiver
|
|
72
|
+
return true if METHODS.include?(name) && console?(receiver)
|
|
73
|
+
|
|
74
|
+
Mutato.config.arid?(name) || (receiver.nil? && helper_name?(name)) || logging?(receiver)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# `puts` to the console, not to an IO the code was handed.
|
|
78
|
+
def console?(receiver)
|
|
79
|
+
receiver.nil? || receiver.slice.match?(/\A(?:\$std(?:out|err)|STD(?:OUT|ERR))\z/)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def logging?(receiver)
|
|
83
|
+
case receiver
|
|
84
|
+
when Prism::CallNode then logging_call?(receiver)
|
|
85
|
+
when Prism::ConstantReadNode, Prism::ConstantPathNode then logging_constant?(receiver.slice)
|
|
86
|
+
when *VARIABLES then receiver.name.to_s.sub(/\A(?:@@?|\$)/, "").match?(LOGGING)
|
|
87
|
+
else false
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# `App.logger`, a reader without arguments (not `Math.log(x)`), or `opts[:logger]`.
|
|
92
|
+
def logging_call?(call)
|
|
93
|
+
arguments = Nodes.arguments_of(call)
|
|
94
|
+
name = call.name
|
|
95
|
+
return name.to_s.match?(LOGGING) && arguments.empty? unless name == :[]
|
|
96
|
+
|
|
97
|
+
(arguments in [key]) && key.slice.match?(/\A[:"']?log(?:ger)?["']?\z/i)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def logging_constant?(name)
|
|
101
|
+
name.split("::").last.match?(LOGGING) || Mutato.config.arid?(name)
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mutato
|
|
4
|
+
# hooks: the seconds the suite and group hooks took, which every run repeats.
|
|
5
|
+
Baseline = Data.define(
|
|
6
|
+
:line_map,
|
|
7
|
+
:durations,
|
|
8
|
+
:order,
|
|
9
|
+
:hooks,
|
|
10
|
+
:locations,
|
|
11
|
+
:boot_lines,
|
|
12
|
+
:loaded_files
|
|
13
|
+
) do
|
|
14
|
+
# `lib/calc/helpers.rb` is tested by `helpers_spec.rb` or `calc_helpers_spec.rb`.
|
|
15
|
+
def self.stems_of(file)
|
|
16
|
+
stems = [File.basename(file, ".rb"), file.delete_suffix(".rb").split("/").drop(1).join("_")]
|
|
17
|
+
stems.reject(&:empty?)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def covering(path, lines)
|
|
21
|
+
ids = lines.flat_map { |line| line_map.fetch("#{path}:#{line}", []) }
|
|
22
|
+
ids.uniq
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def budget(ids, minimum)
|
|
26
|
+
[minimum, ids.sum { |id| durations.fetch(id) } * 5].max + hooks
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def loaded?(path)
|
|
30
|
+
loaded_files.include?(path)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def booted?(path, lines)
|
|
34
|
+
lines.any? { |line| boot_lines.include?("#{path}:#{line}") }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Named tests kill soonest; baseline order keeps order-dependent suites working.
|
|
38
|
+
def prioritize(ids, file)
|
|
39
|
+
stems = Baseline.stems_of(file)
|
|
40
|
+
ids.sort_by { |id| [named_after?(id, stems) ? 0 : 1, order.fetch(id, Float::INFINITY)] }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def named_after?(id, stems)
|
|
46
|
+
base = File.basename(locations.fetch(id))
|
|
47
|
+
stems.any? { |stem| base.start_with?("#{stem}_", "test_#{stem}") }
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
public_constant :Baseline
|
|
51
|
+
end
|
data/lib/mutato/boot.rb
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "coverage"
|
|
4
|
+
|
|
5
|
+
module Mutato
|
|
6
|
+
class Boot
|
|
7
|
+
def initialize(selection, console)
|
|
8
|
+
@selection = selection
|
|
9
|
+
@console = console
|
|
10
|
+
@output = nil
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def suite
|
|
14
|
+
adapter = booted
|
|
15
|
+
# What booting executed, before any test ran.
|
|
16
|
+
executed = Coverage.peek_result
|
|
17
|
+
started = Clock.now
|
|
18
|
+
measured = describe(measure(adapter), started)
|
|
19
|
+
Suite.new(adapter:, baseline: baseline(measured, executed), options:, output:)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def options
|
|
25
|
+
@selection.options
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def output
|
|
29
|
+
@output ||= Output.new(options.out)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def booted
|
|
33
|
+
started = Clock.now
|
|
34
|
+
RSpecAdapter.boot(options.spec_args)
|
|
35
|
+
Mutato.config.run_hooks(:after_boot)
|
|
36
|
+
report_boot(started)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def report_boot(started)
|
|
40
|
+
count = RSpecAdapter.locations.size
|
|
41
|
+
@console.say(
|
|
42
|
+
format(
|
|
43
|
+
"boot: %<seconds>.2fs, %<count>d examples loaded",
|
|
44
|
+
seconds: Clock.since(started),
|
|
45
|
+
count:
|
|
46
|
+
)
|
|
47
|
+
)
|
|
48
|
+
@console.die("spec files failed to load, see the error above") if RSpecAdapter.load_failed?
|
|
49
|
+
@console.die(no_tests) if count.zero?
|
|
50
|
+
RSpecAdapter
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def no_tests
|
|
54
|
+
return "the suite is Minitest; mutato is RSpec-only" if defined?(Minitest::Test)
|
|
55
|
+
|
|
56
|
+
"no tests loaded from #{options.spec_args.join(" ")}"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def measure(adapter)
|
|
60
|
+
log = output.log_path("baseline")
|
|
61
|
+
Mutato.config.run_hooks(:before_mutant)
|
|
62
|
+
check(Child.run(log:) { adapter.baseline(@selection.prefixes) }, log)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def check(measured, log)
|
|
66
|
+
return measured if measured.is_a?(Measure)
|
|
67
|
+
|
|
68
|
+
@console.die("baseline crashed: #{measured.inspect}, see #{log}")
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def describe(measured, started)
|
|
72
|
+
@console.say(measured.summary(Clock.since(started)))
|
|
73
|
+
failed = measured.failed
|
|
74
|
+
if failed.any?
|
|
75
|
+
@console.say("baseline: #{failed.size} failing tests excluded: #{failed.first(5)}")
|
|
76
|
+
end
|
|
77
|
+
@console.die("baseline: nothing passed") if measured.all_failed?
|
|
78
|
+
measured
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def baseline(measured, executed)
|
|
82
|
+
# With autoloading, a file may load first in a test.
|
|
83
|
+
loaded = executed.keys.to_set.merge(measured.seen)
|
|
84
|
+
measured.baseline(
|
|
85
|
+
locations: RSpecAdapter.locations,
|
|
86
|
+
boot_lines: boot_lines(executed),
|
|
87
|
+
loaded_files: loaded
|
|
88
|
+
)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def boot_lines(executed)
|
|
92
|
+
prefixes = @selection.prefixes
|
|
93
|
+
hits = executed.flat_map do |path, data|
|
|
94
|
+
path.start_with?(*prefixes) ? Hits.keys(path, data) : []
|
|
95
|
+
end
|
|
96
|
+
hits.to_set
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mutato
|
|
4
|
+
# Outcomes are kept as they come, so an interrupt can still report them.
|
|
5
|
+
class Campaign
|
|
6
|
+
attr_reader :suite, :experience
|
|
7
|
+
|
|
8
|
+
def initialize(suite, console)
|
|
9
|
+
@suite = suite
|
|
10
|
+
@console = console
|
|
11
|
+
@experience = Experience.fresh
|
|
12
|
+
@outcomes = []
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def try(todo, &)
|
|
16
|
+
watch(&)
|
|
17
|
+
@console.say("#{todo.size} mutants to try")
|
|
18
|
+
todo.each_with_index { |mutation, index| @outcomes << judge(mutation, index, todo) }
|
|
19
|
+
@outcomes
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
# Named, not anonymous: Ruby 3.3.0 rejects `&` forwarded from inside a block.
|
|
25
|
+
def watch(&report)
|
|
26
|
+
%w[INT TERM].each { |signal| trap(signal) { interrupted(signal, &report) } }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def interrupted(signal)
|
|
30
|
+
yield(@outcomes)
|
|
31
|
+
@console.die("interrupted by #{signal}")
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def judge(mutation, index, todo)
|
|
35
|
+
outcome = Trial.new(mutation, @suite, @experience).judge
|
|
36
|
+
@suite.output.append(outcome)
|
|
37
|
+
@console.say(progress("#{index + 1}/#{todo.size}", outcome, mutation))
|
|
38
|
+
outcome
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def progress(position, outcome, mutation)
|
|
42
|
+
format(
|
|
43
|
+
"%<position>8s %<outcome>-11s %<seconds>.2fs %<id>s %<label>s",
|
|
44
|
+
position:,
|
|
45
|
+
outcome: outcome.fetch(:outcome),
|
|
46
|
+
seconds: outcome.fetch(:seconds),
|
|
47
|
+
id: mutation.id,
|
|
48
|
+
label: mutation.label
|
|
49
|
+
)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|