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.
- checksums.yaml +4 -4
- data/.beads/interactions.jsonl +38 -0
- data/.rubocop_todo.yml +9 -0
- data/CHANGELOG.md +47 -0
- data/README.md +65 -7
- data/docs/architecture.md +35 -1
- data/lib/evilution/ast/parser.rb +1 -0
- data/lib/evilution/ast/pattern/filter.rb +1 -0
- data/lib/evilution/ast/pattern/parser.rb +1 -0
- data/lib/evilution/ast/pattern.rb +2 -0
- data/lib/evilution/ast/sorbet_sig_detector.rb +1 -0
- data/lib/evilution/baseline.rb +37 -15
- data/lib/evilution/child_output.rb +1 -1
- data/lib/evilution/cli/command.rb +1 -0
- data/lib/evilution/cli/commands/tests_list.rb +4 -3
- data/lib/evilution/cli/commands.rb +2 -0
- data/lib/evilution/cli/dispatcher.rb +2 -0
- data/lib/evilution/cli/exit_guard.rb +2 -0
- data/lib/evilution/cli/parsed_args.rb +2 -0
- data/lib/evilution/cli/parser/command_extractor.rb +2 -0
- data/lib/evilution/cli/parser/file_args.rb +2 -0
- data/lib/evilution/cli/parser/options_builder.rb +3 -0
- data/lib/evilution/cli/parser/stdin_reader.rb +1 -0
- data/lib/evilution/cli/parser.rb +1 -0
- data/lib/evilution/cli/printers/tests_list.rb +5 -4
- data/lib/evilution/cli/printers.rb +2 -0
- data/lib/evilution/cli/result.rb +2 -0
- data/lib/evilution/cli.rb +5 -0
- data/lib/evilution/config/builders.rb +2 -0
- data/lib/evilution/config/env_loader.rb +2 -0
- data/lib/evilution/config/file_loader.rb +1 -0
- data/lib/evilution/config/sources.rb +1 -0
- data/lib/evilution/config/validators/example_targeting_cache.rb +1 -0
- data/lib/evilution/config/validators/example_targeting_fallback.rb +1 -0
- data/lib/evilution/config/validators/example_targeting_strategy.rb +1 -0
- data/lib/evilution/config/validators/fail_fast.rb +1 -0
- data/lib/evilution/config/validators/hooks.rb +1 -0
- data/lib/evilution/config/validators/ignore_patterns.rb +1 -0
- data/lib/evilution/config/validators/integration.rb +1 -0
- data/lib/evilution/config/validators/isolation.rb +1 -0
- data/lib/evilution/config/validators/jobs.rb +1 -0
- data/lib/evilution/config/validators/preload.rb +1 -0
- data/lib/evilution/config/validators/profile.rb +1 -0
- data/lib/evilution/config/validators/spec_mappings.rb +1 -0
- data/lib/evilution/config/validators/spec_pattern.rb +1 -0
- data/lib/evilution/config/validators/warmup.rb +17 -0
- data/lib/evilution/config/validators.rb +2 -0
- data/lib/evilution/config.rb +4 -3
- data/lib/evilution/coverage.rb +1 -1
- data/lib/evilution/coverage_example_filter.rb +1 -1
- data/lib/evilution/diagnostic.rb +1 -1
- data/lib/evilution/example_filter.rb +1 -1
- data/lib/evilution/integration/loading/body_call_neutralizer.rb +10 -2
- data/lib/evilution/integration/loading/test_load_path.rb +21 -13
- data/lib/evilution/integration/rspec/state_guard/configuration_state.rb +1 -0
- data/lib/evilution/integration/rspec/state_guard/configuration_streams.rb +1 -0
- data/lib/evilution/integration/rspec/state_guard/example_groups_constants.rb +1 -2
- data/lib/evilution/integration/rspec/state_guard/internals.rb +1 -2
- data/lib/evilution/integration/rspec/state_guard/object_space_example_groups.rb +1 -2
- data/lib/evilution/integration/rspec/state_guard/reporter_arrays.rb +1 -0
- data/lib/evilution/integration/rspec/state_guard/world_example_groups.rb +1 -0
- data/lib/evilution/integration/rspec/state_guard/world_filtered_examples.rb +1 -0
- data/lib/evilution/integration/rspec/state_guard/world_sources_by_path.rb +1 -0
- data/lib/evilution/integration/rspec/state_guard.rb +6 -0
- data/lib/evilution/mcp/info_tool/actions/environment.rb +1 -0
- data/lib/evilution/mcp/info_tool/actions/feedback.rb +1 -0
- data/lib/evilution/mcp/info_tool/actions/statuses.rb +1 -0
- data/lib/evilution/mcp/info_tool/actions/subjects.rb +1 -0
- data/lib/evilution/mcp/info_tool/actions/tests.rb +1 -0
- data/lib/evilution/mutation.rb +1 -1
- data/lib/evilution/mutator/operator/argument_list_removal.rb +52 -0
- data/lib/evilution/mutator/operator/argument_propagation.rb +54 -0
- data/lib/evilution/mutator/operator/array_coercion_to_literal.rb +34 -0
- data/lib/evilution/mutator/operator/attribute_write_to_read.rb +34 -0
- data/lib/evilution/mutator/operator/bang_method.rb +11 -3
- data/lib/evilution/mutator/operator/binary_operand_promotion.rb +63 -0
- data/lib/evilution/mutator/operator/block_body_promotion.rb +45 -0
- data/lib/evilution/mutator/operator/block_body_to_nil.rb +52 -0
- data/lib/evilution/mutator/operator/block_body_to_raise.rb +34 -0
- data/lib/evilution/mutator/operator/call_to_nil.rb +34 -0
- data/lib/evilution/mutator/operator/coercion_emptying.rb +60 -0
- data/lib/evilution/mutator/operator/collection_replacement.rb +19 -9
- data/lib/evilution/mutator/operator/comparison_replacement.rb +37 -12
- data/lib/evilution/mutator/operator/const_get_to_constant_path.rb +50 -0
- data/lib/evilution/mutator/operator/dig_to_fetch_chain.rb +45 -0
- data/lib/evilution/mutator/operator/double_negation_removal.rb +22 -0
- data/lib/evilution/mutator/operator/dynamic_dispatch_resolution.rb +79 -0
- data/lib/evilution/mutator/operator/inequality_to_negated_identity.rb +43 -0
- data/lib/evilution/mutator/operator/keyword_argument_removal.rb +47 -0
- data/lib/evilution/mutator/operator/proc_to_lambda.rb +43 -0
- data/lib/evilution/mutator/operator/receiver_constructor_swap.rb +51 -0
- data/lib/evilution/mutator/operator/reduce_to_sum.rb +58 -0
- data/lib/evilution/mutator/operator/regexp_anchor_to_predicate.rb +136 -0
- data/lib/evilution/mutator/operator/safe_navigation_removal.rb +67 -0
- data/lib/evilution/mutator/operator/send_mutation.rb +42 -6
- data/lib/evilution/mutator/operator/symbol_to_proc_replacement.rb +59 -0
- data/lib/evilution/mutator/operator/to_i_to_integer.rb +38 -0
- data/lib/evilution/mutator/primitives.rb +25 -0
- data/lib/evilution/mutator/registry.rb +24 -1
- data/lib/evilution/parallel/pool.rb +1 -0
- data/lib/evilution/parallel_db_warning.rb +1 -1
- data/lib/evilution/rails_warmup.rb +61 -0
- data/lib/evilution/reporter/html/report.rb +1 -0
- data/lib/evilution/result/coverage_gap_grouper.rb +1 -0
- data/lib/evilution/result/subject_scorer.rb +1 -0
- data/lib/evilution/runner/baseline_runner.rb +17 -7
- data/lib/evilution/runner/isolation_resolver.rb +23 -2
- data/lib/evilution/runner/mutation_executor/neutralizer/baseline_failed.rb +17 -10
- data/lib/evilution/source_ast_cache.rb +1 -1
- data/lib/evilution/spec_ast_cache.rb +1 -1
- data/lib/evilution/version.rb +1 -1
- data/lib/evilution.rb +23 -0
- metadata +27 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 171e27cbec4014afbffb3c058ecc0b666caa8f849c33532ed55f84e2ecee3a87
|
|
4
|
+
data.tar.gz: bff682f7afeb96e4b565ae916950ad8b526f5eca2dc00bb88f17a28382b5e9d9
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: c272e9c1cb1c5d0af0bb85856b02a59eb14dfad81bc4285d035eb693826eb8559acf803ac14482acf736da98e531c6b1444bb269fa8d0d3ecb340c16f2942f9e
|
|
7
|
+
data.tar.gz: b5a31b2aa40568c6f43ab9147d8a3cde7c8d2ab9205466a3d459eaca5395b7b31cf2a94a1e997a2e6f982cc4ef8fd189be268f2bfc52c0f897a963eebc9f0718
|
data/.beads/interactions.jsonl
CHANGED
|
@@ -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:
|
|
24
|
+
Then:
|
|
25
|
+
```shell
|
|
26
|
+
bundle install
|
|
27
|
+
```
|
|
25
28
|
|
|
26
|
-
Or standalone:
|
|
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
|
|
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
|
|
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<String, String/Array> | `{}` | 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 (
|
|
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** —
|
|
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::*` |
|
|
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
|
data/lib/evilution/ast/parser.rb
CHANGED
data/lib/evilution/baseline.rb
CHANGED
|
@@ -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.
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
|
|
150
|
+
@fallback_to_full_suite ? "#{hint}." : "#{hint}, or --fallback-full-suite to run the whole suite."
|
|
129
151
|
end
|
|
130
152
|
end
|
|
@@ -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
|
-
|
|
29
|
-
|
|
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
|