evilution 0.30.4 → 0.32.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 (37) hide show
  1. checksums.yaml +4 -4
  2. data/.beads/interactions.jsonl +22 -0
  3. data/.rubocop_todo.yml +6 -0
  4. data/CHANGELOG.md +22 -0
  5. data/README.md +9 -7
  6. data/docs/integrations.md +126 -0
  7. data/docs/isolation.md +28 -0
  8. data/lib/evilution/cli/parser/options_builder.rb +6 -1
  9. data/lib/evilution/config/validators/integration.rb +5 -1
  10. data/lib/evilution/config.rb +14 -4
  11. data/lib/evilution/integration/loading/mutation_applier.rb +16 -8
  12. data/lib/evilution/integration/loading/source_evaluator.rb +4 -1
  13. data/lib/evilution/integration/minitest.rb +1 -1
  14. data/lib/evilution/integration/rspec/baseline_runner.rb +3 -1
  15. data/lib/evilution/integration/rspec/framework_loader.rb +5 -1
  16. data/lib/evilution/integration/rspec.rb +38 -1
  17. data/lib/evilution/integration/test_unit/dispatcher.rb +26 -0
  18. data/lib/evilution/integration/test_unit/framework_loader.rb +33 -0
  19. data/lib/evilution/integration/test_unit/result_builder.rb +53 -0
  20. data/lib/evilution/integration/test_unit/subject_class_registry.rb +26 -0
  21. data/lib/evilution/integration/test_unit/test_file_resolver.rb +48 -0
  22. data/lib/evilution/integration/test_unit.rb +124 -0
  23. data/lib/evilution/integration/test_unit_crash_detector.rb +61 -0
  24. data/lib/evilution/isolation/fork.rb +26 -1
  25. data/lib/evilution/isolation/in_process.rb +20 -3
  26. data/lib/evilution/mcp/info_tool.rb +2 -2
  27. data/lib/evilution/mcp/mutate_tool.rb +3 -2
  28. data/lib/evilution/runner/baseline_runner.rb +3 -1
  29. data/lib/evilution/runner/canary.rb +130 -0
  30. data/lib/evilution/runner.rb +24 -1
  31. data/lib/evilution/spec_ast_cache.rb +20 -3
  32. data/lib/evilution/spec_resolver.rb +16 -2
  33. data/lib/evilution/spec_selector.rb +14 -2
  34. data/lib/evilution/version.rb +1 -1
  35. data/lib/evilution.rb +39 -0
  36. data/script/run_self_baseline +2 -2
  37. metadata +11 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2bd9eaca72cb973c6e11841144b8bae68d2a838aef1ff64c429e18b4090505f1
4
- data.tar.gz: bbdedbc5ef66ca367deeec89a53399ce83a235616fe2ac179ed98bf880f8be9b
3
+ metadata.gz: dc948250a218198fef7420e04ab49e5a2f8e01ff50ae279f5299bb85db07ab77
4
+ data.tar.gz: df6e3a2ea4ee5512f9c946f4df514f8a1a2df8308ac77236ceafa3de81b435d8
5
5
  SHA512:
6
- metadata.gz: b7cf12d8381e05d66b1a2ffcf1c243e3e18d66f8f073de892d40ff139bb6952214ac5c6bdcff3ab026dd475601629790e7c4d5f86f8315a8baf989bcb5b4aa7b
7
- data.tar.gz: 21a523a1f2753c5e70b9fb2d53262d28b169661b75c2bd2ca44ce5f9e5c6234591f6d8dcc935fea8c49a79689d7be5a2883cae6ff4517b711379b80b8d8315bf
6
+ metadata.gz: 43c08e6baa4904c1122d639c957cc1139c9e4760e3e38aed8664a70ff46388aab91b64924816739d4c7a9f00abb049f2da579c50f3b5a585c49b2175f3461dff
7
+ data.tar.gz: e30f5845c332781de9431c9a4238df1ac9048960d3ef36323dac0a8beaf2f5c5d56eb1d3132d618a7e076bb7547520286fc127347499b0b95640331e16912fb8
@@ -387,3 +387,25 @@
387
387
  {"id":"int-011584d4","kind":"field_change","created_at":"2026-05-15T15:55:27.437673072Z","actor":"Denis Kiselev","issue_id":"EV-vxgl","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Fixed PR (merged). MutationApplier#mark_feature_loaded registers realpath in $LOADED_FEATURES post-eval — spec require() no longer reloads original. pagy jobs=4: 0% -> 82.81%. Residual gap vs jobs=1 is separate (EV-wu8w in_process inflation)."}}
388
388
  {"id":"int-c44e7652","kind":"field_change","created_at":"2026-05-16T12:45:46.793709029Z","actor":"Denis Kiselev","issue_id":"EV-xfaj","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Fixed in PR #1260 — run_minitest counts test methods from Minitest::Runnable.runnables registry instead of an evictable reporter"}}
389
389
  {"id":"int-eea19850","kind":"field_change","created_at":"2026-05-16T14:22:49.671269232Z","actor":"Denis Kiselev","issue_id":"EV-wu8w","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Fixed in PR #1264 — run_minitest reads verdict from evilution's own per-run SummaryReporter attached after plugin init"}}
