evilution 0.25.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 (134) hide show
  1. checksums.yaml +4 -4
  2. data/.beads/interactions.jsonl +15 -0
  3. data/.claude/prompts/architect.md +14 -1
  4. data/.claude/skills/create-issue/SKILL.md +55 -0
  5. data/.rubocop_todo.yml +7 -0
  6. data/CHANGELOG.md +38 -0
  7. data/README.md +57 -3
  8. data/lib/evilution/ast/constant_names.rb +34 -0
  9. data/lib/evilution/cache.rb +2 -0
  10. data/lib/evilution/child_output.rb +24 -0
  11. data/lib/evilution/cli/commands/run.rb +9 -0
  12. data/lib/evilution/cli/commands/version.rb +2 -0
  13. data/lib/evilution/cli/parser/options_builder.rb +16 -2
  14. data/lib/evilution/compare/invalid_input.rb +12 -0
  15. data/lib/evilution/compare.rb +1 -10
  16. data/lib/evilution/config/builders/spec_resolver.rb +15 -0
  17. data/lib/evilution/config/builders/spec_selector.rb +16 -0
  18. data/lib/evilution/config/builders.rb +4 -0
  19. data/lib/evilution/config/env_loader.rb +12 -0
  20. data/lib/evilution/config/file_loader.rb +22 -0
  21. data/lib/evilution/config/sources.rb +14 -0
  22. data/lib/evilution/config/validators/base.rb +37 -0
  23. data/lib/evilution/config/validators/example_targeting_cache.rb +37 -0
  24. data/lib/evilution/config/validators/example_targeting_fallback.rb +22 -0
  25. data/lib/evilution/config/validators/fail_fast.rb +11 -0
  26. data/lib/evilution/config/validators/hooks.rb +12 -0
  27. data/lib/evilution/config/validators/ignore_patterns.rb +16 -0
  28. data/lib/evilution/config/validators/integration.rb +11 -0
  29. data/lib/evilution/config/validators/isolation.rb +19 -0
  30. data/lib/evilution/config/validators/jobs.rb +9 -0
  31. data/lib/evilution/config/validators/preload.rb +13 -0
  32. data/lib/evilution/config/validators/spec_mappings.rb +56 -0
  33. data/lib/evilution/config/validators/spec_pattern.rb +12 -0
  34. data/lib/evilution/config/validators.rb +4 -0
  35. data/lib/evilution/config.rb +78 -268
  36. data/lib/evilution/feedback/detector.rb +15 -0
  37. data/lib/evilution/feedback/messages.rb +42 -0
  38. data/lib/evilution/feedback.rb +5 -0
  39. data/lib/evilution/integration/base.rb +4 -155
  40. data/lib/evilution/integration/loading/concern_state_cleaner.rb +49 -0
  41. data/lib/evilution/integration/loading/constant_pinner.rb +24 -0
  42. data/lib/evilution/integration/loading/mutation_applier.rb +52 -0
  43. data/lib/evilution/integration/loading/redefinition_recovery.rb +54 -0
  44. data/lib/evilution/integration/loading/source_evaluator.rb +15 -0
  45. data/lib/evilution/integration/loading/syntax_validator.rb +19 -0
  46. data/lib/evilution/integration/loading.rb +6 -0
  47. data/lib/evilution/integration/rspec/baseline_runner.rb +16 -0
  48. data/lib/evilution/integration/rspec/crash_detector_lifecycle.rb +17 -0
  49. data/lib/evilution/integration/rspec/example_filter_applier.rb +21 -0
  50. data/lib/evilution/integration/rspec/framework_loader.rb +28 -0
  51. data/lib/evilution/integration/rspec/result_builder.rb +40 -0
  52. data/lib/evilution/integration/rspec/state_guard/example_groups_constants.rb +28 -0
  53. data/lib/evilution/integration/rspec/state_guard/internals.rb +19 -0
  54. data/lib/evilution/integration/rspec/state_guard/object_space_example_groups.rb +35 -0
  55. data/lib/evilution/integration/rspec/state_guard/reporter_arrays.rb +32 -0
  56. data/lib/evilution/integration/rspec/state_guard/world_example_groups.rb +20 -0
  57. data/lib/evilution/integration/rspec/state_guard/world_filtered_examples.rb +20 -0
  58. data/lib/evilution/integration/rspec/state_guard/world_sources_by_path.rb +20 -0
  59. data/lib/evilution/integration/rspec/state_guard.rb +40 -0
  60. data/lib/evilution/integration/rspec/test_file_resolver.rb +30 -0
  61. data/lib/evilution/integration/rspec/unresolved_spec_warner.rb +18 -0
  62. data/lib/evilution/integration/rspec.rb +61 -232
  63. data/lib/evilution/isolation/fork.rb +7 -2
  64. data/lib/evilution/load_path/subpath_resolver.rb +25 -0
  65. data/lib/evilution/load_path.rb +4 -0
  66. data/lib/evilution/mcp/info_tool/actions/base.rb +22 -0
  67. data/lib/evilution/mcp/info_tool/actions/environment.rb +42 -0
  68. data/lib/evilution/mcp/info_tool/actions/feedback.rb +16 -0
  69. data/lib/evilution/mcp/info_tool/actions/statuses.rb +10 -0
  70. data/lib/evilution/mcp/info_tool/actions/subjects.rb +47 -0
  71. data/lib/evilution/mcp/info_tool/actions/tests.rb +60 -0
  72. data/lib/evilution/mcp/info_tool/actions.rb +16 -0
  73. data/lib/evilution/mcp/info_tool/config_factory.rb +24 -0
  74. data/lib/evilution/mcp/info_tool/error_mapper.rb +15 -0
  75. data/lib/evilution/mcp/info_tool/request_parser.rb +34 -0
  76. data/lib/evilution/mcp/info_tool/response_formatter.rb +24 -0
  77. data/lib/evilution/mcp/info_tool/status_glossary.rb +75 -0
  78. data/lib/evilution/mcp/info_tool.rb +43 -261
  79. data/lib/evilution/mcp/mutate_tool/error_payload.rb +8 -1
  80. data/lib/evilution/mcp/mutate_tool/report_trimmer.rb +13 -1
  81. data/lib/evilution/mcp/mutate_tool.rb +5 -2
  82. data/lib/evilution/mutator/operator/block_removal.rb +1 -1
  83. data/lib/evilution/mutator/operator/method_body_replacement.rb +18 -2
  84. data/lib/evilution/parallel/work_queue/channel/frame.rb +21 -0
  85. data/lib/evilution/parallel/work_queue/channel.rb +23 -0
  86. data/lib/evilution/parallel/work_queue/collection_state.rb +14 -0
  87. data/lib/evilution/parallel/work_queue/dispatcher.rb +133 -0
  88. data/lib/evilution/parallel/work_queue/validators/optional_positive_int.rb +11 -0
  89. data/lib/evilution/parallel/work_queue/validators/optional_positive_number.rb +11 -0
  90. data/lib/evilution/parallel/work_queue/validators/positive_int.rb +11 -0
  91. data/lib/evilution/parallel/work_queue/validators.rb +6 -0
  92. data/lib/evilution/parallel/work_queue/worker/loop.rb +45 -0
  93. data/lib/evilution/parallel/work_queue/worker.rb +114 -0
  94. data/lib/evilution/parallel/work_queue/worker_stat.rb +17 -0
  95. data/lib/evilution/parallel/work_queue.rb +42 -327
  96. data/lib/evilution/reporter/cli/item_formatters/coverage_gap.rb +18 -0
  97. data/lib/evilution/reporter/cli/item_formatters/disabled.rb +9 -0
  98. data/lib/evilution/reporter/cli/item_formatters/error.rb +14 -0
  99. data/lib/evilution/reporter/cli/item_formatters/result_location.rb +10 -0
  100. data/lib/evilution/reporter/cli/item_formatters.rb +6 -0
  101. data/lib/evilution/reporter/cli/line_formatters/duration.rb +9 -0
  102. data/lib/evilution/reporter/cli/line_formatters/efficiency.rb +18 -0
  103. data/lib/evilution/reporter/cli/line_formatters/feedback_footer.rb +13 -0
  104. data/lib/evilution/reporter/cli/line_formatters/header.rb +10 -0
  105. data/lib/evilution/reporter/cli/line_formatters/mutations.rb +16 -0
  106. data/lib/evilution/reporter/cli/line_formatters/peak_memory.rb +12 -0
  107. data/lib/evilution/reporter/cli/line_formatters/result_line.rb +20 -0
  108. data/lib/evilution/reporter/cli/line_formatters/score.rb +14 -0
  109. data/lib/evilution/reporter/cli/line_formatters/truncation_notice.rb +11 -0
  110. data/lib/evilution/reporter/cli/line_formatters.rb +6 -0
  111. data/lib/evilution/reporter/cli/metrics_block.rb +26 -0
  112. data/lib/evilution/reporter/cli/pct.rb +9 -0
  113. data/lib/evilution/reporter/cli/section.rb +13 -0
  114. data/lib/evilution/reporter/cli/section_renderer.rb +15 -0
  115. data/lib/evilution/reporter/cli/trailer.rb +22 -0
  116. data/lib/evilution/reporter/cli.rb +79 -162
  117. data/lib/evilution/runner/isolation_resolver.rb +20 -2
  118. data/lib/evilution/runner/mutation_executor/mutation_runner.rb +32 -0
  119. data/lib/evilution/runner/mutation_executor/neutralization_pipeline.rb +16 -0
  120. data/lib/evilution/runner/mutation_executor/neutralizer/baseline_failed.rb +46 -0
  121. data/lib/evilution/runner/mutation_executor/neutralizer/infra_error.rb +75 -0
  122. data/lib/evilution/runner/mutation_executor/result_cache.rb +69 -0
  123. data/lib/evilution/runner/mutation_executor/result_notifier.rb +48 -0
  124. data/lib/evilution/runner/mutation_executor/result_packer.rb +39 -0
  125. data/lib/evilution/runner/mutation_executor/strategy/parallel.rb +80 -0
  126. data/lib/evilution/runner/mutation_executor/strategy/sequential.rb +34 -0
  127. data/lib/evilution/runner/mutation_executor.rb +58 -289
  128. data/lib/evilution/runner/subject_pipeline.rb +18 -8
  129. data/lib/evilution/runner.rb +21 -0
  130. data/lib/evilution/version.rb +1 -1
  131. metadata +125 -5
  132. data/lib/evilution/mcp/session_diff_tool.rb +0 -63
  133. data/lib/evilution/mcp/session_list_tool.rb +0 -50
  134. data/lib/evilution/mcp/session_show_tool.rb +0 -57
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 41c28afbfc162902708394c367eb87150663ac64ab7b2228ee72ab7bb3b0549b
4
- data.tar.gz: 269ec61ea7f4d9f083247b022ba7e331bfce85ebc62d75151cf0b14d6769a8f9
3
+ metadata.gz: 12d485d3cce9569229a95a1e8f29403fbb11f8f067e0c438256919521f6d82dc
4
+ data.tar.gz: 11a1adeca7c61fa905757137d6a737c6e389eae584b37fca91a8c9eed57926d0
5
5
  SHA512:
