active_mutator 0.1.0 → 0.1.1
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/README.md +64 -59
- data/lib/active_mutator/accepted_ledger.rb +1 -1
- data/lib/active_mutator/atomic_file.rb +1 -1
- data/lib/active_mutator/baseline.rb +13 -4
- data/lib/active_mutator/baseline_hooks.rb +2 -2
- data/lib/active_mutator/coverage_map.rb +1 -1
- data/lib/active_mutator/fingerprint.rb +1 -1
- data/lib/active_mutator/runner.rb +2 -2
- data/lib/active_mutator/scheduler.rb +24 -3
- data/lib/active_mutator/version.rb +1 -1
- data/lib/active_mutator/worker.rb +2 -2
- metadata +5 -5
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: cfe7e211472b45836efb059eb37472307becf74e85b8441664e8cec836c10c26
|
|
4
|
+
data.tar.gz: 6e0ed299bc84b8c912e73314f58ea88cc2fbb783f6949aea14285a27ea2e5bd9
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 6153450b921e1647a13d14f4e473cc123c045ff738f2cea1b6786797e265b05bc7bc00233bdf66f192296be1c372f0c458f6910992d01716320033b86a0a00e9
|
|
7
|
+
data.tar.gz: b1d9c1133f21dbe6370651c2d00e7e4173620ac13986ce24e8b91931f5f5793985d41cf2f8a7be626b73c948b6a473dec99f6e3560f52630cdad83c8ab3d5d72
|
data/README.md
CHANGED
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
# active_mutator
|
|
2
2
|
|
|
3
|
+
[](https://rubygems.org/gems/active_mutator)
|
|
4
|
+
|
|
3
5
|
Mutation testing for Ruby, built on [Prism](https://github.com/ruby/prism).
|
|
4
|
-
Open source, RSpec-integrated, Rails-first.
|
|
6
|
+
Open source, RSpec-integrated, Rails-first. Available on
|
|
7
|
+
[RubyGems](https://rubygems.org/gems/active_mutator).
|
|
5
8
|
|
|
6
|
-
active_mutator mutates your code one small change at a time (`>`
|
|
7
|
-
`&&`
|
|
8
|
-
examples that cover the mutated line, and reports
|
|
9
|
-
fails to kill. A surviving mutant is a behavior
|
|
10
|
-
a precise, machine-verified test gap.
|
|
9
|
+
active_mutator mutates your code one small change at a time (`>` becomes `>=`,
|
|
10
|
+
`&&` becomes `||`, a statement gets deleted, a condition gets forced, and so
|
|
11
|
+
on). It runs exactly the examples that cover the mutated line, and reports
|
|
12
|
+
every mutant your suite fails to kill. A surviving mutant is a behavior
|
|
13
|
+
change no test notices: a precise, machine-verified test gap.
|
|
11
14
|
|
|
12
15
|
## A surviving mutant, in one example
|
|
13
16
|
|
|
@@ -35,31 +38,31 @@ Surviving mutants:
|
|
|
35
38
|
+ total <= 100
|
|
36
39
|
```
|
|
37
40
|
|
|
38
|
-
Nothing in the test suite calls `discount(100)
|
|
39
|
-
and `<=` disagree. The tests pass, coverage is green
|
|
40
|
-
still unverified. That gap is invisible to coverage and obvious to
|
|
41
|
+
Nothing in the test suite calls `discount(100)`, the one input where `<`
|
|
42
|
+
and `<=` disagree. The tests pass, and coverage is green. But the boundary
|
|
43
|
+
is still unverified. That gap is invisible to coverage and obvious to
|
|
41
44
|
mutation testing. Add `it { expect(calc.discount(100)).to eq(0) }` and the
|
|
42
45
|
mutant is killed.
|
|
43
46
|
|
|
44
47
|
## What is mutation testing?
|
|
45
48
|
|
|
46
49
|
Coverage answers "did a test run this line?" Mutation testing answers "would
|
|
47
|
-
a test *notice* if this line were wrong?"
|
|
48
|
-
|
|
50
|
+
a test *notice* if this line were wrong?" That is a different, and usually
|
|
51
|
+
more useful, question.
|
|
49
52
|
|
|
50
53
|
active_mutator applies one small, syntactically valid change to your code
|
|
51
54
|
(a "mutant") and re-runs only the examples that cover it. If a test fails,
|
|
52
55
|
the mutant is **killed**: your tests correctly reject that wrong behavior.
|
|
53
56
|
If every covering test still passes, the mutant **survived**: something
|
|
54
|
-
changed and nothing noticed. A survivor is not a hypothetical
|
|
55
|
-
exact line, the exact before
|
|
57
|
+
changed and nothing noticed. A survivor is not a hypothetical. It is the
|
|
58
|
+
exact line, the exact before and after diff, and proof that no assertion
|
|
56
59
|
depends on the difference.
|
|
57
60
|
|
|
58
61
|
Mutation score is `(killed + timeout) / (killed + timeout + survived)`.
|
|
59
|
-
100% is usually not the right target
|
|
60
|
-
*equivalent* to the original and can never be killed by any test
|
|
61
|
-
why active_mutator has a committed acceptance ledger
|
|
62
|
-
out with a stated reason instead of chasing an unreachable score.
|
|
62
|
+
100% is usually not the right target. Some mutants are behaviorally
|
|
63
|
+
*equivalent* to the original and can never be killed by any test. That is
|
|
64
|
+
why active_mutator has a committed acceptance ledger. It lets you close
|
|
65
|
+
survivors out with a stated reason instead of chasing an unreachable score.
|
|
63
66
|
|
|
64
67
|
Full primer, including the origin of the technique and further reading:
|
|
65
68
|
**[`docs/guides/what-is-mutation-testing.md`](docs/guides/what-is-mutation-testing.md)**.
|
|
@@ -73,7 +76,7 @@ group :development, :test do
|
|
|
73
76
|
end
|
|
74
77
|
```
|
|
75
78
|
|
|
76
|
-
Requires Ruby
|
|
79
|
+
Requires Ruby 3.2 or later, RSpec, and a green suite. Linux/macOS (MRI fork).
|
|
77
80
|
|
|
78
81
|
## Quick start
|
|
79
82
|
|
|
@@ -82,9 +85,10 @@ bundle install
|
|
|
82
85
|
bundle exec active_mutator app/models/calculator.rb
|
|
83
86
|
```
|
|
84
87
|
|
|
85
|
-
|
|
86
|
-
coverage map
|
|
87
|
-
|
|
88
|
+
The first run performs an instrumented baseline of your suite to build the
|
|
89
|
+
coverage map. The map is cached in `.active_mutator/` and refreshed
|
|
90
|
+
incrementally after that (see
|
|
91
|
+
[`docs/guides/how-it-works.md`](docs/guides/how-it-works.md)).
|
|
88
92
|
Then each mutant runs in its own fork against only its covering examples.
|
|
89
93
|
|
|
90
94
|
### Reading the output
|
|
@@ -115,36 +119,36 @@ Each character on the progress line is one mutant, printed as it finishes:
|
|
|
115
119
|
|
|
116
120
|
| Char | Status | Meaning |
|
|
117
121
|
|---|---|---|
|
|
118
|
-
| `.` | `killed` | a covering test failed
|
|
119
|
-
| `S` | `survived` | every covering test passed
|
|
120
|
-
| `T` | `timeout` | ran past its time budget
|
|
122
|
+
| `.` | `killed` | a covering test failed. Good, the mutant is dead |
|
|
123
|
+
| `S` | `survived` | every covering test passed. This is a test gap |
|
|
124
|
+
| `T` | `timeout` | ran past its time budget. Counted as detected (likely an infinite loop) |
|
|
121
125
|
| `E` | `error` | the worker crashed, or the mutated code raised outside a test assertion |
|
|
122
|
-
| `U` | `uncovered` | no test executes the mutated line at all
|
|
123
|
-
| `A` | `accepted` | matches a known-equivalent entry in the acceptance ledger
|
|
126
|
+
| `U` | `uncovered` | no test executes the mutated line at all. This is coverage debt, worse than a survivor |
|
|
127
|
+
| `A` | `accepted` | matches a known-equivalent entry in the acceptance ledger. Excluded from the score |
|
|
124
128
|
|
|
125
129
|
`invalid` mutants (edits that don't even re-parse as valid Ruby) are
|
|
126
130
|
discarded before scheduling and reported as a count only. Exit code is `1`
|
|
127
|
-
|
|
128
|
-
only `uncovered
|
|
131
|
+
if unaccepted survivors exist, `0` otherwise, including when there are
|
|
132
|
+
only `uncovered`, `accepted`, or `error` results.
|
|
129
133
|
|
|
130
134
|
## How it works, compactly
|
|
131
135
|
|
|
132
|
-
1. **Subject discovery
|
|
136
|
+
1. **Subject discovery**: a Prism visitor finds every method (`def`) in
|
|
133
137
|
your target files.
|
|
134
|
-
2. **Source-span edits
|
|
135
|
-
against the original file, not a rewritten AST
|
|
138
|
+
2. **Source-span edits**: each operator emits byte-range text edits
|
|
139
|
+
against the original file, not a rewritten AST. Every mutant is
|
|
136
140
|
re-parsed with Prism and discarded (`invalid`) if the edit produced
|
|
137
141
|
something that doesn't parse. No unparser is ever built or maintained.
|
|
138
|
-
3. **Coverage-mapped test selection
|
|
139
|
-
every source line to the examples that cover it
|
|
142
|
+
3. **Coverage-mapped test selection**: one instrumented baseline run maps
|
|
143
|
+
every source line to the examples that cover it. Incremental runs
|
|
140
144
|
refresh only what changed instead of re-running the whole suite.
|
|
141
|
-
4. **Fork-per-mutant kill runs
|
|
142
|
-
helper once
|
|
145
|
+
4. **Fork-per-mutant kill runs**: the parent preloads your app and spec
|
|
146
|
+
helper once. Each mutant is inserted and exercised in its own fork
|
|
143
147
|
against just its covering examples, so results can't bleed state
|
|
144
148
|
between mutants.
|
|
145
149
|
|
|
146
150
|
Full architecture, including the coverage-cache format, the fork pipeline,
|
|
147
|
-
the serial lane for browser specs, timeout budgets, and every status
|
|
151
|
+
the serial lane for browser specs, timeout budgets, and every status, is in
|
|
148
152
|
**[`docs/guides/how-it-works.md`](docs/guides/how-it-works.md)**.
|
|
149
153
|
|
|
150
154
|
## Usage
|
|
@@ -157,10 +161,11 @@ active_mutator --since origin/main # PR scope (CI)
|
|
|
157
161
|
active_mutator --subject 'Foo::Bar#baz' # one method
|
|
158
162
|
```
|
|
159
163
|
|
|
160
|
-
Statuses: `killed` (test failed
|
|
161
|
-
(counts as detected), `uncovered` (no covering example
|
|
162
|
-
`accepted` (known-equivalent, see ledger), `error`,
|
|
163
|
-
|
|
164
|
+
Statuses: `killed` (test failed, this is good), `survived` (test gap),
|
|
165
|
+
`timeout` (counts as detected), `uncovered` (no covering example, this is
|
|
166
|
+
coverage debt), `accepted` (known-equivalent, see ledger), `error`,
|
|
167
|
+
`invalid` (discarded).
|
|
168
|
+
Exit code 1 if unaccepted survivors exist.
|
|
164
169
|
|
|
165
170
|
Score = (killed + timeout) / (killed + timeout + survived).
|
|
166
171
|
|
|
@@ -179,7 +184,7 @@ bundle exec active_mutator --changed --accept-survivors # records to ledger
|
|
|
179
184
|
git add .active_mutator_accepted.json # committed state
|
|
180
185
|
```
|
|
181
186
|
|
|
182
|
-
Acceptance takes effect on the
|
|
187
|
+
Acceptance takes effect on the next run. The accepting run still exits 1.
|
|
183
188
|
Agent workflow: see [`docs/skills/mutation-check.md`](docs/skills/mutation-check.md).
|
|
184
189
|
|
|
185
190
|
## CI recipe
|
|
@@ -193,9 +198,9 @@ Agent workflow: see [`docs/skills/mutation-check.md`](docs/skills/mutation-check
|
|
|
193
198
|
| Flag | Default | Meaning |
|
|
194
199
|
|---|---|---|
|
|
195
200
|
| `--jobs N` | half the cores | fork-pool width |
|
|
196
|
-
| `--changed` |
|
|
197
|
-
| `--since REF` |
|
|
198
|
-
| `--subject NAME` |
|
|
201
|
+
| `--changed` | none | mutate uncommitted + untracked work |
|
|
202
|
+
| `--since REF` | none | mutate methods changed since REF |
|
|
203
|
+
| `--subject NAME` | none | one subject, e.g. `Foo#bar` |
|
|
199
204
|
| `--format terminal\|json` | terminal | report format |
|
|
200
205
|
| `--accept-survivors` | off | record survivors to the acceptance ledger |
|
|
201
206
|
| `--force-baseline` | off | ignore cached coverage map |
|
|
@@ -203,9 +208,9 @@ Agent workflow: see [`docs/skills/mutation-check.md`](docs/skills/mutation-check
|
|
|
203
208
|
| `--serial-pattern PAT` | `spec/system/`, `spec/features/` | covering-path prefixes forced serial |
|
|
204
209
|
| `--browser-boot-seconds S` | 15 | serial-lane timeout bump |
|
|
205
210
|
| `--timeout-factor F` / `--timeout-floor S` | 8 / 10 | mutation timeout budget |
|
|
206
|
-
| `--require FILE` |
|
|
211
|
+
| `--require FILE` | none | preload files (repeatable) |
|
|
207
212
|
|
|
208
|
-
Every active_mutator process sets `ENV["ACTIVE_MUTATOR"] = "1"
|
|
213
|
+
Every active_mutator process sets `ENV["ACTIVE_MUTATOR"] = "1"`. Use it to
|
|
209
214
|
guard SimpleCov or other tooling in your spec helper:
|
|
210
215
|
|
|
211
216
|
```ruby
|
|
@@ -214,30 +219,30 @@ SimpleCov.start "rails" unless ENV["ACTIVE_MUTATOR"]
|
|
|
214
219
|
|
|
215
220
|
## Known limits (v1.1)
|
|
216
221
|
|
|
217
|
-
Method bodies only (no class-macro/constant mutation)
|
|
218
|
-
|
|
219
|
-
skipped
|
|
220
|
-
code after the change (nightly `--force-baseline` recovers).
|
|
222
|
+
Method bodies only (no class-macro/constant mutation). RSpec only.
|
|
223
|
+
Heredoc strings are not mutated. `class << self` bodies and nested defs
|
|
224
|
+
are skipped. The incremental baseline can miss examples that only cover
|
|
225
|
+
changed code after the change (nightly `--force-baseline` recovers).
|
|
221
226
|
|
|
222
227
|
## Guides
|
|
223
228
|
|
|
224
|
-
- [What is mutation testing?](docs/guides/what-is-mutation-testing.md)
|
|
225
|
-
the concepts
|
|
226
|
-
- [How it works](docs/guides/how-it-works.md)
|
|
229
|
+
- [What is mutation testing?](docs/guides/what-is-mutation-testing.md):
|
|
230
|
+
the concepts. Kill/survive, score, equivalent mutants, further reading.
|
|
231
|
+
- [How it works](docs/guides/how-it-works.md): architecture. Subject
|
|
227
232
|
discovery, source-span edits, the coverage map, the fork pipeline, and
|
|
228
233
|
honest limits.
|
|
229
|
-
- [Operator reference](docs/guides/operators.md)
|
|
234
|
+
- [Operator reference](docs/guides/operators.md): every mutation
|
|
230
235
|
active_mutator can generate, with before/after examples and what a
|
|
231
236
|
survivor of each one means.
|
|
232
|
-
- [Mutation-check skill](docs/skills/mutation-check.md)
|
|
233
|
-
workflow
|
|
237
|
+
- [Mutation-check skill](docs/skills/mutation-check.md): the agent-facing
|
|
238
|
+
workflow. Run, read survivors, strengthen tests, or accept with a reason.
|
|
234
239
|
|
|
235
240
|
## Contributing
|
|
236
241
|
|
|
237
242
|
Issues and pull requests welcome. Run `bundle exec rspec` before sending a
|
|
238
|
-
change
|
|
239
|
-
sending a change that touches `lib
|
|
240
|
-
you'd want it run on any other codebase.
|
|
243
|
+
change. Also run `bundle exec active_mutator --changed` on your own diff
|
|
244
|
+
before sending a change that touches `lib/`. This is a good idea for the
|
|
245
|
+
same reason you'd want it run on any other codebase.
|
|
241
246
|
|
|
242
247
|
## License
|
|
243
248
|
|
|
@@ -3,7 +3,7 @@ require "set"
|
|
|
3
3
|
|
|
4
4
|
module ActiveMutator
|
|
5
5
|
# Committed, repo-root ledger of accepted (equivalent) survivors.
|
|
6
|
-
# Deliberately NOT inside .active_mutator
|
|
6
|
+
# Deliberately NOT inside .active_mutator/: that dir is gitignored and
|
|
7
7
|
# disposable, while acceptance decisions are durable team/CI state.
|
|
8
8
|
class AcceptedLedger
|
|
9
9
|
FILENAME = ".active_mutator_accepted.json"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
module ActiveMutator
|
|
2
2
|
# flock-guarded write-to-temp + rename. Concurrent runs in one repo (an
|
|
3
|
-
# agent plus a human
|
|
3
|
+
# agent plus a human, the dev-loop case) must not corrupt cache or ledger.
|
|
4
4
|
module AtomicFile
|
|
5
5
|
def self.write(path, content)
|
|
6
6
|
File.open("#{path}.lock", File::CREAT | File::RDWR, 0o644) do |lock|
|
|
@@ -42,13 +42,22 @@ module ActiveMutator
|
|
|
42
42
|
|
|
43
43
|
private
|
|
44
44
|
|
|
45
|
-
|
|
45
|
+
# The cache is disposable and must never be committed. Host projects
|
|
46
|
+
# rarely gitignore it themselves, so the directory ignores its own
|
|
47
|
+
# contents (the node_modules trick).
|
|
48
|
+
def prepare_cache_dir
|
|
46
49
|
FileUtils.mkdir_p(@cache_dir)
|
|
50
|
+
ignore = File.join(@cache_dir, ".gitignore")
|
|
51
|
+
File.write(ignore, "*\n") unless File.exist?(ignore)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def run_baseline!
|
|
55
|
+
prepare_cache_dir
|
|
47
56
|
env = baseline_env(@out_path)
|
|
48
|
-
# out: :err
|
|
57
|
+
# out: :err: the subprocess suite's progress output must not pollute
|
|
49
58
|
# our stdout (breaks `--format json` consumers).
|
|
50
59
|
ok = system(env, "bundle", "exec", "rspec", chdir: @root, out: :err)
|
|
51
|
-
raise BaselineFailed, "baseline suite failed
|
|
60
|
+
raise BaselineFailed, "baseline suite failed, fix the suite before mutating" unless ok
|
|
52
61
|
raise BaselineFailed, "baseline produced no coverage output" unless File.exist?(@out_path)
|
|
53
62
|
end
|
|
54
63
|
|
|
@@ -80,7 +89,7 @@ module ActiveMutator
|
|
|
80
89
|
if targets.any?
|
|
81
90
|
env = baseline_env(partial_out)
|
|
82
91
|
ok = system(env, "bundle", "exec", "rspec", *targets, chdir: @root, out: :err)
|
|
83
|
-
raise BaselineFailed, "partial baseline run failed
|
|
92
|
+
raise BaselineFailed, "partial baseline run failed, fix the suite before mutating" unless ok
|
|
84
93
|
raise BaselineFailed, "partial baseline produced no output" unless File.exist?(partial_out)
|
|
85
94
|
end
|
|
86
95
|
merge_partial!(partial_out, delta)
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
# Loaded standalone via RUBYOPT=-ractive_mutator/baseline_hooks in the host
|
|
2
|
-
# project's suite
|
|
2
|
+
# project's suite, before rspec boots, so Coverage instruments everything
|
|
3
3
|
# the suite loads (including code loaded by spec_helper). Records per-example
|
|
4
4
|
# coverage diffs and writes the inverted map to ACTIVE_MUTATOR_BASELINE_OUT.
|
|
5
5
|
require "json"
|
|
@@ -53,7 +53,7 @@ if ENV["ACTIVE_MUTATOR_BASELINE_OUT"]
|
|
|
53
53
|
root = ENV.fetch("ACTIVE_MUTATOR_ROOT")
|
|
54
54
|
ActiveMutator::BaselineHooks::RECORDS[example.id] =
|
|
55
55
|
ActiveMutator::BaselineHooks.diff_coverage(before, after, root)
|
|
56
|
-
# NOT example.execution_result.run_time
|
|
56
|
+
# NOT example.execution_result.run_time: that is nil until after
|
|
57
57
|
# around hooks complete.
|
|
58
58
|
ActiveMutator::BaselineHooks::TIMES[example.id] = elapsed
|
|
59
59
|
end
|
|
@@ -3,7 +3,7 @@ require "json"
|
|
|
3
3
|
module ActiveMutator
|
|
4
4
|
# Cache format v2: primary data is per-example `records`
|
|
5
5
|
# ({example_id => [[abs_path, line], ...]}); the inverted index is derived
|
|
6
|
-
# in memory at load. A missing/old version is simply stale
|
|
6
|
+
# in memory at load. A missing/old version is simply stale: the cache is
|
|
7
7
|
# disposable, so there is no migration path, only regeneration.
|
|
8
8
|
class CoverageMap
|
|
9
9
|
def self.load(path) = new(JSON.parse(File.read(path)))
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
module ActiveMutator
|
|
2
2
|
# Line-number-independent identity for a mutant, used by the acceptance
|
|
3
3
|
# ledger. `ordinal` disambiguates byte-identical mutants within one subject
|
|
4
|
-
# (e.g. the two `>` in `a > 0 && b > 0`) by source order
|
|
4
|
+
# (e.g. the two `>` in `a > 0 && b > 0`) by source order. Without it,
|
|
5
5
|
# accepting one would silently accept both.
|
|
6
6
|
Fingerprint = Data.define(:file, :subject, :description, :original_snippet, :ordinal) do
|
|
7
7
|
def self.for_mutations(mutations, root:)
|
|
@@ -121,7 +121,7 @@ module ActiveMutator
|
|
|
121
121
|
|
|
122
122
|
# A preloaded helper commonly starts SimpleCov. Its at_exit would fire in
|
|
123
123
|
# THIS parent process at the end of the mutation run, clobbering the
|
|
124
|
-
# project's real coverage data
|
|
124
|
+
# project's real coverage data, and minimum_coverage would exit(1) for a
|
|
125
125
|
# bogus reason. Neutralize it.
|
|
126
126
|
def disarm_simplecov
|
|
127
127
|
SimpleCov.at_exit {} if defined?(SimpleCov)
|
|
@@ -136,7 +136,7 @@ module ActiveMutator
|
|
|
136
136
|
|
|
137
137
|
def warn_stale(ledger, all_fingerprints)
|
|
138
138
|
ledger.stale_entries(all_fingerprints).each do |entry|
|
|
139
|
-
warn "active_mutator: stale accepted fingerprint (no matching mutant): #{entry.subject}
|
|
139
|
+
warn "active_mutator: stale accepted fingerprint (no matching mutant): #{entry.subject}, #{entry.description}"
|
|
140
140
|
end
|
|
141
141
|
end
|
|
142
142
|
end
|
|
@@ -5,10 +5,14 @@ module ActiveMutator
|
|
|
5
5
|
# Parent enforces per-item deadlines with SIGKILL (worker-side timeouts
|
|
6
6
|
# cannot interrupt all infinite loops).
|
|
7
7
|
class Scheduler
|
|
8
|
-
|
|
8
|
+
OrphanedError = Class.new(Error)
|
|
9
|
+
|
|
10
|
+
def initialize(jobs:, worker: Worker.method(:run), on_result: nil,
|
|
11
|
+
orphaned: -> { Process.ppid == 1 })
|
|
9
12
|
@jobs = jobs
|
|
10
13
|
@worker = worker
|
|
11
14
|
@on_result = on_result
|
|
15
|
+
@orphaned = orphaned
|
|
12
16
|
end
|
|
13
17
|
|
|
14
18
|
def run(items)
|
|
@@ -32,6 +36,7 @@ module ActiveMutator
|
|
|
32
36
|
queue = items.dup
|
|
33
37
|
results = []
|
|
34
38
|
until queue.empty? && running.empty?
|
|
39
|
+
abort_if_orphaned!(running)
|
|
35
40
|
spawn(queue.shift, running) while running.size < width && !queue.empty?
|
|
36
41
|
reap(running, results)
|
|
37
42
|
sleep 0.02 unless running.empty?
|
|
@@ -39,6 +44,22 @@ module ActiveMutator
|
|
|
39
44
|
results
|
|
40
45
|
end
|
|
41
46
|
|
|
47
|
+
# SIGKILL on the parent (or a closed terminal, or CI teardown) cannot be
|
|
48
|
+
# trapped, so a killed run would otherwise keep forking through the whole
|
|
49
|
+
# queue with nobody supervising it. Orphaned processes get reparented to
|
|
50
|
+
# init/launchd (ppid 1); when that happens, stop everything and bail.
|
|
51
|
+
def abort_if_orphaned!(running)
|
|
52
|
+
return unless @orphaned.call
|
|
53
|
+
|
|
54
|
+
running.each_key do |pid|
|
|
55
|
+
kill(pid)
|
|
56
|
+
rescue StandardError
|
|
57
|
+
nil
|
|
58
|
+
end
|
|
59
|
+
running.clear
|
|
60
|
+
raise OrphanedError, "parent process died; aborting mutation run"
|
|
61
|
+
end
|
|
62
|
+
|
|
42
63
|
def spawn(item, running)
|
|
43
64
|
reader, writer = IO.pipe
|
|
44
65
|
pid = fork do
|
|
@@ -86,7 +107,7 @@ module ActiveMutator
|
|
|
86
107
|
def kill(pid)
|
|
87
108
|
Process.kill("KILL", -pid) # negative pid = whole process group
|
|
88
109
|
rescue Errno::ESRCH, Errno::EPERM
|
|
89
|
-
# Group not established yet (setpgid race) or already gone
|
|
110
|
+
# Group not established yet (setpgid race) or already gone: direct kill.
|
|
90
111
|
begin
|
|
91
112
|
Process.kill("KILL", pid)
|
|
92
113
|
rescue Errno::ESRCH
|
|
@@ -100,7 +121,7 @@ module ActiveMutator
|
|
|
100
121
|
end
|
|
101
122
|
end
|
|
102
123
|
|
|
103
|
-
# Returns {sig => previous_handler} so #run can restore on exit
|
|
124
|
+
# Returns {sig => previous_handler} so #run can restore on exit,
|
|
104
125
|
# otherwise our traps permanently replace the host's (e.g. RSpec's Ctrl-C).
|
|
105
126
|
def install_signal_handlers(running)
|
|
106
127
|
%w[INT TERM].to_h do |sig|
|
|
@@ -3,7 +3,7 @@ require "set"
|
|
|
3
3
|
|
|
4
4
|
module ActiveMutator
|
|
5
5
|
# Runs INSIDE a fork. Order is critical: RSpec's setup phase loads the spec
|
|
6
|
-
# files, whose spec_helper/rails_helper loads the application
|
|
6
|
+
# files, whose spec_helper/rails_helper loads the application. Only THEN
|
|
7
7
|
# can the mutation be inserted over the loaded original. Insert-first would
|
|
8
8
|
# NameError on any project not preloaded in the parent (all non-Rails
|
|
9
9
|
# projects), and loading app code after insertion would silently restore
|
|
@@ -47,7 +47,7 @@ module ActiveMutator
|
|
|
47
47
|
@writer.flush if @writer.respond_to?(:flush)
|
|
48
48
|
end
|
|
49
49
|
|
|
50
|
-
# RSpec.world holds every group registered in the process
|
|
50
|
+
# RSpec.world holds every group registered in the process, including any
|
|
51
51
|
# top-level groups evaluated while the PARENT preloaded the spec helper
|
|
52
52
|
# (spec/support files with RSpec.describe at load time are common). Those
|
|
53
53
|
# leak into the fork; running them would report their failures as false
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: active_mutator
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.1.
|
|
4
|
+
version: 0.1.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Daniel John
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-07-
|
|
11
|
+
date: 2026-07-14 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: prism
|
|
@@ -52,10 +52,10 @@ dependencies:
|
|
|
52
52
|
- - "~>"
|
|
53
53
|
- !ruby/object:Gem::Version
|
|
54
54
|
version: '3.13'
|
|
55
|
-
description:
|
|
55
|
+
description: Mutation testing for Ruby and Rails. Uses Prism-based source-span mutations
|
|
56
56
|
(no unparser), coverage-mapped test selection, and a fork-per-mutant kill pipeline.
|
|
57
|
-
Scopes to changed methods for a fast dev loop, or a diff for CI
|
|
58
|
-
coverage baseline and a committed acceptance ledger for equivalent mutants.
|
|
57
|
+
Scopes to changed methods for a fast dev loop, or to a diff for CI. Includes an
|
|
58
|
+
incremental coverage baseline and a committed acceptance ledger for equivalent mutants.
|
|
59
59
|
email:
|
|
60
60
|
executables:
|
|
61
61
|
- active_mutator
|