390
+ {"id":"int-cf85d538","kind":"field_change","created_at":"2026-05-16T15:40:28.573223389Z","actor":"Denis Kiselev","issue_id":"EV-5dxk","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Part A (required guard: 0 dispatched test methods -> :error) shipped in 0.30.3 PR #1257. Acceptance met. Part B (test-unit integration) split into its own epic."}}
391
+ {"id":"int-6efa8613","kind":"field_change","created_at":"2026-05-16T15:55:20.799858411Z","actor":"Denis Kiselev","issue_id":"EV-174x","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Fixed and merged 2026-05-12; GH #1194 / #1195 closed. RedefinitionRecovery widened (already registered/initialized/exists); splice approach reverted for memory leak. Bead-close missed at merge time — syncing now."}}
392
+ {"id":"int-ddb8370f","kind":"field_change","created_at":"2026-05-16T15:55:21.07310729Z","actor":"Denis Kiselev","issue_id":"EV-wwx3","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Fixed and merged 2026-05-12; GH #1194 / #1195 closed. RedefinitionRecovery widened (already registered/initialized/exists); splice approach reverted for memory leak. Bead-close missed at merge time — syncing now."}}
393
+ {"id":"int-4ea24fb5","kind":"field_change","created_at":"2026-05-16T16:04:54.395246914Z","actor":"Denis Kiselev","issue_id":"EV-s24s","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Already fixed — both named specs (unparseable_short_circuit_spec.rb, neutralization_integration_spec.rb) pass and destructure via execution.results. Stale in_progress; syncing."}}
394
+ {"id":"int-8b699158","kind":"field_change","created_at":"2026-05-16T17:22:07.345408768Z","actor":"Denis Kiselev","issue_id":"EV-kcuf","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Proof-of-life canary implemented + merged. Runner::Canary runs a synthetic unobservable mutation at session start; aborts if not scored :survived. --[no-]canary flag, config.canary default true."}}
395
+ {"id":"int-e4dcc2b3","kind":"field_change","created_at":"2026-05-27T04:19:00.738936734Z","actor":"Denis Kiselev","issue_id":"EV-qbd6","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Fix landed on master in commit 0f1781f (require 'bundler/setup' at bin/evilution-self:16). Verified 2026-05-27: crash dir lib/evilution/result/*.rb scores 91.11% (744 muts, 656 killed, 64 survived, 24 equivalent, 0 errors); canary passes; dual-runtime harness measures crash dirs again."}}
396
+ {"id":"int-4cb27d5a","kind":"field_change","created_at":"2026-05-28T06:42:48.564599548Z","actor":"Denis Kiselev","issue_id":"EV-pyx6","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Fix merged on master in commit 72b43a3 (PR #1292). Added Evilution.project_base_dir helper + anchored spec/ via Evilution.project_base_dir in framework_loader.rb and baseline_runner.rb. Verified 2026-05-28: bin/evilution-self run lib/evilution/config/env_loader.rb scores 94.59% (was 0 before fix). 4705 examples pass, rubocop clean."}}
397
+ {"id":"int-4cd16cba","kind":"field_change","created_at":"2026-05-29T04:54:00.815774086Z","actor":"Denis Kiselev","issue_id":"EV-8tmc","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Partial fix landed in commit 525787b: 18 of 20 lib/ subdirs now measurable (was 11/20). Two follow-up causes diagnosed and addressed: (1) auto-isolation picking in_process for non-Rails projects + RSpec swallowing Timeout::Error per-example, fixed by script using --isolation=fork explicitly; (2) ChildOutput log_dir relative-path bug under EV-wqxu sandbox CWD, fixed by absolutizing in runner.rb#configure_child_output. Remaining 2 dirs (parallel + toplevel) hang via different root cause — grandchild fork leak — tracked separately as EV-dgjv (GH #1295)."}}
398
+ {"id":"int-991e5acf","kind":"field_change","created_at":"2026-05-30T15:56:24.341278813Z","actor":"Denis Kiselev","issue_id":"EV-auk5","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Closed"}}
399
+ {"id":"int-df2bd661","kind":"field_change","created_at":"2026-05-30T15:56:24.686504141Z","actor":"Denis Kiselev","issue_id":"EV-9d62","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Closed"}}
400
+ {"id":"int-6a1dc0ce","kind":"field_change","created_at":"2026-05-30T15:56:25.04815512Z","actor":"Denis Kiselev","issue_id":"EV-unar","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Closed"}}
401
+ {"id":"int-defdc095","kind":"field_change","created_at":"2026-05-30T15:56:25.406639346Z","actor":"Denis Kiselev","issue_id":"EV-04sc","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Closed"}}
402
+ {"id":"int-bc5a7747","kind":"field_change","created_at":"2026-05-30T17:33:33.191722982Z","actor":"Denis Kiselev","issue_id":"EV-8qiy","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Closed"}}
403
+ {"id":"int-796e3efc","kind":"field_change","created_at":"2026-05-30T17:33:34.539604057Z","actor":"Denis Kiselev","issue_id":"EV-uv11","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Closed"}}
404
+ {"id":"int-5fb67d2b","kind":"field_change","created_at":"2026-05-30T17:46:39.956899342Z","actor":"Denis Kiselev","issue_id":"EV-bcjp","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Closed"}}
405
+ {"id":"int-6ba88696","kind":"field_change","created_at":"2026-05-30T17:59:44.432841837Z","actor":"Denis Kiselev","issue_id":"EV-aqxd","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Closed"}}
406
+ {"id":"int-34eb133e","kind":"field_change","created_at":"2026-05-31T02:20:26.61836178Z","actor":"Denis Kiselev","issue_id":"EV-vumz","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Closed"}}
407
+ {"id":"int-9527af14","kind":"field_change","created_at":"2026-05-31T02:47:48.910182696Z","actor":"Denis Kiselev","issue_id":"EV-zhqc","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Closed"}}
408
+ {"id":"int-dfe331ef","kind":"field_change","created_at":"2026-05-31T02:59:12.044281579Z","actor":"Denis Kiselev","issue_id":"EV-akt2","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Closed"}}
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
+ {"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
+ {"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"}}
data/.rubocop_todo.yml CHANGED
@@ -8,7 +8,13 @@ Metrics/AbcSize:
8
8
 
9
9
  Metrics/ClassLength:
10
10
  Exclude:
11
+ - "lib/evilution/config.rb"
11
12
  - "lib/evilution/isolation/fork.rb"
12
13
  - "lib/evilution/integration/minitest.rb"
13
14
  - "lib/evilution/mcp/mutate_tool.rb"
15
+ - "lib/evilution/runner.rb"
14
16
  - "lib/evilution/runner/isolation_resolver.rb"
17
+
18
+ Metrics/MethodLength:
19
+ Exclude:
20
+ - "lib/evilution/runner.rb"
data/CHANGELOG.md CHANGED
@@ -2,6 +2,28 @@
2
2
 
3
3
  Versioning policy: see [docs/versioning.md](docs/versioning.md).
