evilution 1.2.0 → 1.3.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 (113) hide show
  1. checksums.yaml +4 -4
  2. data/.beads/interactions.jsonl +38 -0
  3. data/.rubocop_todo.yml +9 -0
  4. data/CHANGELOG.md +47 -0
  5. data/README.md +65 -7
  6. data/docs/architecture.md +35 -1
  7. data/lib/evilution/ast/parser.rb +1 -0
  8. data/lib/evilution/ast/pattern/filter.rb +1 -0
  9. data/lib/evilution/ast/pattern/parser.rb +1 -0
  10. data/lib/evilution/ast/pattern.rb +2 -0
  11. data/lib/evilution/ast/sorbet_sig_detector.rb +1 -0
  12. data/lib/evilution/baseline.rb +37 -15
  13. data/lib/evilution/child_output.rb +1 -1
  14. data/lib/evilution/cli/command.rb +1 -0
  15. data/lib/evilution/cli/commands/tests_list.rb +4 -3
  16. data/lib/evilution/cli/commands.rb +2 -0
  17. data/lib/evilution/cli/dispatcher.rb +2 -0
  18. data/lib/evilution/cli/exit_guard.rb +2 -0
  19. data/lib/evilution/cli/parsed_args.rb +2 -0
  20. data/lib/evilution/cli/parser/command_extractor.rb +2 -0
  21. data/lib/evilution/cli/parser/file_args.rb +2 -0
  22. data/lib/evilution/cli/parser/options_builder.rb +3 -0
  23. data/lib/evilution/cli/parser/stdin_reader.rb +1 -0
  24. data/lib/evilution/cli/parser.rb +1 -0
  25. data/lib/evilution/cli/printers/tests_list.rb +5 -4
  26. data/lib/evilution/cli/printers.rb +2 -0
  27. data/lib/evilution/cli/result.rb +2 -0
  28. data/lib/evilution/cli.rb +5 -0
  29. data/lib/evilution/config/builders.rb +2 -0
  30. data/lib/evilution/config/env_loader.rb +2 -0
  31. data/lib/evilution/config/file_loader.rb +1 -0
  32. data/lib/evilution/config/sources.rb +1 -0
  33. data/lib/evilution/config/validators/example_targeting_cache.rb +1 -0
  34. data/lib/evilution/config/validators/example_targeting_fallback.rb +1 -0
  35. data/lib/evilution/config/validators/example_targeting_strategy.rb +1 -0
  36. data/lib/evilution/config/validators/fail_fast.rb +1 -0
  37. data/lib/evilution/config/validators/hooks.rb +1 -0
  38. data/lib/evilution/config/validators/ignore_patterns.rb +1 -0
  39. data/lib/evilution/config/validators/integration.rb +1 -0
  40. data/lib/evilution/config/validators/isolation.rb +1 -0
  41. data/lib/evilution/config/validators/jobs.rb +1 -0
  42. data/lib/evilution/config/validators/preload.rb +1 -0
  43. data/lib/evilution/config/validators/profile.rb +1 -0
  44. data/lib/evilution/config/validators/spec_mappings.rb +1 -0
  45. data/lib/evilution/config/validators/spec_pattern.rb +1 -0
  46. data/lib/evilution/config/validators/warmup.rb +17 -0
  47. data/lib/evilution/config/validators.rb +2 -0
  48. data/lib/evilution/config.rb +4 -3
  49. data/lib/evilution/coverage.rb +1 -1
  50. data/lib/evilution/coverage_example_filter.rb +1 -1
  51. data/lib/evilution/diagnostic.rb +1 -1
  52. data/lib/evilution/example_filter.rb +1 -1
  53. data/lib/evilution/integration/loading/body_call_neutralizer.rb +10 -2
  54. data/lib/evilution/integration/loading/test_load_path.rb +21 -13
  55. data/lib/evilution/integration/rspec/state_guard/configuration_state.rb +1 -0
  56. data/lib/evilution/integration/rspec/state_guard/configuration_streams.rb +1 -0
  57. data/lib/evilution/integration/rspec/state_guard/example_groups_constants.rb +1 -2
  58. data/lib/evilution/integration/rspec/state_guard/internals.rb +1 -2
  59. data/lib/evilution/integration/rspec/state_guard/object_space_example_groups.rb +1 -2
  60. data/lib/evilution/integration/rspec/state_guard/reporter_arrays.rb +1 -0
  61. data/lib/evilution/integration/rspec/state_guard/world_example_groups.rb +1 -0
  62. data/lib/evilution/integration/rspec/state_guard/world_filtered_examples.rb +1 -0
  63. data/lib/evilution/integration/rspec/state_guard/world_sources_by_path.rb +1 -0
  64. data/lib/evilution/integration/rspec/state_guard.rb +6 -0
  65. data/lib/evilution/mcp/info_tool/actions/environment.rb +1 -0
  66. data/lib/evilution/mcp/info_tool/actions/feedback.rb +1 -0
  67. data/lib/evilution/mcp/info_tool/actions/statuses.rb +1 -0
  68. data/lib/evilution/mcp/info_tool/actions/subjects.rb +1 -0
  69. data/lib/evilution/mcp/info_tool/actions/tests.rb +1 -0
  70. data/lib/evilution/mutation.rb +1 -1
  71. data/lib/evilution/mutator/operator/argument_list_removal.rb +52 -0
  72. data/lib/evilution/mutator/operator/argument_propagation.rb +54 -0
  73. data/lib/evilution/mutator/operator/array_coercion_to_literal.rb +34 -0
  74. data/lib/evilution/mutator/operator/attribute_write_to_read.rb +34 -0
  75. data/lib/evilution/mutator/operator/bang_method.rb +11 -3
  76. data/lib/evilution/mutator/operator/binary_operand_promotion.rb +63 -0
  77. data/lib/evilution/mutator/operator/block_body_promotion.rb +45 -0
  78. data/lib/evilution/mutator/operator/block_body_to_nil.rb +52 -0
  79. data/lib/evilution/mutator/operator/block_body_to_raise.rb +34 -0
  80. data/lib/evilution/mutator/operator/call_to_nil.rb +34 -0
  81. data/lib/evilution/mutator/operator/coercion_emptying.rb +60 -0
  82. data/lib/evilution/mutator/operator/collection_replacement.rb +19 -9
  83. data/lib/evilution/mutator/operator/comparison_replacement.rb +37 -12
  84. data/lib/evilution/mutator/operator/const_get_to_constant_path.rb +50 -0
  85. data/lib/evilution/mutator/operator/dig_to_fetch_chain.rb +45 -0
  86. data/lib/evilution/mutator/operator/double_negation_removal.rb +22 -0
  87. data/lib/evilution/mutator/operator/dynamic_dispatch_resolution.rb +79 -0
  88. data/lib/evilution/mutator/operator/inequality_to_negated_identity.rb +43 -0
  89. data/lib/evilution/mutator/operator/keyword_argument_removal.rb +47 -0
  90. data/lib/evilution/mutator/operator/proc_to_lambda.rb +43 -0
  91. data/lib/evilution/mutator/operator/receiver_constructor_swap.rb +51 -0
  92. data/lib/evilution/mutator/operator/reduce_to_sum.rb +58 -0
  93. data/lib/evilution/mutator/operator/regexp_anchor_to_predicate.rb +136 -0
  94. data/lib/evilution/mutator/operator/safe_navigation_removal.rb +67 -0
  95. data/lib/evilution/mutator/operator/send_mutation.rb +42 -6
  96. data/lib/evilution/mutator/operator/symbol_to_proc_replacement.rb +59 -0
  97. data/lib/evilution/mutator/operator/to_i_to_integer.rb +38 -0
  98. data/lib/evilution/mutator/primitives.rb +25 -0
  99. data/lib/evilution/mutator/registry.rb +24 -1
  100. data/lib/evilution/parallel/pool.rb +1 -0
  101. data/lib/evilution/parallel_db_warning.rb +1 -1
  102. data/lib/evilution/rails_warmup.rb +61 -0
  103. data/lib/evilution/reporter/html/report.rb +1 -0
  104. data/lib/evilution/result/coverage_gap_grouper.rb +1 -0
  105. data/lib/evilution/result/subject_scorer.rb +1 -0
  106. data/lib/evilution/runner/baseline_runner.rb +17 -7
  107. data/lib/evilution/runner/isolation_resolver.rb +23 -2
  108. data/lib/evilution/runner/mutation_executor/neutralizer/baseline_failed.rb +17 -10
  109. data/lib/evilution/source_ast_cache.rb +1 -1
  110. data/lib/evilution/spec_ast_cache.rb +1 -1
  111. data/lib/evilution/version.rb +1 -1
  112. data/lib/evilution.rb +23 -0
  113. metadata +27 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4fe2a5694f081965211f6471734e4f1b11d4e02d2a33617b19a0e6fb3b3c685f