6
- metadata.gz: 11f61c28eca30a943b6632066b8992860e2bf67fb1a617e8cf0ac6ccfcbbce33d48baec4452a57127aacaeacf2f834f6d01350be091a5d2bbd279df226e5cbc0
7
- data.tar.gz: 6d3c6f07b8ddda520966ef2bc9616752fa693499fff18e0542e3059a1f1ff16ae9a56f9b68fe96eea6b90cb41981e5f87bc540238a2fd2e29f166f629cf1259f
6
+ metadata.gz: ce9208fa3f3ed3160d2844cdd5e1479cf7b2fa90985371cb95d6978177f371d38ec4bdccf473f79af318cd6c617fda2b1962efe4f389eab1912ab348cc48b475
7
+ data.tar.gz: 57ff4d971829430d5d47525ac0bc5e488b326e6916657aaf9acb3faf0e8795e909523ec8238b6b9fbaace13bdd60c0f437f30d94efbfa82861b42da802a54aac
@@ -230,3 +230,18 @@
230
230
  {"id":"int-94972aae","kind":"field_change","created_at":"2026-04-20T16:13:12.727090894Z","actor":"Denis Kiselev","issue_id":"EV-0fgx","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Merged via PR #813 (commit 262b9ae). Integration spec spec/evilution/compare/integration_spec.rb + e2e fixtures landed. 15/15 examples pass."}}