4
4
 
5
+ ## [0.32.0] - 2026-05-31
6
+
7
+ ### Added
8
+
9
+ - **`--integration test-unit` runs mutations against projects whose suites use the `test-unit` gem** — Rails projects that `require "test/unit/rails/test_help"` make `ActiveSupport::TestCase` inherit from `Test::Unit::TestCase`, and the existing Minitest integration could not dispatch them (`Minitest::Runnable.runnables` is empty because the test classes are not Minitest runnables at all). A dedicated `Evilution::Integration::TestUnit` orchestrator now wires a framework loader (disables the `Test::Unit::AutoRunner` at_exit handler so it does not fire on evilution exit), a subject-class registry (diffs `Test::Unit::TestCase` descendants across `load` to scope each mutation to freshly-loaded suites without a public registry-clear API), a dispatcher (`Test::Unit::UI::Console::TestRunner` with output captured to a `StringIO`), a spec resolver (`test/foo_test.rb` convention, matching Minitest), and a result builder shaping the `passed`/`test_crashed`/`no_tests_ran`/`unresolved` hashes that `classify_status` consumes. `Evilution::Integration::TestUnitCrashDetector` mirrors the Minitest analogue, distinguishing `Test::Unit::Failure` (assertion) from `Test::Unit::Error` (exception/crash) via `TestResult.add_listener(FAULT, ...)`. CLI accepts both `--integration test-unit` and `--integration test_unit`; the MCP `evilution-mutate` tool advertises `test-unit` in its JSON schema enum. Kaminari canary (`kaminari-core/lib/kaminari/models/page_scope_methods.rb`) now scores 63.60% (272 mutations dispatched) where 0.30.x scored 0.00% with the Minitest integration. See [docs/integrations.md](docs/integrations.md). Suggest-tests templates for test-unit are not yet implemented; runs with `--suggest-tests --integration test-unit` fall back to the generic operator-level phrasing (EV-d7re epic + sub-beads EV-8qiy/EV-uv11/EV-bcjp/EV-aqxd/EV-vumz/EV-zhqc/EV-akt2/EV-6az9/EV-d3av, PRs #1314/#1315/#1316/#1318/#1320/#1321, GH #1267)
10
+
11
+ ### Fixed
12
+
13
+ - **Pool workers no longer hang indefinitely in `Process.wait` when a per-mutation child writes a payload but does not exit promptly** — `Evilution::Isolation::Fork#reap_and_decode` called unbounded `Process.wait(pid)` after reading a length-prefixed payload from the marshal pipe. If the per-mutation child had written the payload but was still stuck in `execute_in_child` waiting on a subject grandchild the mutation broke (or a grandchild had written garbage bytes that looked like a valid header+body to the parent's inherited write fd), the pool worker blocked forever; the per-mutation timeout had no effect because `wait_for_result` had already returned. `Evilution::Parallel::WorkQueue::Dispatcher`'s `item_timeout` then fired `"worker timed out after 30s"` and SIGKILLed the pool worker, leaving the per-mutation child orphaned to init. `reap_and_decode` now bounds the wait by `REAP_DEADLINE = 1.0`s and falls through to the TERM → GRACE_PERIOD → KILL → safe_wait ladder when the child has not exited, so a stuck child takes ~3s worst-case instead of indefinite. Previously-unmeasurable parallel/ + toplevel/ self-baseline directories (parallel/work_queue.rb, parallel/work_queue/dispatcher.rb, parallel/work_queue/worker.rb, isolation/, toplevel) now all produce real summaries (EV-dgjv, PR #1296, GH #1295)
14
+ - **`script/run_self_baseline` toplevel batch invocation now passes `--isolation=fork`** — every subdir invocation explicitly passed `--isolation=fork`, but the trailing toplevel batch (`lib/evilution/*.rb` files) omitted it. Default isolation auto-picked in_process for the non-Rails self-mutation, which (combined with RSpec's per-example `Timeout::Error` swallow) caused the toplevel batch to hit dispatcher item_timeout and report `NO_SUMMARY`. The toplevel cmd now matches the per-subdir cmd, so all 20 self-baseline dirs are measurable (EV-dgjv, PR #1296, GH #1295)
15
+
16
+ ## [0.31.0] - 2026-05-27
17
+
18
+ ### Added
19
+
20
+ - **Proof-of-life canary at session start** — before any real mutation runs, `Evilution::Runner::Canary` evals a synthetic, guaranteed-unobservable mutation through the configured integration + isolation. A healthy pipeline must score it `:survived`; any other status aborts the run with a diagnostic, so the user never sees a score that was produced by a broken pipeline (autoload mismatch, reporter-plugin eviction, isolation defect, `fail_if_no_examples` config drift, etc.). On by default; toggle with `--[no-]canary` or `canary: true|false` in `.evilution.yml`. Mirrors the configured `--isolation` so isolation-specific defects are caught too (EV-kcuf, PR #1268, GH #1233)
21
+
22
+ ### Fixed
23
+
24
+ - **Mutation children no longer pollute the working tree with files written to relative paths** — every isolator (fork and in_process) now `Dir.chdir`s into a per-mutation sandbox directory for the duration of `test_command.call`, and removes the sandbox on the way out. Any mutation that turns an absolute path into a relative one — `argument_removal` on `File.join(dir, name)`, `method_call_removal` on `File.expand_path(rel, base)`, etc. — used to write `File.write(name, …)` into the parent process's CWD (typically the repo root) and leak past the run; baselining `lib/evilution/runner/canary.rb` deposited 43 stray files (`canary_<pid>_<hex>_spec.rb`, `evilutioncanary_<pid>_<hex>.rb`) before this fix. Spec resolution and source eval anchor project-relative paths to `Evilution::PROJECT_ROOT` (captured at load), so the sandbox CWD does not break `SpecResolver`, `SpecSelector`, `MutationApplier`, `SourceEvaluator`, or the `RSpec::Core::Runner` / `Minitest.load` invocation sites (EV-wqxu, PR #1281, GH #1278)
25
+ - **`MutationApplier` registers the mutation in `$LOADED_FEATURES` BEFORE evaluating the source, not after** — a sibling `require_relative` chain that loops back to the mutated file during the eval itself (e.g. `lib/evilution/mcp/*.rb` tools whose body requires a peer that requires this file back) re-read the *original* source from disk and clobbered the mutation mid-eval. Every such mutation silently survived. The feature-loaded marker now runs immediately before `@source_evaluator.call`, so the in-progress eval is the canonical source any concurrent require sees. Without this, fork workers started from the same pre-`require` snapshot and the whole file scored 0% (EV-ekax, PR #1272, GH #1269)
26
+
5
27
  ## [0.30.4] - 2026-05-16
6
28
 
7
29
  ### Fixed
data/README.md CHANGED
@@ -101,12 +101,13 @@ Every command, subcommand, and flag listed in this section is part of evilution'
101
101
  | `--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
102
  | `-j`, `--jobs N` | Integer | 1 | Number of parallel workers. Uses demand-driven work distribution with pipe-based IPC. |
103
103
  | `--no-baseline` | Boolean | _(enabled)_ | Skip baseline test suite check. By default, a baseline run detects pre-existing failures and marks those mutations as `neutral`. |
104
+ | `--[no-]canary` | Boolean | _(enabled)_ | Run a proof-of-life synthetic mutation at session start; abort the run if the pipeline misreports it. Catches misconfigured isolation, broken autoload, and reporter-plugin eviction before any real score is produced. Pass `--no-canary` to skip (e.g. CI speed, or when the canary itself is the thing under test). |
104
105
  | `--fail-fast [N]` | Integer | _(none)_ | Stop after N surviving mutants (default 1 if no value given). |
105
106
  | `-v`, `--verbose` | Boolean | false | Verbose output with RSS memory and GC stats per phase and per mutation; also prints error class, message, and first 5 backtrace lines for errored mutations. |
106
- | `--suggest-tests` | Boolean | false | Generate concrete test code in suggestions (RSpec or Minitest, based on `--integration`). |
107
+ | `--suggest-tests` | Boolean | false | Generate concrete test code in suggestions (RSpec or Minitest, based on `--integration`; falls back to generic phrasing for `test-unit`). |
107
108
  | `-q`, `--quiet` | Boolean | false | Suppress output. |
108
109
  | `--stdin` | Boolean | false | Read target file paths from stdin (one per line). |
109
- | `--integration NAME` | String | `rspec` | Test framework integration: `rspec` or `minitest`. |
110
+ | `--integration NAME` | String | `rspec` | Test framework integration: `rspec`, `minitest`, or `test-unit`. See [docs/integrations.md](docs/integrations.md). |
110
111
  | `--[no-]incremental` | Boolean | false | Cache killed/timeout results; skip unchanged mutations on re-runs. Pass `--no-incremental` to override `incremental: true` from the config file for one invocation (e.g. cold-cache debugging). Last flag wins when both are given. |
111
112
  | `--save-session` | Boolean | false | Persist results as timestamped JSON under `.evilution/results/`. |
112
113
  | `--no-progress` | Boolean | _(enabled)_ | Disable the TTY progress bar. |
@@ -168,10 +169,11 @@ schema_version: 1 # opts into strict validation (rejects unknown keys
168
169
  # timeout: 30 # seconds per mutation
169
170
  # format: text # text | json | html
170
171
  # min_score: 0.0 # 0.0–1.0
171
- # integration: rspec # test framework: rspec, minitest
172
+ # integration: rspec # test framework: rspec, minitest, test_unit
172
173
  # suggest_tests: false # concrete test code in suggestions (matches integration)
173
174
  # save_session: false # persist results under .evilution/results/
174
175
  # isolation: auto # auto | fork | in_process (auto selects fork for Rails)
176
+ # canary: true # proof-of-life synthetic mutation at session start (false to skip)
175
177
  # preload: null # path to preload before forking; false to disable; auto-detects for Rails
176
178
  # skip_heredoc_literals: false # skip string literal mutations inside heredocs (recommended for Rails: heredoc SQL/templates rarely have test coverage)
177
179
  # show_disabled: false # report mutations skipped by disable comments
@@ -211,7 +213,7 @@ All keys recognised under `schema_version: 1`:
211
213
  | `format` | String | `text` | Output format: `text`, `json`, `html`. |
212
214
  | `target` | String / null | `null` | Filter expression: method (`Foo#bar`), class (`Foo`), namespace (`Foo*`), descendants (`descendants:Foo`), source glob (`source:**/*.rb`). |
213
215
  | `min_score` | Float | `0.0` | Minimum mutation score (0.0–1.0) for exit code 0. |
214
- | `integration` | String | `rspec` | Test framework: `rspec` or `minitest`. |
216
+ | `integration` | String | `rspec` | Test framework: `rspec`, `minitest`, or `test_unit`. |
215
217
  | `verbose` | Boolean | `false` | Verbose output (RSS/GC stats per phase, error details for errored mutations). |
216
218
  | `quiet` | Boolean | `false` | Suppress output. |
217
219
  | `jobs` | Integer | `1` | Number of parallel workers. |
@@ -515,7 +517,7 @@ These fields are added in addition to the existing `operator`, `file`, `line`, `
515
517
 
516
518
  The `evilution-mutate` tool accepts a `suggest_tests` boolean parameter (default: `false`). When enabled, survived mutation suggestions contain concrete test code that an agent can drop into a test file, instead of static description text. It currently generates RSpec-style suggestions (`it`/`expect` blocks).
517
519
 
518
- Pass `suggest_tests: true` in the `evilution-mutate` call to activate this mode. The CLI also supports `--suggest-tests`; when using the CLI, generated suggestions match the `--integration` setting (RSpec `it`/`expect` blocks or Minitest `def test_`/`assert_equal` methods).
520
+ Pass `suggest_tests: true` in the `evilution-mutate` call to activate this mode. The CLI also supports `--suggest-tests`; when using the CLI, generated suggestions match the `--integration` setting (RSpec `it`/`expect` blocks or Minitest `def test_`/`assert_equal` methods). Concrete templates for `test-unit` are not yet implemented — `test-unit` runs fall back to generic suggestion text.
519
521
 
520
522
  ### Project Config File
521
523
 
@@ -530,7 +532,7 @@ Pass `skip_config: true` to ignore the project config file. This skips loading `
530
532
  | Parameter | Purpose |
531
533
  |---|---|
532
534
  | `incremental` | Cache killed/timeout results across runs — set `true` when iterating on the same files |
533
- | `integration` | `rspec` or `minitest` |
535
+ | `integration` | `rspec`, `minitest`, or `test_unit` |
534
536
  | `isolation` | `auto`, `fork`, or `in_process` |
535
537
  | `baseline` | `false` to skip the baseline suite check when you already know it's green |
536
538
  | `save_session` | Persist results to `.evilution/results/` for inspection via `evilution-session` |
@@ -772,7 +774,7 @@ Tests 4 paths (InProcess isolation, Fork isolation, mutation generation + stripp
772
774
  3. **Filter** — Disable comments, Sorbet `sig` blocks, and AST ignore patterns exclude mutations before execution
773
775
  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
774
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
775
- 6. **Test** — The configured test framework (RSpec or Minitest) executes against the mutated source
777
+ 6. **Test** — The configured test framework (RSpec, Minitest, or Test::Unit) executes against the mutated source
776
778
  7. **Collect** — Source strings and AST nodes are released after use to minimize memory retention
777
779
  8. **Report** — Results aggregated into text, JSON, or HTML, including efficiency metrics and peak memory usage
778
780
 
@@ -0,0 +1,126 @@
1
+ # Test Framework Integrations
2
+
3
+ Evilution supports three test framework integrations, selected via the
4
+ `--integration NAME` CLI flag (or the `integration:` key in `.evilution.yml`).
5
+ Each integration owns its own framework loader, dispatcher, spec resolver,
6
+ and result builder so they can evolve independently.
7
+
8
+ | Integration | Flag value | Default test dir | Default test suffix | Gem dep |
9
+ |---------------|---------------|------------------|---------------------|------------------------|
10
+ | RSpec | `rspec` | `spec/` | `_spec.rb` | `rspec-core` |
11
+ | Minitest | `minitest` | `test/` | `_test.rb` | `minitest` |
12
+ | Test::Unit | `test-unit` | `test/` | `_test.rb` | `test-unit` |
13
+
14
+ ## When to pick which
15
+
16
+ - **`rspec`** (default) — Suites built on `RSpec.describe` / `it` / `expect`.
17
+ - **`minitest`** — Suites that subclass `Minitest::Test` or use
18
+ `Minitest::Spec`'s `describe` / `it`. Includes Rails apps where
19
+ `ActiveSupport::TestCase` inherits from `Minitest::Test`.
20
+ - **`test-unit`** — Suites that subclass `Test::Unit::TestCase`, including
21
+ Rails projects that `require "test/unit/rails/test_help"` (which makes
22
+ `ActiveSupport::TestCase < Test::Unit::TestCase`). The `test-unit` gem's
23
+ `TestCase` classes are *not* `Minitest::Runnable` subclasses, so the
24
+ `minitest` integration cannot dispatch them — pick this integration whenever
25
+ your test helper pulls in `test/unit/rails/test_help`, `test-unit-activerecord`,
26
+ or similar test-unit-specific glue.
27
+
28
+ ## CLI examples
29
+
30
+ ```bash
31
+ # RSpec — default; specs in spec/, named *_spec.rb
32
+ bundle exec evilution run lib/foo.rb
33
+
34
+ # Minitest
35
+ bundle exec evilution run lib/foo.rb \
36
+ --integration minitest --spec test/foo_test.rb
37
+
38
+ # Test::Unit (gem name uses hyphen; symbol value uses underscore)
39
+ bundle exec evilution run lib/foo.rb \
40
+ --integration test-unit --spec test/foo_test.rb
41
+ ```
42
+
43
+ The CLI accepts both `test-unit` and `test_unit` strings; the internal config
44
+ symbol is always `:test_unit`.
45
+
46
+ ## MCP examples
47
+
48
+ The `evilution-mutate` MCP tool exposes `integration` as a JSON schema enum
49
+ of `["rspec", "minitest", "test-unit"]`:
50
+
51
+ ```json
52
+ {
53
+ "tool": "evilution-mutate",
54
+ "args": {
55
+ "files": ["lib/foo.rb"],
56
+ "integration": "test-unit",
57
+ "spec": ["test/foo_test.rb"]
58
+ }
59
+ }
60
+ ```
61
+
62
+ ## Spec resolution conventions
63
+
64
+ When `--spec` is not supplied, evilution resolves a test file from the source
65
+ path using the integration's spec resolver. Strips `lib/` or `app/` prefix
66
+ and rewrites the suffix:
67
+
68
+ | Source | rspec | minitest / test-unit |
69
+ |-------------------------------------|-------------------------------------|------------------------------------|
70
+ | `lib/foo.rb` | `spec/foo_spec.rb` | `test/foo_test.rb` |
71
+ | `lib/foo/bar.rb` | `spec/foo/bar_spec.rb` | `test/foo/bar_test.rb` |
72
+ | `app/models/user.rb` | `spec/models/user_spec.rb` | `test/models/user_test.rb` |
73
+ | `app/controllers/users_controller.rb` | `spec/requests/users_spec.rb` *or* `spec/controllers/users_controller_spec.rb` | `test/integration/users_test.rb` *or* `test/controllers/users_controller_test.rb` |
74
+
75
+ For controllers, the resolver tries the request-spec / integration-test
76
+ location first, then falls back to the controller-spec / controller-test
77
+ location.
78
+
79
+ ## Suggest-tests caveat
80
+
81
+ The `--suggest-tests` mode emits ready-to-paste test snippets for survived
82
+ mutations. Concrete templates currently exist for **rspec** and **minitest**
83
+ only; the **test-unit** integration falls back to the generic operator-level
84
+ suggestion text. Adding test-unit templates is a small follow-up.
85
+
86
+ ## Other Ruby test frameworks
87
+
88
+ No additional integrations are planned at the moment. The three integrations
89
+ above cover the overwhelming majority of Ruby projects with unit / integration
90
+ test suites. Frameworks we considered and deferred:
91
+
92
+ - **Cucumber / Spinach** — BDD scenarios over Gherkin step definitions, a
93
+ fundamentally coarser granularity than mutation testing rewards. Not a
94
+ natural fit; no plans to add.
95
+ - **Sus** — Socketry's async-focused framework, small but actively maintained
96
+ user base. Could be added under the same orchestrator/collaborator pattern
97
+ if a real project surfaces needing it.
98
+ - **Bacon, Test::Spec** — early RSpec-style clones, effectively dormant.
99
+ - **Minitest::Spec** — already covered by the `minitest` integration.
100
+
101
+ If you maintain a project on another framework and would benefit from
102
+ mutation-testing it through evilution, open an issue describing the project
103
+ and the framework's dispatch entry-point — the existing integration layout
104
+ makes new entries cheap to add.
105
+
106
+ ## Behind the scenes
107
+
108
+ Each integration has an orchestrator class at
109
+ `lib/evilution/integration/<name>.rb`. RSpec and Test::Unit additionally
110
+ decompose their collaborators into a sibling directory of the same name
111
+ (`lib/evilution/integration/rspec/...`,
112
+ `lib/evilution/integration/test_unit/...`); Minitest remains a single file.
113
+ The test-unit collaborators are:
114
+
115
+ - `framework_loader.rb` — `require "test-unit"` + disables the at_exit
116
+ auto-run handler so it doesn't fire on evilution exit.
117
+ - `subject_class_registry.rb` — ObjectSpace tracking of newly-loaded
118
+ `Test::Unit::TestCase` subclasses (test-unit has no public
119
+ registry-clear analog to `Minitest::Runnable.runnables`).
120
+ - `dispatcher.rb` — assembles a `Test::Unit::TestSuite` from the new
121
+ classes and runs it via `Test::Unit::UI::Console::TestRunner` with
122
+ output captured to a `StringIO`.
123
+ - `test_file_resolver.rb` — explicit-override + spec-selector +
124
+ fallback glob + warn-once for unresolved sources.
125
+ - `result_builder.rb` — shapes the `passed`/`test_crashed`/
126
+ `no_tests_ran`/`unresolved` Hash that flows into `classify_status`.
data/docs/isolation.md CHANGED
@@ -103,6 +103,33 @@ the MCP server is a long-lived process that handles runs from different
103
103
  projects — preloading one project's Rails stack into a shared process would
104
104
  poison subsequent runs.
105
105
 
106
+ ## Sandboxed working directory
107
+
108
+ Every isolator runs `test_command.call` inside a per-mutation scratch
109
+ directory (`Dir.mktmpdir("evilution-run")`) that is removed on the way out.
110
+ Mutations that turn an absolute path into a relative one — for example
111
+ `argument_removal` on `File.join(dir, name)` collapsing to `File.write(name,
112
+ …)` — would otherwise drop files into the parent process's CWD (typically
113
+ the repo root) and leak past the run.
114
+
115
+ Project-relative paths used by `SpecResolver`, `SpecSelector`, the
116
+ `RSpec::Core::Runner` / `Minitest.load` invocation sites, and the mutated
117
+ file's `__FILE__` in `SourceEvaluator` are anchored at
118
+ `Evilution::PROJECT_ROOT` (captured at load time), so the sandbox CWD does
119
+ not break spec lookup or `require_relative` chains inside the mutant.
120
+
121
+ ## Proof-of-life canary
122
+
123
+ Before any real mutation runs, `Evilution::Runner::Canary` evals a
124
+ synthetic, guaranteed-unobservable mutation through the configured
125
+ integration + isolation. A healthy pipeline must score it `:survived`; any
126
+ other status aborts the run with a diagnostic so the user never sees a
127
+ score produced by a broken pipeline (autoload mismatch, reporter-plugin
128
+ eviction, isolation defect, `fail_if_no_examples` config drift, etc.).
129
+ On by default; toggle with `--[no-]canary` or `canary: true|false` in
130
+ `.evilution.yml`. The canary mirrors the configured `--isolation` so
131
+ isolation-specific defects are caught too.
132
+
106
133
  ## Related flags
107
134
 
108
135
  - `--timeout N` sets the per-mutation time limit. Under `fork`, this drives
@@ -111,5 +138,6 @@ poison subsequent runs.
111
138
  - `--jobs N` runs N workers in parallel. The parallel pool respects the
112
139
  configured isolation strategy, so `--jobs 4 --isolation fork` uses fork
113
140
  isolation per-mutation inside each worker.
141
+ - `--[no-]canary` toggles the proof-of-life pre-flight check (see above).
114
142
 
115
143
  [rails-handle-interrupt]: https://github.com/rails/rails/blob/main/activesupport/lib/active_support/concurrency/thread_monitor.rb
@@ -86,11 +86,16 @@ class Evilution::CLI::Parser::OptionsBuilder
86
86
  "Use --no-incremental to override `incremental: true` from the config file for one run.") do |v|
87
87
  @options[:incremental] = v
88
88
  end
89
+ opts.on("--[no-]canary",
90
+ "Run a proof-of-life synthetic mutation at session start; abort if " \
91
+ "the pipeline misreports it (default: enabled). Use --no-canary to skip.") do |v|
92
+ @options[:canary] = v
93
+ end
89
94
  opts.on("--stdin", "Read target file paths from stdin (one per line)") { @options[:stdin] = true }
90
95
  end
91
96
 
92
97
  def add_runner_mode_options(opts)
93
- opts.on("--integration NAME", "Test integration: rspec, minitest (default: rspec)") { |i| @options[:integration] = i }
98
+ opts.on("--integration NAME", "Test integration: rspec, minitest, test-unit (default: rspec)") { |i| @options[:integration] = i }
94
99
  opts.on("--isolation STRATEGY", "Isolation: auto, fork, in_process (default: auto)") { |s| @options[:isolation] = s }
95
100
  opts.on("--preload FILE", "Preload FILE in the parent process before forking " \
96
101
  "(default: auto-detect spec/rails_helper.rb -> spec/spec_helper.rb -> " \
@@ -3,9 +3,13 @@
3
3
  require_relative "base"
4
4
 
5
5
  class Evilution::Config::Validators::Integration < Evilution::Config::Validators::Base
6
- ALLOWED = %i[rspec minitest].freeze
6
+ ALLOWED = %i[rspec minitest test_unit].freeze
7
7
 
8
+ # CLI users naturally write the gem name `test-unit`; the internal symbol
9
+ # uses underscore form to match the file path and registry key. Normalize
10
+ # hyphenated string input before coercion.
8
11
  def self.call(value)
12
+ value = value.tr("-", "_") if value.is_a?(String)
9
13
  coerce_symbol!(value, allowed: ALLOWED, name: "integration")
10
14
  end
11
15
  end
@@ -20,7 +20,7 @@ class Evilution::Config
20
20
  example_targeting_fallback: :full_file,
21
21
  example_targeting_cache: { max_files: 50, max_blocks: 10_000 },
22
22
  quiet_children: false, quiet_children_dir: "tmp/evilution_children",
23
- profile: :default
23
+ profile: :default, canary: true
24
24
  }.freeze
25
25
 
26
26
  attr_reader :target_files, :schema_version, :timeout, :format,
@@ -31,7 +31,7 @@ class Evilution::Config
31
31
  :skip_heredoc_literals, :related_specs_heuristic,
32
32
  :fallback_to_full_suite, :preload, :spec_mappings, :spec_pattern,
33
33
  :example_targeting, :example_targeting_fallback, :example_targeting_cache,
34
- :spec_selector, :quiet_children, :quiet_children_dir, :profile
34
+ :spec_selector, :quiet_children, :quiet_children_dir, :profile, :canary
35
35
 
36
36
  def initialize(**options)
37
37
  skip_file = options.delete(:skip_config_file) ? true : false
@@ -68,6 +68,10 @@ class Evilution::Config
68
68
  baseline
69
69
  end
70
70
 
71
+ def canary?
72
+ canary
73
+ end
74
+
71
75
  def incremental?
72
76
  incremental
73
77
  end
@@ -130,7 +134,12 @@ class Evilution::Config
130
134
  # Minimum mutation score to pass (0.0 to 1.0, default: 0.0)
131
135
  # min_score: 0.0
132
136
 
133
- # Test integration: rspec, minitest (default: rspec)
137
+ # Proof-of-life canary: run a synthetic, guaranteed-unobservable
138
+ # mutation at session start and abort if the pipeline misreports it
139
+ # (default: true). Set false to skip (e.g. for CI speed).
140
+ # canary: true
141
+
142
+ # Test integration: rspec, minitest, test_unit (default: rspec)
134
143
  # integration: rspec
135
144
 
136
145
  # Number of parallel workers (default: 1)
@@ -237,7 +246,8 @@ class Evilution::Config
237
246
  related_specs_heuristic: nil,
238
247
  fallback_to_full_suite: nil,
239
248
  quiet_children: nil,
240
- quiet_children_dir: nil
249
+ quiet_children_dir: nil,
250
+ canary: nil
241
251
  }.freeze
242
252
  private_constant :SIMPLE_ATTR_TRANSFORMS
243
253
 
@@ -56,21 +56,29 @@ class Evilution::Integration::Loading::MutationApplier
56
56
  def apply(mutation, eval_target)
57
57
  @constant_pinner.call(mutation.original_source)
58
58
  @concern_state_cleaner.call(mutation.file_path)
59
+ mark_feature_loaded(mutation.file_path)
59
60
  @redefinition_recovery.call(mutation.original_source) do
60
61
  @source_evaluator.call(eval_target, mutation.file_path)
61
62
  end
62
- mark_feature_loaded(mutation.file_path)
63
63
  end
64
64
 
65
65
  # The mutated source is eval'd straight into the VM — `eval` does not register
66
- # a `$LOADED_FEATURES` entry. If the spec then `require`s the same file (common
67
- # when the project lazy-loads it and only the test references it), `require`
68
- # reloads the ORIGINAL from disk and clobbers the mutation, so every mutation
69
- # silently survives. Under fork isolation each worker starts from the same
70
- # pre-`require` snapshot, so the whole file scores 0%. Registering the
71
- # canonical path makes a later `require`/`require_relative` a no-op.
66
+ # a `$LOADED_FEATURES` entry. Any later `require`/`require_relative` of the
67
+ # same file then reloads the ORIGINAL from disk and clobbers the mutation, so
68
+ # every mutation silently survives. Two paths trigger that reload:
69
+ # - the spec `require`s the file (it lazy-loads it and only the test
70
+ # references it);
71
+ # - the mutated source's OWN body `require_relative`s a sibling whose body
72
+ # `require_relative`s this file back (e.g. lib/evilution/mcp/*.rb tools).
73
+ # The second reload happens DURING the eval, so registration must precede it:
74
+ # `mark_feature_loaded` runs before `@source_evaluator.call`, not after. Under
75
+ # fork isolation each worker starts from the same pre-`require` snapshot, so
76
+ # without this the whole file scores 0%.
72
77
  def mark_feature_loaded(file_path)
73
- absolute = File.realpath(File.expand_path(file_path))
78
+ # When the isolator has chdir'd into a per-mutation sandbox (EV-wqxu /
79
+ # GH #1278), anchor against PROJECT_ROOT so File.realpath does not chase
80
+ # file_path into a non-existent /tmp path.
81
+ absolute = File.realpath(File.expand_path(file_path, Evilution.project_base_dir))
74
82
  $LOADED_FEATURES << absolute unless $LOADED_FEATURES.include?(absolute)
75
83
  rescue Errno::ENOENT
76
84
  nil
@@ -13,7 +13,10 @@ require_relative "../loading"
13
13
  # substitute the mutated bytes — the privilege level is identical.
14
14
  class Evilution::Integration::Loading::SourceEvaluator
15
15
  def call(source, file_path)
16
- absolute = File.expand_path(file_path)
16
+ # When the isolator has chdir'd into a per-mutation sandbox (EV-wqxu /
17
+ # GH #1278), anchor the eval __FILE__ against PROJECT_ROOT so siblings
18
+ # `require_relative` can find each other from the real source tree.
19
+ absolute = File.expand_path(file_path, Evilution.project_base_dir)
17
20
  eval(source, TOPLEVEL_BINDING, absolute, 1)
18
21
  end
19
22
  end
@@ -128,7 +128,7 @@ class Evilution::Integration::Minitest < Evilution::Integration::Base
128
128
  end
129
129
 
130
130
  def execute_minitest(mutation, files, command)
131
- files.each { |f| load(File.expand_path(f)) }
131
+ files.each { |f| load(File.expand_path(f, Evilution.project_base_dir)) }
132
132
 
133
133
  detector = reset_crash_detector
134
134
  run = run_minitest(build_args(mutation), detector)
@@ -5,7 +5,9 @@ require_relative "../rspec"
5
5
  class Evilution::Integration::RSpec::BaselineRunner
6
6
  def call(spec_file)
7
7
  require "rspec/core"
8
- spec_dir = File.expand_path("spec")
8
+ # Anchor against PROJECT_ROOT under EV-wqxu sandbox CWD; see
9
+ # FrameworkLoader#add_spec_load_path for rationale.
10
+ spec_dir = File.expand_path("spec", Evilution.project_base_dir)
9
11
  $LOAD_PATH.unshift(spec_dir) unless $LOAD_PATH.include?(spec_dir)
10
12
  ::RSpec.reset
11
13
  status = ::RSpec::Core::Runner.run(
@@ -22,7 +22,11 @@ class Evilution::Integration::RSpec::FrameworkLoader
22
22
  private
23
23
 
24
24
  def add_spec_load_path
25
- spec_dir = File.expand_path("spec")
25
+ # Anchor against PROJECT_ROOT inside an isolated worker (EV-wqxu /
26
+ # GH #1278) so the project's spec/ dir lands on $LOAD_PATH — otherwise
27
+ # `require "spec_helper"` resolves to a non-existent sandbox/spec and
28
+ # every mutation errors as "loaded 0 examples".
29
+ spec_dir = File.expand_path("spec", Evilution.project_base_dir)
26
30
  $LOAD_PATH.unshift(spec_dir) unless $LOAD_PATH.include?(spec_dir)
27
31
  end
28
32
  end
@@ -70,13 +70,50 @@ class Evilution::Integration::RSpec < Evilution::Integration::Base
70
70
  targets = @example_filter_applier.call(mutation, files)
71
71
  return @result_builder.unresolved_example(mutation) if targets.nil?
72
72
 
73
- args = ["--format", "progress", "--no-color", "--order", "defined", *targets]
73
+ args = ["--format", "progress", "--no-color", "--order", "defined", *resolve_targets(targets)]
74
74
  command = "rspec #{args.join(" ")}"
75
75
 
76
76
  reset_examples
77
77
  execute_run(args, command)
78
78
  end
79
79
 
80
+ # Targets are passed straight through when they resolve against Dir.pwd,
81
+ # preserving the relative-path contract for callers/tests. When the CWD
82
+ # cannot find them — workers chdir'd into a per-mutation sandbox by the
83
+ # isolator (EV-wqxu / GH #1278) — the targets are expanded against
84
+ # Evilution::PROJECT_ROOT so RSpec::Core::Runner can still load the files.
85
+ def resolve_targets(targets)
86
+ targets.map { |target| resolve_target(target) }
87
+ end
88
+
89
+ # Splits a target into (path, line). RSpec example locations end in
90
+ # `:LINE` (or `:LINE:LINE...` for nested groups), so the suffix is
91
+ # anchored to the END of the string — that way path components which
92
+ # themselves contain colons (e.g. a Windows-style `C:/proj/spec/x.rb`)
93
+ # are not mis-split by a naive `partition(":")`.
94
+ TARGET_LINE_SUFFIX = /\A(.+?)(:\d+(?::\d+)*)\z/
95
+ private_constant :TARGET_LINE_SUFFIX
96
+
97
+ def resolve_target(target)
98
+ return target if target.start_with?("/")
99
+
100
+ if (match = TARGET_LINE_SUFFIX.match(target))
101
+ path = match[1]
102
+ line_suffix = match[2]
103
+ else
104
+ path = target
105
+ line_suffix = ""
106
+ end
107
+
108
+ return target if File.exist?(path)
109
+ return target unless Evilution.in_isolated_worker?
110
+
111
+ absolute = File.expand_path(path, Evilution::PROJECT_ROOT)
112
+ return target unless File.exist?(absolute)
113
+
114
+ "#{absolute}#{line_suffix}"
115
+ end
116
+
80
117
  def execute_run(args, command)
81
118
  detector = @crash_detector_lifecycle.current
82
119
  snapshot = @state_guard.snapshot
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "stringio"
4
+ require_relative "../test_unit"
5
+
6
+ # Builds a Test::Unit::TestSuite from a list of TestCase subclasses and runs
7
+ # it via the console runner with output captured to a StringIO. Owning this
8
+ # responsibility separately keeps the runner library require + suite assembly
9
+ # in one place — used by both the baseline path (Evilution::Integration::TestUnit
10
+ # .run_baseline_test_file) and the per-mutation path (#run_tests).
11
+ module Evilution::Integration::TestUnit::Dispatcher
12
+ module_function
13
+
14
+ def call(test_case_classes, name: "evilution")
15
+ require "test/unit/ui/console/testrunner"
16
+ suite = ::Test::Unit::TestSuite.new(name)
17
+ test_case_classes.each { |klass| suite << klass.suite }
18
+ out = StringIO.new
19
+ runner = ::Test::Unit::UI::Console::TestRunner.new(suite, output: out)
20
+ runner.start
21
+ end
22
+
23
+ def test_method_count(test_case_classes)
24
+ test_case_classes.sum { |klass| klass.suite.tests.length }
25
+ end
26
+ end