4
- data.tar.gz: 3e2637aa9ebb40904cb8def655dc2b5df7e9e0727f22add31267e8183553fe79
3
+ metadata.gz: 171e27cbec4014afbffb3c058ecc0b666caa8f849c33532ed55f84e2ecee3a87
4
+ data.tar.gz: bff682f7afeb96e4b565ae916950ad8b526f5eca2dc00bb88f17a28382b5e9d9
5
5
  SHA512:
6
- metadata.gz: 530e85eaa20e6d6fa18a6b633d4fdca79c5b55a6ca5515f6a0e79951ece9582b077a14921fc9217df3d96567974a256df9b27deb46d014da263fc0cee253da1a
7
- data.tar.gz: 0a4ba7c748b0fa7f7c664cd0a25e7ed68f3d3c59e51536185fbc869c81b20b2d77f6391fe6b6aabe3ae3a848d6ffa3a70ef5d07760d28e520426b871daa97100
6
+ metadata.gz: c272e9c1cb1c5d0af0bb85856b02a59eb14dfad81bc4285d035eb693826eb8559acf803ac14482acf736da98e531c6b1444bb269fa8d0d3ecb340c16f2942f9e
7
+ data.tar.gz: b5a31b2aa40568c6f43ab9147d8a3cde7c8d2ab9205466a3d459eaca5395b7b31cf2a94a1e997a2e6f982cc4ef8fd189be268f2bfc52c0f897a963eebc9f0718
@@ -489,3 +489,41 @@
489
489
  {"id":"int-9a7df3ab","kind":"field_change","created_at":"2026-09-21T07:43:13.101312655Z","actor":"Denis Kiselev","issue_id":"EV-5pob","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Closed"}}
490
490
  {"id":"int-6c08ef8f","kind":"field_change","created_at":"2026-09-21T08:47:57.19015326Z","actor":"Denis Kiselev","issue_id":"EV-f8h3","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Closed"}}
491
491
  {"id":"int-41e28161","kind":"field_change","created_at":"2026-09-21T09:13:10.822154641Z","actor":"Denis Kiselev","issue_id":"EV-vk1f","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Closed"}}
