evilution 0.32.0 → 0.34.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 +4 -4
- data/.beads/interactions.jsonl +28 -0
- data/.rubocop_todo.yml +1 -0
- data/CHANGELOG.md +31 -0
- data/README.md +12 -10
- data/docs/integrations.md +15 -0
- data/docs/isolation.md +46 -2
- data/lib/evilution/baseline.rb +11 -4
- data/lib/evilution/cli/parser/options_builder.rb +17 -0
- data/lib/evilution/config/validators/example_targeting_strategy.rb +22 -0
- data/lib/evilution/config.rb +16 -2
- data/lib/evilution/coverage/digest.rb +16 -0
- data/lib/evilution/coverage/map.rb +64 -0
- data/lib/evilution/coverage/map_builder.rb +82 -0
- data/lib/evilution/coverage/map_store.rb +87 -0
- data/lib/evilution/coverage/recorder.rb +85 -0
- data/lib/evilution/coverage.rb +8 -0
- data/lib/evilution/coverage_example_filter.rb +41 -0
- data/lib/evilution/integration/loading/test_load_path.rb +76 -0
- data/lib/evilution/integration/minitest.rb +5 -1
- data/lib/evilution/integration/rspec/state_guard/configuration_state.rb +72 -0
- data/lib/evilution/integration/rspec/state_guard/configuration_streams.rb +45 -0
- data/lib/evilution/integration/rspec/state_guard.rb +3 -1
- data/lib/evilution/integration/test_unit.rb +12 -4
- data/lib/evilution/isolation/fork.rb +38 -50
- data/lib/evilution/parallel/work_queue/dispatcher/deadline_tracker.rb +63 -0
- data/lib/evilution/parallel/work_queue/dispatcher.rb +70 -25
- data/lib/evilution/parallel/work_queue/worker.rb +50 -14
- data/lib/evilution/parallel/work_queue.rb +8 -0
- data/lib/evilution/process_supervisor.rb +259 -0
- data/lib/evilution/reporter/cli/line_formatters/unresolved_rate_warning.rb +50 -0
- data/lib/evilution/reporter/cli/metrics_block.rb +2 -0
- data/lib/evilution/runner/baseline_runner.rb +52 -0
- data/lib/evilution/runner/isolation_resolver.rb +106 -12
- data/lib/evilution/runner/mutation_executor/strategy/parallel.rb +28 -1
- data/lib/evilution/runner.rb +7 -0
- data/lib/evilution/spec_resolver.rb +147 -9
- data/lib/evilution/spec_selector.rb +14 -4
- data/lib/evilution/version.rb +1 -1
- data/lib/evilution.rb +1 -0
- data/lib/tasks/stress.rake +15 -0
- data/scripts/canary_manifest.yml +47 -0
- data/scripts/compare_targeting +277 -0
- data/scripts/compare_targeting.example.yml +24 -0
- metadata +20 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 005b0edbf9ced13a00a85d07939564cec9f38d93dec0e1d923f30a9d7cb2c779
|
|
4
|
+
data.tar.gz: a7554571383f34fd21ca7d8d61f733e446d1d2c6349816267459b19309605689
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 6227ee0e3df976329f59f286b21cfaa69bbd4455d00a1fe37dc4c77a9ff553f9e2c4fa7e112b7b19d7e6cc45c5ef2f98ccb2e03f7733fbd97ff7be6ad8582c71
|
|
7
|
+
data.tar.gz: e4a36d7eaa5826c8621d042beb1bedb7c612a67118b4456a909caa1f813bb0863098b57e113684faf9a8797ac0993f2d2d2ee2ee92562376c6e3a35cbb92ecad
|
data/.beads/interactions.jsonl
CHANGED
|
@@ -409,3 +409,31 @@
|
|
|
409
409
|
{"id":"int-c7bcc360","kind":"field_change","created_at":"2026-05-31T03:12:47.029582311Z","actor":"Denis Kiselev","issue_id":"EV-6az9","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Closed"}}
|
|
410
410
|
{"id":"int-fa3ade4f","kind":"field_change","created_at":"2026-05-31T08:47:32.000198989Z","actor":"Denis Kiselev","issue_id":"EV-d3av","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Closed"}}
|
|
411
411
|
{"id":"int-b1831323","kind":"field_change","created_at":"2026-05-31T08:47:50.244781995Z","actor":"Denis Kiselev","issue_id":"EV-d7re","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Closed"}}
|
|
412
|
+
{"id":"int-6325348d","kind":"field_change","created_at":"2026-06-05T17:09:56.549588702Z","actor":"Denis Kiselev","issue_id":"EV-rxob","extra":{"field":"status","new_value":"in_progress","old_value":"closed"}}
|
|
413
|
+
{"id":"int-2cd0e23a","kind":"field_change","created_at":"2026-06-06T03:49:11.649710835Z","actor":"Denis Kiselev","issue_id":"EV-axze","extra":{"field":"priority","new_value":"1","old_value":"3"}}
|
|
414
|
+
{"id":"int-586865ca","kind":"field_change","created_at":"2026-06-06T03:49:12.267581064Z","actor":"Denis Kiselev","issue_id":"EV-axze","extra":{"field":"priority","new_value":"2","old_value":"1"}}
|
|
415
|
+
{"id":"int-c295efed","kind":"field_change","created_at":"2026-06-06T03:56:26.595461244Z","actor":"Denis Kiselev","issue_id":"EV-gl1e","extra":{"field":"status","new_value":"in_progress","old_value":"open"}}
|
|
416
|
+
{"id":"int-552b93f8","kind":"field_change","created_at":"2026-06-06T06:34:06.544071647Z","actor":"Denis Kiselev","issue_id":"EV-cnx8","extra":{"field":"status","new_value":"in_progress","old_value":"open"}}
|
|
417
|
+
{"id":"int-38e50f55","kind":"field_change","created_at":"2026-06-06T06:34:58.433491242Z","actor":"Denis Kiselev","issue_id":"EV-gl1e","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Merged via PR #1329"}}
|
|
418
|
+
{"id":"int-121d6c46","kind":"field_change","created_at":"2026-06-06T07:31:53.274033616Z","actor":"Denis Kiselev","issue_id":"EV-cnx8","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Merged via PR #1331"}}
|
|
419
|
+
{"id":"int-17dbc619","kind":"field_change","created_at":"2026-06-06T08:17:59.133451819Z","actor":"Denis Kiselev","issue_id":"EV-jwao","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Merged via PR #1333. Forward INT/TERM to worker process groups before parent dies; WorkerRegistry (lock-free COW pgid registry, signal-safe), register-before-isolate (race fix from review), unregister on reap. Specs + e2e green."}}
|
|
420
|
+
{"id":"int-9eb0b37f","kind":"field_change","created_at":"2026-06-06T10:31:43.322237231Z","actor":"Denis Kiselev","issue_id":"EV-2sh8","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Merged via PR #1335. Mutation child setpgid(0,0) as own group leader; terminate_child group-kills via signal_tree(-pid + pid) on TERM/KILL ladder; sweeps blocking grandchildren on inner timeout. Specs green, memory:check PASS. Flaky-test review hardened (timeout 3s, atomic pid write)."}}
|
|
421
|
+
{"id":"int-cdc02b29","kind":"field_change","created_at":"2026-06-06T15:19:26.153490037Z","actor":"Denis Kiselev","issue_id":"EV-dlnn","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Merged via PR #1344. Flaky /tmp glob replaced with parent-side sandbox capture (spy Dir.mktmpdir, assert the evilution-run dir gone after timeout). Deterministic, fork.rb untouched."}}
|
|
422
|
+
{"id":"int-af4ca762","kind":"field_change","created_at":"2026-06-06T17:23:17.168065946Z","actor":"Denis Kiselev","issue_id":"EV-dwqw","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Merged via PR #1345. Real carrier was RSpec.configuration @preferred_options (color_mode getter reads it first); inner Runner.run --no-color force-merges color_mode=:off in place. StateGuard::ConfigurationState snapshots @preferred_options by dup + stream ivars and restores them; in-process example re-enabled. Visual dots green, suite 4881 green."}}
|
|
423
|
+
{"id":"int-f7253919","kind":"field_change","created_at":"2026-06-07T04:14:56.864785049Z","actor":"Denis Kiselev","issue_id":"EV-z7f5","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Merged PR #1347 (master). opt1+2+3 + Copilot review fixes. Empirical 4-repro re-run remains under EV-rxob canary harness (the dep)."}}
|
|
424
|
+
{"id":"int-41c70ee3","kind":"field_change","created_at":"2026-06-15T07:26:55.682158325Z","actor":"Denis Kiselev","issue_id":"EV-9f3b","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Merged PR #1358: ProcessSupervisor unit + specs"}}
|
|
425
|
+
{"id":"int-9f3581f4","kind":"field_change","created_at":"2026-06-15T08:27:50.649632334Z","actor":"Denis Kiselev","issue_id":"EV-3aw3","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Merged PR #1359: Isolation::Fork routed through ProcessSupervisor"}}
|
|
426
|
+
{"id":"int-9abff5aa","kind":"field_change","created_at":"2026-06-15T09:18:55.659697892Z","actor":"Denis Kiselev","issue_id":"EV-dg69","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Merged PR #1360: outer path (Worker/Runner trap) routed through ProcessSupervisor; WorkerRegistry removed"}}
|
|
427
|
+
{"id":"int-42f490de","kind":"field_change","created_at":"2026-06-15T11:28:14.219616586Z","actor":"Denis Kiselev","issue_id":"EV-7a91","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Merged PR #1361: worker INT/TERM handler reaps inner children, kill_and_reap_all + reset_for_child!, cross-path lifecycle stress spec"}}
|
|
428
|
+
{"id":"int-68e95598","kind":"field_change","created_at":"2026-06-15T11:28:31.205819027Z","actor":"Denis Kiselev","issue_id":"EV-5rrh","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"All 4 Track A steps merged: ProcessSupervisor owns spawn/register/signal-safe registry/group-kill/reap. Inner (Fork) + outer (Worker) paths routed through it; WorkerRegistry removed; Runner trap + worker trap reap on interrupt; cross-path stress spec proves no orphan/zombie."}}
|
|
429
|
+
{"id":"int-0bc92dbf","kind":"field_change","created_at":"2026-06-15T11:49:47.527861306Z","actor":"Denis Kiselev","issue_id":"EV-1q84","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Merged: Coverage::Map + Recorder + MapBuilder (forked full-suite per-example coverage map)"}}
|
|
430
|
+
{"id":"int-207eba53","kind":"field_change","created_at":"2026-06-15T12:01:00.790784019Z","actor":"Denis Kiselev","issue_id":"EV-k1xr","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Merged: Coverage::MapStore + Digest (per-file digest cache, prune-to-fresh partial invalidation, corrupt rebuild)"}}
|
|
431
|
+
{"id":"int-1e19ca1d","kind":"field_change","created_at":"2026-06-15T13:01:49.951898163Z","actor":"Denis Kiselev","issue_id":"EV-lqqk","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Merged PR #1364: CoverageExampleFilter + example_targeting_strategy config/CLI + build_example_filter wiring + executed-lines accuracy fix"}}
|
|
432
|
+
{"id":"int-1e5b072e","kind":"field_change","created_at":"2026-06-15T13:37:22.697876987Z","actor":"Denis Kiselev","issue_id":"EV-51d4","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Merged PR #1365: scripts/compare_targeting 3-mode comparison harness (lost_kills gate + wall-time ratios) + example manifest"}}
|
|
433
|
+
{"id":"int-c50dc4d6","kind":"field_change","created_at":"2026-06-15T17:19:15.781802557Z","actor":"Denis Kiselev","issue_id":"EV-7uui","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Merged PR #1368: direction C — per-resolved-file coverage (absolute locations, resolved-spec capture, accuracy-first empty->lexical). factory_bot 16 lost kills -> 0"}}
|
|
434
|
+
{"id":"int-2e23cd44","kind":"field_change","created_at":"2026-06-16T07:17:22.764949718Z","actor":"Denis Kiselev","issue_id":"EV-5hk5","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Merged via PR #1373 (master)"}}
|
|
435
|
+
{"id":"int-8e319322","kind":"field_change","created_at":"2026-06-16T07:17:22.312793419Z","actor":"Denis Kiselev","issue_id":"EV-bi41","extra":{"field":"status","new_value":"in_progress","old_value":"open"}}
|
|
436
|
+
{"id":"int-93c675ea","kind":"field_change","created_at":"2026-06-16T07:54:16.348995872Z","actor":"Denis Kiselev","issue_id":"EV-bi41","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Merged via PR #1374 (master)"}}
|
|
437
|
+
{"id":"int-555de29c","kind":"field_change","created_at":"2026-06-16T07:54:17.438977358Z","actor":"Denis Kiselev","issue_id":"EV-z03y","extra":{"field":"status","new_value":"in_progress","old_value":"open"}}
|
|
438
|
+
{"id":"int-616ccd27","kind":"field_change","created_at":"2026-06-16T09:31:30.217250542Z","actor":"Denis Kiselev","issue_id":"EV-z03y","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Merged via PR #1375 (master). Dep on EV-rxob is provenance only; code fix shipped."}}
|
|
439
|
+
{"id":"int-6d2f9b35","kind":"field_change","created_at":"2026-06-16T10:00:18.493013642Z","actor":"Denis Kiselev","issue_id":"EV-rxob","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"1.0 real-world validation complete. Rounds 2-4 ran 30+ gems across rspec/minitest/test-unit; no crash/memory blowup. All findings filed+fixed+merged: EV-z7f5/#1347, EV-gl1e/#1329, EV-52hf/#1341, EV-5hk5/#1373, EV-bi41/#1374, EV-z03y/#1375. Round-4 OOB re-verify confirmed fixes compound (state_machines 0->0.922, factory_bot/webmock/http real OOB scores, errors=0). Residual resolution-coverage gaps (behavior-named/flat-prefixed/non-std helper) tracked low-pri: EV-ajby/#1376, EV-6rbd/#1377, EV-no5u/#1378."}}
|
data/.rubocop_todo.yml
CHANGED
data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,37 @@
|
|
|
2
2
|
|
|
3
3
|
Versioning policy: see [docs/versioning.md](docs/versioning.md).
|
|
4
4
|
|
|
5
|
+
## [0.34.0] - 2026-06-16
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- **Coverage-based example targeting (`--example-targeting coverage` / `example_targeting_strategy: coverage`)** — the default `lexical` strategy picks per-mutation examples by name-grepping example bodies for the mutated method/class token, which over-selects (any example that merely *mentions* the name) and under-selects (examples that exercise the code without naming it). The new `coverage` strategy instead runs exactly the examples that **execute the mutated line**, derived from a cached full-suite line-coverage map (`Coverage` stdlib), and falls back to `lexical` for any file the map has not built yet. Enable it with `--example-targeting coverage` (CLI) or `example_targeting_strategy: coverage` (`.evilution.yml`); the YAML key accepts `lexical` or `coverage`. The CLI flag additionally accepts `full_file` to run every resolved example (equivalent to `example_targeting: false` in `.evilution.yml`). See [docs/superpowers/specs/2026-06-07-coverage-based-targeting-design.md](docs/superpowers/specs/2026-06-07-coverage-based-targeting-design.md) (PRs #1364/#1365)
|
|
10
|
+
- **Auto spec-resolution now expands dir-grouped test layouts (`test/unit/<class>/*_test.rb`)** — `Evilution::SpecResolver` resolved a source file only to a single mirror test file, so projects that group a class's tests in a *directory* named after the source (e.g. `state_machines`: `lib/state_machines/branch.rb` → `test/unit/branch/*_test.rb`, 51 files) resolved nothing and scored 0.0 out of the box. The resolver now also matches a grouped directory and expands it into its test files (`*_test.rb` plus the `test_*.rb` prefix convention), ranked below the deterministic file mirror so a 1:1 spec still wins. `SpecSelector` consumes the resulting list and keeps working with custom resolvers that implement only the older `#call` contract. Repro that went 0.0 → 0.92 out of the box: state_machines (EV-bi41, PR #1374, GH #1371)
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- **Non-Rails gems now preload and score out-of-the-box instead of erroring on every mutation** — auto-preload fired only for Rails projects, so a plain rspec/minitest **gem** run with the default `auto` isolation got no parent-process preload (its library and test framework were never loaded), and every mutation errored with `0 examples loaded` / a `NameError` (e.g. `undefined method 'delegate'`) — a 0.0 score with a cryptic cause. `auto` isolation now resolves to `fork` when a `*.gemspec` is detected (not just a Rails root), and the non-Rails autodetect chain now prefers the conventional test helper (`spec/spec_helper.rb` → `test/test_helper.rb` → `test/helper.rb`) — which loads the library *and* the suite's framework/support setup so example groups register — falling back to the gem's `lib/<gem>.rb` entry. When a gem is detected but no conventional helper exists, evilution prints an actionable warning naming the locations it searched and the `--preload` escape hatch, instead of a silent 0%. Out-of-box repros: factory_bot, webmock, http (442 errors → real scores) (EV-z03y, PR #1375, GH #1372)
|
|
15
|
+
- **Parent-process preload puts the test root on `$LOAD_PATH` for minitest/Test::Unit projects** — `IsolationResolver` only ever added `spec/` to `$LOAD_PATH` before requiring the preload file, even for `--integration minitest`. A `test/test_helper.rb` that does a non-relative `require "support/..."` (relying on the runner's `-Itest`) then failed preload with a `LoadError`. Preload is now integration-aware: rspec stays `spec/`-only (so a bare `require "support/foo"` still resolves from `spec/` in projects that have both dirs), while minitest/Test::Unit routes through `Integration::Loading::TestLoadPath` — the same policy the per-mutation test load uses — putting the test root on `$LOAD_PATH`. Repro: httprb/http (`require "support/capture_warning"`) now resolves without `RUBYOPT=-Itest` (EV-5hk5, PR #1373, GH #1370)
|
|
16
|
+
- **Worker and per-mutation child process lifecycle consolidated into `ProcessSupervisor`** — process-group creation (`setpgid`), signal forwarding (SIGINT/SIGTERM → worker groups), and child reaping were spread across `Isolation::Fork`, the parallel worker, and a separate `WorkerRegistry`. They are now centralized in a single `Evilution::ProcessSupervisor`, which removes `WorkerRegistry`, tolerates benign `setpgid` races (`ESRCH`) silently, warns rather than raises on unexpected `EPERM`, and ensures sandbox cleanup on failure — closing process/FD-leak windows on interrupted or worker-recycling runs (PRs #1358 fixes GH #1337, #1360 fixes GH #1339)
|
|
17
|
+
- **`WorkQueue::Dispatcher` per-worker timeout tracking extracted into `DeadlineTracker`** — the per-worker deadline bookkeeping that kills and recycles only a stuck worker (rather than aborting the whole run) is now an isolated, separately-tested `DeadlineTracker` collaborator, simplifying the dispatcher and hardening timeout management under load (PR #1369)
|
|
18
|
+
|
|
19
|
+
## [0.33.0] - 2026-06-07
|
|
20
|
+
|
|
21
|
+
### Added
|
|
22
|
+
|
|
23
|
+
- **Auto spec-resolution now finds specs in non-mirrored test layouts (`spec/unit`, `spec/lib`, `test/unit`, `test/lib`) instead of scoring 0% out of the box** — when `--spec` is omitted, `Evilution::SpecResolver` previously only resolved tests that mirrored the `lib/` tree 1:1 (`lib/foo/bar.rb` → `spec/foo/bar_spec.rb`). Projects that park their suites under a `unit`/`lib` bucket (`spec/unit/foo/bar_spec.rb`, `test/unit/foo/bar_test.rb` — a very common convention) resolved nothing, so every mutation reported `:unresolved` and the score collapsed to 0.0 with no actionable signal. The resolver now layers conventional-subdir candidates (`CONVENTIONAL_SUBDIRS = %w[unit lib]`) and parent-directory fallbacks on top of the deterministic mirror, ranking full mirrors above dropped-namespace and bare-basename guesses. When mutations still cannot be paired with a spec, a new **unresolved-rate warning** surfaces in both CLI (`reporter/cli/line_formatters/unresolved_rate_warning.rb`) and HTML (`reporter/html/sections/unresolved_details.rb`) output with a best-guess suggestion per source file, so a high unresolved rate reads as a resolution problem to fix (pass `--spec`, adjust layout) rather than a silently meaningless 0%. Repros that went 0.0 → real scores out of the box: webmock, doorkeeper, concurrent-ruby (EV-z7f5, PR #1347, GH #1325)
|
|
24
|
+
- **Opt-in parallel/isolation stress + load suite (`rake stress`) plus a weekly CI guardrail** — a new `:stress`-tagged spec (`spec/evilution/parallel/stress_spec.rb`) drives `Evilution::Parallel::WorkQueue` and `Evilution::Isolation::Fork` far past the per-class fixtures: 10k items at `-j8` with worker recycling, simultaneous worker-timeout and worker-death cascades (folding the EV-gl1e per-item-timeout recovery regression), deadline precision under scheduling churn, sustained-load RSS bounds, and hundreds of real forked mutation runs mixing fast and blocking children. It asserts the invariants that matter under load — correct ordered results, no deadlocks (every run is `Timeout`-wrapped), no zombie/unreaped workers (non-blocking `waitpid(WNOHANG)`), no FD leaks, bounded memory growth. Excluded from the default run; lifted by `RUN_STRESS=1` (set automatically by `rake stress`). A scheduled `.github/workflows/stress.yml` runs it weekly (Mondays 06:00 UTC) and on demand with CI-tuned scale knobs (`STRESS_JOBS`/`STRESS_ITEMS`/`STRESS_FORK_MUTS`/`STRESS_RUN_TIMEOUT`). The full-scale run surfaced no new defects — the EV-gl1e/EV-cnx8/EV-jwao/EV-2sh8 isolation hardening below holds under load (EV-axze, PR #1348, GH #861)
|
|
25
|
+
|
|
26
|
+
### Fixed
|
|
27
|
+
|
|
28
|
+
- **A single stuck or dead worker no longer aborts the entire parallel run** — `Evilution::Parallel::WorkQueue::Dispatcher` enforced timeouts with a coarse pool-wide watchdog: one mutation that blocked past `item_timeout` (e.g. a checkout-breaking mutation that stalls a child in `ConditionVariable#wait`) raised `"worker timed out after Ns"` as the run's `first_error` and tore down every worker, and a worker that exited unexpectedly (`handle_dead`) did the same. The dispatcher now tracks a **per-worker deadline**: it kills and recycles only the stuck worker, marks just that worker's in-flight item with a `WorkQueue::TIMED_OUT` / `DIED` sentinel, and continues the remaining work. `Strategy::Parallel` translates the sentinels into `:timeout` / `:error` `MutationResult`s. connection_pool jobs=4 (which previously whole-run-aborted) now completes with the stuck mutation scored `:timed_out` and a real non-zero score (EV-gl1e, PR #1329, GH #1324)
|
|
29
|
+
- **Worker SIGKILL now reaps grandchild processes instead of orphaning them** — when the dispatcher SIGKILLed a stuck pool worker, any grandchildren the worker had forked (the per-mutation test subprocess and anything *it* spawned) were left orphaned to init, leaking processes across a long run. Each worker now makes itself a process-group leader (`Process.setpgid(pid, pid)` in `Worker.spawn`) and `Worker#kill` signals the whole group (`-pid`) with a single-pid fallback, so killing a worker takes its entire subtree down (EV-cnx8, PR #1331, GH #1327)
|
|
30
|
+
- **Terminal interrupts (Ctrl-C) now forward to worker process groups instead of leaking them** — once workers ran in their own process groups (above), a SIGINT/SIGTERM to the evilution parent no longer reached the workers' groups, so an interrupted parallel run could leave workers and their mutation children alive. A new `WorkerRegistry` tracks live worker pgids; the `Runner` signal handler forwards the terminating signal to every registered group. The worker registers its pgid **before** `setpgid` to close a trap race where a signal could arrive after the child had changed groups but before the registry knew about it (EV-jwao, PR #1333, GH #1332)
|
|
31
|
+
- **A mutation that blocks a grandchild no longer hangs the per-mutation timeout** — `Evilution::Isolation::Fork` makes the per-mutation child its own process-group leader (`Process.setpgid(0, 0)`) before running the test command, so when the mutation spawns a grandchild that blocks (or traps interrupts), the timeout's TERM → KILL ladder reaches the whole subtree and the child is bounded rather than wedged behind a blocking grandchild (EV-2sh8, PR #1335, GH #1330)
|
|
32
|
+
- **Minitest / test-unit projects whose test helper is loaded via `require "test_helper"` no longer error every mutation with `LoadError`** — the mutation child did not place the project's test root on `$LOAD_PATH`, so a suite that does a bare `require "test_helper"` (the standard Minitest convention) raised `LoadError` and every mutation reported `:error`, collapsing the run. A new `Evilution::Integration::Loading::TestLoadPath` prepends the relevant test directories to `$LOAD_PATH` for the load, and restores it afterward; the Minitest and Test::Unit integrations both route their test-file loads through it. Repro: state_machines (EV-52hf, PR #1341, GH #1326)
|
|
33
|
+
- **In-process isolation no longer leaks RSpec global configuration into the host process** — an `--isolation=in_process` run mutates `RSpec.configuration` while dispatching each mutation, and two of those fields (`color_mode` and `output_stream`) were left mutated after the run, bleeding RSpec's color setting and redirected output stream into the host process that invoked evilution. A new `ConfigurationState` strategy (wired into `StateGuard`) snapshots and restores the affected configuration fields around each in-process run, so the host's RSpec state is unchanged afterward (EV-dwqw, PR #1345/#1346, GH #1343)
|
|
34
|
+
- **Flaky `fork_spec` sandbox-cleanup example decoupled from global `/tmp`** — the spec asserting that a child timeout cleans up its sandbox temp directory keyed off the shared `/tmp`, so concurrent activity made it intermittently fail. It now scopes to its own directory, removing the false negative (test-only) (EV-dlnn, PR #1344, GH #1342)
|
|
35
|
+
|
|
5
36
|
## [0.32.0] - 2026-05-31
|
|
6
37
|
|
|
7
38
|
### Added
|
data/README.md
CHANGED
|
@@ -94,10 +94,11 @@ Every command, subcommand, and flag listed in this section is part of evilution'
|
|
|
94
94
|
| `-f`, `--format FORMAT` | String | `text` | Output format: `text`, `json`, or `html`. |
|
|
95
95
|
| `--target EXPR` | String | _(none)_ | Only mutate matching methods. Supports method name (`Foo::Bar#calculate`), class (`Foo`), namespace wildcards (`Foo::Bar*`), method-type selectors (`Foo#`, `Foo.`), descendants (`descendants:Foo`), and source globs (`source:lib/**/*.rb`). |
|
|
96
96
|
| `--min-score FLOAT` | Float | 0.0 | Minimum mutation score (0.0–1.0) to pass. |
|
|
97
|
-
| `--spec FILES` | Array | _(none)_ | Spec files to run (comma-separated). Defaults to auto-detection via `SpecResolver
|
|
97
|
+
| `--spec FILES` | Array | _(none)_ | Spec files to run (comma-separated). Defaults to auto-detection via `SpecResolver`, which also resolves non-mirrored (`spec/unit`, `test/unit`) and dir-grouped (`test/unit/<class>/*_test.rb`) layouts. |
|
|
98
98
|
| `--spec-dir DIR` | String | _(none)_ | Include all `*_spec.rb` files in DIR recursively. Composable with `--spec`. |
|
|
99
99
|
| `--spec-pattern GLOB` | String | _(none)_ | Restrict resolved spec candidates to files matching GLOB (e.g. `spec/models/**/*_spec.rb`). |
|
|
100
100
|
| `--no-example-targeting` | Boolean | _(enabled)_ | Disable per-mutation example targeting (always run every example in the resolved spec file). Example targeting scans each example body for symbols from the mutated method and runs only the matching subset. |
|
|
101
|
+
| `--example-targeting MODE` | String | `lexical` | How targeting picks examples: `lexical` (name-grep example bodies for the mutated method/class), `coverage` (run only the examples that actually execute the mutated line, from a cached line-coverage map), or `full_file` (run all resolved examples). |
|
|
101
102
|
| `--example-targeting-fallback MODE` | String | `full_file` | Behavior when no example matches: `full_file` (run the whole spec file) or `unresolved` (skip the mutation as `:unresolved`). |
|
|
102
103
|
| `-j`, `--jobs N` | Integer | 1 | Number of parallel workers. Uses demand-driven work distribution with pipe-based IPC. |
|
|
103
104
|
| `--no-baseline` | Boolean | _(enabled)_ | Skip baseline test suite check. By default, a baseline run detects pre-existing failures and marks those mutations as `neutral`. |
|
|
@@ -113,8 +114,8 @@ Every command, subcommand, and flag listed in this section is part of evilution'
|
|
|
113
114
|
| `--no-progress` | Boolean | _(enabled)_ | Disable the TTY progress bar. |
|
|
114
115
|
| `--quiet-children` | Boolean | false | Redirect each forked worker's stdout/stderr to per-pid files under `tmp/evilution_children/<pid>.{out,err}` so noisy app initializers (Datadog, Bullet, etc.) don't merge with parent output. Trade-off: live worker errors only appear in the side files, not the terminal — `tail -f tmp/evilution_children/*.err` to watch them. |
|
|
115
116
|
| `--quiet-children-dir DIR` | String | `tmp/evilution_children` | Override the directory used by `--quiet-children`. |
|
|
116
|
-
| `--isolation MODE` | String | `auto` | Isolation strategy: `auto`, `fork`, or `in_process`. `auto` selects `fork` for Rails projects. See [docs/isolation.md](docs/isolation.md). |
|
|
117
|
-
| `--preload FILE` | String | _(auto)_ | File to require in parent before forking workers. Auto-detect chain for Rails projects: `spec/rails_helper.rb` → `spec/spec_helper.rb` → `test/test_helper.rb`.
|
|
117
|
+
| `--isolation MODE` | String | `auto` | Isolation strategy: `auto`, `fork`, or `in_process`. `auto` selects `fork` for Rails projects and packaged gems (`*.gemspec`), `in_process` otherwise. See [docs/isolation.md](docs/isolation.md). |
|
|
118
|
+
| `--preload FILE` | String | _(auto)_ | File to require in parent before forking workers. Auto-detect chain for Rails projects: `spec/rails_helper.rb` → `spec/spec_helper.rb` → `test/test_helper.rb`. For non-Rails gems: `spec/spec_helper.rb` → `test/test_helper.rb` → `test/helper.rb`, falling back to the gem entry `lib/<gem>.rb` (with a warning naming the searched paths). Pass `--no-preload` to opt out. |
|
|
118
119
|
| `--no-preload` | Boolean | _(enabled)_ | Disable parent-process preload. |
|
|
119
120
|
| `--skip-heredoc-literals` | Boolean | false | Skip all string literal mutations inside heredocs. |
|
|
120
121
|
| `--show-disabled` | Boolean | false | Report mutations skipped by `# evilution:disable` comments. |
|
|
@@ -172,9 +173,9 @@ schema_version: 1 # opts into strict validation (rejects unknown keys
|
|
|
172
173
|
# integration: rspec # test framework: rspec, minitest, test_unit
|
|
173
174
|
# suggest_tests: false # concrete test code in suggestions (matches integration)
|
|
174
175
|
# save_session: false # persist results under .evilution/results/
|
|
175
|
-
# isolation: auto # auto | fork | in_process (auto selects fork for Rails)
|
|
176
|
+
# isolation: auto # auto | fork | in_process (auto selects fork for Rails + gems)
|
|
176
177
|
# canary: true # proof-of-life synthetic mutation at session start (false to skip)
|
|
177
|
-
# preload: null # path to preload before forking; false to disable; auto-detects for Rails
|
|
178
|
+
# preload: null # path to preload before forking; false to disable; auto-detects for Rails + gems
|
|
178
179
|
# skip_heredoc_literals: false # skip string literal mutations inside heredocs (recommended for Rails: heredoc SQL/templates rarely have test coverage)
|
|
179
180
|
# show_disabled: false # report mutations skipped by disable comments
|
|
180
181
|
# baseline_session: null # path to session file for HTML comparison
|
|
@@ -219,7 +220,7 @@ All keys recognised under `schema_version: 1`:
|
|
|
219
220
|
| `jobs` | Integer | `1` | Number of parallel workers. |
|
|
220
221
|
| `fail_fast` | Integer / null | `null` | Stop after N surviving mutants. `null` = disabled. |
|
|
221
222
|
| `baseline` | Boolean | `true` | Run baseline test suite to detect pre-existing failures (marked `:neutral`). |
|
|
222
|
-
| `isolation` | String | `auto` | Isolation strategy: `auto`, `fork`, `in_process`. `auto` selects `fork` for Rails projects.
|
|
223
|
+
| `isolation` | String | `auto` | Isolation strategy: `auto`, `fork`, `in_process`. `auto` selects `fork` for Rails projects and packaged gems (`*.gemspec`). |
|
|
223
224
|
| `incremental` | Boolean | `false` | Cache killed/timeout results across runs. |
|
|
224
225
|
| `suggest_tests` | Boolean | `false` | Generate concrete test code in survivor suggestions (matches `integration`). |
|
|
225
226
|
| `progress` | Boolean | `true` | TTY progress bar. |
|
|
@@ -232,10 +233,11 @@ All keys recognised under `schema_version: 1`:
|
|
|
232
233
|
| `skip_heredoc_literals` | Boolean | `false` | Skip string literal mutations inside heredocs. |
|
|
233
234
|
| `related_specs_heuristic` | Boolean | `false` | Append related request/integration/feature/system specs for `includes(...)` mutations. |
|
|
234
235
|
| `fallback_to_full_suite` | Boolean | `false` | When no matching spec resolves, run the entire suite instead of marking the mutation `:unresolved`. |
|
|
235
|
-
| `preload` | String / Boolean / null | `null` | File to preload in parent before forking. `false` to disable. `null` to auto-detect for Rails.
|
|
236
|
+
| `preload` | String / Boolean / null | `null` | File to preload in parent before forking. `false` to disable. `null` to auto-detect for Rails projects and packaged gems. |
|
|
236
237
|
| `spec_mappings` | Hash<String, String/Array> | `{}` | Custom mapping from source path to spec path(s). |
|
|
237
238
|
| `spec_pattern` | String / null | `null` | Glob restricting resolved spec candidates. |
|
|
238
239
|
| `example_targeting` | Boolean | `true` | Per-mutation example-level targeting. |
|
|
240
|
+
| `example_targeting_strategy` | String | `lexical` | How targeting picks examples: `lexical` (name-grep) or `coverage` (examples that execute the mutated line, from a cached coverage map). |
|
|
239
241
|
| `example_targeting_fallback` | String | `full_file` | When targeting finds no example: `full_file` or `unresolved`. |
|
|
240
242
|
| `example_targeting_cache` | Hash | `{ max_files: 50, max_blocks: 10000 }` | LRU cache bounds for the example-targeting AST parser. |
|
|
241
243
|
| `quiet_children` | Boolean | `false` | Redirect each worker's stdout/stderr to per-pid files under `quiet_children_dir`. |
|
|
@@ -375,7 +377,7 @@ Compatibility policy for the `1.x` gem line:
|
|
|
375
377
|
| `unresolved` | No spec file resolved for the mutated source — **coverage gap, not a failure**. Use `--fallback-full-suite` to run the full suite instead. | excluded |
|
|
376
378
|
| `unparseable` | Mutated source failed to parse (e.g. dangling heredoc opener after `method_body_replacement`). Short-circuited — never executed. | excluded |
|
|
377
379
|
|
|
378
|
-
Unresolved mutations indicate a missing test mapping — the file has no corresponding test file that the resolver could find (for example, an RSpec `_spec.rb` file or a Minitest `_test.rb` file, depending on configuration). They are reported separately so you can act on them (add a test, adjust test naming, or opt in to the full-suite fallback) without inflating the error count.
|
|
380
|
+
Unresolved mutations indicate a missing test mapping — the file has no corresponding test file that the resolver could find (for example, an RSpec `_spec.rb` file or a Minitest `_test.rb` file, depending on configuration). The resolver searches both the `lib/`-mirrored path and common non-mirrored buckets (`spec/unit`, `spec/lib`, `test/unit`, `test/lib`), so a high unresolved rate usually means a genuinely missing or unconventionally-placed test; a run that leaves many mutations unresolved prints an unresolved-rate warning with a best-guess spec path per source file. They are reported separately so you can act on them (add a test, adjust test naming, pass `--spec`, or opt in to the full-suite fallback) without inflating the error count.
|
|
379
381
|
|
|
380
382
|
## Mutation Operators (74 total)
|
|
381
383
|
|
|
@@ -670,7 +672,7 @@ Use when you know which file was modified and want to verify its test coverage.
|
|
|
670
672
|
bundle exec evilution run lib/models/user.rb lib/models/account.rb lib/models/order.rb
|
|
671
673
|
```
|
|
672
674
|
|
|
673
|
-
Pass multiple file paths on a single invocation to amortise startup cost. The framework (Rails, Sorbet, etc.) and the `preload` chain (`spec/rails_helper.rb` → `spec/spec_helper.rb` → `test/test_helper.rb`) load **once** in the parent process. When `--isolation=fork` is selected (the default `--isolation=auto` resolves to `fork` on Rails projects), every subsequent mutation across all files forks from that warmed parent — materially faster than scripting a `for f in ...; do bundle exec evilution run "$f"; done` loop, which pays the bootstrap per file. With `--isolation=in_process` (default for non-Rails projects under `auto`), there is no per-mutation fork, but the parent-process boot still runs once instead of N times. Per-file paths and line numbers are preserved in the report (`survived[].file`, HTML grouping by source file).
|
|
675
|
+
Pass multiple file paths on a single invocation to amortise startup cost. The framework (Rails, Sorbet, etc.) and the `preload` chain (`spec/rails_helper.rb` → `spec/spec_helper.rb` → `test/test_helper.rb`) load **once** in the parent process. When `--isolation=fork` is selected (the default `--isolation=auto` resolves to `fork` on Rails projects and packaged gems), every subsequent mutation across all files forks from that warmed parent — materially faster than scripting a `for f in ...; do bundle exec evilution run "$f"; done` loop, which pays the bootstrap per file. With `--isolation=in_process` (default for non-Rails, non-gem projects under `auto`), there is no per-mutation fork, but the parent-process boot still runs once instead of N times. Per-file paths and line numbers are preserved in the report (`survived[].file`, HTML grouping by source file).
|
|
674
676
|
|
|
675
677
|
### 6. Fixing surviving mutants
|
|
676
678
|
|
|
@@ -773,7 +775,7 @@ Tests 4 paths (InProcess isolation, Fork isolation, mutation generation + stripp
|
|
|
773
775
|
2. **Extract** — Methods are identified as mutation subjects
|
|
774
776
|
3. **Filter** — Disable comments, Sorbet `sig` blocks, and AST ignore patterns exclude mutations before execution
|
|
775
777
|
4. **Mutate** — 74 operators produce text replacements at precise byte offsets (source-level surgery, no AST unparsing); heredoc literal text is skipped by default. Identical byte-mutations from different operators are deduplicated by `(file_path, mutated_source)` so the count is not inflated by overlap
|
|
776
|
-
5. **Isolate** — Mutations are applied to temporary file copies (never modifying originals); load-path redirection ensures `require` resolves the mutated copy. Default isolation is in-process for plain Ruby projects and fork for Rails projects (auto-detected); `--isolation fork` forces forked child processes. Both sequential and parallel (`--jobs N`) modes respect the configured isolation strategy
|
|
778
|
+
5. **Isolate** — Mutations are applied to temporary file copies (never modifying originals); load-path redirection ensures `require` resolves the mutated copy. Default isolation is in-process for plain Ruby projects (no gemspec) and fork for Rails projects and packaged gems (auto-detected); `--isolation fork` forces forked child processes. Both sequential and parallel (`--jobs N`) modes respect the configured isolation strategy
|
|
777
779
|
6. **Test** — The configured test framework (RSpec, Minitest, or Test::Unit) executes against the mutated source
|
|
778
780
|
7. **Collect** — Source strings and AST nodes are released after use to minimize memory retention
|
|
779
781
|
8. **Report** — Results aggregated into text, JSON, or HTML, including efficiency metrics and peak memory usage
|
data/docs/integrations.md
CHANGED
|
@@ -76,6 +76,21 @@ For controllers, the resolver tries the request-spec / integration-test
|
|
|
76
76
|
location first, then falls back to the controller-spec / controller-test
|
|
77
77
|
location.
|
|
78
78
|
|
|
79
|
+
### Non-mirrored layouts
|
|
80
|
+
|
|
81
|
+
Many projects do not mirror the `lib/` tree 1:1 — they park suites under a
|
|
82
|
+
`unit` or `lib` bucket (`spec/unit/foo/bar_spec.rb`, `test/unit/foo/bar_test.rb`,
|
|
83
|
+
`spec/lib/...`, `test/lib/...`). On top of the deterministic mirror above, the
|
|
84
|
+
resolver also tries these conventional sub-directories plus parent-directory
|
|
85
|
+
fallbacks, ranking the full mirror highest and bare-basename guesses lowest. So
|
|
86
|
+
a `spec/unit`-style layout resolves out of the box without `--spec`.
|
|
87
|
+
|
|
88
|
+
If mutations still cannot be paired with a spec, the run prints an
|
|
89
|
+
**unresolved-rate warning** (and an "unresolved" section in HTML output) with a
|
|
90
|
+
best-guess candidate per source file — so a high unresolved count reads as a
|
|
91
|
+
resolution problem to fix (pass `--spec`, or adjust the layout) rather than a
|
|
92
|
+
silently meaningless 0% score.
|
|
93
|
+
|
|
79
94
|
## Suggest-tests caveat
|
|
80
95
|
|
|
81
96
|
The `--suggest-tests` mode emits ready-to-paste test snippets for survived
|
data/docs/isolation.md
CHANGED
|
@@ -9,7 +9,7 @@ is used.
|
|
|
9
9
|
|
|
10
10
|
| Strategy | What it does | When to use |
|
|
11
11
|
| ------------ | ---------------------------------------------------------------------- | ---------------------------------------------------------- |
|
|
12
|
-
| `auto` | Default. Picks `fork` for Rails projects, `in_process` otherwise.
|
|
12
|
+
| `auto` | Default. Picks `fork` for Rails projects and packaged gems, `in_process` otherwise. | Leave this on unless you have a specific reason to change. |
|
|
13
13
|
| `fork` | Forks a fresh child process per mutant. Parent `SIGKILL`s on timeout. | Rails / ActiveRecord projects; any code that uses mutexes, monitors, or async-interrupt masks. |
|
|
14
14
|
| `in_process` | Runs the mutant inside the runner process under `Timeout.timeout`. | Pure-Ruby libraries that do not use async-interrupt masks. |
|
|
15
15
|
|
|
@@ -42,6 +42,15 @@ a Rails project, evilution emits a warning naming the hazard and proceeds
|
|
|
42
42
|
anyway — sometimes you know the code under test never enters a masked
|
|
43
43
|
section, and that is your call to make.
|
|
44
44
|
|
|
45
|
+
`auto` also resolves to `fork` when the target files live under a packaged
|
|
46
|
+
gem (a directory containing a `*.gemspec`). A gem's library and test
|
|
47
|
+
framework must be loaded in the parent before forking — see [Automatic
|
|
48
|
+
preload](#automatic-preload) — and `in_process` cannot preload them without
|
|
49
|
+
polluting the host process. A non-Rails gem run under the old `in_process`
|
|
50
|
+
default therefore produced 0 examples / 100% errors out of the box; defaulting
|
|
51
|
+
gems to `fork` lets auto-preload fire (EV-z03y, PR #1375). A plain non-Rails,
|
|
52
|
+
non-gem project (no gemspec) still defaults to `in_process`.
|
|
53
|
+
|
|
45
54
|
The same hazard applies to any Ruby code that uses
|
|
46
55
|
`Thread.handle_interrupt(... => :never)`: `Mutex#synchronize`, `Monitor#synchronize`,
|
|
47
56
|
`Queue`, `ActiveSupport::Notifications::Fanout` listeners, and custom cleanup
|
|
@@ -67,7 +76,27 @@ automatically looks for these files in order and preloads the first one it
|
|
|
67
76
|
finds:
|
|
68
77
|
|
|
69
78
|
1. `spec/rails_helper.rb` (RSpec)
|
|
70
|
-
2. `
|
|
79
|
+
2. `spec/spec_helper.rb` (RSpec)
|
|
80
|
+
3. `test/test_helper.rb` (Minitest)
|
|
81
|
+
|
|
82
|
+
For a packaged gem (auto-detected via `*.gemspec`, no Rails root), evilution
|
|
83
|
+
preloads the conventional test helper — which loads both the gem's library
|
|
84
|
+
and the suite's framework/support setup so example groups register — in this
|
|
85
|
+
order, falling back to the gem's library entry point (`lib/<gem>.rb`):
|
|
86
|
+
|
|
87
|
+
1. `spec/spec_helper.rb` (RSpec)
|
|
88
|
+
2. `test/test_helper.rb` (Minitest / Test::Unit)
|
|
89
|
+
3. `test/helper.rb` (flat-layout convention)
|
|
90
|
+
|
|
91
|
+
When a gem is detected but none of those helpers exist, evilution prints a
|
|
92
|
+
warning naming the locations it looked in and pointing at `--preload`, so a
|
|
93
|
+
non-standard test layout reads as a fixable configuration issue rather than a
|
|
94
|
+
silent 0% (EV-z03y, PR #1375).
|
|
95
|
+
|
|
96
|
+
Minitest/Test::Unit helpers that `require "test_helper"` (or any non-relative
|
|
97
|
+
`require "support/..."`) work without `-Itest`: evilution puts the test root
|
|
98
|
+
on `$LOAD_PATH` for the preload just as the test runner would (EV-5hk5, PR
|
|
99
|
+
#1373).
|
|
71
100
|
|
|
72
101
|
No configuration needed.
|
|
73
102
|
|
|
@@ -130,6 +159,21 @@ On by default; toggle with `--[no-]canary` or `canary: true|false` in
|
|
|
130
159
|
`.evilution.yml`. The canary mirrors the configured `--isolation` so
|
|
131
160
|
isolation-specific defects are caught too.
|
|
132
161
|
|
|
162
|
+
## Parallel run resilience (`--jobs N`)
|
|
163
|
+
|
|
164
|
+
Under `--jobs N` a stuck or crashed worker no longer takes the whole run down.
|
|
165
|
+
The work-queue dispatcher tracks a per-worker deadline: if one mutation blocks
|
|
166
|
+
past its timeout (e.g. a mutation that wedges a child in `ConditionVariable#wait`)
|
|
167
|
+
or a worker exits unexpectedly, only that worker is killed and recycled, its
|
|
168
|
+
single in-flight mutation is recorded as `:timeout` / `:error`, and the run
|
|
169
|
+
continues with the remaining work. A single pathological mutation costs you one
|
|
170
|
+
result, not the entire run.
|
|
171
|
+
|
|
172
|
+
Each worker is also its own process-group leader, so killing a worker reaps the
|
|
173
|
+
mutation subprocess and any grandchildren it spawned rather than orphaning them,
|
|
174
|
+
and a terminal interrupt (Ctrl-C) is forwarded to every worker group — an
|
|
175
|
+
aborted parallel run leaves no stray worker or mutation processes behind.
|
|
176
|
+
|
|
133
177
|
## Related flags
|
|
134
178
|
|
|
135
179
|
- `--timeout N` sets the per-mutation time limit. Under `fork`, this drives
|
data/lib/evilution/baseline.rb
CHANGED
|
@@ -113,11 +113,18 @@ class Evilution::Baseline
|
|
|
113
113
|
warned = Set.new
|
|
114
114
|
subjects.map do |s|
|
|
115
115
|
resolved = @spec_resolver.call(s.file_path)
|
|
116
|
-
if resolved.nil? && warned.add?(s.file_path)
|
|
117
|
-
warn "[evilution] No matching test found for #{s.file_path}, running full suite. " \
|
|
118
|
-
"Use --spec to specify the test file."
|
|
119
|
-
end
|
|
116
|
+
warn_no_matching_test(s.file_path) if resolved.nil? && warned.add?(s.file_path)
|
|
120
117
|
resolved || @fallback_dir
|
|
121
118
|
end.uniq
|
|
122
119
|
end
|
|
120
|
+
|
|
121
|
+
def warn_no_matching_test(file_path)
|
|
122
|
+
suggestion = @spec_resolver.suggest(file_path)
|
|
123
|
+
hint = if suggestion
|
|
124
|
+
"Pass --spec #{suggestion} (best guess) or the correct test file."
|
|
125
|
+
else
|
|
126
|
+
"Use --spec to specify the test file."
|
|
127
|
+
end
|
|
128
|
+
warn "[evilution] No matching test found for #{file_path}, running full suite. #{hint}"
|
|
129
|
+
end
|
|
123
130
|
end
|
|
@@ -66,6 +66,11 @@ class Evilution::CLI::Parser::OptionsBuilder
|
|
|
66
66
|
"Disable per-mutation example targeting (run all examples in resolved spec files)") do
|
|
67
67
|
@options[:example_targeting] = false
|
|
68
68
|
end
|
|
69
|
+
opts.on("--example-targeting MODE", %w[lexical coverage full_file],
|
|
70
|
+
"Per-mutation targeting: lexical (default, name-grep), coverage (run only the",
|
|
71
|
+
"examples that execute the mutated line), or full_file (run all resolved examples)") do |m|
|
|
72
|
+
apply_example_targeting_mode(m)
|
|
73
|
+
end
|
|
69
74
|
opts.on("--example-targeting-fallback MODE", %w[full_file unresolved],
|
|
70
75
|
"Fallback when example targeting finds no match: full_file (default) or unresolved") do |m|
|
|
71
76
|
@options[:example_targeting_fallback] = m
|
|
@@ -77,6 +82,18 @@ class Evilution::CLI::Parser::OptionsBuilder
|
|
|
77
82
|
end
|
|
78
83
|
end
|
|
79
84
|
|
|
85
|
+
# Map the single --example-targeting flag onto the two underlying config axes:
|
|
86
|
+
# full_file is "targeting off" (run all resolved examples); lexical/coverage
|
|
87
|
+
# turn targeting on and select the strategy.
|
|
88
|
+
def apply_example_targeting_mode(mode)
|
|
89
|
+
if mode == "full_file"
|
|
90
|
+
@options[:example_targeting] = false
|
|
91
|
+
else
|
|
92
|
+
@options[:example_targeting] = true
|
|
93
|
+
@options[:example_targeting_strategy] = mode.to_sym
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
80
97
|
def add_flag_options(opts)
|
|
81
98
|
opts.on("--fail-fast", "Stop after N surviving mutants " \
|
|
82
99
|
"(default: disabled; if provided without N, uses 1; use --fail-fast=N)") { @options[:fail_fast] ||= 1 }
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base"
|
|
4
|
+
|
|
5
|
+
class Evilution::Config::Validators::ExampleTargetingStrategy < Evilution::Config::Validators::Base
|
|
6
|
+
STRATEGIES = %i[lexical coverage].freeze
|
|
7
|
+
|
|
8
|
+
def self.call(value)
|
|
9
|
+
unless value.is_a?(String) || value.is_a?(Symbol)
|
|
10
|
+
raise Evilution::ConfigError,
|
|
11
|
+
"example_targeting_strategy must be lexical or coverage, got #{value.inspect}"
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
sym = value.to_sym
|
|
15
|
+
unless STRATEGIES.include?(sym)
|
|
16
|
+
raise Evilution::ConfigError,
|
|
17
|
+
"example_targeting_strategy must be lexical or coverage, got #{sym.inspect}"
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
sym
|
|
21
|
+
end
|
|
22
|
+
end
|
data/lib/evilution/config.rb
CHANGED
|
@@ -17,7 +17,7 @@ class Evilution::Config
|
|
|
17
17
|
show_disabled: false, baseline_session: nil, skip_heredoc_literals: false,
|
|
18
18
|
related_specs_heuristic: false, fallback_to_full_suite: false, preload: nil,
|
|
19
19
|
spec_mappings: {}, spec_pattern: nil, example_targeting: true,
|
|
20
|
-
example_targeting_fallback: :full_file,
|
|
20
|
+
example_targeting_fallback: :full_file, example_targeting_strategy: :lexical,
|
|
21
21
|
example_targeting_cache: { max_files: 50, max_blocks: 10_000 },
|
|
22
22
|
quiet_children: false, quiet_children_dir: "tmp/evilution_children",
|
|
23
23
|
profile: :default, canary: true
|
|
@@ -30,7 +30,8 @@ class Evilution::Config
|
|
|
30
30
|
:ignore_patterns, :show_disabled, :baseline_session,
|
|
31
31
|
:skip_heredoc_literals, :related_specs_heuristic,
|
|
32
32
|
:fallback_to_full_suite, :preload, :spec_mappings, :spec_pattern,
|
|
33
|
-
:example_targeting, :example_targeting_fallback, :
|
|
33
|
+
:example_targeting, :example_targeting_fallback, :example_targeting_strategy,
|
|
34
|
+
:example_targeting_cache,
|
|
34
35
|
:spec_selector, :quiet_children, :quiet_children_dir, :profile, :canary
|
|
35
36
|
|
|
36
37
|
def initialize(**options)
|
|
@@ -104,6 +105,10 @@ class Evilution::Config
|
|
|
104
105
|
example_targeting
|
|
105
106
|
end
|
|
106
107
|
|
|
108
|
+
def coverage_targeting?
|
|
109
|
+
example_targeting && example_targeting_strategy == :coverage
|
|
110
|
+
end
|
|
111
|
+
|
|
107
112
|
def fallback_to_full_suite?
|
|
108
113
|
fallback_to_full_suite
|
|
109
114
|
end
|
|
@@ -187,6 +192,13 @@ class Evilution::Config
|
|
|
187
192
|
# without editing the file by exporting EV_DISABLE_EXAMPLE_TARGETING=1.
|
|
188
193
|
# example_targeting: true
|
|
189
194
|
|
|
195
|
+
# How targeting picks examples (default: lexical).
|
|
196
|
+
# lexical - text-grep resolved spec files for the mutated method/class name
|
|
197
|
+
# coverage - run exactly the examples that EXECUTE the mutated line, from a
|
|
198
|
+
# cached full-suite line-coverage map (falls back to lexical for
|
|
199
|
+
# any file the map has not fully built)
|
|
200
|
+
# example_targeting_strategy: lexical
|
|
201
|
+
|
|
190
202
|
# Behavior when targeting finds no matching example (default: full_file).
|
|
191
203
|
# full_file - run every example in the resolved spec files
|
|
192
204
|
# unresolved - mark the mutation :unresolved and skip
|
|
@@ -275,6 +287,7 @@ class Evilution::Config
|
|
|
275
287
|
def assign_example_targeting(merged)
|
|
276
288
|
@example_targeting = merged[:example_targeting] ? true : false
|
|
277
289
|
@example_targeting_fallback = Validators::ExampleTargetingFallback.call(merged[:example_targeting_fallback])
|
|
290
|
+
@example_targeting_strategy = Validators::ExampleTargetingStrategy.call(merged[:example_targeting_strategy])
|
|
278
291
|
@example_targeting_cache = Validators::ExampleTargetingCache.call(merged[:example_targeting_cache])
|
|
279
292
|
end
|
|
280
293
|
end
|
|
@@ -294,6 +307,7 @@ require_relative "config/validators/ignore_patterns"
|
|
|
294
307
|
require_relative "config/validators/spec_pattern"
|
|
295
308
|
require_relative "config/validators/spec_mappings"
|
|
296
309
|
require_relative "config/validators/example_targeting_fallback"
|
|
310
|
+
require_relative "config/validators/example_targeting_strategy"
|
|
297
311
|
require_relative "config/validators/example_targeting_cache"
|
|
298
312
|
require_relative "config/validators/profile"
|
|
299
313
|
require_relative "config/builders"
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require_relative "../coverage"
|
|
5
|
+
|
|
6
|
+
# Stable, content-addressed digest of a source file, used by MapStore to detect
|
|
7
|
+
# when a cached coverage entry has gone stale. Path-independent: the digest
|
|
8
|
+
# depends only on the bytes, so moving a file does not invalidate it. Returns
|
|
9
|
+
# nil for a missing file so callers treat it as "not fresh".
|
|
10
|
+
class Evilution::Coverage::Digest
|
|
11
|
+
def for_file(path)
|
|
12
|
+
return nil unless File.file?(path)
|
|
13
|
+
|
|
14
|
+
::Digest::SHA256.hexdigest(File.binread(path))
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../coverage"
|
|
4
|
+
|
|
5
|
+
# Immutable query over a per-example line-coverage index:
|
|
6
|
+
# source file -> line -> [example locations ("spec.rb:line")].
|
|
7
|
+
# built_files records the source files for which the build completed, so
|
|
8
|
+
# callers can distinguish "line genuinely uncovered" (file built, no entry)
|
|
9
|
+
# from "we never built this file" (must fall back, not assert a gap).
|
|
10
|
+
class Evilution::Coverage::Map
|
|
11
|
+
def self.from_h(hash)
|
|
12
|
+
index = (hash["index"] || {}).transform_values do |lines|
|
|
13
|
+
lines.transform_keys(&:to_i)
|
|
14
|
+
end
|
|
15
|
+
executed = (hash["executed_lines"] || {}).transform_values { |lines| lines.map(&:to_i) }
|
|
16
|
+
new(index: index, built_files: hash["built_files"] || [], executed_lines: executed)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# executed_lines records, per file, the lines that ran at all during the build
|
|
20
|
+
# (including lines covered only at load, e.g. a `def` line, which are
|
|
21
|
+
# attributed to no single example). It lets a caller tell a TRUE coverage gap
|
|
22
|
+
# (line never executed) from a load-covered line an example may still exercise
|
|
23
|
+
# indirectly -- so the latter falls back instead of being mis-skipped.
|
|
24
|
+
def initialize(index:, built_files:, executed_lines: {})
|
|
25
|
+
@index = deep_freeze_index(index)
|
|
26
|
+
@built_files = built_files.to_a.freeze
|
|
27
|
+
@executed_lines = deep_freeze_executed(executed_lines)
|
|
28
|
+
freeze
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def examples_for(file, line)
|
|
32
|
+
@index.dig(file, line) || []
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def built?(file)
|
|
36
|
+
@built_files.include?(file)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def executed?(file, line)
|
|
40
|
+
lines = @executed_lines[file]
|
|
41
|
+
!lines.nil? && lines.include?(line)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def to_h
|
|
45
|
+
{ "index" => @index, "built_files" => @built_files, "executed_lines" => @executed_lines }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def deep_freeze_index(index)
|
|
51
|
+
index.each_with_object({}) do |(file, lines), out|
|
|
52
|
+
frozen_lines = lines.each_with_object({}) do |(line, locs), inner|
|
|
53
|
+
inner[line] = locs.uniq.sort.freeze
|
|
54
|
+
end
|
|
55
|
+
out[file] = frozen_lines.freeze
|
|
56
|
+
end.freeze
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def deep_freeze_executed(executed)
|
|
60
|
+
executed.each_with_object({}) do |(file, lines), out|
|
|
61
|
+
out[file] = lines.map(&:to_i).uniq.sort.freeze
|
|
62
|
+
end.freeze
|
|
63
|
+
end
|
|
64
|
+
end
|