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.
Files changed (45) hide show
  1. checksums.yaml +4 -4
  2. data/.beads/interactions.jsonl +28 -0
  3. data/.rubocop_todo.yml +1 -0
  4. data/CHANGELOG.md +31 -0
  5. data/README.md +12 -10
  6. data/docs/integrations.md +15 -0
  7. data/docs/isolation.md +46 -2
  8. data/lib/evilution/baseline.rb +11 -4
  9. data/lib/evilution/cli/parser/options_builder.rb +17 -0
  10. data/lib/evilution/config/validators/example_targeting_strategy.rb +22 -0
  11. data/lib/evilution/config.rb +16 -2
  12. data/lib/evilution/coverage/digest.rb +16 -0
  13. data/lib/evilution/coverage/map.rb +64 -0
  14. data/lib/evilution/coverage/map_builder.rb +82 -0
  15. data/lib/evilution/coverage/map_store.rb +87 -0
  16. data/lib/evilution/coverage/recorder.rb +85 -0
  17. data/lib/evilution/coverage.rb +8 -0
  18. data/lib/evilution/coverage_example_filter.rb +41 -0
  19. data/lib/evilution/integration/loading/test_load_path.rb +76 -0
  20. data/lib/evilution/integration/minitest.rb +5 -1
  21. data/lib/evilution/integration/rspec/state_guard/configuration_state.rb +72 -0
  22. data/lib/evilution/integration/rspec/state_guard/configuration_streams.rb +45 -0
  23. data/lib/evilution/integration/rspec/state_guard.rb +3 -1
  24. data/lib/evilution/integration/test_unit.rb +12 -4
  25. data/lib/evilution/isolation/fork.rb +38 -50
  26. data/lib/evilution/parallel/work_queue/dispatcher/deadline_tracker.rb +63 -0
  27. data/lib/evilution/parallel/work_queue/dispatcher.rb +70 -25
  28. data/lib/evilution/parallel/work_queue/worker.rb +50 -14
  29. data/lib/evilution/parallel/work_queue.rb +8 -0
  30. data/lib/evilution/process_supervisor.rb +259 -0
  31. data/lib/evilution/reporter/cli/line_formatters/unresolved_rate_warning.rb +50 -0
  32. data/lib/evilution/reporter/cli/metrics_block.rb +2 -0
  33. data/lib/evilution/runner/baseline_runner.rb +52 -0
  34. data/lib/evilution/runner/isolation_resolver.rb +106 -12
  35. data/lib/evilution/runner/mutation_executor/strategy/parallel.rb +28 -1
  36. data/lib/evilution/runner.rb +7 -0
  37. data/lib/evilution/spec_resolver.rb +147 -9
  38. data/lib/evilution/spec_selector.rb +14 -4
  39. data/lib/evilution/version.rb +1 -1
  40. data/lib/evilution.rb +1 -0
  41. data/lib/tasks/stress.rake +15 -0
  42. data/scripts/canary_manifest.yml +47 -0
  43. data/scripts/compare_targeting +277 -0
  44. data/scripts/compare_targeting.example.yml +24 -0
  45. metadata +20 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: dc948250a218198fef7420e04ab49e5a2f8e01ff50ae279f5299bb85db07ab77
4
- data.tar.gz: df6e3a2ea4ee5512f9c946f4df514f8a1a2df8308ac77236ceafa3de81b435d8
3
+ metadata.gz: 005b0edbf9ced13a00a85d07939564cec9f38d93dec0e1d923f30a9d7cb2c779
4
+ data.tar.gz: a7554571383f34fd21ca7d8d61f733e446d1d2c6349816267459b19309605689
5
5
  SHA512:
6
- metadata.gz: 43c08e6baa4904c1122d639c957cc1139c9e4760e3e38aed8664a70ff46388aab91b64924816739d4c7a9f00abb049f2da579c50f3b5a585c49b2175f3461dff
7
- data.tar.gz: e30f5845c332781de9431c9a4238df1ac9048960d3ef36323dac0a8beaf2f5c5d56eb1d3132d618a7e076bb7547520286fc127347499b0b95640331e16912fb8
6
+ metadata.gz: 6227ee0e3df976329f59f286b21cfaa69bbd4455d00a1fe37dc4c77a9ff553f9e2c4fa7e112b7b19d7e6cc45c5ef2f98ccb2e03f7733fbd97ff7be6ad8582c71
7
+ data.tar.gz: e4a36d7eaa5826c8621d042beb1bedb7c612a67118b4456a909caa1f813bb0863098b57e113684faf9a8797ac0993f2d2d2ee2ee92562376c6e3a35cbb92ecad
@@ -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
@@ -14,6 +14,7 @@ Metrics/ClassLength:
14
14
  - "lib/evilution/mcp/mutate_tool.rb"
15
15
  - "lib/evilution/runner.rb"
16
16
  - "lib/evilution/runner/isolation_resolver.rb"
17
+ - "lib/evilution/cli/parser/options_builder.rb"
17
18
 
18
19
  Metrics/MethodLength:
19
20
  Exclude:
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`. 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`. |
@@ -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. | 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
 
@@ -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
@@ -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
@@ -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