492
+ {"id":"int-a85e9307","kind":"field_change","created_at":"2026-09-24T08:59:06.431936036Z","actor":"Denis Kiselev","issue_id":"EV-tsi4.1","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Closed"}}
493
+ {"id":"int-0146928b","kind":"field_change","created_at":"2026-09-24T08:59:57.400402587Z","actor":"Denis Kiselev","issue_id":"EV-tsi4.2","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Won't do: self. insertion is equivalent on Ruby >= 2.7 (private calls with literal self allowed); gem requires >= 3.3"}}
494
+ {"id":"int-d13277bb","kind":"field_change","created_at":"2026-09-24T10:11:27.25351867Z","actor":"Denis Kiselev","issue_id":"EV-tsi4.3","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Closed"}}
495
+ {"id":"int-98f94055","kind":"field_change","created_at":"2026-09-24T11:09:10.574795113Z","actor":"Denis Kiselev","issue_id":"EV-tsi4.4","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Closed"}}
496
+ {"id":"int-cfc17855","kind":"field_change","created_at":"2026-09-25T04:10:24.329240246Z","actor":"Denis Kiselev","issue_id":"EV-tsi4.5","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Closed"}}
497
+ {"id":"int-ff65a252","kind":"field_change","created_at":"2026-09-25T06:05:35.905280887Z","actor":"Denis Kiselev","issue_id":"EV-tsi4.6","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Closed"}}
498
+ {"id":"int-005be0d5","kind":"field_change","created_at":"2026-09-25T06:05:41.780650066Z","actor":"Denis Kiselev","issue_id":"EV-tsi4.7","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Covered by argument_list_removal (EV-tsi4.6, PR #1639)"}}
499
+ {"id":"int-99b3d64d","kind":"field_change","created_at":"2026-09-25T09:43:15.271882913Z","actor":"Denis Kiselev","issue_id":"EV-tsi4.28","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Closed"}}
500
+ {"id":"int-f94cb5b9","kind":"field_change","created_at":"2026-09-25T10:05:26.030418343Z","actor":"Denis Kiselev","issue_id":"EV-tsi4.15","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Closed"}}
501
+ {"id":"int-ab93e0ea","kind":"field_change","created_at":"2026-09-25T11:09:58.712099704Z","actor":"Denis Kiselev","issue_id":"EV-tsi4.8","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Closed"}}
502
+ {"id":"int-f54216bc","kind":"field_change","created_at":"2026-09-25T11:28:25.015240322Z","actor":"Denis Kiselev","issue_id":"EV-tsi4.9","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Closed"}}
503
+ {"id":"int-78532509","kind":"field_change","created_at":"2026-09-25T12:03:26.712050666Z","actor":"Denis Kiselev","issue_id":"EV-tsi4.10","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Closed"}}
504
+ {"id":"int-b04e4ada","kind":"field_change","created_at":"2026-09-25T12:35:14.598132892Z","actor":"Denis Kiselev","issue_id":"EV-tsi4.27","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Closed"}}
505
+ {"id":"int-878aed06","kind":"field_change","created_at":"2026-09-25T16:34:22.646996954Z","actor":"Denis Kiselev","issue_id":"EV-tsi4.11","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Closed"}}
506
+ {"id":"int-a5be78a4","kind":"field_change","created_at":"2026-09-25T17:15:19.778644918Z","actor":"Denis Kiselev","issue_id":"EV-tsi4.12","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Closed"}}
507
+ {"id":"int-b5b54c8c","kind":"field_change","created_at":"2026-09-25T17:47:12.91754374Z","actor":"Denis Kiselev","issue_id":"EV-tsi4.13","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Closed"}}
508
+ {"id":"int-0915250f","kind":"field_change","created_at":"2026-09-26T03:50:18.7581253Z","actor":"Denis Kiselev","issue_id":"EV-tsi4.26","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Closed"}}
509
+ {"id":"int-01656f0a","kind":"field_change","created_at":"2026-09-26T04:02:44.194731168Z","actor":"Denis Kiselev","issue_id":"EV-tsi4.14","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Closed"}}
510
+ {"id":"int-f5cfcd38","kind":"field_change","created_at":"2026-09-26T04:18:06.728081454Z","actor":"Denis Kiselev","issue_id":"EV-tsi4.25","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Closed"}}
511
+ {"id":"int-f611c7c0","kind":"field_change","created_at":"2026-09-26T06:33:30.975279097Z","actor":"Denis Kiselev","issue_id":"EV-tsi4.16","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Closed"}}
512
+ {"id":"int-dbccf56c","kind":"field_change","created_at":"2026-09-26T06:52:37.666559599Z","actor":"Denis Kiselev","issue_id":"EV-tsi4.17","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Closed"}}
513
+ {"id":"int-3ace0c73","kind":"field_change","created_at":"2026-09-26T07:07:52.345696388Z","actor":"Denis Kiselev","issue_id":"EV-tsi4.18","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Closed"}}
514
+ {"id":"int-df7a3ad8","kind":"field_change","created_at":"2026-09-26T07:19:58.301755963Z","actor":"Denis Kiselev","issue_id":"EV-tsi4.24","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Closed"}}
515
+ {"id":"int-ec1508f5","kind":"field_change","created_at":"2026-09-26T07:41:16.328539681Z","actor":"Denis Kiselev","issue_id":"EV-tsi4.19","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Closed"}}
516
+ {"id":"int-4fb31878","kind":"field_change","created_at":"2026-09-26T08:00:27.677084584Z","actor":"Denis Kiselev","issue_id":"EV-tsi4.20","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Closed"}}
517
+ {"id":"int-451c79e2","kind":"field_change","created_at":"2026-09-26T17:48:34.137710758Z","actor":"Denis Kiselev","issue_id":"EV-tsi4.21","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Closed"}}
518
+ {"id":"int-b243b5f3","kind":"field_change","created_at":"2026-09-27T04:19:10.37017749Z","actor":"Denis Kiselev","issue_id":"EV-tsi4.22","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Closed"}}
519
+ {"id":"int-561a3e6f","kind":"field_change","created_at":"2026-09-27T04:37:48.03229929Z","actor":"Denis Kiselev","issue_id":"EV-tsi4.23","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Closed"}}
520
+ {"id":"int-180ba042","kind":"field_change","created_at":"2026-09-27T05:10:45.908206414Z","actor":"Denis Kiselev","issue_id":"EV-27v9.1","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Closed"}}
521
+ {"id":"int-854c1949","kind":"field_change","created_at":"2026-09-27T05:27:27.19234994Z","actor":"Denis Kiselev","issue_id":"EV-27v9.2","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Closed"}}
522
+ {"id":"int-d09d6377","kind":"field_change","created_at":"2026-09-27T10:40:02.318630878Z","actor":"Denis Kiselev","issue_id":"EV-27v9.3","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Closed"}}
523
+ {"id":"int-5bb755e9","kind":"field_change","created_at":"2026-09-27T10:43:06.788713181Z","actor":"Denis Kiselev","issue_id":"EV-27v9.4","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Won't do: hoist yields NoMethodError (killed by execution) or duplicates block_body_promotion; signal covered by EV-27v9.3"}}
524
+ {"id":"int-1344bddb","kind":"field_change","created_at":"2026-09-27T10:54:21.511979983Z","actor":"Denis Kiselev","issue_id":"EV-27v9.5","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Closed"}}
525
+ {"id":"int-8d4fd88a","kind":"field_change","created_at":"2026-09-27T15:12:53.603922398Z","actor":"Denis Kiselev","issue_id":"EV-03as","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Closed"}}
526
+ {"id":"int-e2c0e5da","kind":"field_change","created_at":"2026-09-27T16:02:46.866587228Z","actor":"Denis Kiselev","issue_id":"EV-e9ni","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Closed"}}
527
+ {"id":"int-356e6b7a","kind":"field_change","created_at":"2026-09-28T02:59:40.442681333Z","actor":"Denis Kiselev","issue_id":"EV-ae7u","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Closed"}}
528
+ {"id":"int-a15bfc29","kind":"field_change","created_at":"2026-09-28T03:35:22.865923943Z","actor":"Denis Kiselev","issue_id":"EV-ziy0","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Closed"}}
529
+ {"id":"int-c4c3b0ee","kind":"field_change","created_at":"2026-09-28T05:00:30.703915081Z","actor":"Denis Kiselev","issue_id":"EV-qj40","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Closed"}}
data/.rubocop_todo.yml CHANGED
@@ -16,7 +16,16 @@ Metrics/ClassLength:
16
16
  - "lib/evilution/runner/isolation_resolver.rb"
17
17
  - "lib/evilution/cli/parser/options_builder.rb"
18
18
  - "lib/evilution/spec_resolver.rb"
19
+ - "lib/evilution/mutator/registry.rb"
19
20
 
20
21
  Metrics/MethodLength:
21
22
  Exclude:
22
23
  - "lib/evilution/runner.rb"
24
+
25
+ # The empty forward declaration (`class Evilution::CLI; end`) that lets the
26
+ # children nest under the namespace before the full definition counts as a
27
+ # second top-level class. See "Require conventions" in docs/architecture.md.
28
+ Style/OneClassPerFile:
29
+ Exclude:
30
+ - "lib/evilution/cli.rb"
31
+ - "lib/evilution/integration/rspec/state_guard.rb"
data/CHANGELOG.md CHANGED
@@ -2,6 +2,53 @@
2
2
 
3
3
  Versioning policy: see [docs/versioning.md](docs/versioning.md).
4
4
 
