evilution 0.33.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 07051270c176d0d26e28c2978c6bfa2eb0d76dd2e6ba453387f9731f04a0d79b
4
- data.tar.gz: 1562d91a064d05af5b6264c89eacf93077630bc09b4a149010d6e4e5514409ed
3
+ metadata.gz: 005b0edbf9ced13a00a85d07939564cec9f38d93dec0e1d923f30a9d7cb2c779
4
+ data.tar.gz: a7554571383f34fd21ca7d8d61f733e446d1d2c6349816267459b19309605689
5
5
  SHA512:
6
- metadata.gz: 9957755214557a006e461cadd076d57b5c1d55536e9cec5105df1b1b37580150a6ff5fc8a33c816c0356a8e890eab1ac8e5fcfccac1acb6a46e18c76ba8ec909
7
- data.tar.gz: 8a2a4000adbaf10f178cbed4cb117644d25bd27ec2730fb02024fa68b3cf8424181b0d5e45bbfbe53b3c9a9a37266e306de7592f16443d0d8545ad46f878f4af
6
+ metadata.gz: 6227ee0e3df976329f59f286b21cfaa69bbd4455d00a1fe37dc4c77a9ff553f9e2c4fa7e112b7b19d7e6cc45c5ef2f98ccb2e03f7733fbd97ff7be6ad8582c71
7
+ data.tar.gz: e4a36d7eaa5826c8621d042beb1bedb7c612a67118b4456a909caa1f813bb0863098b57e113684faf9a8797ac0993f2d2d2ee2ee92562376c6e3a35cbb92ecad
@@ -421,3 +421,19 @@
421
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
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
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
@@ -12,9 +12,9 @@ Metrics/ClassLength:
12
12
  - "lib/evilution/isolation/fork.rb"
13
13
  - "lib/evilution/integration/minitest.rb"
14
14
  - "lib/evilution/mcp/mutate_tool.rb"
15
- - "lib/evilution/parallel/work_queue/dispatcher.rb"
16
15
  - "lib/evilution/runner.rb"
17
16
  - "lib/evilution/runner/isolation_resolver.rb"
17
+ - "lib/evilution/cli/parser/options_builder.rb"
18
18
 
19
19
  Metrics/MethodLength:
20
20
  Exclude:
data/CHANGELOG.md CHANGED
@@ -2,6 +2,20 @@
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
+
5
19
  ## [0.33.0] - 2026-06-07
6
20
 
