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 +4 -4
- data/.beads/interactions.jsonl +16 -0
- data/.rubocop_todo.yml +1 -1
- data/CHANGELOG.md +14 -0
- data/README.md +11 -9
- data/docs/isolation.md +31 -2
- data/lib/evilution/cli/parser/options_builder.rb +17 -0
- data/lib/evilution/config/validators/example_targeting_strategy.rb +22 -0
- data/lib/evilution/config.rb +16 -2
- data/lib/evilution/coverage/digest.rb +16 -0
- data/lib/evilution/coverage/map.rb +64 -0
- data/lib/evilution/coverage/map_builder.rb +82 -0
- data/lib/evilution/coverage/map_store.rb +87 -0
- data/lib/evilution/coverage/recorder.rb +85 -0
- data/lib/evilution/coverage.rb +8 -0
- data/lib/evilution/coverage_example_filter.rb +41 -0
- data/lib/evilution/isolation/fork.rb +38 -76
- data/lib/evilution/parallel/work_queue/dispatcher/deadline_tracker.rb +63 -0
- data/lib/evilution/parallel/work_queue/dispatcher.rb +7 -34
- data/lib/evilution/parallel/work_queue/worker.rb +41 -51
- data/lib/evilution/process_supervisor.rb +259 -0
- data/lib/evilution/runner/baseline_runner.rb +52 -0
- data/lib/evilution/runner/isolation_resolver.rb +106 -12
- data/lib/evilution/runner.rb +3 -2
- data/lib/evilution/spec_resolver.rb +66 -0
- data/lib/evilution/spec_selector.rb +14 -4
- data/lib/evilution/version.rb +1 -1
- data/lib/evilution.rb +1 -0
- data/scripts/canary_manifest.yml +47 -0
- data/scripts/compare_targeting +277 -0
- data/scripts/compare_targeting.example.yml +24 -0
- metadata +15 -3
- data/lib/evilution/parallel/work_queue/worker_registry.rb +0 -47
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 005b0edbf9ced13a00a85d07939564cec9f38d93dec0e1d923f30a9d7cb2c779
|
|
4
|
+
data.tar.gz: a7554571383f34fd21ca7d8d61f733e446d1d2c6349816267459b19309605689
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 6227ee0e3df976329f59f286b21cfaa69bbd4455d00a1fe37dc4c77a9ff553f9e2c4fa7e112b7b19d7e6cc45c5ef2f98ccb2e03f7733fbd97ff7be6ad8582c71
|
|
7
|
+
data.tar.gz: e4a36d7eaa5826c8621d042beb1bedb7c612a67118b4456a909caa1f813bb0863098b57e113684faf9a8797ac0993f2d2d2ee2ee92562376c6e3a35cbb92ecad
|
data/.beads/interactions.jsonl
CHANGED
|
@@ -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`.
|
|
117
|
+
| `--isolation MODE` | String | `auto` | Isolation strategy: `auto`, `fork`, or `in_process`. `auto` selects `fork` for Rails projects and packaged gems (`*.gemspec`), `in_process` otherwise. See [docs/isolation.md](docs/isolation.md). |
|
|
118
|
+
| `--preload FILE` | String | _(auto)_ | File to require in parent before forking workers. Auto-detect chain for Rails projects: `spec/rails_helper.rb` → `spec/spec_helper.rb` → `test/test_helper.rb`. For non-Rails gems: `spec/spec_helper.rb` → `test/test_helper.rb` → `test/helper.rb`, falling back to the gem entry `lib/<gem>.rb` (with a warning naming the searched paths). Pass `--no-preload` to opt out. |
|
|
118
119
|
| `--no-preload` | Boolean | _(enabled)_ | Disable parent-process preload. |
|
|
119
120
|
| `--skip-heredoc-literals` | Boolean | false | Skip all string literal mutations inside heredocs. |
|
|
120
121
|
| `--show-disabled` | Boolean | false | Report mutations skipped by `# evilution:disable` comments. |
|
|
@@ -172,9 +173,9 @@ schema_version: 1 # opts into strict validation (rejects unknown keys
|
|
|
172
173
|
# integration: rspec # test framework: rspec, minitest, test_unit
|
|
173
174
|
# suggest_tests: false # concrete test code in suggestions (matches integration)
|
|
174
175
|
# save_session: false # persist results under .evilution/results/
|
|
175
|
-
# isolation: auto # auto | fork | in_process (auto selects fork for Rails)
|
|
176
|
+
# isolation: auto # auto | fork | in_process (auto selects fork for Rails + gems)
|
|
176
177
|
# canary: true # proof-of-life synthetic mutation at session start (false to skip)
|
|
177
|
-
# preload: null # path to preload before forking; false to disable; auto-detects for Rails
|
|
178
|
+
# preload: null # path to preload before forking; false to disable; auto-detects for Rails + gems
|
|
178
179
|
# skip_heredoc_literals: false # skip string literal mutations inside heredocs (recommended for Rails: heredoc SQL/templates rarely have test coverage)
|
|
179
180
|
# show_disabled: false # report mutations skipped by disable comments
|
|
180
181
|
# baseline_session: null # path to session file for HTML comparison
|
|
@@ -219,7 +220,7 @@ All keys recognised under `schema_version: 1`:
|
|
|
219
220
|
| `jobs` | Integer | `1` | Number of parallel workers. |
|
|
220
221
|
| `fail_fast` | Integer / null | `null` | Stop after N surviving mutants. `null` = disabled. |
|
|
221
222
|
| `baseline` | Boolean | `true` | Run baseline test suite to detect pre-existing failures (marked `:neutral`). |
|
|
222
|
-
| `isolation` | String | `auto` | Isolation strategy: `auto`, `fork`, `in_process`. `auto` selects `fork` for Rails projects.
|
|
223
|
+
| `isolation` | String | `auto` | Isolation strategy: `auto`, `fork`, `in_process`. `auto` selects `fork` for Rails projects and packaged gems (`*.gemspec`). |
|
|
223
224
|
| `incremental` | Boolean | `false` | Cache killed/timeout results across runs. |
|
|
224
225
|
| `suggest_tests` | Boolean | `false` | Generate concrete test code in survivor suggestions (matches `integration`). |
|
|
225
226
|
| `progress` | Boolean | `true` | TTY progress bar. |
|
|
@@ -232,10 +233,11 @@ All keys recognised under `schema_version: 1`:
|
|
|
232
233
|
| `skip_heredoc_literals` | Boolean | `false` | Skip string literal mutations inside heredocs. |
|
|
233
234
|
| `related_specs_heuristic` | Boolean | `false` | Append related request/integration/feature/system specs for `includes(...)` mutations. |
|
|
234
235
|
| `fallback_to_full_suite` | Boolean | `false` | When no matching spec resolves, run the entire suite instead of marking the mutation `:unresolved`. |
|
|
235
|
-
| `preload` | String / Boolean / null | `null` | File to preload in parent before forking. `false` to disable. `null` to auto-detect for Rails.
|
|
236
|
+
| `preload` | String / Boolean / null | `null` | File to preload in parent before forking. `false` to disable. `null` to auto-detect for Rails projects and packaged gems. |
|
|
236
237
|
| `spec_mappings` | Hash<String, String/Array> | `{}` | Custom mapping from source path to spec path(s). |
|
|
237
238
|
| `spec_pattern` | String / null | `null` | Glob restricting resolved spec candidates. |
|
|
238
239
|
| `example_targeting` | Boolean | `true` | Per-mutation example-level targeting. |
|
|
240
|
+
| `example_targeting_strategy` | String | `lexical` | How targeting picks examples: `lexical` (name-grep) or `coverage` (examples that execute the mutated line, from a cached coverage map). |
|
|
239
241
|
| `example_targeting_fallback` | String | `full_file` | When targeting finds no example: `full_file` or `unresolved`. |
|
|
240
242
|
| `example_targeting_cache` | Hash | `{ max_files: 50, max_blocks: 10000 }` | LRU cache bounds for the example-targeting AST parser. |
|
|
241
243
|
| `quiet_children` | Boolean | `false` | Redirect each worker's stdout/stderr to per-pid files under `quiet_children_dir`. |
|
|
@@ -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.
|
|
12
|
+
| `auto` | Default. Picks `fork` for Rails projects and packaged gems, `in_process` otherwise. | Leave this on unless you have a specific reason to change. |
|
|
13
13
|
| `fork` | Forks a fresh child process per mutant. Parent `SIGKILL`s on timeout. | Rails / ActiveRecord projects; any code that uses mutexes, monitors, or async-interrupt masks. |
|
|
14
14
|
| `in_process` | Runs the mutant inside the runner process under `Timeout.timeout`. | Pure-Ruby libraries that do not use async-interrupt masks. |
|
|
15
15
|
|
|
@@ -42,6 +42,15 @@ a Rails project, evilution emits a warning naming the hazard and proceeds
|
|
|
42
42
|
anyway — sometimes you know the code under test never enters a masked
|
|
43
43
|
section, and that is your call to make.
|
|
44
44
|
|
|
45
|
+
`auto` also resolves to `fork` when the target files live under a packaged
|
|
46
|
+
gem (a directory containing a `*.gemspec`). A gem's library and test
|
|
47
|
+
framework must be loaded in the parent before forking — see [Automatic
|
|
48
|
+
preload](#automatic-preload) — and `in_process` cannot preload them without
|
|
49
|
+
polluting the host process. A non-Rails gem run under the old `in_process`
|
|
50
|
+
default therefore produced 0 examples / 100% errors out of the box; defaulting
|
|
51
|
+
gems to `fork` lets auto-preload fire (EV-z03y, PR #1375). A plain non-Rails,
|
|
52
|
+
non-gem project (no gemspec) still defaults to `in_process`.
|
|
53
|
+
|
|
45
54
|
The same hazard applies to any Ruby code that uses
|
|
46
55
|
`Thread.handle_interrupt(... => :never)`: `Mutex#synchronize`, `Monitor#synchronize`,
|
|
47
56
|
`Queue`, `ActiveSupport::Notifications::Fanout` listeners, and custom cleanup
|
|
@@ -67,7 +76,27 @@ automatically looks for these files in order and preloads the first one it
|
|
|
67
76
|
finds:
|
|
68
77
|
|
|
69
78
|
1. `spec/rails_helper.rb` (RSpec)
|
|
70
|
-
2. `
|
|
79
|
+
2. `spec/spec_helper.rb` (RSpec)
|
|
80
|
+
3. `test/test_helper.rb` (Minitest)
|
|
81
|
+
|
|
82
|
+
For a packaged gem (auto-detected via `*.gemspec`, no Rails root), evilution
|
|
83
|
+
preloads the conventional test helper — which loads both the gem's library
|
|
84
|
+
and the suite's framework/support setup so example groups register — in this
|
|
85
|
+
order, falling back to the gem's library entry point (`lib/<gem>.rb`):
|
|
86
|
+
|
|
87
|
+
1. `spec/spec_helper.rb` (RSpec)
|
|
88
|
+
2. `test/test_helper.rb` (Minitest / Test::Unit)
|
|
89
|
+
3. `test/helper.rb` (flat-layout convention)
|
|
90
|
+
|
|
91
|
+
When a gem is detected but none of those helpers exist, evilution prints a
|
|
92
|
+
warning naming the locations it looked in and pointing at `--preload`, so a
|
|
93
|
+
non-standard test layout reads as a fixable configuration issue rather than a
|
|
94
|
+
silent 0% (EV-z03y, PR #1375).
|
|
95
|
+
|
|
96
|
+
Minitest/Test::Unit helpers that `require "test_helper"` (or any non-relative
|
|
97
|
+
`require "support/..."`) work without `-Itest`: evilution puts the test root
|
|
98
|
+
on `$LOAD_PATH` for the preload just as the test runner would (EV-5hk5, PR
|
|
99
|
+
#1373).
|
|
71
100
|
|
|
72
101
|
No configuration needed.
|
|
73
102
|
|
|
@@ -66,6 +66,11 @@ class Evilution::CLI::Parser::OptionsBuilder
|
|
|
66
66
|
"Disable per-mutation example targeting (run all examples in resolved spec files)") do
|
|
67
67
|
@options[:example_targeting] = false
|
|
68
68
|
end
|
|
69
|
+
opts.on("--example-targeting MODE", %w[lexical coverage full_file],
|
|
70
|
+
"Per-mutation targeting: lexical (default, name-grep), coverage (run only the",
|
|
71
|
+
"examples that execute the mutated line), or full_file (run all resolved examples)") do |m|
|
|
72
|
+
apply_example_targeting_mode(m)
|
|
73
|
+
end
|
|
69
74
|
opts.on("--example-targeting-fallback MODE", %w[full_file unresolved],
|
|
70
75
|
"Fallback when example targeting finds no match: full_file (default) or unresolved") do |m|
|
|
71
76
|
@options[:example_targeting_fallback] = m
|
|
@@ -77,6 +82,18 @@ class Evilution::CLI::Parser::OptionsBuilder
|
|
|
77
82
|
end
|
|
78
83
|
end
|
|
79
84
|
|
|
85
|
+
# Map the single --example-targeting flag onto the two underlying config axes:
|
|
86
|
+
# full_file is "targeting off" (run all resolved examples); lexical/coverage
|
|
87
|
+
# turn targeting on and select the strategy.
|
|
88
|
+
def apply_example_targeting_mode(mode)
|
|
89
|
+
if mode == "full_file"
|
|
90
|
+
@options[:example_targeting] = false
|
|
91
|
+
else
|
|
92
|
+
@options[:example_targeting] = true
|
|
93
|
+
@options[:example_targeting_strategy] = mode.to_sym
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
80
97
|
def add_flag_options(opts)
|
|
81
98
|
opts.on("--fail-fast", "Stop after N surviving mutants " \
|
|
82
99
|
"(default: disabled; if provided without N, uses 1; use --fail-fast=N)") { @options[:fail_fast] ||= 1 }
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base"
|
|
4
|
+
|
|
5
|
+
class Evilution::Config::Validators::ExampleTargetingStrategy < Evilution::Config::Validators::Base
|
|
6
|
+
STRATEGIES = %i[lexical coverage].freeze
|
|
7
|
+
|
|
8
|
+
def self.call(value)
|
|
9
|
+
unless value.is_a?(String) || value.is_a?(Symbol)
|
|
10
|
+
raise Evilution::ConfigError,
|
|
11
|
+
"example_targeting_strategy must be lexical or coverage, got #{value.inspect}"
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
sym = value.to_sym
|
|
15
|
+
unless STRATEGIES.include?(sym)
|
|
16
|
+
raise Evilution::ConfigError,
|
|
17
|
+
"example_targeting_strategy must be lexical or coverage, got #{sym.inspect}"
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
sym
|
|
21
|
+
end
|
|
22
|
+
end
|
data/lib/evilution/config.rb
CHANGED
|
@@ -17,7 +17,7 @@ class Evilution::Config
|
|
|
17
17
|
show_disabled: false, baseline_session: nil, skip_heredoc_literals: false,
|
|
18
18
|
related_specs_heuristic: false, fallback_to_full_suite: false, preload: nil,
|
|
19
19
|
spec_mappings: {}, spec_pattern: nil, example_targeting: true,
|
|
20
|
-
example_targeting_fallback: :full_file,
|
|
20
|
+
example_targeting_fallback: :full_file, example_targeting_strategy: :lexical,
|
|
21
21
|
example_targeting_cache: { max_files: 50, max_blocks: 10_000 },
|
|
22
22
|
quiet_children: false, quiet_children_dir: "tmp/evilution_children",
|
|
23
23
|
profile: :default, canary: true
|
|
@@ -30,7 +30,8 @@ class Evilution::Config
|
|
|
30
30
|
:ignore_patterns, :show_disabled, :baseline_session,
|
|
31
31
|
:skip_heredoc_literals, :related_specs_heuristic,
|
|
32
32
|
:fallback_to_full_suite, :preload, :spec_mappings, :spec_pattern,
|
|
33
|
-
:example_targeting, :example_targeting_fallback, :
|
|
33
|
+
:example_targeting, :example_targeting_fallback, :example_targeting_strategy,
|
|
34
|
+
:example_targeting_cache,
|
|
34
35
|
:spec_selector, :quiet_children, :quiet_children_dir, :profile, :canary
|
|
35
36
|
|
|
36
37
|
def initialize(**options)
|
|
@@ -104,6 +105,10 @@ class Evilution::Config
|
|
|
104
105
|
example_targeting
|
|
105
106
|
end
|
|
106
107
|
|
|
108
|
+
def coverage_targeting?
|
|
109
|
+
example_targeting && example_targeting_strategy == :coverage
|
|
110
|
+
end
|
|
111
|
+
|
|
107
112
|
def fallback_to_full_suite?
|
|
108
113
|
fallback_to_full_suite
|
|
109
114
|
end
|
|
@@ -187,6 +192,13 @@ class Evilution::Config
|
|
|
187
192
|
# without editing the file by exporting EV_DISABLE_EXAMPLE_TARGETING=1.
|
|
188
193
|
# example_targeting: true
|
|
189
194
|
|
|
195
|
+
# How targeting picks examples (default: lexical).
|
|
196
|
+
# lexical - text-grep resolved spec files for the mutated method/class name
|
|
197
|
+
# coverage - run exactly the examples that EXECUTE the mutated line, from a
|
|
198
|
+
# cached full-suite line-coverage map (falls back to lexical for
|
|
199
|
+
# any file the map has not fully built)
|
|
200
|
+
# example_targeting_strategy: lexical
|
|
201
|
+
|
|
190
202
|
# Behavior when targeting finds no matching example (default: full_file).
|
|
191
203
|
# full_file - run every example in the resolved spec files
|
|
192
204
|
# unresolved - mark the mutation :unresolved and skip
|
|
@@ -275,6 +287,7 @@ class Evilution::Config
|
|
|
275
287
|
def assign_example_targeting(merged)
|
|
276
288
|
@example_targeting = merged[:example_targeting] ? true : false
|
|
277
289
|
@example_targeting_fallback = Validators::ExampleTargetingFallback.call(merged[:example_targeting_fallback])
|
|
290
|
+
@example_targeting_strategy = Validators::ExampleTargetingStrategy.call(merged[:example_targeting_strategy])
|
|
278
291
|
@example_targeting_cache = Validators::ExampleTargetingCache.call(merged[:example_targeting_cache])
|
|
279
292
|
end
|
|
280
293
|
end
|
|
@@ -294,6 +307,7 @@ require_relative "config/validators/ignore_patterns"
|
|
|
294
307
|
require_relative "config/validators/spec_pattern"
|
|
295
308
|
require_relative "config/validators/spec_mappings"
|
|
296
309
|
require_relative "config/validators/example_targeting_fallback"
|
|
310
|
+
require_relative "config/validators/example_targeting_strategy"
|
|
297
311
|
require_relative "config/validators/example_targeting_cache"
|
|
298
312
|
require_relative "config/validators/profile"
|
|
299
313
|
require_relative "config/builders"
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require_relative "../coverage"
|
|
5
|
+
|
|
6
|
+
# Stable, content-addressed digest of a source file, used by MapStore to detect
|
|
7
|
+
# when a cached coverage entry has gone stale. Path-independent: the digest
|
|
8
|
+
# depends only on the bytes, so moving a file does not invalidate it. Returns
|
|
9
|
+
# nil for a missing file so callers treat it as "not fresh".
|
|
10
|
+
class Evilution::Coverage::Digest
|
|
11
|
+
def for_file(path)
|
|
12
|
+
return nil unless File.file?(path)
|
|
13
|
+
|
|
14
|
+
::Digest::SHA256.hexdigest(File.binread(path))
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../coverage"
|
|
4
|
+
|
|
5
|
+
# Immutable query over a per-example line-coverage index:
|
|
6
|
+
# source file -> line -> [example locations ("spec.rb:line")].
|
|
7
|
+
# built_files records the source files for which the build completed, so
|
|
8
|
+
# callers can distinguish "line genuinely uncovered" (file built, no entry)
|
|
9
|
+
# from "we never built this file" (must fall back, not assert a gap).
|
|
10
|
+
class Evilution::Coverage::Map
|
|
11
|
+
def self.from_h(hash)
|
|
12
|
+
index = (hash["index"] || {}).transform_values do |lines|
|
|
13
|
+
lines.transform_keys(&:to_i)
|
|
14
|
+
end
|
|
15
|
+
executed = (hash["executed_lines"] || {}).transform_values { |lines| lines.map(&:to_i) }
|
|
16
|
+
new(index: index, built_files: hash["built_files"] || [], executed_lines: executed)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# executed_lines records, per file, the lines that ran at all during the build
|
|
20
|
+
# (including lines covered only at load, e.g. a `def` line, which are
|
|
21
|
+
# attributed to no single example). It lets a caller tell a TRUE coverage gap
|
|
22
|
+
# (line never executed) from a load-covered line an example may still exercise
|
|
23
|
+
# indirectly -- so the latter falls back instead of being mis-skipped.
|
|
24
|
+
def initialize(index:, built_files:, executed_lines: {})
|
|
25
|
+
@index = deep_freeze_index(index)
|
|
26
|
+
@built_files = built_files.to_a.freeze
|
|
27
|
+
@executed_lines = deep_freeze_executed(executed_lines)
|
|
28
|
+
freeze
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def examples_for(file, line)
|
|
32
|
+
@index.dig(file, line) || []
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def built?(file)
|
|
36
|
+
@built_files.include?(file)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def executed?(file, line)
|
|
40
|
+
lines = @executed_lines[file]
|
|
41
|
+
!lines.nil? && lines.include?(line)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def to_h
|
|
45
|
+
{ "index" => @index, "built_files" => @built_files, "executed_lines" => @executed_lines }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def deep_freeze_index(index)
|
|
51
|
+
index.each_with_object({}) do |(file, lines), out|
|
|
52
|
+
frozen_lines = lines.each_with_object({}) do |(line, locs), inner|
|
|
53
|
+
inner[line] = locs.uniq.sort.freeze
|
|
54
|
+
end
|
|
55
|
+
out[file] = frozen_lines.freeze
|
|
56
|
+
end.freeze
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def deep_freeze_executed(executed)
|
|
60
|
+
executed.each_with_object({}) do |(file, lines), out|
|
|
61
|
+
out[file] = lines.map(&:to_i).uniq.sort.freeze
|
|
62
|
+
end.freeze
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -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
|