5
+ ## [1.3.0] - 2026-09-28
6
+
7
+ Operator expansion release: the `default` profile grows from 88 to 111 operators, mostly around call sites and blocks, and several existing operators gain new replacements. Mutation scores will move — every new operator produces mutants your suite has never been measured against. Pin the gem version and the operator profile if you need a stable score across runs.
8
+
9
+ The fixes come from a field report by [@rubdttcom](https://github.com/rubdttcom) on a Rails 8 / Minitest application (GH #1596–#1599); two of them landed as their own commits.
10
+
11
+ ### Added
12
+
13
+ - **Nineteen new operators for call sites and message sends (`default` profile)** — call nodes were mutated only by selector swaps, receiver stripping and argument nil-substitution (epic GH #1420):
14
+ - **`call_to_nil`** — a call to `nil`, the broadest value-level probe. Skips calls whose value is discarded, receivers of another call and attribute writes, where the result is noise (PR #1635, GH #1455)
15
+ - **`safe_navigation_removal`** — `a&.b` to `a.b`. A survivor means no test reaches the call with a nil receiver. Skips `self` and literal receivers, which are never nil (PR #1636, GH #1457)
16
+ - **`attribute_write_to_read`** — `a.foo = b` to `a.foo`, and `a[i] = b` to `a[i]`: proves the write is observed (PR #1637, GH #1458)
17
+ - **`argument_propagation`** — `normalize(value)` to `value`: proves the method transforms its input (PR #1638, GH #1459)
18
+ - **`argument_list_removal`** — `foo(a, b)` to `foo`, every argument kind at once; also covers the single-argument case `argument_removal` never reached (PR #1639, GH #1460, #1461)
19
+ - **`keyword_argument_removal`** — drops one `key: value` pair at a call site, which no operator touched before (PR #1642, GH #1462)
20
+ - **`double_negation_removal`** — `!!x` to `x`: distinguishes a truthy object from `true` (PR #1643, GH #1463)
21
+ - **`regexp_anchor_to_predicate`** — `x =~ /^foo/` to `x.start_with?("foo")`, and `$` / `\Z` to `end_with?`. Line anchors also match after a newline, so a survivor means no test feeds multi-line input, or the anchor should have been `\A` / `\z` (PR #1644, GH #1464)
22
+ - **`reduce_to_sum`** — `reduce(:+)` to `sum`: differs on empty collections, non-numeric elements and float precision (PR #1646, GH #1465)
23
+ - **`array_coercion_to_literal`** — `Array(x)` to `[x]`: differs for `nil`, arrays and hashes (PR #1647, GH #1466)
24
+ - **`to_i_to_integer`** — `x.to_i` to `Integer(x)`: `Integer()` rejects malformed input and honours radix prefixes (`"0x1A"`, `"012"`) (PR #1648, GH #1467)
25
+ - **`coercion_emptying`** — `to_a` / `to_h` / `to_s` (and `to_ary` / `to_hash` / `to_str`) to `[]` / `{}` / `""`: proves the content is asserted, not just the type (PR #1650, GH #1468)
26
+ - **`dynamic_dispatch_resolution`** — `obj.send(:m)` to `obj.m`, emitted only where visibility checks can differ (PR #1641, GH #1469)
27
+ - **`const_get_to_constant_path`** — `X.const_get(:Y)` to `X::Y`: differs on top-level and private constants and `inherit = false` (PR #1652, GH #1470)
28
+ - **`proc_to_lambda`** — `proc { }` / `Proc.new { }` to `lambda { }`: arity checks, array auto-splat and `return` semantics (PR #1653, GH #1471)
29
+ - **`dig_to_fetch_chain`** — `x.dig(a, b)` to `x.fetch(a).dig(b)` (PR #1654, GH #1472)
30
+ - **`inequality_to_negated_identity`** — `a != b` to `!a.eql?(b)` and `!a.equal?(b)`; skips `nil`, boolean and symbol literals, where all three agree (PR #1656, GH #1473)
31
+ - **`binary_operand_promotion`** — `a + b` to `a` and `b` for arithmetic and bitwise operators, skipping integer identities such as `a + 0` (PR #1657, GH #1474)
32
+ - **`receiver_constructor_swap`** — `Date` / `DateTime` / `Time.parse` to a stricter sibling (`iso8601`, `strptime`, …), keyed on receiver and selector (PR #1658, GH #1475)
33
+ - **`symbol_to_proc_replacement`** — applies the `send_mutation` and `collection_replacement` selector tables to `&:sym` arguments, which previously got only removal (PR #1640, GH #1482)
34
+ - **Three new operators for blocks** — blocks were only ever removed wholesale (epic GH #1421):
35
+ - **`block_body_to_nil`** — the block body to `nil`, keeping the iteration. Skips `loop` and endless `cycle`, which would never terminate (PR #1661, GH #1483)
36
+ - **`block_body_to_raise`** — the block body to `raise`: a survivor means the block is never invoked. Skips bodies with a `rescue` clause, which would swallow it (PR #1662, GH #1484)
37
+ - **`block_body_promotion`** — `Base.transaction { save! }` to `save!` for parameter-less blocks: exposes wrappers whose effect is never tested, such as transactions whose rollback nothing triggers (PR #1663, GH #1485)
38
+ - **`warmup: rails` / `--warmup rails`** — after `--preload`, warms Rails state that otherwise initialises on each fork's first request (I18n, routes, asset paths, templates when `actionview_precompiler` is installed). Every step is side-effect free and skipped with a note if it does not apply. See "Warming up Rails before forking" in the README (PR #1671, GH #1599)
39
+
40
+ ### Changed
41
+
42
+ - **Larger selector tables** — new swaps in `send_mutation` (`bytes`/`chars`, `start_with?`/`end_with?`, `ceil`/`floor`, `to_s` -> `to_str` and the other implicit conversions, `even?`/`odd?`, `pred`/`succ`, `is_a?` -> `instance_of?`, `obj.method(:m)` -> `public_method`, …), `collection_replacement` (`any?` -> `empty?`/`none?`, `find` / `max` / `min` -> `first`/`last`, `fetch` -> `key?`, `delete_if` -> `reject`, `each_with_index` -> `each`, …) and `comparison_replacement` (ordering comparisons to `eql?` / `equal?`, `=~` -> `match?`) (PRs #1649, #1651, #1655, #1659, #1660; GH #1476–#1480)
43
+ - **`bang_method` knows every standard-library bang pair** — the list grew from a 24-name sample to every String, Array, Hash and Set method with an in-place twin (PR #1645, GH #1481)
44
+
45
+ ### Fixed
46
+
47
+ - **`tests list` resolves specs the way `run` does** — it always used the RSpec layout, so Minitest and Test::Unit projects saw `(no spec found)` for every source, and it ignored `spec_mappings` and `spec_pattern`. A source mapped to several spec files now lists each (PR #1667, GH #1596; fix by @rubdttcom)
48
+ - **A preload outside `test/` or `spec/` no longer shadows gems** — the preload's own directory went onto `$LOAD_PATH`, so `--preload config/evilution_preload.rb` made `require "puma"` load the app's `config/puma.rb` (PR #1668, GH #1597; fix by @rubdttcom)
49
+ - **The baseline follows `fallback_to_full_suite` and `spec_mappings`** — for a source with no resolved test it ran the whole test directory even with the fallback off, and printed "running full suite"; in a real Rails suite that run timed out silently. A source declared only through `spec_mappings` also fell back to the directory, and its survivors were then reported `neutral` against a mapped test that passes. The baseline now uses the same spec selection as `run`, skips unresolved sources unless the run falls back, and reports a timed-out baseline explicitly (PR #1669, GH #1598)
50
+ - **Projects that raise on warnings no longer error every mutation of a file with a neutralized class-body call** — before re-evaluating a mutated file, evilution replaces non-idempotent class-body calls such as `deprecate :lift, :coerce` with `nil`, which Ruby flags under `-w` as "possibly useless use of nil in void context". A suite that raises on warnings (dry-monads) then scored 545 of 546 mutations of `maybe.rb` as errors; the replacement is now `()` (GH #1673)
51
+
5
52
  ## [1.2.0] - 2026-09-21
6
53
 
7
54
  Reporting honesty release. A run now says what it did **not** measure instead of letting the headline score speak for the whole target set: files that resolved to no spec fail the run, subjects nothing reached are named, neutral results record why, and the printed verdict and the exit code finally share one threshold. The `default` profile also grows from 80 to 88 operators, so mutation scores will move — every new operator produces mutants your suite has never been measured against. Pin the gem version and the operator profile if you need a stable score across runs.
data/README.md CHANGED
@@ -21,9 +21,15 @@ Add to `Gemfile`:
21
21
  gem "evilution", group: :test
22
22
  ```
23
23
 
24
- Then: `bundle install`
24
+ Then:
25
+ ```shell
26
+ bundle install
27
+ ```
25
28
 
26
- Or standalone: `gem install evilution`
29
+ Or standalone:
30
+ ```shell
31
+ gem install evilution
32
+ ```
27
33
 
28
34
  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.
29
35
 
@@ -88,7 +94,7 @@ Every command, subcommand, and flag listed in this section is part of evilution'
88
94
  | `init` | Generate `.evilution.yml` config file | |
89
95
  | `version` | Print version string | |
90
96
  | `subjects [files]` | List mutation subjects with locations and counts | |
91
- | `tests list [files]` | List spec files mapped to source files | |
97
+ | `tests list [files]` | List the spec files `run` would use for each source (layout, `spec_mappings`, `spec_pattern`) | |
92
98
  | `session list` | List saved session results | |
93
99
  | `session show FILE` | Display detailed session results | |
94
100
  | `session diff A B` | Compare two sessions (fixed/new/persistent) | |
@@ -129,6 +135,7 @@ Every command, subcommand, and flag listed in this section is part of evilution'
129
135
  | `--isolation MODE` | String | `auto` | Isolation strategy: `auto`, `fork`, or `in_process`. `auto` selects `fork` for Rails projects and packaged gems (`*.gemspec`), `in_process` otherwise. See [docs/isolation.md](docs/isolation.md). |
130
136
  | `--preload FILE` | String | _(auto)_ | File to require in parent before forking workers. Auto-detect chain for Rails projects: `spec/rails_helper.rb` → `spec/spec_helper.rb` → `test/test_helper.rb`. For non-Rails gems: `spec/spec_helper.rb` → `test/test_helper.rb` → `test/helper.rb`, falling back to the gem entry `lib/<gem>.rb` (with a warning naming the searched paths). Pass `--no-preload` to opt out. |
131
137
  | `--no-preload` | Boolean | _(enabled)_ | Disable parent-process preload. |
138
+ | `--warmup NAME` | String | `none` | After preload, warm lazily-initialised framework state once in the parent: `none` or `rails`. See [Warming up Rails before forking](#warming-up-rails-before-forking). |
132
139
  | `--skip-heredoc-literals` | Boolean | false | Skip all string literal mutations inside heredocs. |
133
140
  | `--show-disabled` | Boolean | false | Report mutations skipped by `# evilution:disable` comments. |
134
141
  | `--fallback-full-suite` | Boolean | false | When no matching spec/test resolves for a mutation, run the whole test suite instead of marking it `:unresolved` and skipping. |
@@ -158,7 +165,7 @@ Every command, subcommand, and flag listed in this section is part of evilution'
158
165
 
159
166
  Two profiles ship out of the box:
160
167
 
161
- - **`default`** — the 88 stable operators registered in `Mutator::Registry.default`. Suitable for everyday CI runs; balances coverage signal against survivor noise.
168
+ - **`default`** — the 111 stable operators registered in `Mutator::Registry.default`. Suitable for everyday CI runs; balances coverage signal against survivor noise.
162
169
  - **`strict`** — adds extra truthiness mutators on top of `default`. Currently `PredicateToNil` (replaces every `x.predicate?` call with `nil` to surface tests that only assert truthiness rather than exact return values). Use for pre-merge audits where you want maximum sensitivity at the cost of more survivors.
163
170
 
164
171
  Set via `--profile=strict`, the `--strict` shortcut, or `profile: strict` in `.evilution.yml`.
@@ -202,6 +209,7 @@ schema_version: 1 # opts into strict validation (rejects unknown keys
202
209
  # isolation: auto # auto | fork | in_process (auto selects fork for Rails + gems)
203
210
  # canary: true # proof-of-life synthetic mutation at session start (false to skip)
204
211
  # preload: null # path to preload before forking; false to disable; auto-detects for Rails + gems
212
+ # warmup: none # rails: warm I18n, routes, assets and templates once after preload (fork isolation)
205
213
  # skip_heredoc_literals: false # skip string literal mutations inside heredocs (recommended for Rails: heredoc SQL/templates rarely have test coverage)
206
214
  # show_disabled: false # report mutations skipped by disable comments
207
215
  # baseline_session: null # path to session file for HTML comparison
@@ -261,6 +269,7 @@ All keys recognised under `schema_version: 1`:
261
269
  | `related_specs_heuristic` | Boolean | `false` | Append related request/integration/feature/system specs for `includes(...)` mutations. |
262
270
  | `fallback_to_full_suite` | Boolean | `false` | When no matching spec resolves, run the entire suite instead of marking the mutation `:unresolved`. |
263
271
  | `preload` | String / Boolean / null | `null` | File to preload in parent before forking. `false` to disable. `null` to auto-detect for Rails projects and packaged gems. |
272
+ | `warmup` | String | `none` | `rails` warms lazily-initialised Rails state once after preload so forks do not each pay it. `none` / `false` to skip. |
264
273
  | `spec_mappings` | Hash&lt;String, String/Array&gt; | `{}` | Custom mapping from source path to spec path(s). |
265
274
  | `spec_pattern` | String / null | `null` | Glob restricting resolved spec candidates. |
266
275
  | `example_targeting` | Boolean | `true` | Per-mutation example-level targeting. |
@@ -481,7 +490,7 @@ Subjects needing attention (2 subjects in 1 file):
481
490
 
482
491
  A subject is listed when something survived, or when nothing reached it at all — zero verdicts, every mutation unresolved or neutral. Fully-killed subjects are not listed, so the section stays actionable. JSON output carries every subject under `subjects`, whether or not it needs attention, so a CI step can assert on `reached` or on a per-subject `score`.
483
492
 
484
- ## Mutation Operators (88 total)
493
+ ## Mutation Operators (111 total)
485
494
 
486
495
  Each operator name is stable and appears in JSON output under `survived[].operator`.
487
496
 
@@ -514,6 +523,29 @@ Each operator name is stable and appears in JSON output under `survived[].operat
514
523
  | `method_call_removal` | Remove method calls, keep receiver | `obj.foo(x)` -> `obj` |
515
524
  | `argument_removal` | Remove individual arguments | `foo(a, b)` -> `foo(b)` |
516
525
  | `argument_nil_substitution` | Replace arguments with `nil` | `foo(a, b)` -> `foo(nil, b)` |
526
+ | `call_to_nil` | Replace a method call with `nil` (skips void statements, receivers of another call, and attribute or index writes) | `user.name` -> `nil` |
527
+ | `safe_navigation_removal` | Replace `&.` with a plain call (skips `self` and literal receivers) | `user&.name` -> `user.name` |
528
+ | `attribute_write_to_read` | Replace an attribute or index write with the matching read (skips writes `statement_deletion` already removes) | `a.foo = b` -> `a.foo`, `a[i] = b` -> `a[i]` |
529
+ | `argument_propagation` | Replace a call with its only positional argument (skips operator methods, attribute writes and void statements) | `normalize(value)` -> `value` |
530
+ | `argument_list_removal` | Drop a call's whole argument list, keeping any block (skips operator methods and attribute writes) | `foo(a, b)` -> `foo`, `foo(a, &b)` -> `foo(&b)` |
531
+ | `symbol_to_proc_replacement` | Apply the `send_mutation` and `collection_replacement` selector tables to a symbol block-pass (skips alias swaps) | `map(&:to_s)` -> `map(&:to_i)` |
532
+ | `dynamic_dispatch_resolution` | Resolve `send` / `__send__` / `public_send` with a literal symbol into a direct call, only where visibility checks can differ | `obj.send(:m, a)` -> `obj.m(a)` |
533
+ | `keyword_argument_removal` | Drop one `key: value` argument at a call site (keeps `**opts`; skips a lone keyword) | `f(a: 1, b: 2)` -> `f(b: 2)` |
534
+ | `double_negation_removal` | Replace a double negation with its operand | `!!x` -> `x` |
535
+ | `regexp_anchor_to_predicate` | Rewrite a match against an anchored literal regexp as a prefix / suffix predicate (skips `match?` with `\A` / `\z`, an exact equivalent) | `x =~ /^foo/` -> `x.start_with?("foo")` |
536
+ | `reduce_to_sum` | Rewrite a `+` reduction (`reduce` / `inject` with `:+` or `&:+`) into `sum` | `xs.reduce(:+)` -> `xs.sum`, `xs.inject(0, :+)` -> `xs.sum(0)` |
537
+ | `array_coercion_to_literal` | Replace an `Array()` coercion with an array literal | `Array(x)` -> `[x]` |
538
+ | `to_i_to_integer` | Replace lenient `to_i` with strict `Integer()` (skips numeric literal receivers) | `x.to_i` -> `Integer(x)`, `x.to_i(16)` -> `Integer(x, 16)` |
539
+ | `coercion_emptying` | Replace a conversion with an empty value of its type (`to_a` / `to_ary`, `to_h` / `to_hash`, `to_s` / `to_str`) | `x.to_s` -> `""`, `x.to_h` -> `{}` |
540
+ | `const_get_to_constant_path` | Resolve `const_get` with a literal symbol into a constant path | `X.const_get(:Y)` -> `X::Y` |
541
+ | `proc_to_lambda` | Turn `proc { }` / `Proc.new { }` into `lambda { }` (arity checks, local `return`) | `proc { \|x\| x }` -> `lambda { \|x\| x }` |
542
+ | `dig_to_fetch_chain` | Make the first step of a multi-key `dig` strict | `x.dig(a, b)` -> `x.fetch(a).dig(b)` |
543
+ | `inequality_to_negated_identity` | Rewrite `!=` with the stricter `eql?` / `equal?` (skips nil, boolean and symbol literals) | `a != b` -> `!a.eql?(b)`, `!a.equal?(b)` |
544
+ | `binary_operand_promotion` | Replace an arithmetic or bitwise expression with one of its operands (skips integer identities and void statements) | `a + b` -> `a`, `b` |
545
+ | `receiver_constructor_swap` | Swap `Date` / `DateTime` / `Time.parse` for a stricter sibling constructor on the same receiver | `Date.parse(s)` -> `Date.iso8601(s)`, `Date.strptime(s)`, ... |
546
+ | `block_body_to_nil` | Replace a block body with `nil`, keeping the iteration (skips `loop` and endless `cycle` / `cycle(nil)`, which would hang) | `xs.each { \|x\| log(x) }` -> `xs.each { \|x\| nil }` |
547
+ | `block_body_to_raise` | Replace a block body with `raise` to prove the block is invoked (skips bodies with a `rescue` clause, which would swallow it) | `xs.each { \|x\| log(x) }` -> `xs.each { \|x\| raise }` |
548
+ | `block_body_promotion` | Replace a call with its parameter-less block body, run once (skips blocks with parameters or a `rescue` / `ensure` clause) | `Base.transaction { save! }` -> `save!`, `3.times { poll }` -> `poll` |
517
549
  | `keyword_argument` | Remove keyword defaults/params | `def foo(bar: 42)` -> `def foo(bar:)` |
518
550
  | `multiple_assignment` | Remove targets or swap order | `a, b = 1, 2` -> `b, a = 1, 2` |
519
551
  | `block_removal` | Remove blocks from method calls | `items.map { \|x\| x * 2 }` -> `items.map` |
@@ -840,6 +872,17 @@ bundle exec evilution mutate lib/aasm/base.rb --spec spec/unit/event_spec.rb,spe
840
872
  bundle exec evilution mutate lib/aasm/base.rb --fallback-full-suite
841
873
  ```
842
874
 
875
+ To make the mapping permanent, declare it once in `.evilution.yml` under `spec_mappings`; the run, its baseline and `tests list` all honour it. `evilution tests list <file>` prints exactly the spec files a run would use, so it is the quick way to check a mapping before spending a run on it:
876
+
877
+ ```yaml
878
+ spec_mappings:
879
+ lib/aasm/base.rb:
880
+ - spec/unit/event_spec.rb
881
+ - spec/unit/callbacks_spec.rb
882
+ ```
883
+
884
+ Without `--fallback-full-suite` an unresolved source is also left out of the baseline, which does not run the whole test directory for it.
885
+
843
886
  ### Long Minitest fork runs — not a hang
844
887
 
845
888
  Minitest projects under `--isolation=fork` re-bootstrap the test environment (`test_helper.rb`, plugins, runnable state) once per mutation. On constant-heavy files (e.g. Shopify/liquid's `lib/liquid/lexer.rb`, ~270 mutations) the wall-clock cost is dominated by that per-fork bootstrap and any mutations that hit a `--timeout` rather than killing the test fast. A single-worker run (`-j 1`) on a few hundred mutations can take 4+ minutes; combined with `--no-progress` and a non-TTY stderr (CI, redirected logs) the run looks silent the entire time.
@@ -853,7 +896,7 @@ RUBYOPT="-Itest" bundle exec evilution mutate lib/<file>.rb \
853
896
  --spec test/<dir>/<file>_test.rb
854
897
  ```
855
898
 
856
- `-j 4` parallelises across workers, `-t 10` caps any mutation that pathologically loops at 10 s. Expect the run to print progress only when stderr is a TTY (use `bundle exec evilution mutate ... 2>&1 | tee log` to get progress while still saving output). The historical "Minitest fork hangs on liquid" report (GH #1211) turned out to be a slow run + silent UX, not an actual deadlock — the worker logs show steady forward progress when captured via `--quiet-children --quiet-children-dir DIR`.
899
+ `-j 4` parallelises across workers, `-t 10` caps any mutation that pathologically loops at 10 s. On a Rails app, add `--warmup rails` so each fork does not re-initialise I18n, routes and templates on its first request (see [Warming up Rails before forking](#warming-up-rails-before-forking)). Expect the run to print progress only when stderr is a TTY (use `bundle exec evilution mutate ... 2>&1 | tee log` to get progress while still saving output). The historical "Minitest fork hangs on liquid" report (GH #1211) turned out to be a slow run + silent UX, not an actual deadlock — the worker logs show steady forward progress when captured via `--quiet-children --quiet-children-dir DIR`.
857
900
 
858
901
  ### 8. CI gate
859
902
 
@@ -891,6 +934,21 @@ Output buckets:
891
934
 
892
935
  Use in CI to gate merges on `reintroduced` being empty, or to surface `new` survivors for reviewer attention without failing the build on `persistent` debt.
893
936
 
937
+ ## Warming up Rails before forking
938
+
939
+ Under `isolation: fork` the parent preloads once and every mutation runs in a fresh fork, which inherits only what the parent had already initialised. Rails initialises a lot lazily on a process's first request: loading locale files, building route helpers, resolving asset paths, compiling templates. Without a warm-up, every forked mutation whose tests make a request pays that cold start again, often several hundred milliseconds each.
940
+
941
+ `warmup: rails` (or `--warmup rails`) does that initialisation once in the parent, right after the preload:
942
+
943
+ - `I18n.eager_load!`
944
+ - the route set's `url_helpers` (and `eager_load!` for lazily drawn routes on Rails 8)
945
+ - an asset path lookup through the controller helpers
946
+ - template precompilation, only if the [`actionview_precompiler`](https://github.com/jhawthorn/actionview_precompiler) gem is installed
947
+
948
+ Each step is skipped if its library is not loaded, and skipped with a one-line note if it raises, so an app that does not fit a step still runs normally.
949
+
950
+ Do not warm up by sending a real request from your preload (`Rails.application.call(...)`). Controller callbacks change process-global state that every fork then inherits: `I18n.locale`, gem request stores such as PaperTrail's, a session row in the database. Tests that depend on that state then fail in every fork, and evilution reports their mutations as killed. The steps above make no request and leave that state untouched.
951
+
894
952
  ## Parallel Runs with SQLite
895
953
 
896
954
  Running with `-j N` forks worker processes. If your Rails app uses SQLite, every worker opens the same `db/test.sqlite3` file, and concurrent writers collide on the database-level lock. Symptoms: `ActiveRecord::StatementTimeout`, `SQLite3::BusyException`, and slow runs. Evilution classifies these crashes as `:neutral` (see [GH #814](https://github.com/marinazzio/evilution/issues/814)) so the mutation score is not polluted, but the wall-clock penalty remains.
@@ -946,7 +1004,7 @@ points — see [docs/architecture.md](docs/architecture.md).
946
1004
  1. **Parse** — Prism parses Ruby files into ASTs with exact byte offsets
947
1005
  2. **Extract** — Methods are identified as mutation subjects
948
1006
  3. **Filter** — Disable comments, Sorbet `sig` blocks, and AST ignore patterns exclude mutations before execution
949
- 4. **Mutate** — 88 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
1007
+ 4. **Mutate** — 111 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
950
1008
  5. **Isolate** — Mutations are applied to temporary file copies (never modifying originals); load-path redirection ensures `require` resolves the mutated copy. Default isolation is in-process for plain Ruby projects (no gemspec) and fork for Rails projects and packaged gems (auto-detected); `--isolation fork` forces forked child processes. Both sequential and parallel (`--jobs N`) modes respect the configured isolation strategy
951
1009
  6. **Test** — The configured test framework (RSpec, Minitest, or Test::Unit) executes against the mutated source
952
1010
  7. **Collect** — Source strings and AST nodes are released after use to minimize memory retention
data/docs/architecture.md CHANGED
@@ -48,7 +48,7 @@ Everything lives under `lib/evilution/`.
48
48
  | `Config`, `Config::*` | Merge `.evilution.yml` + CLI flags + env, validate, freeze. | `config.rb`, `config/sources.rb`, `config/validators/*` |
49
49
  | `Runner`, `Runner::*` | Orchestrate the whole run. Each stage is its own collaborator. | `runner.rb`, `runner/*` |
50
50
  | `AST`, `Subject` | Prism parse, find method subjects, source surgery, pattern matching, heredoc spans. | `ast/parser.rb`, `ast/source_surgeon.rb`, `subject.rb` |
51
- | `Mutator`, `Mutator::Operator::*` | 88 operators (default profile) that emit byte-edits; registry + profiles. | `mutator/base.rb`, `mutator/registry.rb`, `mutator/operator/*` |
51
+ | `Mutator`, `Mutator::Operator::*` | 111 operators (default profile) that emit byte-edits; registry + profiles. | `mutator/base.rb`, `mutator/registry.rb`, `mutator/operator/*` |
52
52
  | `Mutation` | An immutable mutation record (original/mutated sources, slice, location, parse status). | `mutation.rb` |
53
53
  | `SpecResolver`, `SpecSelector` | Map a source file to its covering spec files (layout heuristics + explicit mappings). | `spec_resolver.rb`, `spec_selector.rb` |
54
54
  | `Isolation::{Fork,InProcess}`, `ProcessSupervisor` | Run one mutation's tests in isolation; process-group lifecycle, sandboxing, TERM/KILL ladder. | `isolation/fork.rb`, `process_supervisor.rb` |
@@ -60,6 +60,40 @@ Everything lives under `lib/evilution/`.
60
60
  | `Session`, `Compare` | Persist runs to `.evilution/results/*.json`; diff two sessions. | `session/store.rb`, `compare.rb` |
61
61
  | `Coverage`, `Equivalent`, `Baseline`, `Cache`, `Hooks`, `MCP` | Coverage-based example targeting, equivalent-mutation detection, baseline capture, incremental cache, lifecycle hooks, MCP server. | respective dirs |
62
62
 
63
+ ## Require conventions
64
+
65
+ Most directories under `lib/evilution/` have a parent file of the same name
66
+ (`runner.rb` next to `runner/`) that declares the namespace. The rules:
67
+
68
+ - **Every child requires its parent.** A file in `runner/` starts with
69
+ `require_relative "../runner"`, a file in `cli/commands/` with
70
+ `require_relative "../commands"`, and so on down the tree.
71
+ - **A parent declares its namespace before requiring its children.** Usually
72
+ the class or module is defined first and children are required at the
73
+ bottom. Where the parent's own body needs its children loaded first (for
74
+ example a constant built from them), it opens with an empty declaration —
75
+ `class Evilution::CLI; end` — then requires the children, then defines the
76
+ rest. `cli.rb` and `integration/rspec/state_guard.rb` do this.
77
+ - **Top-level files (`lib/evilution/*.rb`) are the exception.** Their parent is
78
+ `lib/evilution.rb`, the gem's entry point, which loads everything. A
79
+ top-level file that only needs the root module requires `version.rb`, which
80
+ defines `module Evilution` and nothing else. Only a file that genuinely needs
81
+ the whole gem requires `../evilution` — `runner.rb`, whose collaborators
82
+ reach every operator through `Mutator::Registry`.
83
+
84
+ The parent require and the parent's own `require_relative` of its children
85
+ form a cycle (`runner.rb` → `runner/canary.rb` → `runner.rb`). It is inert:
86
+ Ruby does not load a file twice, so a re-entrant require of a file already
87
+ being loaded returns immediately, and the namespace already exists because the
88
+ parent declared it first. Reviews flagging it as a circular require can be
89
+ answered with this section.
90
+
91
+ Loading an arbitrary file on its own (`require "evilution/cli/command"` in a
92
+ fresh process) is not a goal. The parent requires make most files work that
93
+ way, but not all: a base class whose subclasses are required by the parent
94
+ (`cli/command.rb`) cannot finish defining itself before those subclasses load.
95
+ Load the gem through `require "evilution"`.
96
+
63
97
  ## Data flow, source → result
64
98
 
65
99
  Driven entirely by `Evilution::Runner#call` (`runner.rb:24`). Each step names the
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "prism"
4
+ require_relative "../ast"
4
5
 
5
6
  module Evilution::AST
6
7
  class Parser
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../pattern"
3
4
  require_relative "parser"
4
5
 
5
6
  class Evilution::AST::Pattern::Filter
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../pattern"
3
4
  require_relative "matcher"
4
5
 
5
6
  class Evilution::AST::Pattern::Parser
@@ -1,4 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../ast"
4
+
3
5
  module Evilution::AST::Pattern
4
6
  end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "prism"
4
+ require_relative "../ast"
4
5
 
5
6
  class Evilution::AST::SorbetSigDetector
6
7
  def call(source)
@@ -15,13 +15,24 @@ class Evilution::Baseline
15
15
  end
16
16
  end
17
17
 
18
+ # spec_selector: the object `run` resolves with (integration layout plus
19
+ # spec_mappings / spec_pattern), returning every spec covering a source.
20
+ # Without one, the bare spec_resolver is used. spec_resolver also supplies
21
+ # the best-guess hint for an unresolved source.
22
+ #
23
+ # fallback_to_full_suite: whether an unresolved source runs the whole
24
+ # fallback_dir. When false its mutations are reported unresolved, so the
25
+ # baseline skips it rather than running -- and often timing out on -- the
26
+ # entire test directory.
18
27
  def initialize(spec_resolver: Evilution::SpecResolver.new, timeout: 30, runner: nil,
19
- fallback_dir: "spec", test_files: nil)
28
+ fallback_dir: "spec", test_files: nil, spec_selector: nil, fallback_to_full_suite: true)
20
29
  @spec_resolver = spec_resolver
21
30
  @timeout = timeout
22
31
  @runner = runner
23
32
  @fallback_dir = fallback_dir
24
33
  @test_files = test_files
34
+ @spec_selector = spec_selector
35
+ @fallback_to_full_suite = fallback_to_full_suite
25
36
  end
26
37
 
27
38
  def call(subjects)
@@ -43,7 +54,7 @@ class Evilution::Baseline
43
54
  read_io, write_io = IO.pipe
44
55
  pid = fork_spec_runner(spec_file, read_io, write_io)
45
56
  write_io.close
46
- read_result(read_io, pid)
57
+ read_result(read_io, pid, spec_file)
47
58
  rescue Evilution::Error
48
59
  raise
49
60
  rescue StandardError
@@ -69,7 +80,7 @@ class Evilution::Baseline
69
80
 
70
81
  GRACE_PERIOD = 0.5
71
82
 
72
- def read_result(read_io, pid)
83
+ def read_result(read_io, pid, spec_file)
73
84
  if read_io.wait_readable(@timeout)
74
85
  data = read_io.read
75
86
  Process.wait(pid)
@@ -79,10 +90,15 @@ class Evilution::Baseline
79
90
  result[:passed]
80
91
  else
81
92
  terminate_child(pid)
93
+ warn_timeout(spec_file)
82
94
  false
83
95
  end
84
96
  end
85
97
 
98
+ def warn_timeout(spec_file)
99
+ warn "[evilution] Baseline for #{spec_file} timed out after #{@timeout}s; treating it as failing."
100
+ end
101
+
86
102
  def terminate_child(pid)
87
103
  Evilution::ProcessCleanup.safe_kill("TERM", pid)
88
104
  _, status = Process.waitpid2(pid, Process::WNOHANG)
@@ -98,11 +114,6 @@ class Evilution::Baseline
98
114
 
99
115
  private
100
116
 
101
- # When --spec was provided, run those files only. Auto-discovery is skipped
102
- # entirely — the user has declared what covers their subjects and any
103
- # mismatch between auto-discovery and their declaration is what produced
104
- # the misleading "No matching test found" warning users have reported even
105
- # while passing --spec.
106
117
  def baseline_spec_files(subjects)
107
118
  return Array(@test_files).uniq if @test_files && !@test_files.empty?
108
119
 
@@ -111,20 +122,31 @@ class Evilution::Baseline
111
122
 
112
123
  def resolve_unique_spec_files(subjects)
113
124
  warned = Set.new
114
- subjects.map do |s|
115
- resolved = @spec_resolver.call(s.file_path)
116
- warn_no_matching_test(s.file_path) if resolved.nil? && warned.add?(s.file_path)
117
- resolved || @fallback_dir
125
+ subjects.flat_map do |s|
126
+ specs = specs_for(s.file_path)
127
+ next specs unless specs.empty?
128
+
129
+ warn_no_matching_test(s.file_path) if warned.add?(s.file_path)
130
+ @fallback_to_full_suite ? [@fallback_dir] : []
118
131
  end.uniq
119
132
  end
120
133
 
134
+ def specs_for(file_path)
135
+ Array(@spec_selector ? @spec_selector.call(file_path) : @spec_resolver.call(file_path))
136
+ end
137
+
121
138
  def warn_no_matching_test(file_path)
139
+ action = @fallback_to_full_suite ? "running full suite" : "marking its mutations unresolved"
140
+ warn "[evilution] No matching test found for #{file_path}, #{action}. #{no_match_hint(file_path)}"
141
+ end
142
+
143
+ def no_match_hint(file_path)
122
144
  suggestion = @spec_resolver.suggest(file_path)
123
145
  hint = if suggestion
124
- "Pass --spec #{suggestion} (best guess) or the correct test file."
146
+ "Pass --spec #{suggestion} (best guess) or the correct test file"
125
147
  else
126
- "Use --spec to specify the test file."
148
+ "Use --spec to specify the test file"
127
149
  end
128
- warn "[evilution] No matching test found for #{file_path}, running full suite. #{hint}"
150
+ @fallback_to_full_suite ? "#{hint}." : "#{hint}, or --fallback-full-suite to run the whole suite."
129
151
  end
130
152
  end
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "../evilution"
3
+ require_relative "version"
4
4
 
5
5
  module Evilution::ChildOutput
6
6
  module_function
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../cli"
3
4
  require_relative "result"
4
5
 
5
6
  class Evilution::CLI::Command
@@ -5,7 +5,6 @@ require_relative "../command"
5
5
  require_relative "../dispatcher"
6
6
  require_relative "../printers/tests_list"
7
7
  require_relative "../../config"
8
- require_relative "../../spec_resolver"
9
8
  require_relative "../../git/changed_files"
10
9
 
11
10
  class Evilution::CLI::Commands::TestsList < Evilution::CLI::Command
@@ -25,8 +24,10 @@ class Evilution::CLI::Commands::TestsList < Evilution::CLI::Command
25
24
  return 0
26
25
  end
27
26
 
28
- resolver = Evilution::SpecResolver.new
29
- entries = source_files.map { |source| { source: source, spec: resolver.call(source) } }
27
+ # The same selector `run` uses: the integration's test layout (test/*_test.rb
28
+ # for minitest and test-unit) plus spec_mappings and spec_pattern, so the
29
+ # two commands cannot disagree about which specs cover a source (GH #1596).
30
+ entries = source_files.map { |source| { source: source, specs: Array(config.spec_selector.call(source)) } }
30
31
  Evilution::CLI::Printers::TestsList.new(mode: :resolved, entries: entries).render(@stdout)
31
32
  0
32
33
  end
@@ -1,4 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../cli"
4
+
3
5
  module Evilution::CLI::Commands
4
6
  end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../cli"
4
+
3
5
  module Evilution::CLI::Dispatcher
4
6
  @commands = {}
5
7
 
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../cli"
4
+
3
5
  # Gives evilution the final word on the process exit status.
4
6
  #
5
7
  # `--preload` loads the project's own spec helper into the parent process, and
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../cli"
4
+
3
5
  class Evilution::CLI
4
6
  ParsedArgs = Struct.new(
5
7
  :command, :options, :files, :line_ranges, :stdin_error, :parse_error,
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../parser"
4
+
3
5
  class Evilution::CLI::Parser::CommandExtractor
4
6
  SIMPLE_COMMANDS = {
5
7
  "version" => :version,
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../parser"
4
+
3
5
  module Evilution::CLI::Parser::FileArgs
4
6
  ParsedPaths = Data.define(:files, :ranges)
5
7