231
231
  {"id":"int-4d639e4e","kind":"field_change","created_at":"2026-04-20T16:57:42.95949164Z","actor":"Denis Kiselev","issue_id":"EV-ynlo","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Merged via PR #816. SourceAstCache content-keyed LRU + ExampleFilter integration + BaselineRunner wiring."}}
232
232
  {"id":"int-c15ed3e8","kind":"field_change","created_at":"2026-04-20T17:49:38.782150309Z","actor":"Denis Kiselev","issue_id":"EV-toid","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Closed"}}
233
+ {"id":"int-842a1d3a","kind":"field_change","created_at":"2026-04-21T05:01:55.401885343Z","actor":"Denis Kiselev","issue_id":"EV-h8pw","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Closed"}}
234
+ {"id":"int-c92539a9","kind":"field_change","created_at":"2026-04-21T05:04:50.605824487Z","actor":"Denis Kiselev","issue_id":"EV-vkq9","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Closed"}}
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
+ {"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
+ {"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"}}
@@ -66,17 +66,30 @@ Evilution
66
66
  ::AST::SourceSurgeon # Text-level mutation at byte offsets
67
67
  ::Mutator::Base # Abstract operator base (Prism::Visitor)
68
68
  ::Mutator::Registry # Node type → operator mapping
