saboteur 0.2.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 +47 -0
- data/LICENSE +21 -0
- data/README.md +145 -0
- data/exe/saboteur +12 -0
- data/lib/saboteur/cli.rb +112 -0
- data/lib/saboteur/config.rb +133 -0
- data/lib/saboteur/engine/boot.rb +83 -0
- data/lib/saboteur/engine/candidates.rb +63 -0
- data/lib/saboteur/engine/coverage_store.rb +99 -0
- data/lib/saboteur/engine/diagnostics.rb +37 -0
- data/lib/saboteur/engine/diff_scope.rb +32 -0
- data/lib/saboteur/engine/distribution.rb +74 -0
- data/lib/saboteur/engine/result_cache.rb +93 -0
- data/lib/saboteur/engine/scheduler.rb +215 -0
- data/lib/saboteur/engine/subject_selection.rb +63 -0
- data/lib/saboteur/engine/suite_files.rb +82 -0
- data/lib/saboteur/engine/verdict_check.rb +78 -0
- data/lib/saboteur/engine.rb +133 -0
- data/lib/saboteur/execution/process_table.rb +177 -0
- data/lib/saboteur/execution/process_tree.rb +99 -0
- data/lib/saboteur/execution/protocol.rb +41 -0
- data/lib/saboteur/execution/reevaluator.rb +144 -0
- data/lib/saboteur/execution/runner.rb +191 -0
- data/lib/saboteur/execution/supervisor.rb +337 -0
- data/lib/saboteur/execution/worker_pool.rb +249 -0
- data/lib/saboteur/frameworks/rails.rb +166 -0
- data/lib/saboteur/hooks.rb +51 -0
- data/lib/saboteur/ignore_list.rb +62 -0
- data/lib/saboteur/integration/base.rb +72 -0
- data/lib/saboteur/integration/rspec.rb +143 -0
- data/lib/saboteur/mutation_source/base.rb +26 -0
- data/lib/saboteur/mutation_source/builtin.rb +232 -0
- data/lib/saboteur/mutation_source/edit.rb +18 -0
- data/lib/saboteur/mutation_source/mutation.rb +28 -0
- data/lib/saboteur/reporter/diff.rb +97 -0
- data/lib/saboteur/reporter/stryker.rb +116 -0
- data/lib/saboteur/reporter/terminal.rb +136 -0
- data/lib/saboteur/result.rb +134 -0
- data/lib/saboteur/selection/coverage_map.rb +113 -0
- data/lib/saboteur/selection/git_diff.rb +64 -0
- data/lib/saboteur/selection/recorder.rb +72 -0
- data/lib/saboteur/subject.rb +166 -0
- data/lib/saboteur/subject_finder.rb +98 -0
- data/lib/saboteur/version.rb +6 -0
- data/lib/saboteur.rb +51 -0
- metadata +130 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 6007efb7a83e5669822a1622876457e9e654187acc923981e641835258c7637d
|
|
4
|
+
data.tar.gz: 373680f8e52d7cbd24f1eb3dde2322101eccf565c2752e10a174bd5ff9e6d8e2
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: c0d37ea099ca68c5dc3608e2229446cb4e4494c623e63bb8033eb85a8a10bd7953e2cc84031678a15adda5da0b0da1f6bb1177badde04fa06dbd472f2d674534
|
|
7
|
+
data.tar.gz: 7484ed59697969071030c78ab81b8a716da93b09d4e95900b69b110f087809a6e1293153375104732457205af0e0cfb94f64eb34810242febd1e63be613fb97a
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes are documented here. The format follows
|
|
4
|
+
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project uses
|
|
5
|
+
[Semantic Versioning](https://semver.org/).
|
|
6
|
+
|
|
7
|
+
## [Unreleased]
|
|
8
|
+
|
|
9
|
+
## [0.2.0] - 2026-10-10
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
- `--seed N` (and `seed:` in `.saboteur.yml`, default 1): every test run seeds
|
|
13
|
+
Ruby's random numbers with it, so runs repeat; the report prints the seed.
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
- Linux: killed orphans adopted by the supervisor are reaped reliably, so no
|
|
17
|
+
zombies are left behind (first run on Linux CI).
|
|
18
|
+
- The README status line describes what has actually been tested.
|
|
19
|
+
|
|
20
|
+
## [0.1.0] - 2026-10-10
|
|
21
|
+
|
|
22
|
+
First release that does mutation testing. MIT licensed.
|
|
23
|
+
|
|
24
|
+
### Added
|
|
25
|
+
- `saboteur run` with `--since`, `--subject`, `--timeout-factor`, `--jobs`,
|
|
26
|
+
`--reeval`, `--json`, `--no-cache`, `--incremental`, `--config`.
|
|
27
|
+
- Subjects found with Prism; mutations are byte-range edits: whole-method
|
|
28
|
+
`nil`/`true`/`false`/`raise` first, then comparison, boolean, arithmetic,
|
|
29
|
+
numeric literal, statement removal, condition, negation, safe navigation
|
|
30
|
+
and `||=`.
|
|
31
|
+
- Per-example test selection from a per-test coverage map, cached by content
|
|
32
|
+
hash; timeouts scaled from each test's recorded duration.
|
|
33
|
+
- Verdict check: every kill is re-run on the unchanged method, so a test that
|
|
34
|
+
fails anyway cannot count as a kill.
|
|
35
|
+
- Supervised children: whole process tree tracked and killed, deadline,
|
|
36
|
+
memory and process caps, CPU-limit backstop, recursion guard.
|
|
37
|
+
- Parallel workers (`--jobs N`), including Rails apps on Postgres and SQLite
|
|
38
|
+
with a database copy per worker.
|
|
39
|
+
- RSpec adapter and a Rails profile. Minitest is out of scope.
|
|
40
|
+
- Terminal report with unified diffs, Stryker JSON report, ignore list with
|
|
41
|
+
mandatory reasons, incremental result cache.
|
|
42
|
+
- Mutation-source extension point and lifecycle hooks.
|
|
43
|
+
|
|
44
|
+
## [0.0.1] - unreleased
|
|
45
|
+
|
|
46
|
+
### Added
|
|
47
|
+
- Gem skeleton to reserve the name. No mutation testing yet.
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Vamsi Pavan Mahesh Gunturu
|
|
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,145 @@
|
|
|
1
|
+
# saboteur
|
|
2
|
+
|
|
3
|
+
Mutation testing for Ruby 3.3+.
|
|
4
|
+
|
|
5
|
+
saboteur makes small deliberate changes to your methods (mutants), runs only
|
|
6
|
+
the tests that execute each change, and reports the changes no test notices
|
|
7
|
+
(survivors). Each survivor points at a missing test or at code that does not
|
|
8
|
+
matter.
|
|
9
|
+
|
|
10
|
+
Status: an early release (0.2.0). Tested on the RSpec suites of plain gems
|
|
11
|
+
(dry-inflector, tty-color, hashie, addressable) and of two Rails 8.1 apps:
|
|
12
|
+
human-essentials on Postgres (4,080 mutants, 6 workers) and lobsters on
|
|
13
|
+
SQLite, both on macOS. Not yet tried on MySQL or older Rails versions. RSpec
|
|
14
|
+
only, by design.
|
|
15
|
+
|
|
16
|
+
## What makes it different
|
|
17
|
+
|
|
18
|
+
- **Tests picked per mutant by line coverage.** Each mutant runs only the
|
|
19
|
+
tests that executed the changed statement. A change no test reaches is
|
|
20
|
+
reported as "no coverage", not as a survivor.
|
|
21
|
+
- **Likeliest killers first.** Tests that killed earlier mutants of the same
|
|
22
|
+
method run first, then the fastest; the first failure stops the run.
|
|
23
|
+
- **Coarse first.** Each method's body is first replaced with `nil` and with
|
|
24
|
+
`raise`. If a test suite does not notice that, saboteur reports it and
|
|
25
|
+
skips the fine-grained mutants for that method.
|
|
26
|
+
- **Diff mode.** `--since origin/main` mutates only methods changed on a branch.
|
|
27
|
+
- **Safe to run.** Every mutant runs in a supervised child; anything it
|
|
28
|
+
starts is tracked and killed, with deadline, memory and process caps.
|
|
29
|
+
- **Open report format.** JSON in the Stryker mutation-testing-report schema.
|
|
30
|
+
- **Pluggable mutation sources.** Add your own mutants by file and byte range.
|
|
31
|
+
|
|
32
|
+
## Usage
|
|
33
|
+
|
|
34
|
+
```sh
|
|
35
|
+
bundle exec saboteur run # whole project
|
|
36
|
+
bundle exec saboteur run --since origin/main # methods changed on this branch
|
|
37
|
+
bundle exec saboteur run --subject 'Billing::Invoice#total'
|
|
38
|
+
bundle exec saboteur run --subject Billing # a namespace
|
|
39
|
+
bundle exec saboteur run --incremental # reuse verdicts whose inputs did not change
|
|
40
|
+
bundle exec saboteur run --seed 1234 # repeat a run that used seed 1234
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Every test run starts from the same random seed (1 by default), so tests
|
|
44
|
+
that use `rand` see the same values in each mutant's run and in the check
|
|
45
|
+
that confirms a kill. The report prints the seed; pass it to `--seed` to
|
|
46
|
+
repeat a run.
|
|
47
|
+
|
|
48
|
+
`--incremental` reuses an earlier verdict only while the method, the mutant,
|
|
49
|
+
its covering tests and their files, the lockfile and the config are all
|
|
50
|
+
unchanged. It cannot see a change in a method this one calls, so run without
|
|
51
|
+
it now and then (and in CI on the main branch).
|
|
52
|
+
|
|
53
|
+
Output: counts per status, the score, timing, and a unified diff for every
|
|
54
|
+
survivor. The JSON report goes to `.saboteur/report.json`.
|
|
55
|
+
|
|
56
|
+
Exit codes: 0 no survivors (or all on the ignore list), 1 survivors,
|
|
57
|
+
2 bad configuration, 3 the tool failed.
|
|
58
|
+
|
|
59
|
+
## Configuration
|
|
60
|
+
|
|
61
|
+
`.saboteur.yml` in the project root; CLI flags override it.
|
|
62
|
+
|
|
63
|
+
```yaml
|
|
64
|
+
include: [lib/**/*.rb]
|
|
65
|
+
exclude: [lib/my_gem/version.rb]
|
|
66
|
+
require: [] # boot files, e.g. ./config/environment
|
|
67
|
+
reeval: nesting # or class_eval; see docs/design.md 6.1
|
|
68
|
+
timeout_factor: 3 # per-mutant deadline = factor x its tests' recorded time
|
|
69
|
+
timeout_min: 5
|
|
70
|
+
max_memory_mb: 2048 # memory growth cap per mutant process tree
|
|
71
|
+
max_processes: 64
|
|
72
|
+
ignore_file: .saboteur-ignore.yml
|
|
73
|
+
seed: 1 # Ruby's random seed in every test run
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
### Ignoring a survivor
|
|
77
|
+
|
|
78
|
+
Every survivor is printed with a digest. Accept it with a reason:
|
|
79
|
+
|
|
80
|
+
```yaml
|
|
81
|
+
# .saboteur-ignore.yml
|
|
82
|
+
- subject: "Billing::Invoice#total"
|
|
83
|
+
mutation: "1a2b3c4d5e6f7a8b"
|
|
84
|
+
reason: "equivalent: amounts are always whole cents"
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
An entry without a reason is rejected.
|
|
88
|
+
|
|
89
|
+
### SimpleCov
|
|
90
|
+
|
|
91
|
+
saboteur needs Ruby's `Coverage` for itself. Disable SimpleCov for the run
|
|
92
|
+
(most projects already guard it with an environment variable).
|
|
93
|
+
|
|
94
|
+
## Extending
|
|
95
|
+
|
|
96
|
+
Public API, stable across minor versions:
|
|
97
|
+
|
|
98
|
+
- `Saboteur::MutationSource::Base#mutations_for(subject)` returning
|
|
99
|
+
`Saboteur::MutationSource::Mutation` objects made of `Edit`s (file, byte
|
|
100
|
+
range, replacement). Register with `sources: [builtin, path.rb:ClassName]`.
|
|
101
|
+
- `Saboteur::Integration::Base` for other test frameworks (RSpec is the
|
|
102
|
+
only one that ships).
|
|
103
|
+
- `Saboteur.hook(:before_boot | :after_boot | :before_fork | :after_fork |
|
|
104
|
+
:around_mutant)` in files listed under `hooks:`.
|
|
105
|
+
|
|
106
|
+
## How it works
|
|
107
|
+
|
|
108
|
+
See [docs/design.md](docs/design.md). Validation results are in
|
|
109
|
+
[docs/validation](docs/validation), research in [docs/research](docs/research),
|
|
110
|
+
and the project checklist in [docs/status.md](docs/status.md).
|
|
111
|
+
|
|
112
|
+
## Benchmark
|
|
113
|
+
|
|
114
|
+
A small, non-exhaustive comparison with [mutineer](https://github.com/davidteren/mutineer)
|
|
115
|
+
1.6.0 on four RSpec projects (two libraries, two Rails apps). Each configuration
|
|
116
|
+
ran once on sampled files, so treat the numbers as indicative.
|
|
117
|
+
|
|
118
|
+
| Metric | saboteur | mutineer |
|
|
119
|
+
|---|---|---|
|
|
120
|
+
| Fake kills (lobsters) | 0 of 20 checked | 8 of 8 checked |
|
|
121
|
+
| Rails app with 6 workers | yes, same verdicts as 1 worker | 1 worker only |
|
|
122
|
+
| Runs on the Postgres app | yes | no |
|
|
123
|
+
| Time per mutant, hashie | 0.008 s | 0.092 s |
|
|
124
|
+
| Peak memory, hashie | 595 MB | 471 MB |
|
|
125
|
+
| Choice of operators, CI features | fixed set, fewer | 20 operators, baseline, HTML, GitHub Action |
|
|
126
|
+
|
|
127
|
+
Full results, method and limitations:
|
|
128
|
+
[benchmark page](https://vamsipavanmahesh.github.io/saboteur/benchmark/).
|
|
129
|
+
Scripts and raw data: [bench/vs-mutineer](bench/vs-mutineer).
|
|
130
|
+
|
|
131
|
+
## Requirements
|
|
132
|
+
|
|
133
|
+
Ruby 3.3 or newer on Linux or macOS (it needs `fork`).
|
|
134
|
+
|
|
135
|
+
## Contributing
|
|
136
|
+
|
|
137
|
+
See [CONTRIBUTING.md](CONTRIBUTING.md). Commits need a DCO `Signed-off-by` line.
|
|
138
|
+
|
|
139
|
+
## Acknowledgements
|
|
140
|
+
|
|
141
|
+
Thanks to [Glomo](https://glomo.one) for providing the AI tokens used to build saboteur.
|
|
142
|
+
|
|
143
|
+
## License
|
|
144
|
+
|
|
145
|
+
MIT. Copyright (c) 2026 Vamsi Pavan Mahesh Gunturu. See [LICENSE](LICENSE).
|
data/exe/saboteur
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
# frozen_string_literal: true
|
|
4
|
+
|
|
5
|
+
# Line coverage must be set up before any target code loads, so it happens
|
|
6
|
+
# first. It stays suspended (no counting) except while the coverage map is built.
|
|
7
|
+
require "coverage"
|
|
8
|
+
Coverage.setup(lines: true) if Coverage.state == :idle
|
|
9
|
+
|
|
10
|
+
require "saboteur/cli"
|
|
11
|
+
|
|
12
|
+
exit Saboteur::CLI.new(ARGV).call
|
data/lib/saboteur/cli.rb
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "optparse"
|
|
5
|
+
require_relative "../saboteur"
|
|
6
|
+
|
|
7
|
+
module Saboteur
|
|
8
|
+
class CLI
|
|
9
|
+
USAGE = "Usage: saboteur run [--since REF] [--subject 'Klass#meth'] [--timeout-factor N] [--jobs N] [--seed N]"
|
|
10
|
+
|
|
11
|
+
EXIT_OK = 0
|
|
12
|
+
EXIT_SURVIVORS = 1
|
|
13
|
+
EXIT_USAGE = 2
|
|
14
|
+
EXIT_TOOL = 3
|
|
15
|
+
|
|
16
|
+
class UsageError < ConfigError; end
|
|
17
|
+
|
|
18
|
+
attr_reader :options
|
|
19
|
+
|
|
20
|
+
def initialize(argv, out: $stdout, err: $stderr)
|
|
21
|
+
@argv = argv.dup
|
|
22
|
+
@out = out
|
|
23
|
+
@err = err
|
|
24
|
+
@options = {}
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def call
|
|
28
|
+
case @argv.shift
|
|
29
|
+
when "run" then run
|
|
30
|
+
when "version", "--version" then @out.puts(VERSION) || EXIT_OK
|
|
31
|
+
else usage_error
|
|
32
|
+
end
|
|
33
|
+
rescue OptionParser::ParseError => e
|
|
34
|
+
usage_error(e.message)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def config
|
|
38
|
+
@config ||= Config.load(root: Dir.pwd, path: options[:config], overrides: overrides)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def usage_error(message = nil)
|
|
44
|
+
@err.puts(message) if message
|
|
45
|
+
@err.puts(USAGE)
|
|
46
|
+
EXIT_USAGE
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def run
|
|
50
|
+
parser.parse!(@argv)
|
|
51
|
+
raise UsageError, "unexpected arguments: #{@argv.join(' ')}" if @argv.any?
|
|
52
|
+
|
|
53
|
+
report = Engine.new(config, err: @err).call
|
|
54
|
+
@err.puts
|
|
55
|
+
Reporter::Terminal.new(@out).call(report)
|
|
56
|
+
write_json(report)
|
|
57
|
+
exit_code(report)
|
|
58
|
+
rescue ConfigError => e
|
|
59
|
+
@err.puts("saboteur: #{e.message}")
|
|
60
|
+
EXIT_USAGE
|
|
61
|
+
rescue Error => e
|
|
62
|
+
@err.puts("saboteur: #{e.message}")
|
|
63
|
+
EXIT_TOOL
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def write_json(report)
|
|
67
|
+
path = config.path("json_report")
|
|
68
|
+
return unless path
|
|
69
|
+
|
|
70
|
+
Reporter::Stryker.new(path).call(report)
|
|
71
|
+
@out.puts "\nJSON report: #{path}"
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def exit_code(report)
|
|
75
|
+
executed = report.executed
|
|
76
|
+
return EXIT_TOOL if executed.any? && executed.all? { |result| result.status == :error }
|
|
77
|
+
|
|
78
|
+
report.unignored_survivors.any? ? EXIT_SURVIVORS : EXIT_OK
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def overrides
|
|
82
|
+
options.except(:config)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def parser
|
|
86
|
+
OptionParser.new do |opts|
|
|
87
|
+
opts.banner = USAGE
|
|
88
|
+
opts.on("--since REF", "Only mutate subjects changed since git REF") { |v| options[:since] = v }
|
|
89
|
+
opts.on("--subject EXPR", "Klass#meth, Klass.meth or a namespace; repeatable") { |v| add_subject(v) }
|
|
90
|
+
opts.on("--timeout-factor N", Float, "Multiple of baseline runtime (default 3)") { |v| set(:timeout_factor, v) }
|
|
91
|
+
opts.on("--jobs N", Integer, "Concurrent workers (default 1)") { |v| set(:jobs, v) }
|
|
92
|
+
opts.on("--seed N", Integer, "Seed for Ruby's random numbers in every test run (default 1)") do |v|
|
|
93
|
+
set(:seed, v)
|
|
94
|
+
end
|
|
95
|
+
opts.on("--config PATH", "Config file (default .saboteur.yml)") { |v| set(:config, v) }
|
|
96
|
+
opts.on("--integration NAME", "Test adapter (rspec)") { |v| set(:integration, v) }
|
|
97
|
+
opts.on("--reeval STRATEGY", Execution::Reevaluator::STRATEGIES, "class_eval or nesting") { |v| set(:reeval, v) }
|
|
98
|
+
opts.on("--json PATH", "Stryker JSON report path") { |v| set(:json_report, v) }
|
|
99
|
+
opts.on("--no-cache", "Rebuild the coverage map") { set(:cache, false) }
|
|
100
|
+
opts.on("--incremental", "Reuse earlier verdicts whose inputs did not change") { set(:incremental, true) }
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def add_subject(expr)
|
|
105
|
+
(options[:subjects] ||= []) << expr
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def set(key, value)
|
|
109
|
+
options[key] = value
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "yaml"
|
|
5
|
+
require_relative "mutation_source/builtin"
|
|
6
|
+
|
|
7
|
+
module Saboteur
|
|
8
|
+
# Settings from .saboteur.yml, overridden by CLI flags.
|
|
9
|
+
class Config
|
|
10
|
+
FILE = ".saboteur.yml"
|
|
11
|
+
|
|
12
|
+
DEFAULTS = {
|
|
13
|
+
"integration" => "rspec",
|
|
14
|
+
"require" => [],
|
|
15
|
+
"include" => ["lib/**/*.rb"],
|
|
16
|
+
"exclude" => [],
|
|
17
|
+
"arid" => MutationSource::Builtin::DEFAULT_ARID,
|
|
18
|
+
"timeout_factor" => 3.0,
|
|
19
|
+
"timeout_min" => 5.0,
|
|
20
|
+
"baseline_timeout" => 300.0,
|
|
21
|
+
"coverage_timeout" => 3600.0,
|
|
22
|
+
"max_memory_mb" => 2048,
|
|
23
|
+
"max_processes" => 64,
|
|
24
|
+
"jobs" => 1,
|
|
25
|
+
"ignore_file" => ".saboteur-ignore.yml",
|
|
26
|
+
"sources" => ["builtin"],
|
|
27
|
+
"hooks" => [],
|
|
28
|
+
"rails" => false,
|
|
29
|
+
"reeval" => "nesting",
|
|
30
|
+
"cache_dir" => ".saboteur",
|
|
31
|
+
"json_report" => ".saboteur/report.json",
|
|
32
|
+
"rspec_args" => [],
|
|
33
|
+
"subjects" => [],
|
|
34
|
+
"since" => nil,
|
|
35
|
+
"cache" => true,
|
|
36
|
+
"incremental" => false,
|
|
37
|
+
"seed" => 1
|
|
38
|
+
}.freeze
|
|
39
|
+
|
|
40
|
+
INTEGER_KEYS = %w[jobs max_memory_mb max_processes seed].freeze
|
|
41
|
+
FLOAT_KEYS = %w[timeout_factor timeout_min baseline_timeout coverage_timeout].freeze
|
|
42
|
+
LIST_KEYS = %w[require include exclude arid sources hooks rspec_args subjects].freeze
|
|
43
|
+
|
|
44
|
+
attr_reader :root
|
|
45
|
+
|
|
46
|
+
def self.load(root: Dir.pwd, path: nil, overrides: {})
|
|
47
|
+
path ||= File.join(root, FILE)
|
|
48
|
+
from_file = File.file?(path) ? parse(path) : {}
|
|
49
|
+
new(from_file.merge(stringify(overrides)), root: root)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def self.parse(path)
|
|
53
|
+
data = YAML.safe_load_file(path) || {}
|
|
54
|
+
raise ConfigError, "#{path}: expected a mapping at the top level" unless data.is_a?(Hash)
|
|
55
|
+
|
|
56
|
+
data
|
|
57
|
+
rescue Psych::SyntaxError => e
|
|
58
|
+
raise ConfigError, "#{path}: #{e.message}"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def self.stringify(hash)
|
|
62
|
+
hash.to_h { |key, value| [key.to_s, value] }.compact
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def initialize(values = {}, root: Dir.pwd)
|
|
66
|
+
@root = File.expand_path(root)
|
|
67
|
+
unknown = values.keys - DEFAULTS.keys
|
|
68
|
+
raise ConfigError, "unknown config key(s): #{unknown.join(', ')}" if unknown.any?
|
|
69
|
+
|
|
70
|
+
@values = DEFAULTS.merge(values)
|
|
71
|
+
coerce!
|
|
72
|
+
validate!
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def [](key)
|
|
76
|
+
@values.fetch(key.to_s)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def fetch(key, default = nil)
|
|
80
|
+
@values.fetch(key.to_s, default)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def to_h
|
|
84
|
+
@values.dup
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Ruby files to mutate, as absolute real paths.
|
|
88
|
+
def included_files
|
|
89
|
+
matched = self["include"].flat_map { |pattern| Dir.glob(pattern, base: root) }
|
|
90
|
+
excluded = self["exclude"].flat_map { |pattern| Dir.glob(pattern, base: root) }
|
|
91
|
+
(matched - excluded).select { |file| file.end_with?(".rb") }
|
|
92
|
+
.map { |file| File.realpath(File.join(root, file)) }.uniq.sort
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Patterns that match no file. A pattern goes stale when files move;
|
|
96
|
+
# an exclude that silently stops excluding is a hazard, so say so.
|
|
97
|
+
def unmatched_patterns
|
|
98
|
+
%w[include exclude].flat_map do |key|
|
|
99
|
+
self[key].select { |pattern| Dir.glob(pattern, base: root).empty? }.map { |pattern| "#{key}: #{pattern}" }
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def path(key)
|
|
104
|
+
value = self[key]
|
|
105
|
+
value && File.expand_path(value, root)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
private
|
|
109
|
+
|
|
110
|
+
def coerce!
|
|
111
|
+
INTEGER_KEYS.each { |key| @values[key] = Integer(@values[key]) }
|
|
112
|
+
FLOAT_KEYS.each { |key| @values[key] = Float(@values[key]) }
|
|
113
|
+
LIST_KEYS.each { |key| @values[key] = Array(@values[key]).map(&:to_s) }
|
|
114
|
+
rescue ArgumentError, TypeError => e
|
|
115
|
+
raise ConfigError, "invalid config value: #{e.message}"
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def validate!
|
|
119
|
+
raise ConfigError, "jobs must be >= 1" if self["jobs"] < 1
|
|
120
|
+
raise ConfigError, "seed must be >= 0" if self["seed"].negative?
|
|
121
|
+
raise ConfigError, "max_memory_mb must be > 0" unless self["max_memory_mb"].positive?
|
|
122
|
+
raise ConfigError, "max_processes must be > 0" unless self["max_processes"].positive?
|
|
123
|
+
raise ConfigError, "timeout_factor must be > 0" unless self["timeout_factor"].positive?
|
|
124
|
+
raise ConfigError, "timeout_min must be > 0" unless self["timeout_min"].positive?
|
|
125
|
+
raise ConfigError, "reeval must be one of: #{Execution::Reevaluator::STRATEGIES.join(', ')}" unless
|
|
126
|
+
Execution::Reevaluator::STRATEGIES.include?(self["reeval"].to_s)
|
|
127
|
+
|
|
128
|
+
self["arid"].each { |pattern| Regexp.new(pattern) }
|
|
129
|
+
rescue RegexpError => e
|
|
130
|
+
raise ConfigError, "invalid arid pattern: #{e.message}"
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Saboteur
|
|
5
|
+
class Engine
|
|
6
|
+
# Loads the target once in the parent: hook files, the optional Rails
|
|
7
|
+
# profile, configured boot files, then the test adapter (which loads the
|
|
8
|
+
# test files without running any test). Returns the ready adapter.
|
|
9
|
+
class Boot
|
|
10
|
+
# A test suite that (perhaps only under a mutant) starts a saboteur run
|
|
11
|
+
# would otherwise run saboteur inside a saboteur child, recursively.
|
|
12
|
+
def self.refuse_recursion!
|
|
13
|
+
return unless ENV[Execution::Supervisor::CHILD_ENV]
|
|
14
|
+
|
|
15
|
+
raise Error, "refusing to start: this process is a saboteur child " \
|
|
16
|
+
"(#{Execution::Supervisor::CHILD_ENV} is set). A test is running saboteur itself; stub it."
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Prepended to SimpleCov's singleton: saboteur needs Ruby's Coverage for
|
|
20
|
+
# its own map, and Coverage can only be set up once per process.
|
|
21
|
+
module SimpleCovOff
|
|
22
|
+
def start(*, **)
|
|
23
|
+
nil
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Watches for the app's SimpleCov and turns its start into a no-op the
|
|
28
|
+
# moment the constant is defined, before any line of the app's
|
|
29
|
+
# SimpleCov setup runs. saboteur does not load SimpleCov itself:
|
|
30
|
+
# loading it runs the app's .simplecov at once.
|
|
31
|
+
module SimpleCovWatch
|
|
32
|
+
def const_added(name)
|
|
33
|
+
super
|
|
34
|
+
Boot.silence_simplecov if name == :SimpleCov && equal?(Object)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def self.watch_for_simplecov
|
|
39
|
+
Object.singleton_class.prepend(SimpleCovWatch) unless Object.singleton_class.include?(SimpleCovWatch)
|
|
40
|
+
silence_simplecov if defined?(::SimpleCov)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def self.silence_simplecov
|
|
44
|
+
return if ::SimpleCov.singleton_class.include?(SimpleCovOff)
|
|
45
|
+
|
|
46
|
+
::SimpleCov.singleton_class.prepend(SimpleCovOff)
|
|
47
|
+
warn "saboteur: SimpleCov.start ignored for this run (saboteur needs Ruby's Coverage itself)"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# The Rails profile when rails: true, else nil (set by load_target).
|
|
51
|
+
attr_reader :rails
|
|
52
|
+
|
|
53
|
+
def initialize(config, hooks)
|
|
54
|
+
@config = config
|
|
55
|
+
@hooks = hooks
|
|
56
|
+
@root = File.realpath(config.root)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def load_target
|
|
60
|
+
self.class.watch_for_simplecov
|
|
61
|
+
load_hooks
|
|
62
|
+
@rails = rails = Frameworks::Rails.new(@config, @hooks) if @config["rails"]
|
|
63
|
+
rails&.before_boot
|
|
64
|
+
@hooks.run(:before_boot)
|
|
65
|
+
@config["require"].each { |file| require File.expand_path(file, @root) }
|
|
66
|
+
integration = Integration.fetch(@config["integration"]).new(@config)
|
|
67
|
+
integration.setup
|
|
68
|
+
rails&.after_boot
|
|
69
|
+
@hooks.run(:after_boot)
|
|
70
|
+
integration
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
def load_hooks
|
|
76
|
+
Saboteur.current_hooks = @hooks
|
|
77
|
+
@config["hooks"].each { |file| load File.expand_path(file, @root) }
|
|
78
|
+
ensure
|
|
79
|
+
Saboteur.current_hooks = nil
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "prism"
|
|
5
|
+
|
|
6
|
+
module Saboteur
|
|
7
|
+
class Engine
|
|
8
|
+
# Builds the mutation sources from config and turns their proposals for
|
|
9
|
+
# a subject into runnable candidates: applied, re-parsed, de-duplicated,
|
|
10
|
+
# coarse first. Returns [[mutation, mutated_text]].
|
|
11
|
+
class Candidates
|
|
12
|
+
def self.build_sources(config)
|
|
13
|
+
root = File.realpath(config.root)
|
|
14
|
+
config["sources"].map do |entry|
|
|
15
|
+
next MutationSource::Builtin.new(arid: config["arid"]) if entry == "builtin"
|
|
16
|
+
|
|
17
|
+
file, const = entry.split(":", 2)
|
|
18
|
+
raise ConfigError, "mutation source #{entry.inspect} must be builtin or path.rb:ClassName" unless const
|
|
19
|
+
|
|
20
|
+
require File.expand_path(file, root)
|
|
21
|
+
Object.const_get(const).new
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def initialize(sources, diagnostics)
|
|
26
|
+
@sources = sources
|
|
27
|
+
@diagnostics = diagnostics
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def for(subject)
|
|
31
|
+
return [] unless parses?(subject.source)
|
|
32
|
+
|
|
33
|
+
seen = Set[subject.source]
|
|
34
|
+
list = @sources.flat_map { |source| source.mutations_for(subject) }.filter_map do |mutation|
|
|
35
|
+
candidate(subject, mutation, seen)
|
|
36
|
+
end
|
|
37
|
+
list.sort_by.with_index { |(mutation, _), index| [mutation.coarse ? 0 : 1, index] }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def candidate(subject, mutation, seen)
|
|
43
|
+
text = subject.apply(mutation.edits)
|
|
44
|
+
return if seen.include?(text)
|
|
45
|
+
|
|
46
|
+
seen << text
|
|
47
|
+
unless parses?(text)
|
|
48
|
+
@diagnostics.count_invalid_mutation
|
|
49
|
+
return
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
[mutation, text]
|
|
53
|
+
rescue ArgumentError => e
|
|
54
|
+
@diagnostics.warn("#{subject.id}: dropped #{mutation.source} mutation: #{e.message}")
|
|
55
|
+
nil
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def parses?(text)
|
|
59
|
+
Prism.parse(text).success?
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|