evilution 0.26.0 → 0.27.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 (115) hide show
  1. checksums.yaml +4 -4
  2. data/.beads/interactions.jsonl +10 -0
  3. data/.rubocop_todo.yml +7 -0
  4. data/CHANGELOG.md +22 -0
  5. data/README.md +57 -3
  6. data/lib/evilution/cache.rb +2 -0
  7. data/lib/evilution/child_output.rb +24 -0
  8. data/lib/evilution/cli/commands/run.rb +9 -0
  9. data/lib/evilution/cli/commands/version.rb +2 -0
  10. data/lib/evilution/cli/parser/options_builder.rb +16 -2
  11. data/lib/evilution/config/builders/spec_resolver.rb +15 -0
  12. data/lib/evilution/config/builders/spec_selector.rb +16 -0
  13. data/lib/evilution/config/builders.rb +4 -0
  14. data/lib/evilution/config/env_loader.rb +12 -0
  15. data/lib/evilution/config/file_loader.rb +22 -0
  16. data/lib/evilution/config/sources.rb +14 -0
  17. data/lib/evilution/config/validators/base.rb +37 -0
  18. data/lib/evilution/config/validators/example_targeting_cache.rb +37 -0
  19. data/lib/evilution/config/validators/example_targeting_fallback.rb +22 -0
  20. data/lib/evilution/config/validators/fail_fast.rb +11 -0
  21. data/lib/evilution/config/validators/hooks.rb +12 -0
  22. data/lib/evilution/config/validators/ignore_patterns.rb +16 -0
  23. data/lib/evilution/config/validators/integration.rb +11 -0
  24. data/lib/evilution/config/validators/isolation.rb +19 -0
  25. data/lib/evilution/config/validators/jobs.rb +9 -0
  26. data/lib/evilution/config/validators/preload.rb +13 -0
  27. data/lib/evilution/config/validators/spec_mappings.rb +56 -0
  28. data/lib/evilution/config/validators/spec_pattern.rb +12 -0
  29. data/lib/evilution/config/validators.rb +4 -0
  30. data/lib/evilution/config.rb +78 -268
  31. data/lib/evilution/feedback/detector.rb +15 -0
  32. data/lib/evilution/feedback/messages.rb +42 -0
  33. data/lib/evilution/feedback.rb +5 -0
  34. data/lib/evilution/integration/rspec/baseline_runner.rb +16 -0
  35. data/lib/evilution/integration/rspec/crash_detector_lifecycle.rb +17 -0
  36. data/lib/evilution/integration/rspec/example_filter_applier.rb +21 -0
  37. data/lib/evilution/integration/rspec/framework_loader.rb +28 -0
  38. data/lib/evilution/integration/rspec/result_builder.rb +40 -0
  39. data/lib/evilution/integration/rspec/state_guard/example_groups_constants.rb +28 -0
  40. data/lib/evilution/integration/rspec/state_guard/internals.rb +19 -0
  41. data/lib/evilution/integration/rspec/state_guard/object_space_example_groups.rb +35 -0
  42. data/lib/evilution/integration/rspec/state_guard/reporter_arrays.rb +32 -0
  43. data/lib/evilution/integration/rspec/state_guard/world_example_groups.rb +20 -0
  44. data/lib/evilution/integration/rspec/state_guard/world_filtered_examples.rb +20 -0
  45. data/lib/evilution/integration/rspec/state_guard/world_sources_by_path.rb +20 -0
  46. data/lib/evilution/integration/rspec/state_guard.rb +40 -0
  47. data/lib/evilution/integration/rspec/test_file_resolver.rb +30 -0
  48. data/lib/evilution/integration/rspec/unresolved_spec_warner.rb +18 -0
  49. data/lib/evilution/integration/rspec.rb +61 -232
  50. data/lib/evilution/isolation/fork.rb +7 -2
  51. data/lib/evilution/mcp/info_tool/actions/base.rb +22 -0
  52. data/lib/evilution/mcp/info_tool/actions/environment.rb +42 -0
  53. data/lib/evilution/mcp/info_tool/actions/feedback.rb +16 -0
  54. data/lib/evilution/mcp/info_tool/actions/statuses.rb +10 -0
  55. data/lib/evilution/mcp/info_tool/actions/subjects.rb +47 -0
  56. data/lib/evilution/mcp/info_tool/actions/tests.rb +60 -0
  57. data/lib/evilution/mcp/info_tool/actions.rb +16 -0
  58. data/lib/evilution/mcp/info_tool/config_factory.rb +24 -0
  59. data/lib/evilution/mcp/info_tool/error_mapper.rb +15 -0
  60. data/lib/evilution/mcp/info_tool/request_parser.rb +34 -0
  61. data/lib/evilution/mcp/info_tool/response_formatter.rb +24 -0
  62. data/lib/evilution/mcp/info_tool/status_glossary.rb +75 -0
  63. data/lib/evilution/mcp/info_tool.rb +43 -261
  64. data/lib/evilution/mcp/mutate_tool/error_payload.rb +8 -1
  65. data/lib/evilution/mcp/mutate_tool/report_trimmer.rb +13 -1
  66. data/lib/evilution/mcp/mutate_tool.rb +5 -2
  67. data/lib/evilution/mutator/operator/block_removal.rb +1 -1
  68. data/lib/evilution/mutator/operator/method_body_replacement.rb +18 -2
  69. data/lib/evilution/parallel/work_queue/channel/frame.rb +21 -0
  70. data/lib/evilution/parallel/work_queue/channel.rb +23 -0
  71. data/lib/evilution/parallel/work_queue/collection_state.rb +14 -0
  72. data/lib/evilution/parallel/work_queue/dispatcher.rb +133 -0
  73. data/lib/evilution/parallel/work_queue/validators/optional_positive_int.rb +11 -0
  74. data/lib/evilution/parallel/work_queue/validators/optional_positive_number.rb +11 -0
  75. data/lib/evilution/parallel/work_queue/validators/positive_int.rb +11 -0
  76. data/lib/evilution/parallel/work_queue/validators.rb +6 -0
  77. data/lib/evilution/parallel/work_queue/worker/loop.rb +45 -0
  78. data/lib/evilution/parallel/work_queue/worker.rb +114 -0
  79. data/lib/evilution/parallel/work_queue/worker_stat.rb +17 -0
  80. data/lib/evilution/parallel/work_queue.rb +42 -327
  81. data/lib/evilution/reporter/cli/item_formatters/coverage_gap.rb +18 -0
  82. data/lib/evilution/reporter/cli/item_formatters/disabled.rb +9 -0
  83. data/lib/evilution/reporter/cli/item_formatters/error.rb +14 -0
  84. data/lib/evilution/reporter/cli/item_formatters/result_location.rb +10 -0
  85. data/lib/evilution/reporter/cli/item_formatters.rb +6 -0
  86. data/lib/evilution/reporter/cli/line_formatters/duration.rb +9 -0
  87. data/lib/evilution/reporter/cli/line_formatters/efficiency.rb +18 -0
  88. data/lib/evilution/reporter/cli/line_formatters/feedback_footer.rb +13 -0
  89. data/lib/evilution/reporter/cli/line_formatters/header.rb +10 -0
  90. data/lib/evilution/reporter/cli/line_formatters/mutations.rb +16 -0
  91. data/lib/evilution/reporter/cli/line_formatters/peak_memory.rb +12 -0
  92. data/lib/evilution/reporter/cli/line_formatters/result_line.rb +20 -0
  93. data/lib/evilution/reporter/cli/line_formatters/score.rb +14 -0
  94. data/lib/evilution/reporter/cli/line_formatters/truncation_notice.rb +11 -0
  95. data/lib/evilution/reporter/cli/line_formatters.rb +6 -0
  96. data/lib/evilution/reporter/cli/metrics_block.rb +26 -0
  97. data/lib/evilution/reporter/cli/pct.rb +9 -0
  98. data/lib/evilution/reporter/cli/section.rb +13 -0
  99. data/lib/evilution/reporter/cli/section_renderer.rb +15 -0
  100. data/lib/evilution/reporter/cli/trailer.rb +22 -0
  101. data/lib/evilution/reporter/cli.rb +79 -162
  102. data/lib/evilution/runner/isolation_resolver.rb +9 -2
  103. data/lib/evilution/runner/mutation_executor/mutation_runner.rb +32 -0
  104. data/lib/evilution/runner/mutation_executor/neutralization_pipeline.rb +16 -0
  105. data/lib/evilution/runner/mutation_executor/neutralizer/baseline_failed.rb +46 -0
  106. data/lib/evilution/runner/mutation_executor/neutralizer/infra_error.rb +75 -0
  107. data/lib/evilution/runner/mutation_executor/result_cache.rb +69 -0
  108. data/lib/evilution/runner/mutation_executor/result_notifier.rb +48 -0
  109. data/lib/evilution/runner/mutation_executor/result_packer.rb +39 -0
  110. data/lib/evilution/runner/mutation_executor/strategy/parallel.rb +80 -0
  111. data/lib/evilution/runner/mutation_executor/strategy/sequential.rb +34 -0
  112. data/lib/evilution/runner/mutation_executor.rb +58 -289
  113. data/lib/evilution/runner.rb +21 -0
  114. data/lib/evilution/version.rb +1 -1
  115. metadata +113 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3b561b61697995a22463ea15828357375b1ce9868ad6ee87f3f331336d26b890
