mutineer 0.10.0 → 0.11.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/CHANGELOG.md +47 -0
- data/README.md +30 -1
- data/lib/mutineer/cli.rb +83 -50
- data/lib/mutineer/config.rb +25 -14
- data/lib/mutineer/coverage_map.rb +27 -7
- data/lib/mutineer/daemon_client.rb +17 -2
- data/lib/mutineer/daemon_server.rb +93 -9
- data/lib/mutineer/external_backend.rb +13 -1
- data/lib/mutineer/isolation.rb +18 -8
- data/lib/mutineer/mutators/statement_removal.rb +12 -10
- data/lib/mutineer/rails_worker_db.rb +137 -0
- data/lib/mutineer/reporter.rb +45 -4
- data/lib/mutineer/runner.rb +221 -53
- data/lib/mutineer/version.rb +1 -1
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 42d278a96730cdf9616492a720c701ba9119195299f64fa74b339b3737e4e545
|
|
4
|
+
data.tar.gz: ef2148e5752ea9790403ca256e4bc6672b5e8c2656289d74b9cd3ffcd067f475
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 82879be78e820ef184dca76bf52ad883298f3c0e8becdb6344061b32321f44945464025c6f7d8064e9b649486b6b5f258b60b43baf0ec6f0d38356b314af5793
|
|
7
|
+
data.tar.gz: 67399daaa12b9b312a4ad0fcad144ae763783d2f5994e474b1303ad7ed0ddd5c5d42fba5b39c750115a0614356b4e4a2593238b91e39eee38edd96981c4afa5a
|
data/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,50 @@ All notable changes to this project are documented here. The format is based on
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.11.1] - 2026-07-23
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
- **`--daemon` score disclosure** — stop claiming “no coverage narrowing / lower
|
|
13
|
+
bound” on every run (narrowing already ships). Warn only when the coverage map
|
|
14
|
+
is unavailable and the run falls back to the full `--test` set (#48).
|
|
15
|
+
- **`--threshold` CI fidelity** — exit 1 when a positive threshold is set but the
|
|
16
|
+
run produced only errors/timeouts/uncapturable (nil score with a broken harness)
|
|
17
|
+
instead of silently green; pure no_coverage / all-ignored still skips the gate
|
|
18
|
+
(#49). Non-numeric `--threshold` / YAML `threshold` is a usage error (exit 2),
|
|
19
|
+
not a silent gate-off (#51).
|
|
20
|
+
- **`--daemon` contracts** — reject `--framework rspec` (exit 2); force
|
|
21
|
+
`--strategy reload` with a clear warning when redefine was requested (daemon
|
|
22
|
+
whole-file only) (#50). **In-process `--rails` always serial** unless
|
|
23
|
+
`--daemon` (only daemon has per-worker DB isolation) (#55).
|
|
24
|
+
- **Isolation timeout** — kill the child process group and honor a clean exit
|
|
25
|
+
that races the deadline (not always timeout) (#52). **External backend** —
|
|
26
|
+
signal death is `error`, not `killed` (#53).
|
|
27
|
+
- **`--fail-fast` is serial** on the in-process path (matches daemon) so the
|
|
28
|
+
survivor set is deterministic under any `--jobs` (#54).
|
|
29
|
+
- **Dry-run uses `collect_jobs`** so candidates match a real run (#59).
|
|
30
|
+
- **Daemon schema load once per worker** (not every mutant fork) (#56).
|
|
31
|
+
- **Nested `statement_removal`** visits inner statement lists (#62).
|
|
32
|
+
- Dead seams: remove unused `RailsWorkerDb.provision`, simplify parallel daemon
|
|
33
|
+
fail-fast branch, drop unused `validate_daemon!` arg (#60).
|
|
34
|
+
- Archive historical implementation spec under `docs/archive/` (#61).
|
|
35
|
+
|
|
36
|
+
## [0.11.0] - 2026-07-02
|
|
37
|
+
|
|
38
|
+
### Added
|
|
39
|
+
- **`--daemon` backend — fast, parallel-safe Rails mutation testing** (#26/#27
|
|
40
|
+
Phase 2). Boots the app **once** in a persistent daemon and forks per mutant
|
|
41
|
+
(restoring shared-boot speed), and gives **each parallel worker its own
|
|
42
|
+
database** so `--jobs N` is safe under Rails for the first time — parallel
|
|
43
|
+
verdicts are proven identical to serial (no fixture cross-talk). Coverage
|
|
44
|
+
narrowing is restored on this path (each mutant runs only its covering tests;
|
|
45
|
+
a mutant on an uncovered line is `no_coverage`), so the daemon score is
|
|
46
|
+
comparable to the in-process `--rails` score. Opt in with `--rails --daemon`
|
|
47
|
+
(also `daemon: true` in `.mutineer.yml`); `--daemon` can't be combined with
|
|
48
|
+
`--test-command`. **SQLite** today (hermetic, CI-proven); **Postgres**
|
|
49
|
+
per-worker provisioning is in progress (#34/#35). The gem core stays Prism +
|
|
50
|
+
stdlib, zero runtime dependencies — worker-DB routing uses the app's own
|
|
51
|
+
ActiveRecord.
|
|
52
|
+
|
|
9
53
|
## [0.10.0] - 2026-07-02
|
|
10
54
|
|
|
11
55
|
### Added
|
|
@@ -200,6 +244,9 @@ Rails hardening + CI batch (issues #8–#13), all verified Rails-free.
|
|
|
200
244
|
- `.mutineer.yml` configuration (CLI > config > default precedence).
|
|
201
245
|
- Byte-correct source handling for multibyte (UTF-8) sources.
|
|
202
246
|
|
|
247
|
+
[0.11.1]: https://github.com/davidteren/mutineer/releases/tag/v0.11.1
|
|
248
|
+
[0.11.0]: https://github.com/davidteren/mutineer/releases/tag/v0.11.0
|
|
249
|
+
[0.10.0]: https://github.com/davidteren/mutineer/releases/tag/v0.10.0
|
|
203
250
|
[0.9.1]: https://github.com/davidteren/mutineer/releases/tag/v0.9.1
|
|
204
251
|
[0.9.0]: https://github.com/davidteren/mutineer/releases/tag/v0.9.0
|
|
205
252
|
[0.8.0]: https://github.com/davidteren/mutineer/releases/tag/v0.8.0
|
data/README.md
CHANGED
|
@@ -57,6 +57,7 @@ mutineer run lib/calculator.rb --test test/calculator_test.rb --threshold 90
|
|
|
57
57
|
| `--verbose` | Surface the real error when a fork capture fails (alias `--debug`) |
|
|
58
58
|
| `--strategy NAME` | Mutation application: `reload` whole-file (default) or `redefine` surgical (`7a`/`7b` accepted as deprecated aliases) |
|
|
59
59
|
| `--test-command CMD` | Run the suite as a subprocess in the app's own runtime (for apps on Ruby < 3.4); `CMD` must contain `%{files}`. See [Apps on Ruby < 3.4](#apps-on-ruby--34) |
|
|
60
|
+
| `--daemon` | Boot the app once in a persistent daemon and fork per mutant, with per-worker DB isolation so `--jobs N` is safe under Rails (needs `--rails`/`--boot`; not with `--test-command`). See [the daemon backend](#faster-parallel-safe-rails-the---daemon-backend) |
|
|
60
61
|
| `--format human\|json\|html` | Report format (default: human; `html` is a self-contained file) |
|
|
61
62
|
| `--output FILE` | Write the report to FILE instead of stdout |
|
|
62
63
|
| `--dry-run` | List candidate mutations without executing (honors suppression) |
|
|
@@ -104,6 +105,33 @@ Add Mutineer to your Gemfile's test group:
|
|
|
104
105
|
gem "mutineer", group: :test, require: false
|
|
105
106
|
```
|
|
106
107
|
|
|
108
|
+
### Faster, parallel-safe Rails (the `--daemon` backend)
|
|
109
|
+
|
|
110
|
+
`--rails` boots your app once but runs mutants **serially** — parallel `--jobs`
|
|
111
|
+
under Rails is unsafe, because every worker shares one test database and clobbers
|
|
112
|
+
the others' fixtures. `--daemon` fixes both: it boots the app once in a persistent
|
|
113
|
+
helper and forks per mutant, and gives **each parallel worker its own database**,
|
|
114
|
+
so `--jobs N` is safe and its verdicts are proven identical to a serial run.
|
|
115
|
+
|
|
116
|
+
```sh
|
|
117
|
+
RAILS_ENV=test bundle exec mutineer run \
|
|
118
|
+
app/models/order.rb --test test/models/order_test.rb \
|
|
119
|
+
--rails --daemon --jobs 4
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
- **One boot, forked per mutant** — restores the shared-boot speed.
|
|
123
|
+
- **Coverage-guided** — each mutant runs only its covering tests (like `--rails`);
|
|
124
|
+
a mutant on an uncovered line is `no_coverage`, so the score stays comparable to
|
|
125
|
+
the in-process `--rails` score.
|
|
126
|
+
- **Safe `--jobs N`** — each worker routes to its own copy of the test database, so
|
|
127
|
+
parallel verdicts equal serial (no fixture cross-talk).
|
|
128
|
+
- **One backend at a time** — `--daemon` can't be combined with `--test-command`
|
|
129
|
+
(choose one), and it needs an app to boot (`--rails` or `--boot`).
|
|
130
|
+
|
|
131
|
+
Status: **SQLite** today (hermetic, CI-proven). **Postgres** per-worker
|
|
132
|
+
provisioning is in progress (#34/#35); until it lands, use `--daemon` with a
|
|
133
|
+
SQLite test database, or drop `--jobs` to run serially on other adapters.
|
|
134
|
+
|
|
107
135
|
### Apps on Ruby < 3.4
|
|
108
136
|
|
|
109
137
|
Mutineer's own process needs Ruby ≥ 3.4 (it parses with stdlib Prism), and the
|
|
@@ -136,7 +164,8 @@ Tradeoffs (Phase 1) — this path is correct but not free:
|
|
|
136
164
|
is scored as a kill. Mutineer prints this caveat on every run and aborts up
|
|
137
165
|
front (a "smoke check") if your unmutated suite isn't green.
|
|
138
166
|
- **Reload strategy only** (`--strategy redefine` is rejected on this path) and
|
|
139
|
-
**serial** (`--jobs` is forced to 1
|
|
167
|
+
**serial** (`--jobs` is forced to 1). For apps on Ruby ≥ 3.4, `--daemon` gives
|
|
168
|
+
safe parallelism instead (see [the daemon backend](#faster-parallel-safe-rails-the---daemon-backend)).
|
|
140
169
|
|
|
141
170
|
## Suppressing equivalent mutants
|
|
142
171
|
|
data/lib/mutineer/cli.rb
CHANGED
|
@@ -51,6 +51,9 @@ module Mutineer
|
|
|
51
51
|
subprocess (for apps on Ruby < 3.4). CMD must contain
|
|
52
52
|
%{files} (expands to the --test paths). Env is inherited,
|
|
53
53
|
e.g. RAILS_ENV=test mutineer run ... --test-command "..."
|
|
54
|
+
--daemon Boot the app ONCE in a persistent daemon and fork per
|
|
55
|
+
mutant, with per-worker DB isolation so --jobs N is safe
|
|
56
|
+
under Rails (needs --rails/--boot; not with --test-command)
|
|
54
57
|
--format human|json|html Report format (default: human)
|
|
55
58
|
--output FILE Write the report to FILE instead of stdout
|
|
56
59
|
--dry-run List mutations without executing
|
|
@@ -90,12 +93,20 @@ module Mutineer
|
|
|
90
93
|
end
|
|
91
94
|
o.on("--list-operators") { show_operators = true }
|
|
92
95
|
o.on("--dry-run") { opts[:dry_run] = true }
|
|
93
|
-
o.on("--fail-fast") { opts[:fail_fast] = true }
|
|
96
|
+
o.on("--fail-fast") { opts[:fail_fast] = true; explicit << :fail_fast }
|
|
94
97
|
o.on("--only NAME") { |v| opts[:only] = v; explicit << :only }
|
|
95
98
|
o.on("--since REF") { |v| opts[:since] = v; explicit << :since }
|
|
96
99
|
o.on("--test FILE") { |v| (opts[:tests] ||= []) << v }
|
|
97
100
|
o.on("--operators LIST") { |v| opts[:operators] = v.split(",").map(&:strip); explicit << :operators }
|
|
98
|
-
o.on("--threshold FLOAT")
|
|
101
|
+
o.on("--threshold FLOAT") do |v|
|
|
102
|
+
f = Float(v, exception: false)
|
|
103
|
+
if f.nil?
|
|
104
|
+
warn "mutineer: --threshold requires a number between 0 and 100 (got: #{v.inspect})"
|
|
105
|
+
exit 2
|
|
106
|
+
end
|
|
107
|
+
opts[:threshold] = f
|
|
108
|
+
explicit << :threshold
|
|
109
|
+
end
|
|
99
110
|
o.on("--jobs N") { |v| opts[:jobs] = v; explicit << :jobs }
|
|
100
111
|
o.on("--strategy STRAT") { |v| opts[:strategy] = v; explicit << :strategy }
|
|
101
112
|
o.on("--framework NAME") { |v| opts[:framework] = v; explicit << :framework }
|
|
@@ -112,6 +123,9 @@ module Mutineer
|
|
|
112
123
|
# #27: run the target suite as a subprocess in the app's OWN runtime so
|
|
113
124
|
# mutineer (Ruby >= 3.4) can mutation-test apps pinned to an older Ruby.
|
|
114
125
|
o.on("--test-command CMD") { |v| opts[:test_command] = v; explicit << :test_command }
|
|
126
|
+
# #26/#27 Phase 2: boot the app ONCE in a persistent daemon and fork per
|
|
127
|
+
# mutant, with per-worker DB isolation so --jobs N is safe under Rails.
|
|
128
|
+
o.on("--daemon") { opts[:daemon] = true; explicit << :daemon }
|
|
115
129
|
end
|
|
116
130
|
|
|
117
131
|
begin
|
|
@@ -247,15 +261,16 @@ module Mutineer
|
|
|
247
261
|
preflight_output!(config.output) if config.output
|
|
248
262
|
preflight_baseline!(config.baseline) if config.baseline
|
|
249
263
|
|
|
250
|
-
#
|
|
251
|
-
#
|
|
252
|
-
# populated config.tests. Runs after every flag/usage check above so a
|
|
253
|
-
# mistyped flag still reports the flag; skipped under --dry-run (no tests
|
|
254
|
-
# needed). validate_paths! then sees the inferred (real) tests.
|
|
264
|
+
# When --test is omitted, infer each source's test by convention. Autopair
|
|
265
|
+
# also re-detects framework from inferred tests when --framework was not set.
|
|
255
266
|
autopair!(config, explicit) unless config.dry_run
|
|
256
267
|
|
|
257
|
-
#
|
|
258
|
-
#
|
|
268
|
+
# Daemon validation runs AFTER autopair so auto-inferred *_spec.rb tests
|
|
269
|
+
# cannot bypass the RSpec rejection (framework would still be minitest if we
|
|
270
|
+
# validated before discovery).
|
|
271
|
+
validate_daemon!(config) if config.daemon
|
|
272
|
+
|
|
273
|
+
# Boot mode needs at least one --test file (nothing to select from otherwise).
|
|
259
274
|
if config.boot && config.tests.empty?
|
|
260
275
|
warn "mutineer: --boot/--rails requires at least one --test file"
|
|
261
276
|
exit 2
|
|
@@ -300,6 +315,36 @@ module Mutineer
|
|
|
300
315
|
config.jobs = 1
|
|
301
316
|
end
|
|
302
317
|
|
|
318
|
+
# --daemon selects the persistent-daemon backend (boot once, fork per mutant,
|
|
319
|
+
# per-worker DB isolation on SQLite). Usage errors exit 2: cannot combine with
|
|
320
|
+
# --test-command; requires --rails or --boot; minitest only; reload strategy only
|
|
321
|
+
# (redefine needs a shared VM surgical path the daemon does not ship).
|
|
322
|
+
#
|
|
323
|
+
# @api private
|
|
324
|
+
# @param config [Mutineer::Config] run configuration.
|
|
325
|
+
# @return [void]
|
|
326
|
+
def self.validate_daemon!(config)
|
|
327
|
+
if config.test_command
|
|
328
|
+
warn "mutineer: choose one backend — --daemon and --test-command cannot be combined"
|
|
329
|
+
exit 2
|
|
330
|
+
end
|
|
331
|
+
unless config.rails || config.boot
|
|
332
|
+
warn "mutineer: --daemon needs an app to boot; add --rails (or --boot FILE)"
|
|
333
|
+
exit 2
|
|
334
|
+
end
|
|
335
|
+
if config.framework == "rspec"
|
|
336
|
+
warn "mutineer: --daemon supports only --framework minitest " \
|
|
337
|
+
"(rspec is not implemented on the daemon path yet)"
|
|
338
|
+
exit 2
|
|
339
|
+
end
|
|
340
|
+
return if config.strategy == "reload"
|
|
341
|
+
|
|
342
|
+
# --rails defaults strategy to redefine; daemon always whole-file loads.
|
|
343
|
+
warn "[mutineer] --daemon uses --strategy reload " \
|
|
344
|
+
"(redefine is not supported on the daemon path); forcing reload."
|
|
345
|
+
config.strategy = "reload"
|
|
346
|
+
end
|
|
347
|
+
|
|
303
348
|
# --since needs a real git repo and a resolvable ref; either failure is a
|
|
304
349
|
# usage error (exit 2) so CI sees "bad invocation," not "tests too weak."
|
|
305
350
|
# Validates the --since ref.
|
|
@@ -427,17 +472,18 @@ module Mutineer
|
|
|
427
472
|
reporter.report(out: $stdout, err: $stderr, threshold: config.threshold,
|
|
428
473
|
format: config.format, output: config.output, baseline: delta)
|
|
429
474
|
|
|
430
|
-
#
|
|
431
|
-
#
|
|
432
|
-
#
|
|
433
|
-
#
|
|
475
|
+
# Warn (stderr, so it never pollutes json/html) that an external run's score
|
|
476
|
+
# is not comparable to an in-process run: no coverage narrowing (uncovered
|
|
477
|
+
# mutants count as survivors), and an infra failure is scored as a kill
|
|
478
|
+
# (upper bound). Daemon coverage fallback warnings are emitted from the runner
|
|
479
|
+
# only when the map is unavailable — not on every --daemon run.
|
|
434
480
|
if config.test_command
|
|
435
481
|
warn "[mutineer] --test-command score is an upper bound, not comparable to an " \
|
|
436
482
|
"in-process run: no coverage narrowing (uncovered mutants count as survivors) " \
|
|
437
483
|
"and an infra failure is scored as a kill."
|
|
438
484
|
end
|
|
439
485
|
|
|
440
|
-
#
|
|
486
|
+
# Nudge toward the opt-in tier-2 operators (human report only — never
|
|
441
487
|
# pollute JSON output).
|
|
442
488
|
if !%w[json html].include?(config.format) && (hint = tier2_hint(config.operators))
|
|
443
489
|
puts hint
|
|
@@ -464,50 +510,37 @@ module Mutineer
|
|
|
464
510
|
"enable with --operators <list>."
|
|
465
511
|
end
|
|
466
512
|
|
|
467
|
-
# Runs dry-run mode.
|
|
513
|
+
# Runs dry-run mode. Reuses Runner.collect_jobs (+ filter_since) so the
|
|
514
|
+
# candidate list cannot drift from a real run's job selection.
|
|
468
515
|
#
|
|
469
516
|
# @param config [Mutineer::Config] run configuration.
|
|
470
517
|
# @return [void]
|
|
471
518
|
def self.dry_run(config)
|
|
472
519
|
operator_classes = MutatorRegistry.resolve(config.operators || MutatorRegistry::DEFAULT_NAMES)
|
|
473
|
-
|
|
520
|
+
jobs, ignored_results, source_map = Runner.collect_jobs(config, operator_classes)
|
|
521
|
+
# Narrow jobs and ignored the same way so the summary matches the printed list.
|
|
522
|
+
if config.since
|
|
523
|
+
jobs = Runner.filter_since(jobs, source_map, config)
|
|
524
|
+
ignored_jobs = ignored_results.map { |r| [r.subject, r.mutation, r.id] }
|
|
525
|
+
ignored = Runner.filter_since(ignored_jobs, source_map, config).size
|
|
526
|
+
else
|
|
527
|
+
ignored = ignored_results.size
|
|
528
|
+
end
|
|
529
|
+
|
|
474
530
|
per_operator = Hash.new(0)
|
|
475
531
|
skipped = 0
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
changed = if config.since
|
|
482
|
-
ChangedLines.for(ref: config.since, files: config.sources,
|
|
483
|
-
project_root: config.project_root)
|
|
484
|
-
end
|
|
485
|
-
|
|
486
|
-
Project.discover(config.sources, only: config.only).each do |subject|
|
|
487
|
-
source = (sources[subject.file] ||= Parser.parse_file(subject.file).source.source)
|
|
488
|
-
# #22: honor suppression so the preview matches what a real run mutates.
|
|
489
|
-
# Mirror execute's per-subject shape (ids need the full mutation list).
|
|
490
|
-
disabled = Runner.suppress_map(source)
|
|
491
|
-
mutations = operator_classes.flat_map { |klass| klass.new.mutations_for(subject, source) }
|
|
492
|
-
ids = MutantId.for_subject(subject, source, mutations)
|
|
493
|
-
mutations.each_with_index do |mutation, i|
|
|
494
|
-
unless mutation.valid?(source)
|
|
495
|
-
skipped += 1
|
|
496
|
-
next
|
|
497
|
-
end
|
|
498
|
-
line = source.byteslice(0, mutation.start_offset).count("\n") + 1
|
|
499
|
-
next if changed && !changed[File.expand_path(subject.file, config.project_root)]&.include?(line)
|
|
500
|
-
|
|
501
|
-
if Runner.suppressed?(mutation.operator, line, ids[i], disabled, ignore_set)
|
|
502
|
-
ignored += 1
|
|
503
|
-
next
|
|
504
|
-
end
|
|
505
|
-
|
|
506
|
-
per_operator[mutation.operator] += 1
|
|
507
|
-
original = source.byteslice(mutation.start_offset...mutation.end_offset)
|
|
508
|
-
puts "[#{mutation.operator}] #{subject.qualified_name} " \
|
|
509
|
-
"#{subject.file}:#{line} `#{original}` -> `#{mutation.replacement}`"
|
|
532
|
+
jobs.each do |subject, mutation, _id|
|
|
533
|
+
source = source_map[subject.file]
|
|
534
|
+
unless mutation.valid?(source)
|
|
535
|
+
skipped += 1
|
|
536
|
+
next
|
|
510
537
|
end
|
|
538
|
+
|
|
539
|
+
line = source.byteslice(0, mutation.start_offset).count("\n") + 1
|
|
540
|
+
per_operator[mutation.operator] += 1
|
|
541
|
+
original = source.byteslice(mutation.start_offset...mutation.end_offset)
|
|
542
|
+
puts "[#{mutation.operator}] #{subject.qualified_name} " \
|
|
543
|
+
"#{subject.file}:#{line} `#{original}` -> `#{mutation.replacement}`"
|
|
511
544
|
end
|
|
512
545
|
|
|
513
546
|
total = per_operator.values.sum
|
data/lib/mutineer/config.rb
CHANGED
|
@@ -25,9 +25,8 @@ module Mutineer
|
|
|
25
25
|
:cache_dir, :project_root, :load_paths,
|
|
26
26
|
:jobs, :format, :output, :strategy, :require_paths,
|
|
27
27
|
:boot, :rails, :since, :framework, :verbose, :ignore,
|
|
28
|
-
# :daemon
|
|
29
|
-
#
|
|
30
|
-
# coerce + KNOWN_KEYS). For now they're set programmatically (tests/Runner).
|
|
28
|
+
# :daemon is user-facing as of U8 (--daemon flag + KNOWN_KEYS + boolean coerce).
|
|
29
|
+
# :daemon_timeout stays programmatic (set by tests/Runner; no flag yet).
|
|
31
30
|
:baseline, :baseline_epsilon, :fail_fast, :test_command,
|
|
32
31
|
:daemon, :daemon_timeout,
|
|
33
32
|
keyword_init: true
|
|
@@ -35,7 +34,7 @@ module Mutineer
|
|
|
35
34
|
# Config file name.
|
|
36
35
|
CONFIG_FILE = ".mutineer.yml"
|
|
37
36
|
# Keys accepted in .mutineer.yml (R7). `require` maps to the :require_paths field.
|
|
38
|
-
KNOWN_KEYS = %w[operators jobs threshold only require boot rails since framework verbose ignore baseline fail_fast test_command].freeze
|
|
37
|
+
KNOWN_KEYS = %w[operators jobs threshold only require boot rails since framework verbose ignore baseline fail_fast test_command daemon].freeze
|
|
39
38
|
|
|
40
39
|
def initialize(**kwargs)
|
|
41
40
|
super
|
|
@@ -113,17 +112,21 @@ module Mutineer
|
|
|
113
112
|
file_hash.each { |k, v| merged[k] = v unless explicit.include?(k) }
|
|
114
113
|
config = new(**merged)
|
|
115
114
|
|
|
116
|
-
# --rails sugar: boot config/environment
|
|
117
|
-
#
|
|
118
|
-
#
|
|
115
|
+
# --rails sugar: boot config/environment. Prefer redefine only for the
|
|
116
|
+
# in-process path (daemon is whole-file reload only). In-process --rails
|
|
117
|
+
# shares one test database — force serial unless --daemon.
|
|
119
118
|
if config.rails
|
|
120
119
|
config.boot ||= "config/environment"
|
|
121
|
-
config.
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
120
|
+
unless config.daemon || explicit.include?(:strategy)
|
|
121
|
+
config.strategy = "redefine"
|
|
122
|
+
end
|
|
123
|
+
unless config.daemon
|
|
124
|
+
if config.jobs.to_i > 1
|
|
125
|
+
warn "[mutineer] --rails without --daemon runs serially (shared test DB); " \
|
|
126
|
+
"forcing --jobs 1. Use --daemon for safe --jobs N."
|
|
127
|
+
end
|
|
128
|
+
config.jobs = 1
|
|
129
|
+
end
|
|
127
130
|
end
|
|
128
131
|
|
|
129
132
|
# Auto-detect the framework only when neither CLI nor config file set it
|
|
@@ -163,12 +166,20 @@ module Mutineer
|
|
|
163
166
|
case known_key
|
|
164
167
|
when "operators" then filter_operators(Array(value).map(&:to_s), file_name)
|
|
165
168
|
when "jobs" then value.to_i
|
|
166
|
-
when "threshold"
|
|
169
|
+
when "threshold"
|
|
170
|
+
f = Float(value, exception: false)
|
|
171
|
+
if f.nil?
|
|
172
|
+
raise ConfigError, "#{file_name}: threshold must be a number between 0 and 100 " \
|
|
173
|
+
"(got: #{value.inspect})"
|
|
174
|
+
end
|
|
175
|
+
f
|
|
167
176
|
when "require" then Array(value).map(&:to_s)
|
|
168
177
|
when "boot" then value.to_s
|
|
169
178
|
when "framework" then value.to_s
|
|
170
179
|
when "rails" then value == true || value.to_s == "true"
|
|
180
|
+
when "daemon" then value == true || value.to_s == "true"
|
|
171
181
|
when "verbose" then value == true || value.to_s == "true"
|
|
182
|
+
when "fail_fast" then value == true || value.to_s == "true"
|
|
172
183
|
when "ignore" then Array(value).map(&:to_s)
|
|
173
184
|
when "baseline" then value.to_s
|
|
174
185
|
when "test_command" then value.to_s
|
|
@@ -21,7 +21,24 @@ module Mutineer
|
|
|
21
21
|
class CoverageMap
|
|
22
22
|
DEFAULT_CAPTURE_TIMEOUT = 120 # seconds, per coverage subprocess (R3)
|
|
23
23
|
|
|
24
|
-
attr_reader :project_root, :failed_test_files, :phase_a_ran
|
|
24
|
+
attr_reader :project_root, :failed_test_files, :phase_a_ran, :map
|
|
25
|
+
|
|
26
|
+
# Build a QUERY-ONLY map from data captured elsewhere (the daemon builds the map
|
|
27
|
+
# app-side and ships `map` + `failed_test_files` over IPC; the tool reconstructs it
|
|
28
|
+
# here for per-mutant selection). Skips the capture machinery entirely — only the
|
|
29
|
+
# three fields #tests_for / #method_uncapturable? read are set (U7).
|
|
30
|
+
#
|
|
31
|
+
# @param map [Hash] the "file:line" => [test_files] map.
|
|
32
|
+
# @param failed_test_files [Array<String>] test files whose capture failed.
|
|
33
|
+
# @param project_root [String] project root (for path relativization).
|
|
34
|
+
# @return [Mutineer::CoverageMap] a query-only map.
|
|
35
|
+
def self.from_data(map:, failed_test_files:, project_root:)
|
|
36
|
+
instance = allocate
|
|
37
|
+
instance.instance_variable_set(:@map, map || {})
|
|
38
|
+
instance.instance_variable_set(:@failed_test_files, failed_test_files || [])
|
|
39
|
+
instance.instance_variable_set(:@project_root, project_root)
|
|
40
|
+
instance
|
|
41
|
+
end
|
|
25
42
|
|
|
26
43
|
def initialize(source_paths:, test_paths:, cache_dir: ".mutineer",
|
|
27
44
|
load_paths: ["lib"], project_root: Dir.pwd,
|
|
@@ -54,9 +71,9 @@ module Mutineer
|
|
|
54
71
|
# the booted parent instead. Inverts into the same map #tests_for reads, and
|
|
55
72
|
# reuses the digest cache (the digest mixes in the boot file so a boot cache
|
|
56
73
|
# never collides with a standalone one).
|
|
57
|
-
def build_via_fork(
|
|
74
|
+
def build_via_fork(after_fork: nil)
|
|
58
75
|
warn_external_sources
|
|
59
|
-
cached_or { run_phase_a_via_fork(
|
|
76
|
+
cached_or { run_phase_a_via_fork(after_fork: after_fork) }
|
|
60
77
|
end
|
|
61
78
|
|
|
62
79
|
# Phase B lookup: the test files that cover `file:line`, or [] when none do.
|
|
@@ -159,7 +176,7 @@ module Mutineer
|
|
|
159
176
|
# per-source coverage counts. record() inverts them exactly as the subprocess
|
|
160
177
|
# path does. ponytail: serial fork (one test at a time) — boot apps fork
|
|
161
178
|
# cheaply via COW and per-test isolation matters more than throughput here.
|
|
162
|
-
def run_phase_a_via_fork(
|
|
179
|
+
def run_phase_a_via_fork(after_fork:)
|
|
163
180
|
@phase_a_ran = true
|
|
164
181
|
@map = {}
|
|
165
182
|
@failed_test_files = []
|
|
@@ -169,7 +186,7 @@ module Mutineer
|
|
|
169
186
|
# Tri-state payload (KTD-1): Hash = coverage, String = error diagnostic
|
|
170
187
|
# from the child, nil = pipe gone / empty.
|
|
171
188
|
# ponytail/#9: this String diagnostic is what #9 turns into an :uncapturable status.
|
|
172
|
-
case (coverage = fork_capture(absolute(test_path), abs_sources,
|
|
189
|
+
case (coverage = fork_capture(absolute(test_path), abs_sources, after_fork))
|
|
173
190
|
when Hash then record(coverage, test_path)
|
|
174
191
|
when String
|
|
175
192
|
fail_test(test_path, @verbose ? "fork capture failed: #{coverage}" :
|
|
@@ -182,7 +199,7 @@ module Mutineer
|
|
|
182
199
|
# Fork the booted parent, run one test under the inherited Coverage, and
|
|
183
200
|
# return its per-source counts hash (or nil on failure). Reuses the same
|
|
184
201
|
# fork + Marshal-over-pipe + hard-exit! discipline as WorkerPool/Isolation.
|
|
185
|
-
def fork_capture(abs_test, abs_sources,
|
|
202
|
+
def fork_capture(abs_test, abs_sources, after_fork)
|
|
186
203
|
rd, wr = IO.pipe
|
|
187
204
|
# #19: Marshal output is binary — an un-binmoded pipe can raise
|
|
188
205
|
# Encoding::UndefinedConversionError on write, which the child's rescue then
|
|
@@ -193,7 +210,10 @@ module Mutineer
|
|
|
193
210
|
rd.close
|
|
194
211
|
payload =
|
|
195
212
|
begin
|
|
196
|
-
|
|
213
|
+
# Fork-safety hook: the in-process path reconnects AR; the daemon routes
|
|
214
|
+
# to its worker DB. Nil (non-Rails) = no-op. Injected so this file needs
|
|
215
|
+
# neither Runner (Prism) nor Rails.
|
|
216
|
+
after_fork&.call
|
|
197
217
|
Coverage.result(clear: true, stop: false) # discard pre-test delta
|
|
198
218
|
TestRunners.for(@framework).run([abs_test])
|
|
199
219
|
# lines:true yields {file => {lines: [...]}}; reduce to the counts
|
|
@@ -55,15 +55,17 @@ module Mutineer
|
|
|
55
55
|
# @param payload [Hash] {"code" => mutated ruby, "source_file" => path}.
|
|
56
56
|
# @param tests [Array<String>] covering test file paths.
|
|
57
57
|
# @param timeout [Numeric] per-mutant wall-clock timeout (seconds).
|
|
58
|
+
# @param worker [Integer] worker slot; the daemon routes the fork to
|
|
59
|
+
# `<db>-<worker>` (#26 isolation). Defaults to 0 (serial in U5).
|
|
58
60
|
# @return [String] one of survived/killed/error/timeout.
|
|
59
|
-
def request(id:, payload:, tests:, timeout:)
|
|
61
|
+
def request(id:, payload:, tests:, timeout:, worker: 0)
|
|
60
62
|
# A crash can surface on the WRITE (daemon died idle between requests →
|
|
61
63
|
# Errno::EPIPE) as well as the read (EOF), so guard both: either way, respawn
|
|
62
64
|
# for future mutants and score THIS one error (re-running a crash-causing
|
|
63
65
|
# mutant could loop). Never let a dead pipe abort the whole run.
|
|
64
66
|
reply =
|
|
65
67
|
begin
|
|
66
|
-
send_line("id" => id, "payload" => payload, "tests" => tests, "timeout" => timeout)
|
|
68
|
+
send_line("id" => id, "worker" => worker, "payload" => payload, "tests" => tests, "timeout" => timeout)
|
|
67
69
|
read_line
|
|
68
70
|
rescue Errno::EPIPE, IOError
|
|
69
71
|
nil
|
|
@@ -74,6 +76,19 @@ module Mutineer
|
|
|
74
76
|
"error"
|
|
75
77
|
end
|
|
76
78
|
|
|
79
|
+
# #26/U7: ask the daemon to build the coverage map app-side and return it.
|
|
80
|
+
# One-shot control message (no id). Returns `{"map"=>..., "failed_test_files"=>...}`
|
|
81
|
+
# (possibly with an `"error"`), or nil if the daemon vanished — the caller then
|
|
82
|
+
# falls back to running the full test set (no narrowing) rather than mis-scoring.
|
|
83
|
+
#
|
|
84
|
+
# @return [Hash, nil] the coverage payload, or nil on a dead pipe.
|
|
85
|
+
def coverage
|
|
86
|
+
send_line("cmd" => "coverage")
|
|
87
|
+
read_line
|
|
88
|
+
rescue Errno::EPIPE, IOError
|
|
89
|
+
nil
|
|
90
|
+
end
|
|
91
|
+
|
|
77
92
|
# Graceful shutdown; leaves no orphaned daemon/child.
|
|
78
93
|
#
|
|
79
94
|
# @return [void]
|