mutineer 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 +42 -0
- data/LICENSE +21 -0
- data/README.md +118 -0
- data/bin/mutineer +6 -0
- data/lib/mutineer/cli.rb +261 -0
- data/lib/mutineer/config.rb +145 -0
- data/lib/mutineer/coverage_map.rb +222 -0
- data/lib/mutineer/isolation.rb +157 -0
- data/lib/mutineer/minitest_integration.rb +43 -0
- data/lib/mutineer/mutation.rb +21 -0
- data/lib/mutineer/mutator_registry.rb +54 -0
- data/lib/mutineer/mutators/arithmetic.rb +29 -0
- data/lib/mutineer/mutators/base.rb +23 -0
- data/lib/mutineer/mutators/boolean_connector.rb +42 -0
- data/lib/mutineer/mutators/boolean_literal.rb +43 -0
- data/lib/mutineer/mutators/comparison.rb +36 -0
- data/lib/mutineer/mutators/condition_negation.rb +40 -0
- data/lib/mutineer/mutators/literal_mutation.rb +40 -0
- data/lib/mutineer/mutators/return_nil.rb +63 -0
- data/lib/mutineer/mutators/statement_removal.rb +34 -0
- data/lib/mutineer/parser.rb +26 -0
- data/lib/mutineer/project.rb +69 -0
- data/lib/mutineer/reporter.rb +204 -0
- data/lib/mutineer/result.rb +77 -0
- data/lib/mutineer/runner.rb +175 -0
- data/lib/mutineer/subject.rb +19 -0
- data/lib/mutineer/version.rb +5 -0
- data/lib/mutineer/worker_pool.rb +112 -0
- data/lib/mutineer.rb +29 -0
- metadata +104 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 4020e2cd0e53ac4f406998d2c251d26abee9f75c4e6ceef365f2ffe565be50ab
|
|
4
|
+
data.tar.gz: f42dbc71077fc802b56f878e27885568f3d128f244875fb678dede5f6940f385
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: dc24bb60419b7d701a85a09c70495c9d9d7ce2cd2d5b5a10621cb03a940276c3526dc650ee4deb7f93529e09b67b872777c2483419979d5740a83aa9ac9bfd26
|
|
7
|
+
data.tar.gz: ff59317a64ab0e3478c7dde381ac9752b56c83920d1a63bcf6528ac826f286d787b2d560c1946e897790bd47325ac2209d22b537e3baa146b90f2bedcd2f0c72
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented here. The format is based on
|
|
4
|
+
[Keep a Changelog](https://keepachangelog.com/), and this project adheres to
|
|
5
|
+
[Semantic Versioning](https://semver.org/).
|
|
6
|
+
|
|
7
|
+
## [0.2.0] - 2026-06-28
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
- **Boot mode for Rails (and any app needing its environment booted)** —
|
|
11
|
+
`--rails` boots `config/environment` once in the parent and forks per mutant
|
|
12
|
+
(children inherit the booted app), defaults the strategy to `redefine`, and
|
|
13
|
+
reconnects ActiveRecord in each fork for DB fork-safety. `--boot FILE` boots a
|
|
14
|
+
custom entry point. Boot mode requires `--test` files and runs them for every
|
|
15
|
+
mutant (coverage-guided selection in boot mode is future work). `.mutineer.yml`
|
|
16
|
+
accepts `boot:` and `rails:`.
|
|
17
|
+
- GitHub Actions CI (test suite + gem build on Ruby 3.4, ubuntu + macos).
|
|
18
|
+
|
|
19
|
+
### Changed
|
|
20
|
+
- `--strategy` values are now `reload` / `redefine` (canonical); `7a` / `7b`
|
|
21
|
+
remain accepted as deprecated aliases.
|
|
22
|
+
|
|
23
|
+
## [0.1.0] - 2026-06-28
|
|
24
|
+
|
|
25
|
+
### Added
|
|
26
|
+
- Initial release of Mutineer — a clean-room, Prism-based mutation-testing tool
|
|
27
|
+
for Ruby with zero runtime dependencies (Ruby ≥ 3.4).
|
|
28
|
+
- Mutation operators: arithmetic, comparison, boolean-connector, boolean-literal,
|
|
29
|
+
statement-removal (Tier 1, default); return-nil, literal-mutation,
|
|
30
|
+
condition-negation (Tier 2, opt-in via `--operators`).
|
|
31
|
+
- Coverage-guided test selection with a digest-keyed, auto-invalidating cache.
|
|
32
|
+
- Fork-isolated, parallel execution (`--jobs`) with per-mutant timeouts.
|
|
33
|
+
- Two application strategies: `reload` (whole-file, default) and `redefine`
|
|
34
|
+
(surgical), verified to agree on namespaced multi-statement methods. (`7a`/`7b`
|
|
35
|
+
accepted as deprecated aliases.)
|
|
36
|
+
- `run`, `--dry-run`, `--threshold`, `--only`, `--operators`, `--strategy`,
|
|
37
|
+
`--format human|json`, `--output`, `--list-operators`.
|
|
38
|
+
- `.mutineer.yml` configuration (CLI > config > default precedence).
|
|
39
|
+
- Byte-correct source handling for multibyte (UTF-8) sources.
|
|
40
|
+
|
|
41
|
+
[0.2.0]: https://github.com/davidteren/mutineer/releases/tag/v0.2.0
|
|
42
|
+
[0.1.0]: https://github.com/davidteren/mutineer/releases/tag/v0.1.0
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 David Teren
|
|
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,118 @@
|
|
|
1
|
+
# Mutineer
|
|
2
|
+
|
|
3
|
+
A clean-room mutation-testing tool for Ruby. Mutineer mutates your source one
|
|
4
|
+
change at a time, runs your Minitest suite against each mutant, and reports the
|
|
5
|
+
ones your tests failed to catch — the gaps where your suite isn't actually
|
|
6
|
+
testing anything.
|
|
7
|
+
|
|
8
|
+
- **Prism + stdlib only** — zero runtime dependencies (Ruby ≥ 3.4).
|
|
9
|
+
- **One mutation per mutant**, validity-checked by re-parsing.
|
|
10
|
+
- **Fork-isolated**, parallel execution (Linux + macOS).
|
|
11
|
+
- **Coverage-guided** — each mutant runs only the test files that cover its line.
|
|
12
|
+
|
|
13
|
+
📖 **[mutineer.github.io →](https://davidteren.github.io/mutineer/)** — overview, operators, and usage.
|
|
14
|
+
|
|
15
|
+
## Install
|
|
16
|
+
|
|
17
|
+
```sh
|
|
18
|
+
gem install mutineer
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Or in a Gemfile:
|
|
22
|
+
|
|
23
|
+
```ruby
|
|
24
|
+
gem "mutineer", group: :test
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Usage
|
|
28
|
+
|
|
29
|
+
```sh
|
|
30
|
+
mutineer run <source...> --test <test...> [options]
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Mutate `lib/calculator.rb`, checking it against its test, and fail CI if the
|
|
34
|
+
mutation score drops below 90%:
|
|
35
|
+
|
|
36
|
+
```sh
|
|
37
|
+
mutineer run lib/calculator.rb --test test/calculator_test.rb --threshold 90
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
### Options
|
|
41
|
+
|
|
42
|
+
| Flag | Meaning |
|
|
43
|
+
|------|---------|
|
|
44
|
+
| `--test FILE` | Test file covering the sources (repeatable) |
|
|
45
|
+
| `--operators LIST` | Comma-separated operator names (default: the Tier-1 set) |
|
|
46
|
+
| `--threshold FLOAT` | Exit 1 when the score is below FLOAT (default: 0 = off) |
|
|
47
|
+
| `--only NAME` | Restrict to one fully-qualified subject, e.g. `Calculator#add` |
|
|
48
|
+
| `--jobs N` | Parallel worker count (default: processor count) |
|
|
49
|
+
| `--strategy NAME` | Mutation application: `reload` whole-file (default) or `redefine` surgical (`7a`/`7b` accepted as deprecated aliases) |
|
|
50
|
+
| `--format human\|json` | Report format (default: human) |
|
|
51
|
+
| `--output FILE` | Write the report to FILE instead of stdout |
|
|
52
|
+
| `--dry-run` | List candidate mutations without executing |
|
|
53
|
+
| `--list-operators` | List available operators (default vs optional) and exit |
|
|
54
|
+
| `--version`, `--help` | Print version / usage and exit |
|
|
55
|
+
|
|
56
|
+
### Exit codes
|
|
57
|
+
|
|
58
|
+
| Code | Meaning |
|
|
59
|
+
|------|---------|
|
|
60
|
+
| `0` | Score ≥ threshold (or no threshold set) |
|
|
61
|
+
| `1` | Survivors below threshold, or a runtime error |
|
|
62
|
+
| `2` | Usage / invalid-flag error |
|
|
63
|
+
|
|
64
|
+
### Operators
|
|
65
|
+
|
|
66
|
+
Run `mutineer --list-operators` to see them. Default (Tier 1): `arithmetic`,
|
|
67
|
+
`comparison`, `boolean_connector`, `boolean_literal`, `statement_removal`.
|
|
68
|
+
Available but off by default (Tier 2, enable via `--operators`): `return_nil`,
|
|
69
|
+
`literal_mutation`, `condition_negation`.
|
|
70
|
+
|
|
71
|
+
## Rails apps
|
|
72
|
+
|
|
73
|
+
Rails code needs its environment booted before the suite runs, so point Mutineer
|
|
74
|
+
at your app with `--rails` and run it inside the project's bundle:
|
|
75
|
+
|
|
76
|
+
```sh
|
|
77
|
+
RAILS_ENV=test bundle exec mutineer run \
|
|
78
|
+
app/models/order.rb --test test/models/order_test.rb --rails
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
`--rails` boots `config/environment` once in the parent process (every mutant
|
|
82
|
+
then forks and inherits it), defaults `--strategy` to `redefine` (surgical — it
|
|
83
|
+
avoids reloading files into the app tree), and reconnects ActiveRecord in each
|
|
84
|
+
fork so the database connection is fork-safe. Use `--boot FILE` to boot a
|
|
85
|
+
different entry point. Boot mode requires at least one `--test` file and runs
|
|
86
|
+
those tests for every mutant (coverage-guided selection is not yet available in
|
|
87
|
+
boot mode).
|
|
88
|
+
|
|
89
|
+
Add Mutineer to your Gemfile's test group:
|
|
90
|
+
|
|
91
|
+
```ruby
|
|
92
|
+
gem "mutineer", group: :test, require: false
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
## Configuration
|
|
96
|
+
|
|
97
|
+
Mutineer reads an optional `.mutineer.yml` from the project root (nearest one,
|
|
98
|
+
walking up). CLI flags override config; config overrides defaults.
|
|
99
|
+
|
|
100
|
+
Sources are positional CLI arguments and test files come from `--test`; the
|
|
101
|
+
config file accepts these keys: `operators`, `threshold`, `jobs`, `only`,
|
|
102
|
+
`require` (extra files to load before mutating), and `boot`/`rails`.
|
|
103
|
+
|
|
104
|
+
```yaml
|
|
105
|
+
# .mutineer.yml
|
|
106
|
+
operators: [arithmetic, comparison, boolean_connector, boolean_literal, statement_removal]
|
|
107
|
+
threshold: 90
|
|
108
|
+
jobs: 4
|
|
109
|
+
require:
|
|
110
|
+
- config/environment
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
Coverage results are cached in `.mutineer/coverage.json` (digest-keyed; rebuilt
|
|
114
|
+
automatically when sources change). Add `.mutineer/` to your `.gitignore`.
|
|
115
|
+
|
|
116
|
+
## License
|
|
117
|
+
|
|
118
|
+
MIT — see [LICENSE](LICENSE).
|
data/bin/mutineer
ADDED
data/lib/mutineer/cli.rb
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optparse"
|
|
4
|
+
require "set"
|
|
5
|
+
require_relative "version"
|
|
6
|
+
require_relative "config"
|
|
7
|
+
require_relative "parser"
|
|
8
|
+
require_relative "project"
|
|
9
|
+
require_relative "runner"
|
|
10
|
+
require_relative "reporter"
|
|
11
|
+
require_relative "mutator_registry"
|
|
12
|
+
|
|
13
|
+
module Mutineer
|
|
14
|
+
# Command-line entry point. `start` is the single public method called by
|
|
15
|
+
# bin/mutineer; it parses argv, acts, and exits with a pinned code.
|
|
16
|
+
#
|
|
17
|
+
# Exit codes (taxonomy consistent across M1–M5):
|
|
18
|
+
# 0 success / requested output (--version, --help, score >= threshold)
|
|
19
|
+
# 1 survivors below threshold, or a runtime error
|
|
20
|
+
# 2 usage / flag error (unknown subcommand, invalid flag, unknown operator,
|
|
21
|
+
# out-of-range threshold)
|
|
22
|
+
class CLI
|
|
23
|
+
BANNER = <<~USAGE
|
|
24
|
+
Usage: mutineer [options] <command> [args]
|
|
25
|
+
|
|
26
|
+
Commands:
|
|
27
|
+
run [options] <source...> --test <test...> Mutate, run, and report
|
|
28
|
+
run --dry-run [options] <source...> Print candidate mutations only
|
|
29
|
+
|
|
30
|
+
Run options:
|
|
31
|
+
--test FILE Test file covering the sources (repeatable)
|
|
32
|
+
--operators LIST Comma-separated operator names (default: Tier 1 set)
|
|
33
|
+
--threshold FLOAT Fail (exit 1) when score < FLOAT (default: 0 = off)
|
|
34
|
+
--only NAME Restrict to one fully-qualified subject
|
|
35
|
+
--jobs N Parallel worker count (default: processor count)
|
|
36
|
+
--strategy NAME reload (whole-file) or redefine (surgical); default: reload
|
|
37
|
+
--boot FILE Require FILE once in the parent to boot the app env, then
|
|
38
|
+
fork per mutant (Rails apps; requires --test, no coverage)
|
|
39
|
+
--rails Sugar for --boot config/environment --strategy redefine
|
|
40
|
+
--format human|json Report format (default: human)
|
|
41
|
+
--output FILE Write the report to FILE instead of stdout
|
|
42
|
+
--dry-run List mutations without executing
|
|
43
|
+
|
|
44
|
+
Options:
|
|
45
|
+
--list-operators List available operators (default vs optional) and exit
|
|
46
|
+
--version Print version and exit
|
|
47
|
+
--help Print this help and exit
|
|
48
|
+
USAGE
|
|
49
|
+
|
|
50
|
+
# Field symbols whose config-file value is suppressed when the flag is typed.
|
|
51
|
+
PRECEDENCE_FLAGS = %i[operators jobs threshold only].freeze
|
|
52
|
+
|
|
53
|
+
# Deprecated internal strategy names, mapped to their canonical equivalents.
|
|
54
|
+
STRATEGY_ALIASES = { "7a" => "reload", "7b" => "redefine" }.freeze
|
|
55
|
+
|
|
56
|
+
def self.start(argv)
|
|
57
|
+
opts = {} # symbol => value, the CLI-provided Config fields
|
|
58
|
+
explicit = Set.new # precedence keys the user typed (KTD3)
|
|
59
|
+
show_operators = false
|
|
60
|
+
|
|
61
|
+
parser = OptionParser.new do |o|
|
|
62
|
+
o.banner = BANNER
|
|
63
|
+
o.on("--version") do
|
|
64
|
+
puts Mutineer::VERSION
|
|
65
|
+
exit 0
|
|
66
|
+
end
|
|
67
|
+
o.on("--help") do
|
|
68
|
+
puts BANNER
|
|
69
|
+
exit 0
|
|
70
|
+
end
|
|
71
|
+
o.on("--list-operators") { show_operators = true }
|
|
72
|
+
o.on("--dry-run") { opts[:dry_run] = true }
|
|
73
|
+
o.on("--only NAME") { |v| opts[:only] = v; explicit << :only }
|
|
74
|
+
o.on("--test FILE") { |v| (opts[:tests] ||= []) << v }
|
|
75
|
+
o.on("--operators LIST") { |v| opts[:operators] = v.split(",").map(&:strip); explicit << :operators }
|
|
76
|
+
o.on("--threshold FLOAT") { |v| opts[:threshold] = v.to_f; explicit << :threshold }
|
|
77
|
+
o.on("--jobs N") { |v| opts[:jobs] = v; explicit << :jobs }
|
|
78
|
+
o.on("--strategy STRAT") { |v| opts[:strategy] = v; explicit << :strategy }
|
|
79
|
+
o.on("--boot FILE") { |v| opts[:boot] = v; explicit << :boot }
|
|
80
|
+
o.on("--rails") { opts[:rails] = true }
|
|
81
|
+
o.on("--format FORMAT") { |v| opts[:format] = v }
|
|
82
|
+
o.on("--output FILE") { |v| opts[:output] = v }
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
begin
|
|
86
|
+
parser.parse!(argv)
|
|
87
|
+
rescue OptionParser::InvalidOption, OptionParser::MissingArgument => e
|
|
88
|
+
warn "mutineer: #{e.message}"
|
|
89
|
+
exit 2
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
if show_operators
|
|
93
|
+
list_operators
|
|
94
|
+
exit 0
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
if argv.empty?
|
|
98
|
+
puts BANNER
|
|
99
|
+
exit 0
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
begin
|
|
103
|
+
file_path = Config.find_file
|
|
104
|
+
file_hash = file_path ? Config.from_file(file_path) : {}
|
|
105
|
+
config = Config.resolve(opts, file_hash, explicit)
|
|
106
|
+
rescue Mutineer::ConfigError => e
|
|
107
|
+
# R8: the lib layer raises instead of killing the host; the CLI maps a
|
|
108
|
+
# config (usage) error to exit 2.
|
|
109
|
+
warn "mutineer: #{e.message}"
|
|
110
|
+
exit 2
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
case argv.first
|
|
114
|
+
when "run"
|
|
115
|
+
config.sources = argv[1..]
|
|
116
|
+
run(config)
|
|
117
|
+
else
|
|
118
|
+
warn "mutineer: unknown command '#{argv.first}'"
|
|
119
|
+
exit 2
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def self.list_operators
|
|
124
|
+
MutatorRegistry::ALL.each_key do |name|
|
|
125
|
+
state = MutatorRegistry.default?(name) ? "default" : "disabled"
|
|
126
|
+
puts format("%-20s tier %d %-9s %s",
|
|
127
|
+
name, MutatorRegistry.tier(name), state, MutatorRegistry::DESCRIPTIONS[name])
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def self.run(config)
|
|
132
|
+
if config.sources.empty?
|
|
133
|
+
warn "mutineer: run requires at least one source file"
|
|
134
|
+
exit 2
|
|
135
|
+
end
|
|
136
|
+
validate!(config)
|
|
137
|
+
|
|
138
|
+
config.dry_run ? dry_run(config) : execute(config)
|
|
139
|
+
rescue ArgumentError => e
|
|
140
|
+
# Unknown --operators value surfaces here; no backtrace reaches the user.
|
|
141
|
+
warn "mutineer: #{e.message}"
|
|
142
|
+
exit 2
|
|
143
|
+
rescue SystemCallError => e
|
|
144
|
+
# R5: a missing/unreadable path reaches here as Errno::ENOENT etc. — a plain
|
|
145
|
+
# message and usage exit, never a raw backtrace.
|
|
146
|
+
warn "mutineer: #{e.message}"
|
|
147
|
+
exit 2
|
|
148
|
+
rescue SyntaxError => e
|
|
149
|
+
# A syntactically invalid source file surfaces when `require`d; report it
|
|
150
|
+
# cleanly rather than dumping a backtrace.
|
|
151
|
+
warn "mutineer: cannot load source: #{e.message}"
|
|
152
|
+
exit 1
|
|
153
|
+
rescue Mutineer::ParseError => e
|
|
154
|
+
warn "mutineer: error reading: #{e.message}"
|
|
155
|
+
exit 1
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Flag validation: every flag/usage failure exits 2 (C7), consistent with the
|
|
159
|
+
# taxonomy above — CI can tell "mistyped flag" from "tests too weak."
|
|
160
|
+
def self.validate!(config)
|
|
161
|
+
unless (0.0..100.0).cover?(config.threshold)
|
|
162
|
+
warn "mutineer: --threshold must be between 0 and 100"
|
|
163
|
+
exit 2
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
jobs = Integer(config.jobs.to_s, exception: false)
|
|
167
|
+
if jobs.nil? || jobs < 1
|
|
168
|
+
warn "mutineer: --jobs requires a positive integer (got: #{config.jobs})"
|
|
169
|
+
exit 2
|
|
170
|
+
end
|
|
171
|
+
config.jobs = jobs
|
|
172
|
+
|
|
173
|
+
unless %w[human json].include?(config.format)
|
|
174
|
+
warn %(mutineer: unknown format "#{config.format}". Expected: human, json)
|
|
175
|
+
exit 2
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# Canonical strategies are reload|redefine; 7a/7b are accepted as deprecated
|
|
179
|
+
# aliases. Normalize to canonical so the rest of the pipeline sees one name.
|
|
180
|
+
config.strategy = STRATEGY_ALIASES.fetch(config.strategy, config.strategy)
|
|
181
|
+
unless %w[reload redefine].include?(config.strategy)
|
|
182
|
+
warn %(mutineer: unknown strategy "#{config.strategy}". Expected: reload, redefine)
|
|
183
|
+
exit 2
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# Boot mode does no coverage selection — every mutant runs the given tests —
|
|
187
|
+
# so at least one --test file is mandatory (there is nothing to select from).
|
|
188
|
+
if config.boot && config.tests.empty?
|
|
189
|
+
warn "mutineer: --boot/--rails requires at least one --test file"
|
|
190
|
+
exit 2
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
preflight_output!(config.output) if config.output
|
|
194
|
+
validate_paths!(config)
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# R5: validate path existence up front so a typo is a clean usage error (exit
|
|
198
|
+
# 2), not an Errno::ENOENT backtrace from deep in the run. Flag checks run
|
|
199
|
+
# first so a bad flag still reports the flag, not the missing file.
|
|
200
|
+
def self.validate_paths!(config)
|
|
201
|
+
missing = (config.sources + config.tests)
|
|
202
|
+
.reject { |p| File.exist?(File.expand_path(p, config.project_root)) }
|
|
203
|
+
return if missing.empty?
|
|
204
|
+
|
|
205
|
+
warn "mutineer: no such file: #{missing.join(', ')}"
|
|
206
|
+
exit 2
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def self.preflight_output!(path)
|
|
210
|
+
dir = File.dirname(File.expand_path(path))
|
|
211
|
+
return if File.directory?(dir) && File.writable?(dir)
|
|
212
|
+
|
|
213
|
+
reason = File.directory?(dir) ? "directory is not writable" : "no such directory"
|
|
214
|
+
warn "mutineer: cannot write to #{path}: #{reason}"
|
|
215
|
+
exit 2
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def self.execute(config)
|
|
219
|
+
if config.tests.empty?
|
|
220
|
+
warn "mutineer: run requires at least one --test file (or use --dry-run)"
|
|
221
|
+
exit 2
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
aggregate, source_map = Runner.execute(config)
|
|
225
|
+
reporter = Reporter.new(aggregate, source_map)
|
|
226
|
+
reporter.report(out: $stdout, err: $stderr, threshold: config.threshold,
|
|
227
|
+
format: config.format, output: config.output)
|
|
228
|
+
exit reporter.exit_code(threshold: config.threshold)
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def self.dry_run(config)
|
|
232
|
+
operator_classes = MutatorRegistry.resolve(config.operators || MutatorRegistry::DEFAULT_NAMES)
|
|
233
|
+
sources = {}
|
|
234
|
+
per_operator = Hash.new(0)
|
|
235
|
+
skipped = 0
|
|
236
|
+
|
|
237
|
+
Project.discover(config.sources, only: config.only).each do |subject|
|
|
238
|
+
source = (sources[subject.file] ||= Parser.parse_file(subject.file).source.source)
|
|
239
|
+
operator_classes.each do |klass|
|
|
240
|
+
klass.new.mutations_for(subject, source).each do |mutation|
|
|
241
|
+
unless mutation.valid?(source)
|
|
242
|
+
skipped += 1
|
|
243
|
+
next
|
|
244
|
+
end
|
|
245
|
+
per_operator[mutation.operator] += 1
|
|
246
|
+
original = source.byteslice(mutation.start_offset...mutation.end_offset)
|
|
247
|
+
line = source.byteslice(0, mutation.start_offset).count("\n") + 1
|
|
248
|
+
puts "[#{mutation.operator}] #{subject.qualified_name} " \
|
|
249
|
+
"#{subject.file}:#{line} `#{original}` -> `#{mutation.replacement}`"
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
total = per_operator.values.sum
|
|
255
|
+
breakdown = per_operator.map { |op, n| "#{op}: #{n}" }.join(", ")
|
|
256
|
+
summary = breakdown.empty? ? "" : "#{breakdown} — "
|
|
257
|
+
puts "#{summary}#{total} mutations (dry run, not executed); #{skipped} skipped (invalid)"
|
|
258
|
+
exit 0
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
end
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "etc"
|
|
4
|
+
require "yaml"
|
|
5
|
+
|
|
6
|
+
module Mutineer
|
|
7
|
+
# Raised by the config layer instead of calling exit/abort — a data class must
|
|
8
|
+
# never kill the host process (R8). The CLI rescues this and maps it to exit 2.
|
|
9
|
+
class ConfigError < StandardError; end
|
|
10
|
+
|
|
11
|
+
# Plain run configuration, populated by the CLI (or directly by the
|
|
12
|
+
# integration test). `operators` nil means "all default operators";
|
|
13
|
+
# `threshold` 0.0 means the CI gate is off (spec §10).
|
|
14
|
+
#
|
|
15
|
+
# M5 adds: jobs (parallel workers), format (human|json), output (report file),
|
|
16
|
+
# strategy (reload|redefine), require_paths (extra files to load). Config loading and
|
|
17
|
+
# the CLI > file > default precedence merge live here (KTD3/KTD4).
|
|
18
|
+
#
|
|
19
|
+
# Boot mode adds: boot (a file to require ONCE in the parent so the app env —
|
|
20
|
+
# e.g. Rails — is booted before forking; sources are then NOT manually required)
|
|
21
|
+
# and rails (sugar: defaults boot to config/environment and strategy to redefine,
|
|
22
|
+
# and reconnects ActiveRecord per fork).
|
|
23
|
+
Config = Struct.new(
|
|
24
|
+
:sources, :tests, :operators, :threshold, :only, :dry_run,
|
|
25
|
+
:cache_dir, :project_root, :load_paths,
|
|
26
|
+
:jobs, :format, :output, :strategy, :require_paths,
|
|
27
|
+
:boot, :rails,
|
|
28
|
+
keyword_init: true
|
|
29
|
+
) do
|
|
30
|
+
CONFIG_FILE = ".mutineer.yml"
|
|
31
|
+
# Keys accepted in .mutineer.yml (R7). `require` maps to the :require_paths field.
|
|
32
|
+
KNOWN_KEYS = %w[operators jobs threshold only require boot rails].freeze
|
|
33
|
+
|
|
34
|
+
def initialize(**kwargs)
|
|
35
|
+
super
|
|
36
|
+
self.sources ||= []
|
|
37
|
+
self.tests ||= []
|
|
38
|
+
self.threshold ||= 0.0
|
|
39
|
+
self.dry_run ||= false
|
|
40
|
+
self.cache_dir ||= ".mutineer"
|
|
41
|
+
self.project_root ||= Dir.pwd
|
|
42
|
+
self.load_paths ||= ["lib"]
|
|
43
|
+
self.jobs ||= Etc.nprocessors
|
|
44
|
+
self.format ||= "human"
|
|
45
|
+
self.strategy ||= "reload"
|
|
46
|
+
self.require_paths ||= []
|
|
47
|
+
self.rails = false if rails.nil?
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Walk from `start` toward `home`, returning the first .mutineer.yml path found
|
|
51
|
+
# or nil. Checks `home` itself, then stops; if `start` is above `home`
|
|
52
|
+
# (e.g. /tmp), the walk continues to the filesystem root (KTD4). Pure
|
|
53
|
+
# discovery — reads no file content.
|
|
54
|
+
def self.find_file(start = Dir.pwd, home = File.expand_path("~"))
|
|
55
|
+
dir = File.expand_path(start)
|
|
56
|
+
loop do
|
|
57
|
+
candidate = File.join(dir, CONFIG_FILE)
|
|
58
|
+
return candidate if File.file?(candidate)
|
|
59
|
+
break if dir == home
|
|
60
|
+
|
|
61
|
+
parent = File.dirname(dir)
|
|
62
|
+
break if parent == dir # filesystem root
|
|
63
|
+
|
|
64
|
+
dir = parent
|
|
65
|
+
end
|
|
66
|
+
nil
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Parse a .mutineer.yml into a symbol-keyed hash of recognized keys. Unknown
|
|
70
|
+
# keys / unknown operator names emit a one-line stderr warning and are ignored
|
|
71
|
+
# (R7). A YAML syntax error raises ConfigError (R7a/R8) — never a silent
|
|
72
|
+
# fallback to defaults, and never an exit from the lib layer.
|
|
73
|
+
def self.from_file(path)
|
|
74
|
+
raw = YAML.safe_load(File.read(path)) || {}
|
|
75
|
+
name = File.basename(path)
|
|
76
|
+
unless raw.is_a?(Hash)
|
|
77
|
+
warn "mutineer: #{name} ignored: expected a YAML mapping of keys to values"
|
|
78
|
+
return {}
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
out = {}
|
|
82
|
+
raw.each do |key, value|
|
|
83
|
+
ks = key.to_s
|
|
84
|
+
unless KNOWN_KEYS.include?(ks)
|
|
85
|
+
warn "mutineer: unknown config key #{ks.inspect} in #{name} " \
|
|
86
|
+
"(known: #{KNOWN_KEYS.join(', ')}); ignored"
|
|
87
|
+
next
|
|
88
|
+
end
|
|
89
|
+
out[field_for(ks)] = coerce(ks, value, name)
|
|
90
|
+
end
|
|
91
|
+
out
|
|
92
|
+
rescue Psych::SyntaxError => e
|
|
93
|
+
raise ConfigError, "#{File.basename(path)} parse error: #{e.message}"
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Apply precedence (KTD3): start from the CLI-provided values, then fill in a
|
|
97
|
+
# config-file value only for keys the user did NOT type on the command line.
|
|
98
|
+
# `explicit` is a Set of field symbols the CLI saw with a value; a Set (not
|
|
99
|
+
# nil-sentinels) is used because some valid values are zero/false.
|
|
100
|
+
def self.resolve(cli_opts, file_hash, explicit)
|
|
101
|
+
merged = cli_opts.dup
|
|
102
|
+
file_hash.each { |k, v| merged[k] = v unless explicit.include?(k) }
|
|
103
|
+
config = new(**merged)
|
|
104
|
+
|
|
105
|
+
# --rails sugar: boot config/environment and prefer the surgical (redefine)
|
|
106
|
+
# strategy, which avoids writing tempfiles into the app tree and Zeitwerk
|
|
107
|
+
# reload hazards. An explicit --strategy always wins.
|
|
108
|
+
if config.rails
|
|
109
|
+
config.boot ||= "config/environment"
|
|
110
|
+
config.strategy = "redefine" unless explicit.include?(:strategy)
|
|
111
|
+
end
|
|
112
|
+
config
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def self.field_for(known_key)
|
|
116
|
+
known_key == "require" ? :require_paths : known_key.to_sym
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def self.coerce(known_key, value, file_name)
|
|
120
|
+
case known_key
|
|
121
|
+
when "operators" then filter_operators(Array(value).map(&:to_s), file_name)
|
|
122
|
+
when "jobs" then value.to_i
|
|
123
|
+
when "threshold" then value.to_f
|
|
124
|
+
when "require" then Array(value).map(&:to_s)
|
|
125
|
+
when "boot" then value.to_s
|
|
126
|
+
when "rails" then value == true || value.to_s == "true"
|
|
127
|
+
else value
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Drop (with a warning) operator names the registry doesn't know (R7).
|
|
132
|
+
# Referenced lazily so config.rb carries no load-order dependency on the
|
|
133
|
+
# registry; by the time a config is parsed at runtime, it is loaded.
|
|
134
|
+
def self.filter_operators(names, file_name)
|
|
135
|
+
known = MutatorRegistry::ALL.keys
|
|
136
|
+
names.select do |n|
|
|
137
|
+
next true if known.include?(n)
|
|
138
|
+
|
|
139
|
+
warn "mutineer: unknown operator #{n.inspect} in #{file_name} " \
|
|
140
|
+
"(known: #{known.join(', ')}); ignored"
|
|
141
|
+
false
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|