4
- data.tar.gz: 4a0cb24bfe2e9e9478ab5d752f05ea4ea69582e324e736241f1fe6b1e5e581cd
3
+ metadata.gz: 12d485d3cce9569229a95a1e8f29403fbb11f8f067e0c438256919521f6d82dc
4
+ data.tar.gz: 11a1adeca7c61fa905757137d6a737c6e389eae584b37fca91a8c9eed57926d0
5
5
  SHA512:
6
- metadata.gz: 416aaea92d50fc3a969c350fd36d47e5b8b625ba4b1c64b66f208731cfaf1250714b8bc8e494bbc1586922357886dd96026f31e9f96b7e2cd849e43f402de3e3
7
- data.tar.gz: e81fc9c5edfa9e25b4a7077f2776c2256bf5211bc5d88b53cc604ef735d64366ee3b3a294a480f49d3107c7634ff98459f6f46e0a8ede3248cec2b72007f940e
6
+ metadata.gz: ce9208fa3f3ed3160d2844cdd5e1479cf7b2fa90985371cb95d6978177f371d38ec4bdccf473f79af318cd6c617fda2b1962efe4f389eab1912ab348cc48b475
7
+ data.tar.gz: 57ff4d971829430d5d47525ac0bc5e488b326e6916657aaf9acb3faf0e8795e909523ec8238b6b9fbaace13bdd60c0f437f30d94efbfa82861b42da802a54aac
@@ -235,3 +235,13 @@
235
235
  {"id":"int-2abceed3","kind":"field_change","created_at":"2026-04-24T05:18:13.096916772Z","actor":"Denis Kiselev","issue_id":"EV-kjac","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Closed"}}