69
- ::Mutator::Operator::* # 18 concrete mutation operators
69
+ ::Mutator::Operator::* # 72 concrete mutation operators
70
70
  ::Isolation::Fork # Fork + pipe per mutation
71
+ ::Integration::Base # Template-method orchestrator; delegates mutation apply to Loading::MutationApplier
71
72
  ::Integration::RSpec # RSpec programmatic test runner
73
+ ::Integration::Minitest # Minitest programmatic test runner
74
+ ::Integration::Loading::MutationApplier # Composes the mutation-apply pipeline
75
+ ::Integration::Loading::SyntaxValidator # Prism parse check
76
+ ::Integration::Loading::SourceEvaluator # eval w/ TOPLEVEL_BINDING + absolute path
77
+ ::Integration::Loading::ConstantPinner # const_get top-level constants to defeat Zeitwerk re-autoload
78
+ ::Integration::Loading::RedefinitionRecovery # Strip constants + retry on "already defined"
79
+ ::Integration::Loading::ConcernStateCleaner # Clear AS::Concern @_included_block / @_prepended_block
80
+ ::AST::ConstantNames # Prism walk → fully-qualified class/module names
81
+ ::LoadPath::SubpathResolver # Shortest $LOAD_PATH-relative path for a file
72
82
  ::Coverage::Collector # Ruby Coverage module wrapper
73
83
  ::Coverage::TestMap # Source line → test file mapping
84
+ ::Compare::Categorizer # Fixed / new / persistent / flaky / reintroduced bucketing
85
+ ::Compare::Normalizer # Canonicalize mutation records for cross-run compare
74
86
  ::Diff::Parser # Git diff output parser
75
87
  ::Diff::FileFilter # Filter subjects to changed code
76
88
  ::Result::MutationResult # Single mutation outcome
77
89
  ::Result::Summary # Aggregated results
78
90
  ::Reporter::JSON # Structured output for AI agents
79
91
  ::Reporter::CLI # Human-readable terminal output
92
+ ::Reporter::HTML # Section-based HTML report
80
93
  ::Reporter::Suggestion # Actionable fix hint generator