7
21
  ### 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`. Errors with the full chain listed if none exist; pass `--no-preload` to opt out. |
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&lt;String, String/Array&gt; | `{}` | 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`. |
@@ -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/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. | Leave this on unless you have a specific reason to change. |
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. `test/test_helper.rb` (Minitest)
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
 
@@ -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
@@ -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, :example_targeting_cache,
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
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "coverage"
4
+ require "stringio"
5
+ require_relative "../coverage"
6
+ require_relative "map"
7
+ require_relative "recorder"
8
+
9
+ # Builds a per-example coverage Map by running the given spec files under
10
+ # ::Coverage (lines mode) with the Recorder installed as an around(:each)
11
+ # hook. The run happens in a FORKED CHILD so the global ::RSpec.configure
12
+ # hook, ::RSpec.world mutation, and ::Coverage state never leak into the
13
+ # calling process; the parent receives only the serialized map over a pipe.
14
+ class Evilution::Coverage::MapBuilder
15
+ LOCATION_LINE_SUFFIX = /\A(.+?)(:\d+(?::\d+)*)\z/
16
+
17
+ # RSpec reports example locations relative to its run dir as "./spec/x.rb:5".
18
+ # Store them ABSOLUTE (path expanded against the project root, line suffix
19
+ # preserved) so they replay regardless of the per-mutation run's CWD --
20
+ # Integration::RSpec#resolve_target passes absolute targets through unchanged.
21
+ def self.absolute_location(raw, root)
22
+ match = LOCATION_LINE_SUFFIX.match(raw)
23
+ path, suffix = match ? [match[1], match[2]] : [raw, ""]
24
+ "#{File.expand_path(path, root)}#{suffix}"
25
+ end
26
+
27
+ def initialize(spec_files:, target_files:, project_root: Evilution::PROJECT_ROOT)
28
+ @spec_files = spec_files
29
+ @target_files = target_files
30
+ @project_root = project_root.to_s
31
+ end
32
+
33
+ def call
34
+ read_io, write_io = IO.pipe
35
+ read_io.binmode
36
+ write_io.binmode
37
+ pid = fork do
38
+ read_io.close
39
+ Marshal.dump(build_in_child.to_h, write_io)
40
+ write_io.close
41
+ exit!(0)
42
+ end
43
+ write_io.close
44
+ payload = read_io.read
45
+ read_io.close
46
+ Process.wait(pid)
47
+ # Trust boundary: payload is a Marshal dump this process's own forked child
48
+ # produced over a private pipe -- same rationale as Channel::Frame.
49
+ Evilution::Coverage::Map.from_h(Marshal.load(payload))
50
+ end
51
+
52
+ private
53
+
54
+ # Runs entirely inside the forked child.
55
+ def build_in_child
56
+ recorder = Evilution::Coverage::Recorder.new(target_files: @target_files)
57
+ ::Coverage.start(lines: true) unless ::Coverage.running?
58
+ # The child inherited the parent's fully-loaded RSpec.world; wipe it so the
59
+ # nested runner executes ONLY @spec_files and never re-enters the host suite
60
+ # (which would recursively fork this builder).
61
+ reset_world
62
+ install_hook(recorder)
63
+ ::RSpec::Core::Runner.run(@spec_files, StringIO.new, StringIO.new)
64
+ recorder.to_map(built_files: @target_files)
65
+ end
66
+
67
+ def reset_world
68
+ ::RSpec.respond_to?(:clear_examples) ? ::RSpec.clear_examples : ::RSpec.reset
69
+ end
70
+
71
+ def install_hook(recorder)
72
+ root = @project_root
73
+ ::RSpec.configure do |config|
74
+ config.around(:each) do |example|
75
+ location = Evilution::Coverage::MapBuilder.absolute_location(
76
+ example.metadata[:location].to_s, root
77
+ )
78
+ recorder.around_example(location) { example.run }
79
+ end
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "fileutils"
5
+ require_relative "../coverage"
6
+ require_relative "map"
7
+ require_relative "digest"
8
+
9
+ # Disk cache for a per-example coverage Map under .evilution/coverage/, keyed by
10
+ # a per-file content digest so a map survives across runs and invalidates one
11
+ # file at a time.
12
+ #
13
+ # load is partial: it returns a Map pruned to the files whose on-disk content
14
+ # still matches the cached digest. A stale or deleted file is dropped (so its
15
+ # `built?` is false and the caller falls back to lexical targeting) while every
16
+ # fresh file stays queryable. A missing or corrupt cache returns nil, signalling
17
+ # the caller to rebuild from scratch.
18
+ class Evilution::Coverage::MapStore
19
+ DEFAULT_ROOT = ".evilution/coverage"
20
+ CACHE_FILE = "map.json"
21
+
22
+ def initialize(root: DEFAULT_ROOT, digest: Evilution::Coverage::Digest.new)
23
+ @root = root
24
+ @digest = digest
25
+ end
26
+
27
+ def save(map, source_files)
28
+ payload = { "digests" => digests_for(source_files), "map" => map.to_h }
29
+ FileUtils.mkdir_p(@root)
30
+ File.write(cache_path, JSON.generate(payload))
31
+ end
32
+
33
+ def load(source_files)
34
+ payload = read_payload
35
+ return nil unless payload
36
+
37
+ cached_digests = payload["digests"] || {}
38
+ fresh = source_files.select { |file| fresh?(file, cached_digests) }
39
+ pruned_map(payload["map"] || {}, fresh)
40
+ end
41
+
42
+ # Source files whose on-disk content no longer matches the cache (changed,
43
+ # deleted, or never cached) -- the caller rebuilds these. Every file is stale
44
+ # when there is no cache at all.
45
+ def stale_files(source_files)
46
+ payload = read_payload
47
+ return source_files.dup unless payload
48
+
49
+ cached_digests = payload["digests"] || {}
50
+ source_files.reject { |file| fresh?(file, cached_digests) }
51
+ end
52
+
53
+ private
54
+
55
+ def fresh?(file, cached_digests)
56
+ cached = cached_digests[file]
57
+ !cached.nil? && cached == @digest.for_file(file)
58
+ end
59
+
60
+ def pruned_map(raw_map, fresh_files)
61
+ index = (raw_map["index"] || {}).slice(*fresh_files)
62
+ built = (raw_map["built_files"] || []) & fresh_files
63
+ executed = (raw_map["executed_lines"] || {}).slice(*fresh_files)
64
+ Evilution::Coverage::Map.from_h(
65
+ "index" => index, "built_files" => built, "executed_lines" => executed
66
+ )
67
+ end
68
+
69
+ def digests_for(source_files)
70
+ source_files.each_with_object({}) do |file, out|
71
+ digest = @digest.for_file(file)
72
+ out[file] = digest unless digest.nil?
73
+ end
74
+ end
75
+
76
+ def read_payload
77
+ return nil unless File.file?(cache_path)
78
+
79
+ JSON.parse(File.read(cache_path))
80
+ rescue JSON::ParserError, Errno::ENOENT
81
+ nil
82
+ end
83
+
84
+ def cache_path
85
+ File.join(@root, CACHE_FILE)
86
+ end
87
+ end