236
236
  {"id":"int-03bc2f7f","kind":"field_change","created_at":"2026-04-24T05:40:10.83959826Z","actor":"Denis Kiselev","issue_id":"EV-hklf","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Closed"}}
237
237
  {"id":"int-2781044c","kind":"field_change","created_at":"2026-04-24T05:40:11.975030418Z","actor":"Denis Kiselev","issue_id":"EV-cpku","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Folded into EV-hklf — error rename 'no method found' → 'no subject matched' shipped in PR #872."}}
238
+ {"id":"int-3fd6fda2","kind":"field_change","created_at":"2026-04-24T17:15:49.060178577Z","actor":"Denis Kiselev","issue_id":"EV-3ew5","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"PR #884 merged to master"}}
239
+ {"id":"int-95f8d2f1","kind":"field_change","created_at":"2026-04-25T14:49:14.844074734Z","actor":"Denis Kiselev","issue_id":"EV-owgh","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Merged via PR #890"}}
240
+ {"id":"int-d786895b","kind":"field_change","created_at":"2026-04-25T15:46:53.977607499Z","actor":"Denis Kiselev","issue_id":"EV-a6de","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Merged via PR #891"}}
241
+ {"id":"int-a297634a","kind":"field_change","created_at":"2026-04-25T16:56:08.483381206Z","actor":"Denis Kiselev","issue_id":"EV-ilu3","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Merged via PR #892"}}
242
+ {"id":"int-0647d42f","kind":"field_change","created_at":"2026-04-25T17:06:39.083006592Z","actor":"Denis Kiselev","issue_id":"EV-aa4x","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Merged via PR #893"}}
243
+ {"id":"int-86ea8eca","kind":"field_change","created_at":"2026-04-25T17:28:36.516431743Z","actor":"Denis Kiselev","issue_id":"EV-6x6g","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Merged via PR #894"}}
244
+ {"id":"int-811fef5f","kind":"field_change","created_at":"2026-04-25T17:39:31.325092593Z","actor":"Denis Kiselev","issue_id":"EV-6c51","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Merged via PR #895"}}
245
+ {"id":"int-9d182026","kind":"field_change","created_at":"2026-04-25T17:54:26.23542935Z","actor":"Denis Kiselev","issue_id":"EV-67yh","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Merged via PR #897"}}
246
+ {"id":"int-7ede49f5","kind":"field_change","created_at":"2026-04-25T18:16:18.358350457Z","actor":"Denis Kiselev","issue_id":"EV-zvhp","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Merged via PR #898"}}
247
+ {"id":"int-c28f7ee0","kind":"field_change","created_at":"2026-04-26T02:28:38.321564801Z","actor":"Denis Kiselev","issue_id":"EV-vev8","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Merged via PR #899"}}
data/.rubocop_todo.yml ADDED
@@ -0,0 +1,7 @@
1
+ # This file lists per-file metric exclusions that should eventually be paid down
2
+ # (refactor the file rather than expanding the exclude list). Inherited from .rubocop.yml.
3
+
4
+ Metrics/AbcSize:
5
+ Exclude:
6
+ - "lib/evilution/config.rb"
7
+ - "lib/evilution/runner.rb"
data/CHANGELOG.md CHANGED
@@ -1,5 +1,27 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.27.0] - 2026-04-26
4
+
5
+ ### Added
6
+
7
+ - **`prism` declared as a runtime dependency (`>= 1.5, < 2`)** — Rails 7.1 stacks pin `prism 0.19` (which lacks `IfNode#subsequent`), causing a `NoMethodError` on the first `if` evilution mutated. Bundler now refuses incompatible prism versions at install time instead of crashing at runtime (#876, PR #891)
8
+ - **`--[no-]incremental` CLI flag** — `--no-incremental` overrides `incremental: true` from the config file for one invocation (cold-cache debugging, CI escape hatch). Last flag wins when both forms are given (#878, PR #897)
9
+ - **`--quiet-children` and `--quiet-children-dir DIR` flags** — redirect each forked worker's stdout/stderr to per-pid files under `tmp/evilution_children/<pid>.{out,err}` (configurable). Keeps parent output clean when app initializers (Datadog, Bullet, etc.) emit warnings on every fork. Trade-off: live worker errors only appear in the side files (`tail -f tmp/evilution_children/*.err`) (#880, PR #899)
10
+ - **Preload autodetect chain extended** — `Runner::IsolationResolver` now probes `spec/rails_helper.rb` → `spec/spec_helper.rb` → `test/test_helper.rb` (was: rails_helper + test_helper only). Rails projects that consolidate everything into `spec/spec_helper.rb` no longer need an explicit `preload:` setting. When the chain finds nothing under a Rails project, raises a `ConfigError` listing every path tried and pointing at `--preload` / `--no-preload` (#879, PR #898)
11
+ - **`evilution version` prints the bundled `mcp` gem version** — second line shows `mcp gem X.Y.Z (server compatibility)`. Run inside the same bundle the MCP server uses to confirm what's loaded after a `bundle update` (#883, PR #894)
12
+ - **Public feedback channel exposed across CLI and MCP surfaces** — when a run hits friction (`errored`, `unparseable`, or `unresolved` buckets > 0; baseline failure; MCP error response), evilution surfaces a single GitHub Discussions URL so agents can suggest filing feedback. CLI text reports gain a one-line footer; the CLI error-exit path emits the same line on stderr (both suppressed by `--quiet`, `--format=json`, `--format=html`). MCP `evilution-mutate` responses embed `feedback_url` + `feedback_hint` on friction (and always on error payloads); the `minimal` verbosity contract is preserved (no extra keys). New MCP `evilution-info action=feedback` returns `{ discussion_url, version, guidance_for_agent }` on demand for any feedback intent including missing-capability requests on clean runs. Tool descriptions and the README MCP "Feedback channel" subsection prominently document the **explicit user-consent gate** (agents must never post on the user's behalf without explicit approval) and **privacy expectations** (never include secrets, env vars, project name, file paths, source code, or class/method names from user code — the channel is public). By construction, no run-derived data is embedded in any feedback field — agent + user compose any actual payload themselves (#900, PR #901)
13
+
14
+ ### Fixed
15
+
16
+ - **`Cache#store` crashed with `TypeError: no implicit conversion of nil into String` when `incremental: true` and `jobs >= 2`** — `Strategy::Parallel#run_batch` called `batch.each(&:strip_sources!)` before `@cache.store`, leaving `mutation.original_source = nil` for `Digest::SHA256.hexdigest`. Reordered so store runs before strip; added a defensive nil-source guard in `Cache#store` mirroring the existing one in `Cache#fetch` (#875, PR #890)
17
+ - **`block_removal` produced unparseable mutations on block-pass arguments (`map!(&:sym)`, `index_by(&:id)`, `flat_map(&block)`)** — operator stripped the `BlockArgumentNode` from inside the call's parens, leaving a dangling open paren. Operator now skips emission when `node.block.is_a?(Prism::BlockArgumentNode)`; explicit `{}` / `do..end` blocks are unaffected (#881, PR #895)
18
+ - **`method_body_replacement` errored at runtime when generating the `super`-replacement on methods whose enclosing class had no parent implementation** — `super`-replacement now only emitted when the original body already calls `super` (`SuperNode` or `ForwardingSuperNode`), using that as a heuristic that a super target exists in this context. Methods without `super` get only the `nil` and `self` replacements (#877, PR #892)
19
+
20
+ ### Documentation
21
+
22
+ - **README "Installing on Rails 7.1 + Ruby 3.3" section** — covers the `cgi 0.5.0` (Rails-pinned) vs `cgi 0.5.1` (Ruby 3.3 default-gem) Bundler activation conflict, the sidecar `Gemfile.local` workaround (`eval_gemfile("Gemfile")` + add evilution + prism), the `BUNDLE_GEMFILE=Gemfile.local` invocation, and guidance on whether to commit or `.gitignore` the resulting `Gemfile.local.lock` (#882, PR #893)
23
+ - **README MCP "After upgrading the gem: restart the MCP server" subsection** — explains that the MCP server is a long-lived stdio process the agent host spawns; `bundle update evilution` swaps the gem on disk but the running process keeps the old code in memory until restart. Symptom is opaque "Internal error" responses to flags the old build doesn't recognize (#883, PR #894)
24
+
3
25
  ## [0.26.0] - 2026-04-24
4
26
 
5
27
  ### Removed
data/README.md CHANGED
@@ -21,6 +21,44 @@ Then: `bundle install`
21
21
 
22
22
  Or standalone: `gem install evilution`
23
23
 
24
+ Requires `prism >= 1.5, < 2`. Older Rails apps (e.g. Rails 7.1 pins `prism 0.19`) must upgrade prism — the gemspec constraint forces bundler to resolve a compatible 1.x version. If your app pins `prism 2.x`, bundler will reject the install until evilution widens its upper bound.
25
+
26
+ ### Installing on Rails 7.1 + Ruby 3.3
27
+
28
+ Two Bundler conflicts hit fresh installs on this stack:
29
+
30
+ 1. **`cgi` activation conflict.** Rails 7.1's `Gemfile.lock` pins `cgi 0.5.0`. Ruby 3.3.x ships `cgi 0.5.1` as a default gem. Loading evilution via Bundler aborts with:
31
+
32
+ ```
33
+ Gem::LoadError: can't activate cgi-0.5.1, already activated cgi-0.5.0.
34
+ Make sure all dependencies are added to Gemfile.
35
+ ```
36
+
37
+ 2. **`prism` pin.** Same lockfile pins `prism 0.19`, which lacks the `IfNode#subsequent` accessor evilution uses (older Prism releases exposed it as `consequent`). Symptom: `NoMethodError: undefined method 'subsequent' for an instance of Prism::IfNode`.
38
+
39
+ Both resolve cleanly with a sidecar `Gemfile.local` that re-evaluates the project Gemfile and adds evilution + prism on top — no edits to the main `Gemfile.lock`:
40
+
41
+ ```ruby
42
+ # Gemfile.local
43
+ eval_gemfile("Gemfile")
44
+
45
+ group :test, :development do
46
+ gem "evilution"
47
+ gem "prism", "~> 1.5"
48
+ end
49
+ ```
50
+
51
+ Then invoke evilution against that Gemfile:
52
+
53
+ ```sh
54
+ BUNDLE_GEMFILE=Gemfile.local bundle install
55
+ BUNDLE_GEMFILE=Gemfile.local bundle exec evilution run lib/foo.rb
56
+ ```
57
+
58
+ The first command writes a sibling `Gemfile.local.lock`. Decide whether to commit or `.gitignore` it the same way you would for any developer-only Gemfile — typically gitignored when only one or two engineers run mutation testing locally, committed when CI also runs evilution against the sidecar Gemfile.
59
+
60
+ The evilution gemspec already declares `prism >= 1.5, < 2`, so adding the `gem "prism"` line above is only necessary on stacks that also pin prism in `Gemfile.lock`.
61
+
24
62
  ## Command Reference
25
63
 
26
64
  ```
@@ -67,11 +105,13 @@ The shorter alias `evil` ships alongside `evilution` and accepts identical argum
67
105
  | `-q`, `--quiet` | Boolean | false | Suppress output. |
68
106
  | `--stdin` | Boolean | false | Read target file paths from stdin (one per line). |
69
107
  | `--integration NAME` | String | `rspec` | Test framework integration: `rspec` or `minitest`. |
70
- | `--incremental` | Boolean | false | Cache killed/timeout results; skip unchanged mutations on re-runs. |
108
+ | `--[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. |
71
109
  | `--save-session` | Boolean | false | Persist results as timestamped JSON under `.evilution/results/`. |
72
110
  | `--no-progress` | Boolean | _(enabled)_ | Disable the TTY progress bar. |
111
+ | `--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. |
112
+ | `--quiet-children-dir DIR` | String | `tmp/evilution_children` | Override the directory used by `--quiet-children`. |
73
113
  | `--isolation MODE` | String | `auto` | Isolation strategy: `auto`, `fork`, or `in_process`. `auto` selects `fork` for Rails projects. See [docs/isolation.md](docs/isolation.md). |
74
- | `--preload FILE` | String | _(auto)_ | File to require in parent before forking workers (e.g. `spec/rails_helper.rb`). Auto-detected for Rails. |
114
+ | `--preload FILE` | String | _(auto)_ | File to require in parent before forking workers. Auto-detect chain for Rails projects: `spec/rails_helper.rb` → `spec/spec_helper.rb` → `test/test_helper.rb`. Errors with the full chain listed if none exist; pass `--no-preload` to opt out. |
75
115
  | `--no-preload` | Boolean | _(enabled)_ | Disable parent-process preload. |
76
116
  | `--skip-heredoc-literals` | Boolean | false | Skip all string literal mutations inside heredocs. |
77
117
  | `--show-disabled` | Boolean | false | Report mutations skipped by `# evilution:disable` comments. |
@@ -323,7 +363,7 @@ The server exposes the following tools:
323
363
  |---|---|
324
364
  | `evilution-mutate` | Run mutation testing on target files with structured JSON results |
325
365
  | `evilution-session` | Inspect mutation testing history — `action: list` browses saved sessions, `action: show` displays one, `action: diff` compares two (fixed/new/persistent survivors, score delta) |
326
- | `evilution-info` | Discovery before mutation — `action: subjects` lists mutatable methods with mutation counts, `action: tests` resolves which specs cover given sources, `action: environment` dumps the effective config |
366
+ | `evilution-info` | Discovery before mutation — `action: subjects` lists mutatable methods with mutation counts, `action: tests` resolves which specs cover given sources, `action: environment` dumps the effective config, `action: statuses` returns the mutation-result status glossary, `action: feedback` returns the public Discussions URL plus consent + privacy guidance for posting feedback |
327
367
 
328
368
  ### Verbosity Control
329
369
 
@@ -375,6 +415,20 @@ Pass `skip_config: true` to ignore the project config file. This skips loading `
375
415
 
376
416
  > **Note**: `.mcp.json` is gitignored by default since it is a local editor/agent configuration file.
377
417
 
418
+ ### After upgrading the gem: restart the MCP server
419
+
420
+ The MCP server is a long-lived stdio process spawned by the agent host. `bundle update evilution` swaps the gem on disk but the running process keeps the old code in memory — symptom is opaque "Internal error" responses to flags or shapes the old build doesn't recognize. Restart the server (reload the workspace in Claude Code / Copilot / etc.) so the new gem loads.
421
+
422
+ `evilution version` prints the gem version and the bundled `mcp` gem version on separate lines — run it in the same bundle the MCP server uses to confirm what's loaded.
423
+
424
+ ### Feedback channel
425
+
426
+ When evilution causes friction (errors, usage problems, missing capabilities you wish were there), the MCP responses include a `feedback_url` plus `feedback_hint`. The `evilution-info` tool also exposes `action=feedback`, which returns the channel URL and posting guidance on demand.
427
+
428
+ **Agents must never post on the user's behalf without explicit user permission.** Show the user exactly what you would post, get explicit approval, then post. Never include secrets, environment variables, the project name, file paths, source code, or class/method names from user code — the feedback channel is public.
429
+
430
+ Discussion URL: <https://github.com/marinazzio/evilution/discussions>
431
+
378
432
  ## Recommended Workflows for AI Agents
379
433
 
380
434
  ### 1. Full project scan
@@ -27,6 +27,8 @@ class Evilution::Cache
27
27
  end
28
28
 
29
29
  def store(mutation, result_data)
30
+ return if mutation.original_source.nil?
31
+
30
32
  file_key = file_key(mutation)
31
33
  entry_key = entry_key(mutation)
32
34
  data = read_file(file_key) || {}
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../evilution"
4
+
5
+ module Evilution::ChildOutput
6
+ module_function
7
+
8
+ class << self
9
+ attr_accessor :log_dir
10
+ end
11
+
12
+ # Per-run truncation happens once in the parent (Runner#configure_child_output);
13
+ # within a run, multiple forks reusing the same PID (pool worker recycle, per-mutation
14
+ # forks) append so cross-fork output isn't lost.
15
+ def redirect!
16
+ return unless log_dir
17
+
18
+ pid = Process.pid
19
+ $stdout.reopen(File.join(log_dir, "#{pid}.out"), "a")
20
+ $stderr.reopen(File.join(log_dir, "#{pid}.err"), "a")
21
+ $stdout.sync = true
22
+ $stderr.sync = true
23
+ end
24
+ end
@@ -10,6 +10,7 @@ require_relative "../../runner"
10
10
  require_relative "../../hooks"
11
11
  require_relative "../../hooks/registry"
12
12
  require_relative "../../hooks/loader"
13
+ require_relative "../../feedback/messages"
13
14
 
14
15
  class Evilution::CLI::Commands::Run < Evilution::CLI::Command
15
16
  def call
@@ -34,10 +35,18 @@ class Evilution::CLI::Commands::Run < Evilution::CLI::Command
34
35
  @stdout.puts(JSON.generate(error_payload(error)))
35
36
  Evilution::CLI::Result.new(exit_code: 2, error: error, error_rendered: true)
36
37
  else
38
+ @stderr.puts(Evilution::Feedback::Messages.cli_footer) unless quiet?(config, file_options)
37
39
  Evilution::CLI::Result.new(exit_code: 2, error: error)
38
40
  end
39
41
  end
40
42
 
43
+ def quiet?(config, file_options)
44
+ return config.quiet unless config.nil?
45
+ return true if @options[:quiet]
46
+
47
+ file_options && file_options[:quiet]
48
+ end
49
+
41
50
  def build_hooks(config)
42
51
  return nil if config.hooks.empty?
43
52
 
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "mcp"
3
4
  require_relative "../commands"
4
5
  require_relative "../command"
5
6
  require_relative "../dispatcher"
@@ -10,6 +11,7 @@ class Evilution::CLI::Commands::Version < Evilution::CLI::Command
10
11
 
11
12
  def perform
12
13
  @stdout.puts(Evilution::VERSION)
14
+ @stdout.puts("mcp gem #{::MCP::VERSION} (server compatibility)")
13
15
  0
14
16
  end
15
17
  end
@@ -70,15 +70,29 @@ class Evilution::CLI::Parser::OptionsBuilder
70
70
  opts.on("--fail-fast", "Stop after N surviving mutants " \
71
71
  "(default: disabled; if provided without N, uses 1; use --fail-fast=N)") { @options[:fail_fast] ||= 1 }
72
72
  opts.on("--no-baseline", "Skip baseline test suite check") { @options[:baseline] = false }
73
- opts.on("--incremental", "Cache killed/timeout results; skip re-running them on unchanged files") { @options[:incremental] = true }
73
+ opts.on("--[no-]incremental",
74
+ "Cache killed/timeout results; skip re-running them on unchanged files. " \
75
+ "Use --no-incremental to override `incremental: true` from the config file for one run.") do |v|
76
+ @options[:incremental] = v
77
+ end
74
78
  opts.on("--integration NAME", "Test integration: rspec, minitest (default: rspec)") { |i| @options[:integration] = i }
75
79
  opts.on("--isolation STRATEGY", "Isolation: auto, fork, in_process (default: auto)") { |s| @options[:isolation] = s }
76
80
  opts.on("--preload FILE", "Preload FILE in the parent process before forking " \
77
- "(default: auto-detect spec/rails_helper.rb for Rails projects)") { |f| @options[:preload] = f }
81
+ "(default: auto-detect spec/rails_helper.rb -> spec/spec_helper.rb -> " \
82
+ "test/test_helper.rb for Rails projects)") { |f| @options[:preload] = f }
78
83
  opts.on("--no-preload", "Disable parent-process preload even for Rails projects") { @options[:preload] = false }
79
84
  opts.on("--stdin", "Read target file paths from stdin (one per line)") { @options[:stdin] = true }
80
85
  opts.on("--suggest-tests", "Generate concrete test code in suggestions (RSpec or Minitest)") { @options[:suggest_tests] = true }
81
86
  opts.on("--no-progress", "Disable progress bar") { @options[:progress] = false }
87
+ opts.on("--quiet-children",
88
+ "Redirect each child process's stdout/stderr to per-pid files under " \
89
+ "tmp/evilution_children (or --quiet-children-dir DIR), keeping parent output clean.") do
90
+ @options[:quiet_children] = true
91
+ end
92
+ opts.on("--quiet-children-dir DIR",
93
+ "Directory for --quiet-children per-pid log files (default: tmp/evilution_children)") do |d|
94
+ @options[:quiet_children_dir] = d
95
+ end
82
96
  end
83
97
 
84
98
  def add_extra_flag_options(opts)
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../builders"
4
+ require_relative "../../spec_resolver"
5
+
6
+ class Evilution::Config::Builders::SpecResolver
7
+ def self.call(integration:)
8
+ case integration
9
+ when :minitest
10
+ Evilution::SpecResolver.new(test_dir: "test", test_suffix: "_test.rb", request_dir: "integration")
11
+ else
12
+ Evilution::SpecResolver.new
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../builders"
4
+ require_relative "spec_resolver"
5
+ require_relative "../../spec_selector"
6
+
7
+ class Evilution::Config::Builders::SpecSelector
8
+ def self.call(spec_files:, spec_mappings:, spec_pattern:, integration:)
9
+ Evilution::SpecSelector.new(
10
+ spec_files: spec_files,
11
+ spec_mappings: spec_mappings,
12
+ spec_pattern: spec_pattern,
13
+ spec_resolver: Evilution::Config::Builders::SpecResolver.call(integration: integration)
14
+ )
15
+ end
16
+ end
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Evilution::Config::Builders
4
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Evilution::Config::EnvLoader
4
+ module_function
5
+
6
+ def load
7
+ opts = {}
8
+ val = ENV.fetch("EV_DISABLE_EXAMPLE_TARGETING", nil)
9
+ opts[:example_targeting] = false if val && !val.empty? && val != "0"
10
+ opts
11
+ end
12
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module Evilution::Config::FileLoader
6
+ module_function
7
+
8
+ def load
9
+ Evilution::Config::CONFIG_FILES.each do |path|
10
+ next unless File.exist?(path)
11
+
12
+ data = YAML.safe_load_file(path, symbolize_names: true)
13
+ return data.is_a?(Hash) ? data : {}
14
+ rescue Psych::SyntaxError, Psych::DisallowedClass => e
15
+ raise Evilution::ConfigError.new("failed to parse config file #{path}: #{e.message}", file: path)
16
+ rescue SystemCallError => e
17
+ raise Evilution::ConfigError.new("cannot read config file #{path}: #{e.message}", file: path)
18
+ end
19
+
20
+ {}
21
+ end
22
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "file_loader"
4
+ require_relative "env_loader"
5
+
6
+ module Evilution::Config::Sources
7
+ module_function
8
+
9
+ def merge(explicit:, skip_file:)
10
+ file = skip_file ? {} : Evilution::Config::FileLoader.load
11
+ env = Evilution::Config::EnvLoader.load
12
+ Evilution::Config::DEFAULTS.merge(file).merge(env).merge(explicit)
13
+ end
14
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../validators"
4
+
5
+ class Evilution::Config::Validators::Base
6
+ def self.call(_value)
7
+ raise NotImplementedError
8
+ end
9
+
10
+ class << self
11
+ private
12
+
13
+ def coerce_symbol!(value, allowed:, name:)
14
+ raise Evilution::ConfigError, "#{name} must be #{allowed.join(" or ")}, got nil" if value.nil?
15
+
16
+ unless value.is_a?(String) || value.is_a?(Symbol)
17
+ raise Evilution::ConfigError, "#{name} must be #{allowed.join(" or ")}, got #{value.inspect}"
18
+ end
19
+
20
+ sym = value.to_sym
21
+ return sym if allowed.include?(sym)
22
+
23
+ raise Evilution::ConfigError, "#{name} must be #{allowed.join(" or ")}, got #{sym.inspect}"
24
+ end
25
+
26
+ def coerce_positive_int!(value, name:)
27
+ raise Evilution::ConfigError, "#{name} must be a positive integer, got #{value.inspect}" if value.is_a?(Float)
28
+
29
+ int = Integer(value)
30
+ raise Evilution::ConfigError, "#{name} must be a positive integer, got #{int}" unless int >= 1
31
+
32
+ int
33
+ rescue ::ArgumentError, ::TypeError
34
+ raise Evilution::ConfigError, "#{name} must be a positive integer, got #{value.inspect}"
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ class Evilution::Config::Validators::ExampleTargetingCache < Evilution::Config::Validators::Base
6
+ def self.call(value)
7
+ raise Evilution::ConfigError, "example_targeting_cache must be a Hash, got #{value.class}" unless value.is_a?(Hash)
8
+
9
+ normalized = normalize_keys(value)
10
+ merged = Evilution::Config::DEFAULTS[:example_targeting_cache].merge(normalized)
11
+ require_positive_int!(merged, :max_files)
12
+ require_positive_int!(merged, :max_blocks)
13
+ merged
14
+ end
15
+
16
+ class << self
17
+ private
18
+
19
+ def normalize_keys(value)
20
+ value.each_with_object({}) do |(k, v), acc|
21
+ unless k.is_a?(String) || k.is_a?(Symbol)
22
+ raise Evilution::ConfigError,
23
+ "example_targeting_cache keys must be Strings or Symbols, got #{k.inspect}"
24
+ end
25
+ acc[k.to_sym] = v
26
+ end
27
+ end
28
+
29
+ def require_positive_int!(cache, key)
30
+ v = cache[key]
31
+ return if v.is_a?(Integer) && v >= 1
32
+
33
+ raise Evilution::ConfigError,
34
+ "example_targeting_cache.#{key} must be a positive integer, got #{v.inspect}"
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ class Evilution::Config::Validators::ExampleTargetingFallback < Evilution::Config::Validators::Base
6
+ FALLBACKS = %i[full_file unresolved].freeze
7
+
8
+ def self.call(value)
9
+ unless value.is_a?(String) || value.is_a?(Symbol)
10
+ raise Evilution::ConfigError,
11
+ "example_targeting_fallback must be full_file or unresolved, got #{value.inspect}"
12
+ end
13
+
14
+ sym = value.to_sym
15
+ unless FALLBACKS.include?(sym)
16
+ raise Evilution::ConfigError,
17
+ "example_targeting_fallback must be full_file or unresolved, got #{sym.inspect}"
18
+ end
19
+
20
+ sym
21
+ end
22
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ class Evilution::Config::Validators::FailFast < Evilution::Config::Validators::Base
6
+ def self.call(value)
7
+ return nil if value.nil?
8
+
9
+ coerce_positive_int!(value, name: "fail_fast")
10
+ end
11
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ class Evilution::Config::Validators::Hooks < Evilution::Config::Validators::Base
6
+ def self.call(value)
7
+ return {} if value.nil?
8
+ raise Evilution::ConfigError, "hooks must be a mapping of event names to file paths, got #{value.class}" unless value.is_a?(Hash)
9
+
10
+ value
11
+ end
12
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ class Evilution::Config::Validators::IgnorePatterns < Evilution::Config::Validators::Base
6
+ def self.call(value)
7
+ patterns = Array(value)
8
+ patterns.each do |pattern|
9
+ unless pattern.is_a?(String)
10
+ raise Evilution::ConfigError,
11
+ "ignore_patterns must be an array of strings, got #{pattern.class} (#{pattern.inspect})"
12
+ end
13
+ end
14
+ patterns
15
+ end
16
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ class Evilution::Config::Validators::Integration < Evilution::Config::Validators::Base
6
+ ALLOWED = %i[rspec minitest].freeze
7
+
8
+ def self.call(value)
9
+ coerce_symbol!(value, allowed: ALLOWED, name: "integration")
10
+ end
11
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ class Evilution::Config::Validators::Isolation < Evilution::Config::Validators::Base
6
+ ALLOWED = %i[auto fork in_process].freeze
7
+ MESSAGE = "isolation must be auto, fork, or in_process"
8
+
9
+ def self.call(value)
10
+ raise Evilution::ConfigError, "#{MESSAGE}, got nil" if value.nil?
11
+
12
+ raise Evilution::ConfigError, "#{MESSAGE}, got #{value.inspect}" unless value.is_a?(String) || value.is_a?(Symbol)
13
+
14
+ sym = value.to_sym
15
+ return sym if ALLOWED.include?(sym)
16
+
17
+ raise Evilution::ConfigError, "#{MESSAGE}, got #{sym.inspect}"
18
+ end
19
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ class Evilution::Config::Validators::Jobs < Evilution::Config::Validators::Base
6
+ def self.call(value)
7
+ coerce_positive_int!(value, name: "jobs")
8
+ end
9
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ class Evilution::Config::Validators::Preload < Evilution::Config::Validators::Base
6
+ def self.call(value)
7
+ return nil if value.nil?
8
+ return false if value == false
9
+ return value if value.is_a?(String)
10
+
11
+ raise Evilution::ConfigError, "preload must be nil, false, or a String path, got #{value.inspect}"
12
+ end
13
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ class Evilution::Config::Validators::SpecMappings < Evilution::Config::Validators::Base
6
+ def self.call(value)
7
+ return {} if value.nil?
8
+
9
+ raise Evilution::ConfigError, "spec_mappings must be a Hash, got #{value.class}" unless value.is_a?(Hash)
10
+
11
+ normalized = value.each_with_object({}) do |(source, specs), acc|
12
+ key = normalize_key(source)
13
+ acc[key] = normalize_value(key, specs)
14
+ end
15
+
16
+ warn_missing(normalized)
17
+ normalized
18
+ end
19
+
20
+ class << self
21
+ private
22
+
23
+ def normalize_key(source)
24
+ key = source.to_s
25
+ key = key.delete_prefix("#{Dir.pwd}/") if key.start_with?("/")
26
+ key.delete_prefix("./")
27
+ end
28
+
29
+ def normalize_value(source, specs)
30
+ case specs
31
+ when String then [specs]
32
+ when Array
33
+ specs.each do |entry|
34
+ unless entry.is_a?(String)
35
+ raise Evilution::ConfigError,
36
+ "spec_mappings[#{source.inspect}] entries must be string paths, got #{entry.class}"
37
+ end
38
+ end
39
+ specs
40
+ else
41
+ raise Evilution::ConfigError,
42
+ "spec_mappings[#{source.inspect}] must be a string or array of strings, got #{specs.class}"
43
+ end
44
+ end
45
+
46
+ def warn_missing(mappings)
47
+ mappings.each do |source, specs|
48
+ specs.each do |spec_path|
49
+ next if File.exist?(spec_path)
50
+
51
+ warn "[evilution] spec_mappings[#{source.inspect}]: #{spec_path} not found, skipping"
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ class Evilution::Config::Validators::SpecPattern < Evilution::Config::Validators::Base
6
+ def self.call(value)
7
+ return nil if value.nil?
8
+ return value if value.is_a?(String)
9
+
10
+ raise Evilution::ConfigError, "spec_pattern must be nil or a String glob, got #{value.class}"
11
+ end
12
+ end