81
94
  ```
82
95
 
@@ -0,0 +1,55 @@
1
+ ---
2
+ name: create-issue
3
+ description: Create a beads issue and a matching GitHub issue, cross-linked via external-ref and description
4
+ argument-hint: "--title <title> --description <desc> --type <type> --priority <0-4>"
5
+ user-invocable: true
6
+ disable-model-invocation: true
7
+ ---
8
+
9
+ # Create Cross-Linked Issue (Beads + GitHub)
10
+
11
+ Create a beads issue and a matching GitHub issue in Port-Royal/vanilla-mafia, with each linking to the other.
12
+
13
+ ## Arguments
14
+
15
+ All arguments are passed as `$ARGUMENTS`. Parse them as bd-style flags:
16
+
17
+ - `--title` (required): Issue title
18
+ - `--description` (required): Why this issue exists and what needs to be done
19
+ - `--type` (required): `bug`, `feature`, or `task`
20
+ - `--priority` (required): 0-4 (0=critical, 4=backlog)
21
+ - `--parent` (optional): Parent beads issue ID for sub-tasks
22
+
23
+ Example:
24
+ ```
25
+ /create-issue --title "Fix login redirect" --description "After login, users are redirected to /dashboard instead of the page they came from" --type bug --priority 1
26
+ ```
27
+
28
+ ## Steps
29
+
30
+ 1. **Create the GitHub issue first** (to get the GH number):
31
+ ```
32
+ gh issue create --repo Port-Royal/vanilla-mafia --title "<title>" --body "<description>"
33
+ ```
34
+ Extract the issue number from the output.
35
+
36
+ 2. **Create the beads issue** with `--external-ref gh-<number>`:
37
+ ```
38
+ bd create --title "<title>" --description "<description>\n\nGH: #<gh-number>" --type <type> --priority <priority> --external-ref gh-<number>
39
+ ```
40
+ Extract the beads issue ID from the output.
41
+
42
+ 3. **Update the GitHub issue body** to include the beads ID:
43
+ ```
44
+ gh api repos/Port-Royal/vanilla-mafia/issues/<number> --method PATCH -f body="<description>
45
+
46
+ Beads: <beads-id>"
47
+ ```
48
+ Use `gh api` (not `gh issue edit`) to avoid the Projects Classic GraphQL error.
49
+
50
+ 4. **If `--parent` was provided**, set the parent on the beads issue:
51
+ ```
52
+ bd update <beads-id> --parent <parent-id>
53
+ ```
54
+
55
+ 5. **Display the result**: show both IDs in format `<beads-id> (GH #<number>)`.
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,43 @@
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
+
25
+ ## [0.26.0] - 2026-04-24
26
+
27
+ ### Removed
28
+
29
+ - **Deprecated MCP session tools removed** — `evilution-session-list`, `evilution-session-show`, `evilution-session-diff` shims (deprecated since #637 after consolidation into `evilution-session`) deleted (#686, PR #851)
30
+
31
+ ### Fixed
32
+
33
+ - **`preload` silently ignored under `:in_process` isolation** — `Runner::IsolationResolver#perform_preload` gated the preload on `resolve_isolation == :fork`, so non-Rails projects (auto-resolving to `:in_process`) silently skipped `preload:` from `.evilution.yml` or `--preload`. Preload now runs for `:in_process` too (#868, PR #871)
34
+ - **`--target ClassName` silently narrowed to git-changed files** — when only a class/method target was given with no file scope, `Runner::SubjectPipeline#target_files` fell back to `Git::ChangedFiles`, producing a misleading `no method found matching 'X'` error when the class file was not in the working-tree diff. Target files now resolve from the configured source when a method/class target is given without explicit file scope (#869, PR #872)
35
+
36
+ ### Changed
37
+
38
+ - **Internal `Evilution::Compare` refactor** — `lib/evilution/compare.rb` split into one class per file under `lib/evilution/compare/` (`Categorizer`, `Detector`, `Fingerprint`, `InvalidInput`, `Normalizer`, `Record`); `rubocop:disable Style/OneClassPerFile` removed (#825, PR #852)
39
+ - **Internal `Evilution::Integration::Base` refactor** — decomposed into focused collaborators under `Evilution::Integration::Loading::*` (`SyntaxValidator`, `SourceEvaluator`, `ConstantPinner`, `RedefinitionRecovery`, `ConcernStateCleaner`, `MutationApplier`) plus shared helpers `Evilution::AST::ConstantNames` and `Evilution::LoadPath::SubpathResolver`. `Integration::Base` now delegates mutation application to an injectable `MutationApplier`; no user-visible behavior change (#845, PR #873)
40
+
3
41
  ## [0.25.0] - 2026-04-21
4
42
 
5
43
  ### Added
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
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+ require_relative "../ast"
5
+
6
+ # Walks a Prism AST and returns every class/module constant declared, nested
7
+ # names rendered fully-qualified (e.g. "Foo::Bar"). Order is source order:
8
+ # outer declarations precede their nested children.
9
+ class Evilution::AST::ConstantNames
10
+ def call(source)
11
+ result = Prism.parse(source)
12
+ return [] if result.failure?
13
+
14
+ collect(result.value)
15
+ end
16
+
17
+ private
18
+
19
+ def collect(node, nesting = [])
20
+ names = []
21
+ case node
22
+ when Prism::ModuleNode, Prism::ClassNode
23
+ const = node.constant_path.full_name
24
+ qualified = nesting.any? && !const.include?("::") ? "#{nesting.join("::")}::#{const}" : const
25
+ names << qualified
26
+ names.concat(collect(node.body, nesting + [const])) if node.body
27
+ when Prism::ProgramNode
28
+ names.concat(collect(node.statements, nesting)) if node.statements
29
+ when Prism::StatementsNode
30
+ node.body.each { |child| names.concat(collect(child, nesting)) }
31
+ end
32
+ names
33
+ end
34
+ end
@@ -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,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../compare"
4
+
5
+ class Evilution::Compare::InvalidInput < StandardError
6
+ attr_reader :index
7
+
8
+ def initialize(message, index: nil)
9
+ super(message)
10
+ @index = index
11
+ end
12
+ end
@@ -1,15 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # rubocop:disable Style/OneClassPerFile
4
3
  module Evilution::Compare
5
4
  end
6
5
 
7
- class Evilution::Compare::InvalidInput < StandardError
8
- attr_reader :index
9
-
10
- def initialize(message, index: nil)
11
- super(message)
12
- @index = index
13
- end
14
- end
15
- # rubocop:enable Style/OneClassPerFile
6
+ require_relative "compare/invalid_input"
@@ -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